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 . + +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):