Browse Source

Export armatures

pull/436/head
Jrius 2 months ago
parent
commit
ca28135f16
  1. 119
      korman/exporter/animation.py
  2. 92
      korman/exporter/armature.py
  3. 45
      korman/exporter/convert.py
  4. 4
      korman/helpers.py

119
korman/exporter/animation.py

@ -16,66 +16,103 @@
import bpy import bpy
from collections import defaultdict from collections import defaultdict
from contextlib import ExitStack
import functools import functools
import itertools import itertools
import math import math
import mathutils import mathutils
from typing import * from typing import *
import weakref import weakref
import re
from PyHSPlasma import * from PyHSPlasma import *
from . import utils from . import utils
from ..helpers import GoodNeighbor from ..helpers import *
class AnimationConverter: class AnimationConverter:
def __init__(self, exporter): def __init__(self, exporter):
self._exporter = weakref.ref(exporter) self._exporter = weakref.ref(exporter)
self._bl_fps = bpy.context.scene.render.fps 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: def convert_frame_time(self, frame_num: int) -> float:
return frame_num / self._bl_fps 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, *, 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]: start: Optional[int] = None, end: Optional[int] = None, bake_frame_step: Optional[int] = None) -> Iterable[plAGApplicator]:
if not bo.plasma_object.has_animation_data: if not bo.plasma_object.has_animation_data:
return [] return []
temporary_actions = [] def fetch_animation_data(id_data):
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 is not None:
if id_data.animation_data is not None: if id_data.animation_data is not None:
action = id_data.animation_data.action 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 action, getattr(action, "fcurves", [])
return None, [] return None, []
obj_action, obj_fcurves = fetch_animation_data(bo)
data_action, data_fcurves = fetch_animation_data(bo.data)
temporary_action = None
try: try:
obj_action, obj_fcurves = fetch_animation_data(bo, True) if bake_frame_step is not None and obj_action is not None:
data_action, data_fcurves = fetch_animation_data(bo.data, False) 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) # 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 # 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)) applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp, start, end))
finally: 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 ! # 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] 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, def _convert_camera_animation(self, bo, so, obj_fcurves, data_fcurves, anim_name: str,
start: Optional[int], end: Optional[int]): start: Optional[int], end: Optional[int]):
has_fov_anim = False has_fov_anim = False

92
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

45
korman/exporter/convert.py

@ -21,6 +21,7 @@ import functools
import inspect import inspect
from pathlib import Path from pathlib import Path
from typing import * from typing import *
from mathutils import Matrix
from ..helpers import TemporaryObject from ..helpers import TemporaryObject
from ..korlib import ConsoleToggler from ..korlib import ConsoleToggler
@ -28,6 +29,7 @@ from ..korlib import ConsoleToggler
from PyHSPlasma import * from PyHSPlasma import *
from .animation import AnimationConverter from .animation import AnimationConverter
from .armature import ArmatureConverter
from .camera import CameraConverter from .camera import CameraConverter
from .decal import DecalConverter from .decal import DecalConverter
from . import explosions from . import explosions
@ -56,6 +58,7 @@ class Exporter:
physics: PhysicsConverter = ... physics: PhysicsConverter = ...
light: LightConverter = ... light: LightConverter = ...
animation: AnimationConverter = ... animation: AnimationConverter = ...
armature: ArmatureConverter = ...
output: OutputFiles = ... output: OutputFiles = ...
camera: CameraConverter = ... camera: CameraConverter = ...
image: ImageCache = ... image: ImageCache = ...
@ -80,6 +83,7 @@ class Exporter:
self.physics = PhysicsConverter(self) self.physics = PhysicsConverter(self)
self.light = LightConverter(self) self.light = LightConverter(self)
self.animation = AnimationConverter(self) self.animation = AnimationConverter(self)
self.armature = ArmatureConverter(self)
self.output = OutputFiles(self, self._op.filepath) self.output = OutputFiles(self, self._op.filepath)
self.camera = CameraConverter(self) self.camera = CameraConverter(self)
self.image = ImageCache(self) self.image = ImageCache(self)
@ -252,12 +256,20 @@ class Exporter:
def _export_actor(self, so, bo): def _export_actor(self, so, bo):
"""Exports a Coordinate Interface if we need one""" """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): 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 # 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's CoordinateInterface... Because life just has to be backwards.
parent = bo.parent
if parent is not None: if parent is not None:
if parent.plasma_object.enabled: if parent.plasma_object.enabled:
self.report.msg(f"Attaching to parent SceneObject '{parent.name}'") 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( The object may not appear in the correct location or animate properly.".format(
bo.name, parent.name)) 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""" """Ensures that the SceneObject has a CoordinateInterface"""
if so is None: if so is None:
so = self.mgr.find_create_object(plSceneObject, bl=bl) so = self.mgr.find_create_object(plSceneObject, bl=bl)
ci = None
if so.coord is None: if so.coord is None:
ci_cls = bl.plasma_object.ci_type ci_cls = bl.plasma_object.ci_type
ci = self.mgr.add_object(ci_cls, bl=bl, so=so) 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 # 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.worldToLocal = ci.localToWorld.inverse()
ci.localToParent = utils.matrix44(bl.matrix_local) ci.localToParent = utils.matrix44(matrix_local)
ci.parentToLocal = ci.localToParent.inverse() ci.parentToLocal = ci.localToParent.inverse()
return ci return ci
return so.coord.object return so.coord.object
@ -334,6 +353,9 @@ class Exporter:
mod.export(self, bl_obj, sceneobject) mod.export(self, bl_obj, sceneobject)
inc_progress() inc_progress()
def _export_armature_blobj(self, so, bo):
pass
def _export_camera_blobj(self, so, bo): def _export_camera_blobj(self, so, bo):
# Hey, guess what? Blender's camera data is utter crap! # Hey, guess what? Blender's camera data is utter crap!
camera = bo.data.plasma_camera camera = bo.data.plasma_camera
@ -403,7 +425,7 @@ class Exporter:
inc_progress() inc_progress()
def has_coordiface(self, bo): def has_coordiface(self, bo):
if bo.type in {"CAMERA", "EMPTY", "LAMP"}: if bo.type in {"CAMERA", "EMPTY", "LAMP", "ARMATURE"}:
return True return True
if bo.parent is not None or bo.children: if bo.parent is not None or bo.children:
return True return True
@ -469,7 +491,7 @@ class Exporter:
# Maybe this was an embedded context manager? # Maybe this was an embedded context manager?
if hasattr(temporary, "__enter__"): if hasattr(temporary, "__enter__"):
ctx_temporary = self.exit_stack.enter_context(temporary) 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) return handle_temporary(ctx_temporary, parent)
else: else:
raise RuntimeError("Temporary object of type '{}' generated by '{}' was unhandled".format(temporary.__class__, parent.name)) raise RuntimeError("Temporary object of type '{}' generated by '{}' was unhandled".format(temporary.__class__, parent.name))
@ -522,6 +544,13 @@ class Exporter:
return temporary return temporary
def do_pre_export(bo): 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: for mod in bo.plasma_modifiers.modifiers:
proc = getattr(mod, "pre_export", None) proc = getattr(mod, "pre_export", None)
if proc is not None: if proc is not None:

4
korman/helpers.py

@ -45,8 +45,10 @@ def copy_object(bl, name: Optional[str] = None):
class GoodNeighbor: class GoodNeighbor:
"""Leave Things the Way You Found Them! (TM)""" """Leave Things the Way You Found Them! (TM)"""
def __enter__(self): def __init__(self):
self._tracking = {} self._tracking = {}
def __enter__(self):
return self return self
def track(self, cls, attr, value): def track(self, cls, attr, value):

Loading…
Cancel
Save