From a6c7c7ba992a0706f073fd65937f660092f1be75 Mon Sep 17 00:00:00 2001 From: Jrius <2261279+Jrius@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:00:27 +0100 Subject: [PATCH] Improve handling of temporary objects, fix various issues --- korman/exporter/animation.py | 181 ++++++++++++++++------------------- korman/exporter/armature.py | 47 ++++----- korman/exporter/convert.py | 4 +- 3 files changed, 108 insertions(+), 124 deletions(-) diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py index d14e235..44f7966 100644 --- a/korman/exporter/animation.py +++ b/korman/exporter/animation.py @@ -39,11 +39,50 @@ class AnimationConverter: def convert_frame_time(self, frame_num: int) -> float: return frame_num / self._bl_fps - def copy_armature_animation_to_temporary_bones(self, arm_bo: bpy.types.Object, generated_bones): + def convert_object_animations(self, bo: bpy.types.Object, so: plSceneObject, anim_name: str, *, + start: Optional[int] = None, end: Optional[int] = None, bake_frame_step: Optional[int] = None) -> Iterable[plAGApplicator]: + if not bo.plasma_object.has_animation_data: + return [] + + def fetch_animation_data(id_data): + if id_data is not None: + if id_data.animation_data is not None: + action = id_data.animation_data.action + return action, getattr(action, "fcurves", []) + return None, [] + + obj_action, obj_fcurves = fetch_animation_data(bo) + data_action, data_fcurves = fetch_animation_data(bo.data) + + if bake_frame_step is not None and obj_action is not None: + obj_action = self._bake_animation_data(bo, bake_frame_step, start, end) + obj_fcurves = obj_action.fcurves + + # We're basically just going to throw all the FCurves at the controller converter (read: wall) + # and see what sticks. PlasmaMAX has some nice animation channel stuff that allows for some + # form of separation, but Blender's NLA editor is way confusing and appears to not work with + # things that aren't the typical position, rotation, scale animations. + applicators = [] + if isinstance(bo.data, bpy.types.Camera): + applicators.append(self._convert_camera_animation(bo, so, obj_fcurves, data_fcurves, anim_name, start, end)) + else: + applicators.append(self._convert_transform_animation(bo, obj_fcurves, bo.matrix_local, bo.matrix_parent_inverse, start=start, end=end)) + if bo.plasma_modifiers.soundemit.enabled: + applicators.extend(self._convert_sound_volume_animation(bo.name, obj_fcurves, bo.plasma_modifiers.soundemit, start, end)) + if isinstance(bo.data, bpy.types.Lamp): + lamp = bo.data + applicators.extend(self._convert_lamp_color_animation(bo.name, data_fcurves, lamp, start, end)) + if isinstance(lamp, bpy.types.SpotLamp): + applicators.extend(self._convert_spot_lamp_animation(bo.name, data_fcurves, lamp, start, end)) + if isinstance(lamp, bpy.types.PointLamp): + applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp, start, end)) + + return [i for i in applicators if i is not None] + + def copy_armature_animation_to_temporary_bones(self, arm_bo: bpy.types.Object, generated_bones, handle_temporary): # Enable the anim group. We will need it to reroute anim messages to children bones. # Please note: Plasma supports grouping many animation channels into a single ATC anim, but only programmatically. # So instead we will do it the Cyan Way(tm), which is to make dozens or maybe even hundreds of small ATC anims. Derp. - temporary_objects = [] anim = arm_bo.plasma_modifiers.animation anim_group = arm_bo.plasma_modifiers.animation_group do_bake = anim.bake @@ -51,101 +90,48 @@ class AnimationConverter: toggle = GoodNeighbor() toggle.track(anim, "bake", False) toggle.track(anim_group, "enabled", True) - temporary_objects.append(toggle) - temporary_objects.append(exit_stack) + handle_temporary(toggle) + handle_temporary(exit_stack) armature_action = arm_bo.animation_data.action if do_bake: armature_action = self._bake_animation_data(arm_bo, anim.bake_frame_step, None, None) - try: - for bone_name, bone in generated_bones.items(): - fcurves = [] - for fcurve in armature_action.fcurves: - match = self._bone_data_path_regex.match(fcurve.data_path) - if not match: - continue - name, data_path = match.groups() - if name != bone_name: - continue - fcurves.append((fcurve, data_path)) - - if not fcurves: - # No animation data for this bone. + for bone_name, bone in generated_bones.items(): + fcurves = [] + for fcurve in armature_action.fcurves: + match = self._bone_data_path_regex.match(fcurve.data_path) + if not match: continue + name, data_path = match.groups() + if name != bone_name: + continue + fcurves.append((fcurve, data_path)) - # Copy animation data. - anim_data = bone.animation_data_create() - action = bpy.data.actions.new("{}_action".format(bone.name)) - temporary_objects.append(action) - anim_data.action = action - for fcurve, data_path in fcurves: - new_curve = action.fcurves.new(data_path, fcurve.array_index) - for point in fcurve.keyframe_points: - # Thanks to bone_parent we can just copy the animation without a care in the world ! :P - p = new_curve.keyframe_points.insert(point.co[0], point.co[1]) - for original_marker in armature_action.pose_markers: - marker = action.pose_markers.new(original_marker.name) - marker.frame = original_marker.frame - - # Copy animation modifier and its properties if it exists. - # Cheating ? Very much yes. Do not try this at home, kids. - bone.plasma_modifiers["animation"] = arm_bo.plasma_modifiers["animation"] - bone.plasma_modifiers["animation_loop"] = arm_bo.plasma_modifiers["animation_loop"] - child = exit_stack.enter_context(TemporaryCollectionItem(anim_group.children)) - child.child_anim = bone - finally: - if do_bake: - bpy.data.actions.remove(armature_action) - - return temporary_objects - - def convert_object_animations(self, bo: bpy.types.Object, so: plSceneObject, anim_name: str, *, - start: Optional[int] = None, end: Optional[int] = None, bake_frame_step: Optional[int] = None) -> Iterable[plAGApplicator]: - if not bo.plasma_object.has_animation_data: - return [] - - def fetch_animation_data(id_data): - if id_data is not None: - if id_data.animation_data is not None: - action = id_data.animation_data.action - return action, getattr(action, "fcurves", []) - return None, [] - - obj_action, obj_fcurves = fetch_animation_data(bo) - data_action, data_fcurves = fetch_animation_data(bo.data) - temporary_action = None - - try: - if bake_frame_step is not None and obj_action is not None: - temporary_action = obj_action = self._bake_animation_data(bo, bake_frame_step, start, end) - obj_fcurves = obj_action.fcurves - - # We're basically just going to throw all the FCurves at the controller converter (read: wall) - # and see what sticks. PlasmaMAX has some nice animation channel stuff that allows for some - # form of separation, but Blender's NLA editor is way confusing and appears to not work with - # things that aren't the typical position, rotation, scale animations. - applicators = [] - if isinstance(bo.data, bpy.types.Camera): - applicators.append(self._convert_camera_animation(bo, so, obj_fcurves, data_fcurves, anim_name, start, end)) - else: - applicators.append(self._convert_transform_animation(bo, obj_fcurves, bo.matrix_local, bo.matrix_parent_inverse, start=start, end=end)) - if bo.plasma_modifiers.soundemit.enabled: - applicators.extend(self._convert_sound_volume_animation(bo.name, obj_fcurves, bo.plasma_modifiers.soundemit, start, end)) - if isinstance(bo.data, bpy.types.Lamp): - lamp = bo.data - applicators.extend(self._convert_lamp_color_animation(bo.name, data_fcurves, lamp, start, end)) - if isinstance(lamp, bpy.types.SpotLamp): - applicators.extend(self._convert_spot_lamp_animation(bo.name, data_fcurves, lamp, start, end)) - if isinstance(lamp, bpy.types.PointLamp): - applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp, start, end)) - - finally: - if temporary_action is not None: - # Baking data is temporary, but the lifetime of our user's data is eternal ! - bpy.data.actions.remove(temporary_action) + if not fcurves: + # No animation data for this bone. + continue - return [i for i in applicators if i is not None] + # Copy animation data. + anim_data = bone.animation_data_create() + action = bpy.data.actions.new("{}_action".format(bone.name)) + handle_temporary(action) + anim_data.action = action + for fcurve, data_path in fcurves: + new_curve = action.fcurves.new(data_path, fcurve.array_index) + for point in fcurve.keyframe_points: + # Thanks to bone_parent we can just copy the animation without a care in the world ! :P + p = new_curve.keyframe_points.insert(point.co[0], point.co[1]) + for original_marker in armature_action.pose_markers: + marker = action.pose_markers.new(original_marker.name) + marker.frame = original_marker.frame + + # Copy animation modifier and its properties if it exists. + # Cheating ? Very much yes. Do not try this at home, kids. + bone.plasma_modifiers["animation"] = arm_bo.plasma_modifiers["animation"] + bone.plasma_modifiers["animation_loop"] = arm_bo.plasma_modifiers["animation_loop"] + child = exit_stack.enter_context(TemporaryCollectionItem(anim_group.children)) + child.child_anim = bone def _bake_animation_data(self, bo, bake_frame_step: int, start: Optional[int] = None, end: Optional[int] = None): # Baking animations is a Blender operator, so requires a bit of boilerplate... @@ -158,15 +144,14 @@ class AnimationConverter: # Do bake, but make sure we don't mess the user's data. old_action = bo.animation_data.action - try: - keyframes = [keyframe.co[0] for curve in old_action.fcurves for keyframe in curve.keyframe_points] - frame_start = start if start is not None else min(keyframes) - frame_end = end if end is not None else max(keyframes) - bpy.ops.nla.bake(frame_start=frame_start, frame_end=frame_end, step=bake_frame_step, only_selected=False, visual_keying=True, bake_types={"POSE", "OBJECT"}) - baked_anim = bo.animation_data.action - return baked_anim - finally: - bo.animation_data.action = old_action + toggle.track(bo.animation_data, "action", old_action) + keyframes = [keyframe.co[0] for curve in old_action.fcurves for keyframe in curve.keyframe_points] + frame_start = start if start is not None else min(keyframes) + frame_end = end if end is not None else max(keyframes) + bpy.ops.nla.bake(frame_start=frame_start, frame_end=frame_end, step=bake_frame_step, only_selected=False, visual_keying=True, bake_types={"POSE", "OBJECT"}) + baked_anim = bo.animation_data.action + self._exporter().exit_stack.enter_context(TemporaryObject(baked_anim, bpy.data.actions.remove)) + return baked_anim def _convert_camera_animation(self, bo, so, obj_fcurves, data_fcurves, anim_name: str, start: Optional[int], end: Optional[int]): diff --git a/korman/exporter/armature.py b/korman/exporter/armature.py index a2dcfb4..d65aa15 100644 --- a/korman/exporter/armature.py +++ b/korman/exporter/armature.py @@ -26,26 +26,29 @@ class ArmatureConverter: self._skinned_objects_modifiers = {} self._bones_local_to_world = {} - def convert_armature_to_empties(self, bo): + def convert_armature_to_empties(self, bo, handle_temporary): # Creates Blender equivalents to each bone of the armature, adjusting a whole bunch of stuff along the way. # Yes, this is ugly, but required to get anims to export properly. I tried other ways to export armatures, # but AFAICT sooner or later you have to implement similar hacks. Might as well generate something that the # animation exporter can already deal with, with no modification... - # Obviously the returned temporary_objects will be cleaned up afterwards. + # Obviously the created objects will be cleaned up afterwards. armature = bo.data - pose = bo.pose if armature.pose_position == "POSE" else None generated_bones = {} # name: Blender empty. - temporary_objects = [] - for bone in armature.bones: - if bone.parent: - continue - self._export_bone(bo, bone, bo, Matrix.Identity(4), pose, generated_bones, temporary_objects) - - if bo.plasma_modifiers.animation.enabled and bo.animation_data is not None and bo.animation_data.action is not None: - # Let the anim exporter handle the anim crap. - temporary_objects.extend(self._exporter().animation.copy_armature_animation_to_temporary_bones(bo, generated_bones)) - - return temporary_objects + temporary_bones = [] + # Note: ideally we would give the temporary bone objects to handle_temporary as soon as they are created. + # However we need to delay until we actually create their animation modifiers, so that these get exported. + try: + for bone in armature.bones: + if bone.parent: + continue + self._export_bone(bo, bone, bo, Matrix.Identity(4), bo.pose, armature.pose_position == "POSE", generated_bones, temporary_bones) + + if bo.plasma_modifiers.animation.enabled and bo.animation_data is not None and bo.animation_data.action is not None: + # Let the anim exporter handle the anim crap. + self._exporter().animation.copy_armature_animation_to_temporary_bones(bo, generated_bones, handle_temporary) + finally: + for bone in temporary_bones: + handle_temporary(bone) def get_bone_local_to_world(self, bo): return self._bones_local_to_world[bo] @@ -73,14 +76,14 @@ class ArmatureConverter: return True return False - def _export_bone(self, bo, bone, parent, matrix, pose, generated_bones, temporary_objects): + def _export_bone(self, bo, bone, parent, matrix, pose, pose_mode, generated_bones, temporary_bones): bone_empty = bpy.data.objects.new(ArmatureConverter.get_bone_name(bo, bone), None) bpy.context.scene.objects.link(bone_empty) bone_empty.plasma_object.enabled = True + pose_bone = pose.bones[bone.name] + bone_empty.rotation_mode = pose_bone.rotation_mode - if pose is not None: - pose_bone = pose.bones[bone.name] - bone_empty.rotation_mode = pose_bone.rotation_mode + if pose_mode: pose_matrix = pose_bone.matrix_basis else: pose_matrix = Matrix.Identity(4) @@ -94,18 +97,16 @@ class ArmatureConverter: bone_parent.parent = parent bone_empty.parent = bone_parent bone_empty.matrix_local = Matrix.Identity(4) - temporary_objects.append(bone_parent) + temporary_bones.append(bone_parent) bone_parent.matrix_local = matrix * bone.matrix_local * pose_matrix # The bone's local to world matrix may change when we copy animations over, which we don't want. # Cache the matrix so we can use it when exporting meshes. self._bones_local_to_world[bone_empty] = bo.matrix_world * bone.matrix_local - - temporary_objects.append(bone_empty) + temporary_bones.append(bone_empty) generated_bones[bone.name] = bone_empty for child in bone.children: - child_empty = self._export_bone(bo, child, bone_empty, bone.matrix_local.inverted(), pose, generated_bones, temporary_objects) - return bone_empty + self._export_bone(bo, child, bone_empty, bone.matrix_local.inverted(), pose, pose_mode, generated_bones, temporary_bones) @staticmethod def get_bone_name(bo, bone): diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 98f6f73..73684e0 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -560,9 +560,7 @@ class Exporter: so = self.mgr.find_create_object(plSceneObject, bl=bo) self._export_actor(so, bo) # Bake all armature bones to empties - this will make it easier to export animations and such. - for temporary_object in self.armature.convert_armature_to_empties(bo): - # This could be a Blender object, an action, a context manager... I have no idea, handle_temporary will figure it out ! - handle_temporary(temporary_object, bo) + self.armature.convert_armature_to_empties(bo, lambda obj: handle_temporary(obj, bo)) for mod in bo.plasma_modifiers.modifiers: proc = getattr(mod, "pre_export", None) if proc is not None: