diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py index e34eef6..44f7966 100644 --- a/korman/exporter/animation.py +++ b/korman/exporter/animation.py @@ -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 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\\["(.*)"]\\.(.*)$') 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 [] @@ -50,6 +54,10 @@ class AnimationConverter: 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 @@ -71,6 +79,80 @@ class AnimationConverter: 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) + 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) + + 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)) + 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... + 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)) + 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 diff --git a/korman/exporter/armature.py b/korman/exporter/armature.py new file mode 100644 index 0000000..d65aa15 --- /dev/null +++ b/korman/exporter/armature.py @@ -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 . + +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) + + 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, 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) + 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) + + @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..9a421d5 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, Vector 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,34 +256,57 @@ class Exporter: def _export_actor(self, so, bo): """Exports a Coordinate Interface if we need one""" - if self.has_coordiface(bo): - self._export_coordinate_interface(so, bo) - - # 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}'") - 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)) - - def _export_coordinate_interface(self, so, bl): + parent_bone_name = bo.parent_bone if parent is not None else 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_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_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. + 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, 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) + 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 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 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(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 +361,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 +433,14 @@ class Exporter: inc_progress() def has_coordiface(self, bo): - if bo.type in {"CAMERA", "EMPTY", "LAMP"}: + 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: return True @@ -413,9 +450,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): @@ -468,8 +504,9 @@ 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: + 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 +559,11 @@ 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. + 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: diff --git a/korman/exporter/mesh.py b/korman/exporter/mesh.py index 9812352..6074afe 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,24 @@ 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) + + # Disable armatures if need be (only when exporting) + self._disable_armatures(i) + + 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 @@ -234,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 @@ -351,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() @@ -365,6 +393,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 +404,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 +499,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 +616,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 +698,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 +832,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 b3bd4ac..fc34bb4 100644 --- a/korman/helpers.py +++ b/korman/helpers.py @@ -46,8 +46,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): 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/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): 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")