fix(gltf_auto_export): fix animation export (#148)

* fix(gltf_auto_export): added  fix for correct "deep-copying" of objects & hierarchy so that animated
 meshes & nested hierarchies get exported correctly
* test(gltf_auto_export):
   * added visual testing to check for overall regression :
       * added screenshoting in bevy app
       * added visual compare with reference screenshot on python side
    * added testing of correct export of animated models 
    * added testing of correct export of empties
    * added testing of correct export of nested hierarchies
    * added testing of correct export of blueprints, with & without components etc 
* fixes #147
This commit is contained in:
Mark Moissette 2024-02-29 15:27:02 +01:00 committed by GitHub
parent dfc2be8c50
commit 7ffcd55f5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 177 additions and 21 deletions

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

View File

@ -1,15 +1,93 @@
pub mod in_game; pub mod in_game;
use std::time::Duration; use std::{
fs::{self},
time::Duration,
};
use bevy_gltf_blueprints::{AnimationPlayerLink, BlueprintName};
pub use in_game::*; pub use in_game::*;
use bevy::{prelude::*, time::common_conditions::on_timer}; use bevy::{
prelude::*, render::view::screenshot::ScreenshotManager, time::common_conditions::on_timer,
window::PrimaryWindow,
};
use bevy_gltf_worlflow_examples_common::{AppState, GameState}; use bevy_gltf_worlflow_examples_common::{AppState, GameState};
use crate::{TupleTestF32, UnitTest};
fn start_game(mut next_app_state: ResMut<NextState<AppState>>) { fn start_game(mut next_app_state: ResMut<NextState<AppState>>) {
next_app_state.set(AppState::AppLoading); next_app_state.set(AppState::AppLoading);
} }
// if the export from Blender worked correctly, we should have animations (simplified here by using AnimationPlayerLink)
// if the export from Blender worked correctly, we should have an Entity called "Cylinder" that has two components: UnitTest, TupleTestF32
// if the export from Blender worked correctly, we should have an Entity called "Blueprint4_nested" that has a child called "Blueprint3" that has a "BlueprintName" component with value Blueprint3
fn validate_export(
parents: Query<&Parent>,
children: Query<&Children>,
names: Query<&Name>,
blueprints: Query<(Entity, &Name, &BlueprintName)>,
animation_player_links: Query<(Entity, &AnimationPlayerLink)>,
exported_cylinder: Query<(Entity, &Name, &UnitTest, &TupleTestF32)>,
empties_candidates: Query<(Entity, &Name, &GlobalTransform)>,
) {
let animations_found = !animation_player_links.is_empty();
let mut cylinder_found = false;
if let Ok(nested_cylinder) = exported_cylinder.get_single() {
let parent_name = names
.get(parents.get(nested_cylinder.0).unwrap().get())
.unwrap();
cylinder_found = parent_name.to_string() == *"Cube.001"
&& nested_cylinder.1.to_string() == *"Cylinder"
&& nested_cylinder.3 .0 == 75.1;
}
let mut nested_blueprint_found = false;
for (entity, name, blueprint_name) in blueprints.iter() {
if name.to_string() == *"Blueprint4_nested" && blueprint_name.0 == *"Blueprint4_nested" {
if let Ok(cur_children) = children.get(entity) {
for child in cur_children.iter() {
if let Ok((_, child_name, child_blueprint_name)) = blueprints.get(*child) {
if child_name.to_string() == *"Blueprint3"
&& child_blueprint_name.0 == *"Blueprint3"
{
nested_blueprint_found = true;
}
}
}
}
}
}
let mut empty_found = false;
for (_, name, _) in empties_candidates.iter() {
if name.to_string() == *"Empty" {
empty_found = true;
break;
}
}
fs::write(
"bevy_diagnostics.json",
format!(
"{{ \"animations\": {}, \"cylinder_found\": {} , \"nested_blueprint_found\": {}, \"empty_found\": {} }}",
animations_found, cylinder_found, nested_blueprint_found, empty_found
),
)
.expect("Unable to write file");
}
fn generate_screenshot(
main_window: Query<Entity, With<PrimaryWindow>>,
mut screenshot_manager: ResMut<ScreenshotManager>,
) {
screenshot_manager
.save_screenshot_to_disk(main_window.single(), "screenshot.png")
.unwrap();
}
fn exit_game(mut app_exit_events: ResMut<Events<bevy::app::AppExit>>) { fn exit_game(mut app_exit_events: ResMut<Events<bevy::app::AppExit>>) {
app_exit_events.send(bevy::app::AppExit); app_exit_events.send(bevy::app::AppExit);
} }
@ -18,11 +96,14 @@ pub struct GamePlugin;
impl Plugin for GamePlugin { impl Plugin for GamePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Update, (spawn_test).run_if(in_state(GameState::InGame))) app.add_systems(Update, (spawn_test).run_if(in_state(GameState::InGame)))
.add_systems(Update, validate_export)
.add_systems(Update, generate_screenshot.run_if(on_timer(Duration::from_secs_f32(0.2)))) // TODO: run once
.add_systems(OnEnter(AppState::MenuRunning), start_game) .add_systems(OnEnter(AppState::MenuRunning), start_game)
.add_systems(OnEnter(AppState::AppRunning), setup_game)
.add_systems( .add_systems(
Update, Update,
exit_game.run_if(on_timer(Duration::from_secs_f32(0.5))), exit_game.run_if(on_timer(Duration::from_secs_f32(0.5))),
) // shut down the app after this time ) // shut down the app after this time
.add_systems(OnEnter(AppState::AppRunning), setup_game); ;
} }
} }

View File

@ -7,11 +7,11 @@ use std::ops::Range;
#[derive(Component, Reflect, Default, Debug)] #[derive(Component, Reflect, Default, Debug)]
#[reflect(Component)] #[reflect(Component)]
struct UnitTest; pub struct UnitTest;
#[derive(Component, Reflect, Default, Debug, Deref, DerefMut)] #[derive(Component, Reflect, Default, Debug, Deref, DerefMut)]
#[reflect(Component)] #[reflect(Component)]
struct TupleTestF32(f32); pub struct TupleTestF32(pub f32);
#[derive(Component, Reflect, Default, Debug, Deref, DerefMut)] #[derive(Component, Reflect, Default, Debug, Deref, DerefMut)]
#[reflect(Component)] #[reflect(Component)]

View File

@ -24,12 +24,38 @@ def remove_unwanted_custom_properties(object):
if cp in object: if cp in object:
del object[cp] del object[cp]
def duplicate_object(object):
obj_copy = object.copy()
if object.data:
data = object.data.copy()
obj_copy.data = data
if object.animation_data and object.animation_data.action:
obj_copy.animation_data.action = object.animation_data.action.copy()
return obj_copy
#also removes unwanted custom_properties for all objects in hiearchy
def duplicate_object_recursive(object, parent, collection):
original_name = object.name
object.name = original_name + "____bak"
copy = duplicate_object(object)
copy.name = original_name
collection.objects.link(copy)
remove_unwanted_custom_properties(copy)
if parent:
copy.parent = parent
for child in object.children:
duplicate_object_recursive(child, copy, collection)
return copy
# copies the contents of a collection into another one while replacing library instances with empties # copies the contents of a collection into another one while replacing library instances with empties
def copy_hollowed_collection_into(source_collection, destination_collection, parent_empty=None, filter=None, library_collections=[], addon_prefs={}): def copy_hollowed_collection_into(source_collection, destination_collection, parent_empty=None, filter=None, library_collections=[], addon_prefs={}):
collection_instances_combine_mode = getattr(addon_prefs, "collection_instances_combine_mode") collection_instances_combine_mode = getattr(addon_prefs, "collection_instances_combine_mode")
legacy_mode = getattr(addon_prefs, "export_legacy_mode") legacy_mode = getattr(addon_prefs, "export_legacy_mode")
collection_instances_combine_mode= collection_instances_combine_mode collection_instances_combine_mode= collection_instances_combine_mode
for object in source_collection.objects: for object in source_collection.objects:
if filter is not None and filter(object) is False: if filter is not None and filter(object) is False:
continue continue
@ -54,19 +80,13 @@ def copy_hollowed_collection_into(source_collection, destination_collection, par
if parent_empty is not None: if parent_empty is not None:
empty_obj.parent = parent_empty empty_obj.parent = parent_empty
else: else:
# we create a copy of our object, to leave the original one as it is
original_name = object.name # we create a copy of our object and its children, to leave the original one as it is
object.name = original_name + "____bak" if object.parent == None:
copy = object.copy() copy = duplicate_object_recursive(object, None, destination_collection)
copy.name = original_name
remove_unwanted_custom_properties(copy)
if parent_empty is not None: if parent_empty is not None:
copy.parent = parent_empty copy.parent = parent_empty
destination_collection.objects.link(copy)
else:
# root_objects.append(object)
destination_collection.objects.link(copy)
# for every sub-collection of the source, copy its content into a new sub-collection of the destination # for every sub-collection of the source, copy its content into a new sub-collection of the destination
for collection in source_collection.children: for collection in source_collection.children:

View File

@ -220,3 +220,23 @@ def did_export_parameters_change(current_params, previous_params):
if not key in components_holder: if not key in components_holder:
components_holder[key] = components[key] components_holder[key] = components[key]
""" """
# potentially useful alternative
def duplicate_object2(object, original_name):
print("copy object", object)
with bpy.context.temp_override(object=object, active_object = object):
bpy.ops.object.duplicate(linked=False)
new_obj = bpy.context.active_object
print("new obj", new_obj, "bpy.context.view_layer", bpy.context.view_layer.objects)
for obj in bpy.context.view_layer.objects:
print("obj", obj)
bpy.context.view_layer.update()
new_obj.name = original_name
if object.animation_data:
print("OJECT ANIMATION")
new_obj.animation_data.action = object.animation_data.action.copy()
return new_obj

View File

@ -4,3 +4,6 @@ addopts = -svv
testpaths = testpaths =
tests tests
# dependencies:
# pytest_blender
# pixelmatch

View File

@ -5,6 +5,9 @@ import json
import pytest import pytest
import shutil import shutil
from PIL import Image
from pixelmatch.contrib.PIL import pixelmatch
@pytest.fixture @pytest.fixture
def setup_data(request): def setup_data(request):
print("\nSetting up resources...") print("\nSetting up resources...")
@ -25,7 +28,13 @@ def setup_data(request):
if os.path.exists(other_materials_path): if os.path.exists(other_materials_path):
shutil.rmtree(other_materials_path)""" shutil.rmtree(other_materials_path)"""
diagnostics_file_path = os.path.join(root_path, "bevy_diagnostics.json")
if os.path.exists(diagnostics_file_path):
os.remove(diagnostics_file_path)
screenshot_observed_path = os.path.join(root_path, "screenshot.png")
if os.path.exists(screenshot_observed_path):
os.remove(screenshot_observed_path)
request.addfinalizer(finalizer) request.addfinalizer(finalizer)
@ -65,7 +74,8 @@ def test_export_complex(setup_data):
export_output_folder="./models", export_output_folder="./models",
export_scene_settings=True, export_scene_settings=True,
export_blueprints=True, export_blueprints=True,
export_legacy_mode=False export_legacy_mode=False,
export_animations=True
) )
# blueprint1 => has an instance, got changed, should export # blueprint1 => has an instance, got changed, should export
# blueprint2 => has NO instance, but marked as asset, should export # blueprint2 => has NO instance, but marked as asset, should export
@ -80,6 +90,8 @@ def test_export_complex(setup_data):
assert os.path.exists(os.path.join(models_path, "library", "Blueprint3.glb")) == True assert os.path.exists(os.path.join(models_path, "library", "Blueprint3.glb")) == True
assert os.path.exists(os.path.join(models_path, "library", "Blueprint4_nested.glb")) == True assert os.path.exists(os.path.join(models_path, "library", "Blueprint4_nested.glb")) == True
assert os.path.exists(os.path.join(models_path, "library", "Blueprint5.glb")) == False assert os.path.exists(os.path.join(models_path, "library", "Blueprint5.glb")) == False
assert os.path.exists(os.path.join(models_path, "library", "Blueprint6_animated.glb")) == True
assert os.path.exists(os.path.join(models_path, "library", "Blueprint7_hierarchy.glb")) == True
# now run bevy # now run bevy
bla = "cargo run --features bevy/dynamic_linking" bla = "cargo run --features bevy/dynamic_linking"
@ -91,3 +103,23 @@ def test_export_complex(setup_data):
return_code = subprocess.call(["cargo", "run", "--features", "bevy/dynamic_linking"], cwd=root_path) return_code = subprocess.call(["cargo", "run", "--features", "bevy/dynamic_linking"], cwd=root_path)
print("RETURN CODE OF BEVY APP", return_code) print("RETURN CODE OF BEVY APP", return_code)
assert return_code == 0 assert return_code == 0
with open(os.path.join(root_path, "bevy_diagnostics.json")) as diagnostics_file:
diagnostics = json.load(diagnostics_file)
print("diagnostics", diagnostics)
assert diagnostics["animations"] == True
assert diagnostics["cylinder_found"] == True
assert diagnostics["empty_found"] == True
# last but not least, do a visual compare
screenshot_expected_path = os.path.join(root_path, "expected_screenshot.png")
screenshot_observed_path = os.path.join(root_path, "screenshot.png")
img_a = Image.open(screenshot_expected_path)
img_b = Image.open(screenshot_observed_path)
img_diff = Image.new("RGBA", img_a.size)
mismatch = pixelmatch(img_a, img_b, img_diff, includeAA=True)
print("image mismatch", mismatch)
assert mismatch < 50