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
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

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
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:

4
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):

Loading…
Cancel
Save