Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 83 additions & 1 deletion korman/exporter/animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,31 @@
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be between mathutils and typing.


from PyHSPlasma import *

from . import utils
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\\["(.*)"]\\.(.*)$')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self._bone_data_path_regex = re.compile('^pose\\.bones\\["(.*)"]\\.(.*)$')
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 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 []

Expand All @@ -50,6 +54,10 @@ def fetch_animation_data(id_data):
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
Expand All @@ -71,6 +79,80 @@ def fetch_animation_data(id_data):

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.
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)
Comment on lines +91 to +92
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these changes need to be tracked and reset after the entire export process, or could we get away with only tracking them in this function?

handle_temporary(toggle)
handle_temporary(exit_stack)
Comment on lines +93 to +94
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I like this idea, to be honest.


armature_action = arm_bo.animation_data.action
if do_bake:
armature_action = self._bake_animation_data(arm_bo, anim.bake_frame_step, None, None)

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

# Copy animation data.
anim_data = bone.animation_data_create()
action = bpy.data.actions.new("{}_action".format(bone.name))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
action = bpy.data.actions.new("{}_action".format(bone.name))
action = bpy.data.actions.new(f"{bone.name}_action")

handle_temporary(action)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of doing this. Korman prefers to use generators to handle temporary object book-keeping.

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...
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
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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer this over passing a handle_temporary lambda around.

return baked_anim

def _convert_camera_animation(self, bo, so, obj_fcurves, data_fcurves, anim_name: str,
start: Optional[int], end: Optional[int]):
has_fov_anim = False
Expand Down
123 changes: 123 additions & 0 deletions korman/exporter/armature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# 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)
self._skinned_objects_modifiers = {}
self._bones_local_to_world = {}

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 created objects will be cleaned up afterwards.
armature = bo.data
generated_bones = {} # name: Blender empty.
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)
Comment on lines +40 to +51
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of this try... finally thing, it would be better to make whatever code calls this generator aware, so you can flatten this out to something like

for bone in (i for i in armature.bones if not i.parent):
     yield self._export_bone(foo, bar)


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)
Comment on lines +68 to +73
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could clean this up with something like

armatures = [
    mod for mod in bo.modifiers
    if mod.type == "ARMATURE" and mod.object and mod.object.plasma_object.enabled and mod.use_vertex_groups and mod.show_render
]

if len(armatures):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if len(armatures):
if armatures:

self._skinned_objects_modifiers[bo] = armatures
return True
return False

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_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...
bone_parent = bpy.data.objects.new(bone_empty.name + "_REST", None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
bone_parent = bpy.data.objects.new(bone_empty.name + "_REST", None)
bone_parent = bpy.data.objects.new(f"{bone_empty.name}_REST", None)

String concats are bad in Python.

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)
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_bones.append(bone_empty)
generated_bones[bone.name] = bone_empty

for child in bone.children:
self._export_bone(bo, child, bone_empty, bone.matrix_local.inverted(), pose, pose_mode, generated_bones, temporary_bones)
Comment on lines +79 to +109
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should probably be some form of TemporaryObject that gets handed off to the exporter to be disposed of when Korman exits.


@staticmethod
def get_bone_name(bo, bone):
if isinstance(bone, str):
return "{}_{}".format(bo.name, bone)
return "{}_{}".format(bo.name, bone.name)
Comment on lines +114 to +115
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return "{}_{}".format(bo.name, bone)
return "{}_{}".format(bo.name, bone.name)
return f"{bo.name}_{bone}"
return f"{bo.name}_{bone.name}"


@property
def _mgr(self):
return self._exporter().mgr

@property
def _report(self):
return self._exporter().report
Loading