From c3ac168b947e17b7a26623535d0984641d038243 Mon Sep 17 00:00:00 2001 From: Jrius <2261279+Jrius@users.noreply.github.com> Date: Sun, 15 Dec 2024 14:06:25 +0100 Subject: [PATCH 1/6] Animations: allow baking keyframes --- korman/exporter/animation.py | 78 ++++++++++++++++++++--------- korman/properties/modifiers/anim.py | 3 +- korman/properties/prop_anim.py | 23 +++++++++ korman/ui/ui_anim.py | 5 ++ 4 files changed, 85 insertions(+), 24 deletions(-) diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py index e34eef6..ab70e78 100644 --- a/korman/exporter/animation.py +++ b/korman/exporter/animation.py @@ -26,6 +26,7 @@ import weakref from PyHSPlasma import * from . import utils +from ..helpers import GoodNeighbor class AnimationConverter: def __init__(self, exporter): @@ -36,38 +37,69 @@ class AnimationConverter: return frame_num / self._bl_fps def convert_object_animations(self, bo: bpy.types.Object, so: plSceneObject, anim_name: str, *, - start: Optional[int] = None, end: Optional[int] = None) -> Iterable[plAGApplicator]: + 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): + temporary_actions = [] + + def bake_animation_data(): + # Baking animations is a Blender operator, so requires a bit of boilerplate... + with GoodNeighbor() as toggle: + # Make sure we have only this object selected. + toggle.track(bo, "hide", False) + for i in bpy.data.objects: + i.select = i == bo + bpy.context.scene.objects.active = bo + + # Do bake, but make sure we don't mess the user's data. + old_action = bo.animation_data.action + try: + frame_start = start if start is not None else bpy.context.scene.frame_start + frame_end = end if end is not None else bpy.context.scene.frame_end + bpy.ops.nla.bake(frame_start=frame_start, frame_end=frame_end, step=bake_frame_step, visual_keying=True, bake_types={"POSE", "OBJECT"}) + action = bo.animation_data.action + finally: + bo.animation_data.action = old_action + temporary_actions.append(action) + return action + + def fetch_animation_data(id_data, can_bake): if id_data is not None: if id_data.animation_data is not None: action = id_data.animation_data.action + if bake_frame_step is not None and can_bake: + action = bake_animation_data() return action, getattr(action, "fcurves", []) return None, [] - obj_action, obj_fcurves = fetch_animation_data(bo) - data_action, data_fcurves = fetch_animation_data(bo.data) - - # 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)) + try: + obj_action, obj_fcurves = fetch_animation_data(bo, True) + data_action, data_fcurves = fetch_animation_data(bo.data, False) + + # 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: + for action in temporary_actions: + # Baking data is temporary, but the lifetime of our user's data is eternal ! + bpy.data.actions.remove(action) return [i for i in applicators if i is not None] diff --git a/korman/properties/modifiers/anim.py b/korman/properties/modifiers/anim.py index 8db48fb..e17c9a5 100644 --- a/korman/properties/modifiers/anim.py +++ b/korman/properties/modifiers/anim.py @@ -99,8 +99,9 @@ class PlasmaAnimationModifier(ActionModifier, PlasmaModifierProperties): start, end = min((start, end)), max((start, end)) else: start, end = None, None + bake_frame_step = anim.bake_frame_step if anim.bake else None - applicators = converter.convert_object_animations(bo, so, anim_name, start=start, end=end) + applicators = converter.convert_object_animations(bo, so, anim_name, start=start, end=end, bake_frame_step=bake_frame_step) if not applicators: exporter.report.warn(f"Animation '{anim_name}' generated no applicators. Nothing will be exported.") continue diff --git a/korman/properties/prop_anim.py b/korman/properties/prop_anim.py index 47fb593..f8e0f85 100644 --- a/korman/properties/prop_anim.py +++ b/korman/properties/prop_anim.py @@ -127,6 +127,29 @@ class PlasmaAnimation(bpy.types.PropertyGroup): bpy.types.Texture: "plasma_layer.anim_loop_end", }, }, + "bake": { + "type": BoolProperty, + "property": { + "name": "Bake Keyframes", + "description": "Bake animation keyframes on export. This generates a lot more intermediary keyframes but allows exporting inverse kinematics, and may improve timing for complex animations", + "default": False, + }, + "entire_animation": { + bpy.types.Object: "plasma_modifiers.animation.bake", + }, + }, + "bake_frame_step": { + "type": IntProperty, + "property": { + "name": "Frame step", + "description": "How many frames between each keyframe sample", + "default": 1, + "min": 1, + }, + "entire_animation": { + bpy.types.Object: "plasma_modifiers.animation.bake_frame_step", + }, + }, "sdl_var": { "type": StringProperty, "property": { diff --git a/korman/ui/ui_anim.py b/korman/ui/ui_anim.py index 3741854..c1f2689 100644 --- a/korman/ui/ui_anim.py +++ b/korman/ui/ui_anim.py @@ -67,6 +67,11 @@ def draw_single_animation(layout, anim): col.active = anim.loop and not anim.sdl_var col.prop_search(anim, "loop_start", action, "pose_markers", icon="PMARKER") col.prop_search(anim, "loop_end", action, "pose_markers", icon="PMARKER") + layout.separator() + split = layout.split() + split.prop(anim, "bake") + if anim.bake: + split.prop(anim, "bake_frame_step") layout.separator() layout.prop(anim, "sdl_var") From ca28135f16d57c30aa367e5057a8c20a4398749f Mon Sep 17 00:00:00 2001 From: Jrius <2261279+Jrius@users.noreply.github.com> Date: Mon, 16 Dec 2024 20:38:47 +0100 Subject: [PATCH 2/6] Export armatures --- korman/exporter/animation.py | 119 ++++++++++++++++++++++++++--------- korman/exporter/armature.py | 92 +++++++++++++++++++++++++++ korman/exporter/convert.py | 45 ++++++++++--- korman/helpers.py | 4 +- 4 files changed, 220 insertions(+), 40 deletions(-) create mode 100644 korman/exporter/armature.py diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py index ab70e78..84b6a93 100644 --- a/korman/exporter/animation.py +++ b/korman/exporter/animation.py @@ -16,66 +16,103 @@ import bpy from collections import defaultdict +from contextlib import ExitStack import functools import itertools import math import mathutils from typing import * import weakref +import re from PyHSPlasma import * from . import utils -from ..helpers import GoodNeighbor +from ..helpers import * class AnimationConverter: def __init__(self, exporter): self._exporter = weakref.ref(exporter) self._bl_fps = bpy.context.scene.render.fps + self._bone_data_path_regex = re.compile('^pose\\.bones\\["(.*)"]\\.(.*)$') 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): + # 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 + exit_stack = ExitStack() + toggle = GoodNeighbor() + toggle.track(anim, "bake", False) + toggle.track(anim_group, "enabled", True) + temporary_objects.append(toggle) + temporary_objects.append(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(): + child = exit_stack.enter_context(TemporaryCollectionItem(anim_group.children)) + child.child_anim = bone + # 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"] + + # Now 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 + self._exporter().report.warn(str([(i.data_path, i.array_index) for i in armature_action.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 + 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 + 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 [] - temporary_actions = [] - - def bake_animation_data(): - # Baking animations is a Blender operator, so requires a bit of boilerplate... - with GoodNeighbor() as toggle: - # Make sure we have only this object selected. - toggle.track(bo, "hide", False) - for i in bpy.data.objects: - i.select = i == bo - bpy.context.scene.objects.active = bo - - # Do bake, but make sure we don't mess the user's data. - old_action = bo.animation_data.action - try: - frame_start = start if start is not None else bpy.context.scene.frame_start - frame_end = end if end is not None else bpy.context.scene.frame_end - bpy.ops.nla.bake(frame_start=frame_start, frame_end=frame_end, step=bake_frame_step, visual_keying=True, bake_types={"POSE", "OBJECT"}) - action = bo.animation_data.action - finally: - bo.animation_data.action = old_action - temporary_actions.append(action) - return action - - def fetch_animation_data(id_data, can_bake): + 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 - if bake_frame_step is not None and can_bake: - action = bake_animation_data() 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: - obj_action, obj_fcurves = fetch_animation_data(bo, True) - data_action, data_fcurves = fetch_animation_data(bo.data, False) + 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 @@ -97,12 +134,32 @@ class AnimationConverter: applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp, start, end)) finally: - for action in temporary_actions: + if temporary_action is not None: # Baking data is temporary, but the lifetime of our user's data is eternal ! - bpy.data.actions.remove(action) + bpy.data.actions.remove(temporary_action) return [i for i in applicators if i is not None] + 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... + with GoodNeighbor() as toggle: + # Make sure we have only this object selected. + toggle.track(bo, "hide", False) + for i in bpy.data.objects: + i.select = i == bo + bpy.context.scene.objects.active = bo + + # Do bake, but make sure we don't mess the user's data. + old_action = bo.animation_data.action + try: + frame_start = start if start is not None else bpy.context.scene.frame_start + frame_end = end if end is not None else bpy.context.scene.frame_end + 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 + def _convert_camera_animation(self, bo, so, obj_fcurves, data_fcurves, anim_name: str, start: Optional[int], end: Optional[int]): has_fov_anim = False diff --git a/korman/exporter/armature.py b/korman/exporter/armature.py new file mode 100644 index 0000000..7063000 --- /dev/null +++ b/korman/exporter/armature.py @@ -0,0 +1,92 @@ +# This file is part of Korman. +# +# Korman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Korman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Korman. If not, see <http://www.gnu.org/licenses/>. + +import bpy +from mathutils import Matrix +import weakref +from PyHSPlasma import * + +from . import utils + +class ArmatureConverter: + def __init__(self, exporter): + self._exporter = weakref.ref(exporter) + + def convert_armature_to_empties(self, bo): + # 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 create something that the + # animation exporter can already deal with, with no modification... + # Don't worry, we'll return a list of temporary objects to clean up after ourselves. + 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 + + def _export_bone(self, bo, bone, parent, matrix, pose, generated_bones, temporary_objects): + 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 + + # Grmbl, animation is relative to rest pose in Blender, and relative to parent in Plasma... + # Using matrix_parent_inverse or manually adjust keyframes will just mess up rotation keyframes, + # so let's just insert an extra empty object to correct all that. This is why the CoordinateInterface caches computed matrices, after all... + bone_parent = bpy.data.objects.new(bone_empty.name + "_REST", None) + bpy.context.scene.objects.link(bone_parent) + bone_parent.plasma_object.enabled = True + bone_parent.parent = parent + bone_empty.parent = bone_parent + bone_empty.matrix_local = Matrix.Identity(4) + + if pose is not None: + pose_bone = pose.bones[bone.name] + bone_empty.rotation_mode = pose_bone.rotation_mode + pose_matrix = pose_bone.matrix_basis + else: + pose_bone = None + pose_matrix = Matrix.Identity(4) + + temporary_objects.append(bone_empty) + temporary_objects.append(bone_parent) + generated_bones[bone.name] = bone_empty + bone_parent.matrix_local = matrix * bone.matrix_local.to_4x4() * pose_matrix + + 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 + + @staticmethod + def get_bone_name(bo, bone): + if isinstance(bone, str): + return "{}_{}".format(bo.name, bone) + return "{}_{}".format(bo.name, bone.name) + + @property + def _mgr(self): + return self._exporter().mgr + + @property + def _report(self): + return self._exporter().report diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index c12627b..f2cc4a8 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -21,6 +21,7 @@ import functools import inspect from pathlib import Path from typing import * +from mathutils import Matrix from ..helpers import TemporaryObject from ..korlib import ConsoleToggler @@ -28,6 +29,7 @@ from ..korlib import ConsoleToggler from PyHSPlasma import * from .animation import AnimationConverter +from .armature import ArmatureConverter from .camera import CameraConverter from .decal import DecalConverter from . import explosions @@ -56,6 +58,7 @@ class Exporter: physics: PhysicsConverter = ... light: LightConverter = ... animation: AnimationConverter = ... + armature: ArmatureConverter = ... output: OutputFiles = ... camera: CameraConverter = ... image: ImageCache = ... @@ -80,6 +83,7 @@ class Exporter: self.physics = PhysicsConverter(self) self.light = LightConverter(self) self.animation = AnimationConverter(self) + self.armature = ArmatureConverter(self) self.output = OutputFiles(self, self._op.filepath) self.camera = CameraConverter(self) self.image = ImageCache(self) @@ -252,12 +256,20 @@ class Exporter: def _export_actor(self, so, bo): """Exports a Coordinate Interface if we need one""" + parent = bo.parent + parent_bone_name = bo.parent_bone + offset_matrix = None + if parent_bone_name: + # Object is parented to a bone, so use it instead. + parent_bone = parent.data.bones[parent_bone_name] + parent = bpy.context.scene.objects[ArmatureConverter.get_bone_name(parent, parent_bone)] + # ...Actually, it's parented to the bone's /tip/. So we need to offset the child... + offset_matrix = Matrix.Translation((0, 0, -parent_bone.length)) if self.has_coordiface(bo): - self._export_coordinate_interface(so, bo) + self._export_coordinate_interface(so, bo, offset_matrix) # If this object has a parent, then we will need to go upstream and add ourselves to the # parent's CoordinateInterface... Because life just has to be backwards. - parent = bo.parent if parent is not None: if parent.plasma_object.enabled: self.report.msg(f"Attaching to parent SceneObject '{parent.name}'") @@ -268,18 +280,25 @@ class Exporter: The object may not appear in the correct location or animate properly.".format( bo.name, parent.name)) - def _export_coordinate_interface(self, so, bl): + def _export_coordinate_interface(self, so, bl, matrix: Matrix = None): """Ensures that the SceneObject has a CoordinateInterface""" if so is None: so = self.mgr.find_create_object(plSceneObject, bl=bl) + ci = None if so.coord is None: ci_cls = bl.plasma_object.ci_type ci = self.mgr.add_object(ci_cls, bl=bl, so=so) - + if ci is not None or matrix is not None: + # We just created the CI, or we have an extra transform that we may have previously skipped. + matrix_local = bl.matrix_local + matrix_world = bl.matrix_world + if matrix is not None: + matrix_local = matrix_local * matrix + matrix_world = matrix_world * matrix # Now we have the "fun" work of filling in the CI - ci.localToWorld = utils.matrix44(bl.matrix_world) + ci.localToWorld = utils.matrix44(matrix_world) ci.worldToLocal = ci.localToWorld.inverse() - ci.localToParent = utils.matrix44(bl.matrix_local) + ci.localToParent = utils.matrix44(matrix_local) ci.parentToLocal = ci.localToParent.inverse() return ci return so.coord.object @@ -334,6 +353,9 @@ class Exporter: mod.export(self, bl_obj, sceneobject) inc_progress() + def _export_armature_blobj(self, so, bo): + pass + def _export_camera_blobj(self, so, bo): # Hey, guess what? Blender's camera data is utter crap! camera = bo.data.plasma_camera @@ -403,7 +425,7 @@ class Exporter: inc_progress() def has_coordiface(self, bo): - if bo.type in {"CAMERA", "EMPTY", "LAMP"}: + if bo.type in {"CAMERA", "EMPTY", "LAMP", "ARMATURE"}: return True if bo.parent is not None or bo.children: return True @@ -469,7 +491,7 @@ class Exporter: # Maybe this was an embedded context manager? if hasattr(temporary, "__enter__"): ctx_temporary = self.exit_stack.enter_context(temporary) - if ctx_temporary is not None: + if ctx_temporary is not None and ctx_temporary != temporary: return handle_temporary(ctx_temporary, parent) else: raise RuntimeError("Temporary object of type '{}' generated by '{}' was unhandled".format(temporary.__class__, parent.name)) @@ -522,6 +544,13 @@ class Exporter: return temporary def do_pre_export(bo): + if bo.type == "ARMATURE": + 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) for mod in bo.plasma_modifiers.modifiers: proc = getattr(mod, "pre_export", None) if proc is not None: diff --git a/korman/helpers.py b/korman/helpers.py index 35c0176..8092415 100644 --- a/korman/helpers.py +++ b/korman/helpers.py @@ -45,8 +45,10 @@ def copy_object(bl, name: Optional[str] = None): class GoodNeighbor: """Leave Things the Way You Found Them! (TM)""" - def __enter__(self): + def __init__(self): self._tracking = {} + + def __enter__(self): return self def track(self, cls, attr, value): From a6ddffed4e7c8d1941292d7eefa7ab54c7d47c91 Mon Sep 17 00:00:00 2001 From: Jrius <2261279+Jrius@users.noreply.github.com> Date: Sat, 1 Feb 2025 22:00:09 +0100 Subject: [PATCH 3/6] Export skinned objects (WIP). --- korman/exporter/animation.py | 38 ++++--- korman/exporter/armature.py | 54 +++++++--- korman/exporter/convert.py | 46 +++++--- korman/exporter/mesh.py | 198 ++++++++++++++++++++++++++++++++++- korman/helpers.py | 9 +- korman/render.py | 1 + 6 files changed, 296 insertions(+), 50 deletions(-) diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py index 84b6a93..d14e235 100644 --- a/korman/exporter/animation.py +++ b/korman/exporter/animation.py @@ -60,19 +60,7 @@ class AnimationConverter: try: for bone_name, bone in generated_bones.items(): - child = exit_stack.enter_context(TemporaryCollectionItem(anim_group.children)) - child.child_anim = bone - # 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"] - - # Now 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 - self._exporter().report.warn(str([(i.data_path, i.array_index) for i in armature_action.fcurves])) + fcurves = [] for fcurve in armature_action.fcurves: match = self._bone_data_path_regex.match(fcurve.data_path) if not match: @@ -80,6 +68,18 @@ class AnimationConverter: name, data_path = match.groups() if name != bone_name: continue + fcurves.append((fcurve, data_path)) + + if not fcurves: + # No animation data for this bone. + continue + + # 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 @@ -87,6 +87,13 @@ class AnimationConverter: 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) @@ -152,8 +159,9 @@ class AnimationConverter: # Do bake, but make sure we don't mess the user's data. old_action = bo.animation_data.action try: - frame_start = start if start is not None else bpy.context.scene.frame_start - frame_end = end if end is not None else bpy.context.scene.frame_end + 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 diff --git a/korman/exporter/armature.py b/korman/exporter/armature.py index 7063000..a2dcfb4 100644 --- a/korman/exporter/armature.py +++ b/korman/exporter/armature.py @@ -23,13 +23,15 @@ from . import utils class ArmatureConverter: def __init__(self, exporter): self._exporter = weakref.ref(exporter) + self._skinned_objects_modifiers = {} + self._bones_local_to_world = {} def convert_armature_to_empties(self, bo): # 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 create something that the + # 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... - # Don't worry, we'll return a list of temporary objects to clean up after ourselves. + # Obviously the returned temporary_objects will be cleaned up afterwards. armature = bo.data pose = bo.pose if armature.pose_position == "POSE" else None generated_bones = {} # name: Blender empty. @@ -45,11 +47,44 @@ class ArmatureConverter: return temporary_objects + def get_bone_local_to_world(self, bo): + return self._bones_local_to_world[bo] + + def get_skin_modifiers(self, bo): + if self.is_skinned(bo): + return self._skinned_objects_modifiers[bo] + return [] + + def is_skinned(self, bo): + if bo.type != "MESH": + return False + if bo in self._skinned_objects_modifiers: + return True + + # We need to cache the armature modifiers, because mesh.py will likely fiddle with them later. + armatures = [] + for mod in bo.modifiers: + # Armature modifiers only result in exporting skinning if they are linked to an exported armature. + # If the armature is not exported, the deformation will simply get baked into the exported mesh. + if mod.type == "ARMATURE" and mod.object is not None and mod.object.plasma_object.enabled and mod.use_vertex_groups and mod.show_render: + armatures.append(mod) + if len(armatures): + self._skinned_objects_modifiers[bo] = armatures + return True + return False + def _export_bone(self, bo, bone, parent, matrix, pose, generated_bones, temporary_objects): 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 + if pose is not None: + pose_bone = pose.bones[bone.name] + bone_empty.rotation_mode = pose_bone.rotation_mode + pose_matrix = pose_bone.matrix_basis + else: + pose_matrix = Matrix.Identity(4) + # Grmbl, animation is relative to rest pose in Blender, and relative to parent in Plasma... # Using matrix_parent_inverse or manually adjust keyframes will just mess up rotation keyframes, # so let's just insert an extra empty object to correct all that. This is why the CoordinateInterface caches computed matrices, after all... @@ -59,19 +94,14 @@ class ArmatureConverter: bone_parent.parent = parent bone_empty.parent = bone_parent bone_empty.matrix_local = Matrix.Identity(4) - - if pose is not None: - pose_bone = pose.bones[bone.name] - bone_empty.rotation_mode = pose_bone.rotation_mode - pose_matrix = pose_bone.matrix_basis - else: - pose_bone = None - pose_matrix = Matrix.Identity(4) + temporary_objects.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_objects.append(bone_parent) generated_bones[bone.name] = bone_empty - bone_parent.matrix_local = matrix * bone.matrix_local.to_4x4() * pose_matrix for child in bone.children: child_empty = self._export_bone(bo, child, bone_empty, bone.matrix_local.inverted(), pose, generated_bones, temporary_objects) diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index f2cc4a8..98f6f73 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -257,28 +257,33 @@ class Exporter: def _export_actor(self, so, bo): """Exports a Coordinate Interface if we need one""" parent = bo.parent - parent_bone_name = bo.parent_bone + parent_bone_name = bo.parent_bone if parent is not None else None offset_matrix = None - if parent_bone_name: + if parent_bone_name and parent.plasma_object.enabled: # Object is parented to a bone, so use it instead. parent_bone = parent.data.bones[parent_bone_name] parent = bpy.context.scene.objects[ArmatureConverter.get_bone_name(parent, parent_bone)] # ...Actually, it's parented to the bone's /tip/. So we need to offset the child... - offset_matrix = Matrix.Translation((0, 0, -parent_bone.length)) + offset_matrix = Matrix.Translation(bo.matrix_local.row[1].xyz * parent_bone.length) if self.has_coordiface(bo): self._export_coordinate_interface(so, bo, offset_matrix) - # If this object has a parent, then we will need to go upstream and add ourselves to the - # parent's CoordinateInterface... Because life just has to be backwards. - if parent is not None: - if parent.plasma_object.enabled: - self.report.msg(f"Attaching to parent SceneObject '{parent.name}'") - parent_ci = self._export_coordinate_interface(None, parent) - parent_ci.addChild(so.key) - else: - self.report.warn("You have parented Plasma Object '{}' to '{}', which has not been marked for export. \ - The object may not appear in the correct location or animate properly.".format( - bo.name, parent.name)) + # If this object has a parent, then we will need to go upstream and add ourselves to the + # parent's CoordinateInterface... Because life just has to be backwards. + if parent is not None: + if parent.plasma_object.enabled: + if not self.armature.is_skinned(parent): + self.report.msg(f"Attaching to parent SceneObject '{parent.name}'") + parent_ci = self._export_coordinate_interface(None, parent) + parent_ci.addChild(so.key) + else: + self.report.warn("You have parented Plasma Object '{}' to '{}', which is deformed by an armature. This is not supported. \ + The object may not appear in the correct location or animate properly.".format( + bo.name, parent.name)) + else: + self.report.warn("You have parented Plasma Object '{}' to '{}', which has not been marked for export. \ + The object may not appear in the correct location or animate properly.".format( + bo.name, parent.name)) def _export_coordinate_interface(self, so, bl, matrix: Matrix = None): """Ensures that the SceneObject has a CoordinateInterface""" @@ -425,6 +430,13 @@ class Exporter: inc_progress() def has_coordiface(self, bo): + if self.armature.is_skinned(bo): + # We forbid skinned objects from having a coordinate interface. See mesh.py for details. + for mod in bo.plasma_modifiers.modifiers: + if mod.enabled and mod.requires_actor: + raise RuntimeError("Object '{}' is skinned, which is incompatible with modifier '{}'.".format(bo, mod)) + return False + if bo.type in {"CAMERA", "EMPTY", "LAMP", "ARMATURE"}: return True if bo.parent is not None or bo.children: @@ -435,9 +447,8 @@ class Exporter: return True for mod in bo.plasma_modifiers.modifiers: - if mod.enabled: - if mod.requires_actor: - return True + if mod.enabled and mod.requires_actor: + return True return False def _post_process_scene_objects(self): @@ -490,6 +501,7 @@ class Exporter: def handle_temporary(temporary, parent): # Maybe this was an embedded context manager? if hasattr(temporary, "__enter__"): + log_msg(f"'{parent.name}': generated context manager '{temporary}' ") ctx_temporary = self.exit_stack.enter_context(temporary) if ctx_temporary is not None and ctx_temporary != temporary: return handle_temporary(ctx_temporary, parent) diff --git a/korman/exporter/mesh.py b/korman/exporter/mesh.py index 9812352..d30b42f 100644 --- a/korman/exporter/mesh.py +++ b/korman/exporter/mesh.py @@ -21,6 +21,7 @@ from math import fabs from typing import Iterable import weakref +from .armature import ArmatureConverter from ..exporter.logger import ExportProgressLogger from . import explosions from .. import helpers @@ -157,6 +158,8 @@ class _GeoData: self.blender2gs = [{} for i in range(numVtxs)] self.triangles = [] self.vertices = [] + self.max_deform_bones = 0 + self.total_weight_by_bones = {} class _MeshManager: @@ -166,6 +169,8 @@ class _MeshManager: self._report = report self._entered = False self._overrides = {} + self._objects_armatures = {} + self._geospans_armatures = {} @staticmethod def add_progress_presteps(report): @@ -200,15 +205,30 @@ class _MeshManager: # Remember, storing actual pointers to the Blender objects can cause bad things to # happen because Blender's memory management SUCKS! self._overrides[i.name] = { "mesh": i.data.name, "modifiers": [] } - i.data = i.to_mesh(scene, True, "RENDER", calc_tessface=False) # If the modifiers are left on the object, the lightmap bake can break under some - # situations. Therefore, we now cache the modifiers and clear them away... + # situations. Therefore, we now cache the modifiers and will clear them away... if i.plasma_object.enabled: cache_mods = self._overrides[i.name]["modifiers"] + for mod in i.modifiers: - cache_mods.append(self._build_prop_dict(mod)) + mod_prop_dict = self._build_prop_dict(mod) + cache_mods.append(mod_prop_dict) + + armatures = [] + for armature_mod in self._exporter().armature.get_skin_modifiers(i): + # We'll use armatures to export bones later. Disable it so it doesn't get baked into the mesh. + armatures.append(armature_mod.object) + # Note that this gets reverted when we reapply cached modifiers. + armature_mod.show_render = False + if armatures: + self._objects_armatures[i.name] = armatures + + i.data = i.to_mesh(scene, True, "RENDER", calc_tessface=False) + + if i.plasma_object.enabled: i.modifiers.clear() + self._report.progress_increment() return self @@ -365,6 +385,9 @@ class MeshConverter(_MeshManager): for dspan in loc.values(): log_msg("[DrawableSpans '{}']", dspan.key.name) + # We do one last pass to register bones. + self._register_bones_before_merge(dspan) + # This mega-function does a lot: # 1. Converts SourceSpans (geospans) to Icicles and bakes geometry into plGBuffers # 2. Calculates the Icicle bounds @@ -373,6 +396,87 @@ class MeshConverter(_MeshManager): dspan.composeGeometry(True, True) inc_progress() + def _register_bones_before_merge(self, dspan): + # We export all armatures used by this DSpan only once, unless an object uses multiple armatures - in which case, we treat the list of armatures as a single armature: + # [Armature 0: bone A, bone B, bone C...] + # [Armature 1: bone D, bone E, bone F...] + # [Armature 0, armature 1: bone A, bone B, bone C... bone D, bone E, bone F...] + # But really, the latter case should NEVER happen because users never use more than one armature modifier, cm'on. + # NOTE: we will export all bones, even those that are not used by any vertices in the DSpan. Would be too complex otherwise. + # NOTE: DSpan bone transforms are shared between all drawables in this dspan. This implies all skinned meshes must share the same + # coordinate system - and that's easier if these objects simply don't have a coordinate interface. + # Hence, we forbid skinned objects from having a coordint. + # The alternative is exporting each bone once per drawable with different l2w/w2l/l2b/b2l, but this is more complex + # and there is no good reason to do so - this just increases the risk of deformations going crazy due to a misaligned object. + + armature_list_to_base_matrix = {} + + def find_create_armature(armature_list): + nonlocal armature_list_to_base_matrix + existing_base_matrix = armature_list_to_base_matrix.get(armature_list) + if existing_base_matrix is not None: + # Armature already exported. Return the base bone ID. + return existing_base_matrix + + # Not already exported. Do so now. + # Will be used to offset the DI/icicle's baseMatrix. + base_matrix_id = dspan.numTransforms + armature_list_to_base_matrix[armature_list] = base_matrix_id + for armature in armature_list: + # Create the null bone. We have 1 per matrix + identity = hsMatrix44.Identity() + dspan.addTransform(identity, identity, identity, identity) + # Now create the transforms for all bones, and make sure they are referenced by a draw interface. + for bone in armature.data.bones: + find_create_bone(armature, bone) + + return base_matrix_id + + def find_create_bone(armature_bo, bone_bo): + bone_so_name = ArmatureConverter.get_bone_name(armature_bo, bone_bo) + bone_empty_bo = bpy.context.scene.objects[bone_so_name] + bone_empty_so = self._mgr.find_object(plSceneObject, bl=bone_empty_bo) + + # Add the bone's transform. + identity = hsMatrix44.Identity() + localToWorld = utils.matrix44(self._exporter().armature.get_bone_local_to_world(bone_empty_bo)) + transform_index = dspan.addTransform( \ + # local2world, world2local: always identity it seems. + identity, identity, \ + # local2bone, bone2local + localToWorld.inverse(), localToWorld) + + # Add a draw interface to the object itself (if not already done). + di = self._mgr.find_create_object(plDrawInterface, bl=bone_empty_bo, so=bone_empty_so) + bone_empty_so.draw = di.key + + # If the DI already has a reference to the DSpan, add the transform to the dspan's DIIndex. + # If not, create the DIIndex and the transform, and add it to the DI. + found = False + for key, id in di.drawables: + if dspan.key == key: + # Already exported because the user has an object with two armatures. + # Just readd the transform... + dii = dspan.DIIndices[id] + dii.indices = (*dii.indices, transform_index) + if not found: + dii = plDISpanIndex() + dii.flags = plDISpanIndex.kMatrixOnly + dii.indices = (transform_index,) + di_index = dspan.addDIIndex(dii) + di.addDrawable(dspan.key, di_index) + + for i in range(len(dspan.sourceSpans)): + geospan = dspan.sourceSpans[i] + # Let's get any armature data. + armature_info = self._geospans_armatures.get((dspan, i)) + if armature_info is None: + continue + armatures = armature_info[0] + # Export the armature (if not already done), and retrieve the BaseMatrix. + geospan.baseMatrix = find_create_armature(tuple(armatures)) + geospan.numMatrices = sum(len(arm.data.bones) for arm in armatures) + 1 + def _export_geometry(self, bo, mesh, materials, geospans, mat2span_LUT): self._report.msg(f"Converting geometry from '{mesh.name}'...") @@ -387,6 +491,26 @@ class MeshConverter(_MeshManager): color = self._find_vtx_color_layer(mesh.tessface_vertex_colors, autocolor=not lm.bake_lightmap, manual=True) alpha = self._find_vtx_alpha_layer(mesh.tessface_vertex_colors) + # And retrieve the vertex groups that are deformed by an armature. + armatures = self._objects_armatures.get(bo.name) + export_deform = armatures is not None + if export_deform: + # We will need to remap IDs of each bone per armature usage. This is annoying, especially since we support multiple armatures... + i = 1 + all_bone_names = {} + for armature in armatures: + for bone in armature.data.bones: + all_bone_names[bone.name] = i + i += 1 + # This will map the group ID (used by Blender vertices) to the bone index exported to Plasma. + # Theoretically supports multiple armatures, except if the two armatures have the same bone names (because that would be REALLY asking for a lot here). + # If the bone is not found, we'll just map to the null bone. + group_id_to_bone_id = [all_bone_names.get(group.name, 0) for group in bo.vertex_groups] + # We will also need to know which bones deform the most vertices per material, for max/pen bones. + for gd in geodata.values(): + gd.total_weight_by_bones = { j: 0.0 for j in range(i) } + warned_extra_bone_weights = False + # Convert Blender faces into things we can stuff into libHSPlasma for i, tessface in enumerate(mesh.tessfaces): data = geodata.get(tessface.material_index) @@ -484,6 +608,37 @@ class MeshConverter(_MeshManager): uvs.append(dPosDv) geoVertex.uvs = uvs + if export_deform: + # Get bone ID and weight from the vertex' "group" data. + # While we're at it, sort it by weight, and filter groups that + # have no bone assigned. Take only the first 3 bones. + weights = sorted([ \ + (group_id_to_bone_id[group.group], group.weight) \ + for group in source.groups \ + if group.weight > 0 \ + ], key=lambda t: t[1], reverse=True) + if len(weights) > 3 and not warned_extra_bone_weights: + warned_extra_bone_weights = True + self._report.warn(f"'{bo.name}': only three bones can deform a vertex at a time. Please use Weight Tools -> Limit Total at 3 to ensure deformation is consistent between Blender and Plasma.") + weights = weights[:3] + total_weight = sum((w[1] for w in weights)) + # NOTE: Blender will ALWAYS normalize bone weights when deforming ! + # This means if weights don't add up to 1, we CANNOT assign the remaining weight to the null bone. + # For instance, a vertex with a single bone of weight 0.25 will move as if it were weighted 1.0. + # However, a vertex with no bone at all will not move at all (null bone). + weights = [(id, weight / total_weight) for id, weight in weights] + # Count how many bones deform this vertex, so we know how many skin indices to enable. + num_bones = len(weights) + data.max_deform_bones = max(data.max_deform_bones, num_bones) + # Keep track of how much weight each bone is handling + for id, weight in weights: + data.total_weight_by_bones[id] += weight + # Pad to 3 weights to make it simpler. + weights += [(0, 0.0)] * (3 - len(weights)) + # And store all this into the vertex. + geoVertex.indices = weights[0][0] | (weights[1][0] << 8) | (weights[2][0] << 16) + geoVertex.weights = tuple((weight for id, weight in weights)) + idx = len(data.vertices) data.blender2gs[vertex][normcoluv] = idx data.vertices.append(geoVertex) @@ -535,6 +690,39 @@ class MeshConverter(_MeshManager): uvMap[numUVs - 1].normalize() vtx.uvs = uvMap + if export_deform: + # MaxBoneIdx and PenBoneIdx: these are the indices of the two highest-weighted bones on the mesh. + # Plasma will use those to compute a bounding box for the deformed object at run-time, + # in order to clip them when outside the view frustum. + # (The new BB is computed by extending the base BB with two versions of itself transformed by the max/pen bones). + # See plDrawableSpans::IUpdateMatrixPaletteBoundsHack. + # This is... about as reliable as you can expect: it kinda works, it's not great. This will have to do for now. + # (If you ask me, I'd rip the entire thing out and just not clip anything, framerate be damned.) + # Note that max/pen bones are determined by how many vertices they deform, which is a bit different (and more efficient) + # than whatever the Max plugin does in plMAXVertexAccumulator::StuffMyData. + sorted_ids_by_weight = sorted(((weight, id) for id, weight in data.total_weight_by_bones.items()), reverse = True) + # We should be guaranteed to have at least two bones - there are no armatures with no bones (...right?), + # and there is always the null bone if we really have nothing else. + geospan.maxBoneIdx = sorted_ids_by_weight[0][1] + geospan.penBoneIdx = sorted_ids_by_weight[1][1] + + # This is also a good time to specify how many bones per vertices we allow, for optimization purposes. + max_deform_bones = data.max_deform_bones + if max_deform_bones == 3: + geospan.format |= plGeometrySpan.kSkin3Weights | plGeometrySpan.kSkinIndices + elif max_deform_bones == 2: + geospan.format |= plGeometrySpan.kSkin2Weights | plGeometrySpan.kSkinIndices + else: # max_bones_per_vert == 1 + geospan.format |= plGeometrySpan.kSkin1Weight + if len(group_id_to_bone_id) > 1: + geospan.format |= plGeometrySpan.kSkinIndices + else: + # No skin indices required... BUT! We have assigned some weight to the null bone on top of the only bone, so we need to fix that. + for vtx in data.vertices: + weight = vtx.weights[0] + weight = 1 - weight + vtx.weights = (weight, 0.0, 0.0) + # If we're still here, let's add our data to the GeometrySpan geospan.indices = data.triangles geospan.vertices = data.vertices @@ -636,9 +824,13 @@ class MeshConverter(_MeshManager): dspan = self._find_create_dspan(bo, i.geospan, i.pass_index) self._report.msg("Exported hsGMaterial '{}' geometry into '{}'", i.geospan.material.name, dspan.key.name) + armatures = self._objects_armatures.get(bo.name) idx = dspan.addSourceSpan(i.geospan) diidx = _diindices.setdefault(dspan, []) diidx.append(idx) + if armatures is not None: + bone_id_to_name = {group.index: group.name for group in bo.vertex_groups} + self._geospans_armatures[(dspan, idx)] = (armatures, bone_id_to_name) # Step 3.1: Harvest Span indices and create the DIIndices drawables = [] diff --git a/korman/helpers.py b/korman/helpers.py index 8092415..fc34bb4 100644 --- a/korman/helpers.py +++ b/korman/helpers.py @@ -18,6 +18,7 @@ import bpy from contextlib import contextmanager import math from typing import * +from uuid import uuid4 @contextmanager def bmesh_from_object(bl): @@ -64,12 +65,14 @@ class GoodNeighbor: @contextmanager def TemporaryCollectionItem(collection): item = collection.add() + # Blender may recreate the `item` instance as the collection grows and shrink... + # Assign it a unique name so we know which item to delete later on. + name = item.name = str(uuid4()) try: yield item finally: - index = next((i for i, j in enumerate(collection) if j == item), None) - if index is not None: - collection.remove(index) + index = collection.find(name) + collection.remove(index) class TemporaryObject: def __init__(self, obj, remove_func): diff --git a/korman/render.py b/korman/render.py index 9677754..fa238f6 100644 --- a/korman/render.py +++ b/korman/render.py @@ -40,6 +40,7 @@ from bl_ui import properties_data_mesh properties_data_mesh.DATA_PT_normals.COMPAT_ENGINES.add("PLASMA_GAME") properties_data_mesh.DATA_PT_uv_texture.COMPAT_ENGINES.add("PLASMA_GAME") properties_data_mesh.DATA_PT_vertex_colors.COMPAT_ENGINES.add("PLASMA_GAME") +properties_data_mesh.DATA_PT_vertex_groups.COMPAT_ENGINES.add("PLASMA_GAME") del properties_data_mesh def _whitelist_all(mod): 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 4/6] 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: From 8c74677dd6349b5ad8ee5fa2a0219494ded4dfa1 Mon Sep 17 00:00:00 2001 From: Jrius <2261279+Jrius@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:24:26 +0100 Subject: [PATCH 5/6] Fix parenting to bone --- korman/exporter/convert.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 73684e0..9a421d5 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -21,7 +21,7 @@ import functools import inspect from pathlib import Path from typing import * -from mathutils import Matrix +from mathutils import Matrix, Vector from ..helpers import TemporaryObject from ..korlib import ConsoleToggler @@ -258,15 +258,17 @@ class Exporter: """Exports a Coordinate Interface if we need one""" parent = bo.parent parent_bone_name = bo.parent_bone if parent is not None else None - offset_matrix = None + offset_matrix_local = None + offset_matrix_world = None if parent_bone_name and parent.plasma_object.enabled: # Object is parented to a bone, so use it instead. parent_bone = parent.data.bones[parent_bone_name] parent = bpy.context.scene.objects[ArmatureConverter.get_bone_name(parent, parent_bone)] # ...Actually, it's parented to the bone's /tip/. So we need to offset the child... - offset_matrix = Matrix.Translation(bo.matrix_local.row[1].xyz * parent_bone.length) + offset_matrix_local = Matrix.Translation(Vector((0, parent_bone.length, 0))) + offset_matrix_world = Matrix.Translation(bo.matrix_world.row[1].xyz * parent_bone.length) if self.has_coordiface(bo): - self._export_coordinate_interface(so, bo, offset_matrix) + self._export_coordinate_interface(so, bo, offset_matrix_local, offset_matrix_world) # If this object has a parent, then we will need to go upstream and add ourselves to the # parent's CoordinateInterface... Because life just has to be backwards. @@ -285,7 +287,7 @@ class Exporter: The object may not appear in the correct location or animate properly.".format( bo.name, parent.name)) - def _export_coordinate_interface(self, so, bl, matrix: Matrix = None): + def _export_coordinate_interface(self, so, bl, offset_matrix_local: Matrix = None, offset_matrix_world: Matrix = None): """Ensures that the SceneObject has a CoordinateInterface""" if so is None: so = self.mgr.find_create_object(plSceneObject, bl=bl) @@ -293,13 +295,14 @@ class Exporter: if so.coord is None: ci_cls = bl.plasma_object.ci_type ci = self.mgr.add_object(ci_cls, bl=bl, so=so) - if ci is not None or matrix is not None: + if ci is not None or offset_matrix_local is not None or offset_matrix_world is not None: # We just created the CI, or we have an extra transform that we may have previously skipped. matrix_local = bl.matrix_local matrix_world = bl.matrix_world - if matrix is not None: - matrix_local = matrix_local * matrix - matrix_world = matrix_world * matrix + if offset_matrix_local is not None: + matrix_local = offset_matrix_local * matrix_local + if offset_matrix_world is not None: + matrix_world = offset_matrix_world * matrix_world # Now we have the "fun" work of filling in the CI ci.localToWorld = utils.matrix44(matrix_world) ci.worldToLocal = ci.localToWorld.inverse() From 3ed604f4008fbafa5b01ea31a788cc921218f2f7 Mon Sep 17 00:00:00 2001 From: Jrius <2261279+Jrius@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:40:01 +0100 Subject: [PATCH 6/6] Fix lightmap baking --- korman/exporter/mesh.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/korman/exporter/mesh.py b/korman/exporter/mesh.py index d30b42f..6074afe 100644 --- a/korman/exporter/mesh.py +++ b/korman/exporter/mesh.py @@ -215,14 +215,8 @@ class _MeshManager: mod_prop_dict = self._build_prop_dict(mod) cache_mods.append(mod_prop_dict) - armatures = [] - for armature_mod in self._exporter().armature.get_skin_modifiers(i): - # We'll use armatures to export bones later. Disable it so it doesn't get baked into the mesh. - armatures.append(armature_mod.object) - # Note that this gets reverted when we reapply cached modifiers. - armature_mod.show_render = False - if armatures: - self._objects_armatures[i.name] = armatures + # Disable armatures if need be (only when exporting) + self._disable_armatures(i) i.data = i.to_mesh(scene, True, "RENDER", calc_tessface=False) @@ -254,6 +248,10 @@ class _MeshManager: setattr(mod, key, value) self._entered = False + def _disable_armatures(self, bo): + # Overridden when necessary. + pass + def is_collapsed(self, bo) -> bool: return bo.name in self._overrides @@ -371,6 +369,16 @@ class MeshConverter(_MeshManager): return geospan + def _disable_armatures(self, bo): + armatures = [] + for armature_mod in self._exporter().armature.get_skin_modifiers(bo): + # We'll use armatures to export bones later. Disable it so it doesn't get baked into the mesh. + armatures.append(armature_mod.object) + # Note that this gets reverted when we reapply cached modifiers. + armature_mod.show_render = False + if armatures: + self._objects_armatures[bo.name] = armatures + def finalize(self): """Prepares all baked Plasma geometry to be flushed to the disk""" self._report.progress_advance()