Browse Source

Merge 3ed604f400 into 22892ef702

pull/436/merge
Jrius 1 week ago committed by GitHub
parent
commit
cd95e1d3f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 84
      korman/exporter/animation.py
  2. 123
      korman/exporter/armature.py
  3. 90
      korman/exporter/convert.py
  4. 206
      korman/exporter/mesh.py
  5. 4
      korman/helpers.py
  6. 3
      korman/properties/modifiers/anim.py
  7. 23
      korman/properties/prop_anim.py
  8. 1
      korman/render.py
  9. 5
      korman/ui/ui_anim.py

84
korman/exporter/animation.py

@ -16,27 +16,31 @@
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 *
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 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) -> 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 []
@ -50,6 +54,10 @@ class AnimationConverter:
obj_action, obj_fcurves = fetch_animation_data(bo) obj_action, obj_fcurves = fetch_animation_data(bo)
data_action, data_fcurves = fetch_animation_data(bo.data) 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) # 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
# form of separation, but Blender's NLA editor is way confusing and appears to not work with # 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] 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, 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

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

90
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, Vector
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,34 +256,57 @@ 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"""
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 parent = bo.parent
if parent is not None: parent_bone_name = bo.parent_bone if parent is not None else None
if parent.plasma_object.enabled: offset_matrix_local = None
self.report.msg(f"Attaching to parent SceneObject '{parent.name}'") offset_matrix_world = None
parent_ci = self._export_coordinate_interface(None, parent) if parent_bone_name and parent.plasma_object.enabled:
parent_ci.addChild(so.key) # Object is parented to a bone, so use it instead.
else: parent_bone = parent.data.bones[parent_bone_name]
self.report.warn("You have parented Plasma Object '{}' to '{}', which has not been marked for export. \ parent = bpy.context.scene.objects[ArmatureConverter.get_bone_name(parent, parent_bone)]
The object may not appear in the correct location or animate properly.".format( # ...Actually, it's parented to the bone's /tip/. So we need to offset the child...
bo.name, parent.name)) 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)
def _export_coordinate_interface(self, so, bl): 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""" """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 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 # 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 +361,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 +433,14 @@ class Exporter:
inc_progress() inc_progress()
def has_coordiface(self, bo): 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 return True
if bo.parent is not None or bo.children: if bo.parent is not None or bo.children:
return True return True
@ -413,9 +450,8 @@ class Exporter:
return True return True
for mod in bo.plasma_modifiers.modifiers: for mod in bo.plasma_modifiers.modifiers:
if mod.enabled: if mod.enabled and mod.requires_actor:
if mod.requires_actor: return True
return True
return False return False
def _post_process_scene_objects(self): def _post_process_scene_objects(self):
@ -468,8 +504,9 @@ class Exporter:
def handle_temporary(temporary, parent): def handle_temporary(temporary, parent):
# Maybe this was an embedded context manager? # Maybe this was an embedded context manager?
if hasattr(temporary, "__enter__"): if hasattr(temporary, "__enter__"):
log_msg(f"'{parent.name}': generated context manager '{temporary}' ")
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 +559,11 @@ 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.
self.armature.convert_armature_to_empties(bo, lambda obj: handle_temporary(obj, 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:

206
korman/exporter/mesh.py

@ -21,6 +21,7 @@ from math import fabs
from typing import Iterable from typing import Iterable
import weakref import weakref
from .armature import ArmatureConverter
from ..exporter.logger import ExportProgressLogger from ..exporter.logger import ExportProgressLogger
from . import explosions from . import explosions
from .. import helpers from .. import helpers
@ -157,6 +158,8 @@ class _GeoData:
self.blender2gs = [{} for i in range(numVtxs)] self.blender2gs = [{} for i in range(numVtxs)]
self.triangles = [] self.triangles = []
self.vertices = [] self.vertices = []
self.max_deform_bones = 0
self.total_weight_by_bones = {}
class _MeshManager: class _MeshManager:
@ -166,6 +169,8 @@ class _MeshManager:
self._report = report self._report = report
self._entered = False self._entered = False
self._overrides = {} self._overrides = {}
self._objects_armatures = {}
self._geospans_armatures = {}
@staticmethod @staticmethod
def add_progress_presteps(report): def add_progress_presteps(report):
@ -200,15 +205,24 @@ class _MeshManager:
# Remember, storing actual pointers to the Blender objects can cause bad things to # Remember, storing actual pointers to the Blender objects can cause bad things to
# happen because Blender's memory management SUCKS! # happen because Blender's memory management SUCKS!
self._overrides[i.name] = { "mesh": i.data.name, "modifiers": [] } 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 # 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: if i.plasma_object.enabled:
cache_mods = self._overrides[i.name]["modifiers"] cache_mods = self._overrides[i.name]["modifiers"]
for mod in i.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() i.modifiers.clear()
self._report.progress_increment() self._report.progress_increment()
return self return self
@ -234,6 +248,10 @@ class _MeshManager:
setattr(mod, key, value) setattr(mod, key, value)
self._entered = False self._entered = False
def _disable_armatures(self, bo):
# Overridden when necessary.
pass
def is_collapsed(self, bo) -> bool: def is_collapsed(self, bo) -> bool:
return bo.name in self._overrides return bo.name in self._overrides
@ -351,6 +369,16 @@ class MeshConverter(_MeshManager):
return geospan 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): def finalize(self):
"""Prepares all baked Plasma geometry to be flushed to the disk""" """Prepares all baked Plasma geometry to be flushed to the disk"""
self._report.progress_advance() self._report.progress_advance()
@ -365,6 +393,9 @@ class MeshConverter(_MeshManager):
for dspan in loc.values(): for dspan in loc.values():
log_msg("[DrawableSpans '{}']", dspan.key.name) 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: # This mega-function does a lot:
# 1. Converts SourceSpans (geospans) to Icicles and bakes geometry into plGBuffers # 1. Converts SourceSpans (geospans) to Icicles and bakes geometry into plGBuffers
# 2. Calculates the Icicle bounds # 2. Calculates the Icicle bounds
@ -373,6 +404,87 @@ class MeshConverter(_MeshManager):
dspan.composeGeometry(True, True) dspan.composeGeometry(True, True)
inc_progress() 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): def _export_geometry(self, bo, mesh, materials, geospans, mat2span_LUT):
self._report.msg(f"Converting geometry from '{mesh.name}'...") 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) 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) 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 # Convert Blender faces into things we can stuff into libHSPlasma
for i, tessface in enumerate(mesh.tessfaces): for i, tessface in enumerate(mesh.tessfaces):
data = geodata.get(tessface.material_index) data = geodata.get(tessface.material_index)
@ -484,6 +616,37 @@ class MeshConverter(_MeshManager):
uvs.append(dPosDv) uvs.append(dPosDv)
geoVertex.uvs = uvs 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) idx = len(data.vertices)
data.blender2gs[vertex][normcoluv] = idx data.blender2gs[vertex][normcoluv] = idx
data.vertices.append(geoVertex) data.vertices.append(geoVertex)
@ -535,6 +698,39 @@ class MeshConverter(_MeshManager):
uvMap[numUVs - 1].normalize() uvMap[numUVs - 1].normalize()
vtx.uvs = uvMap 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 # If we're still here, let's add our data to the GeometrySpan
geospan.indices = data.triangles geospan.indices = data.triangles
geospan.vertices = data.vertices geospan.vertices = data.vertices
@ -636,9 +832,13 @@ class MeshConverter(_MeshManager):
dspan = self._find_create_dspan(bo, i.geospan, i.pass_index) dspan = self._find_create_dspan(bo, i.geospan, i.pass_index)
self._report.msg("Exported hsGMaterial '{}' geometry into '{}'", self._report.msg("Exported hsGMaterial '{}' geometry into '{}'",
i.geospan.material.name, dspan.key.name) i.geospan.material.name, dspan.key.name)
armatures = self._objects_armatures.get(bo.name)
idx = dspan.addSourceSpan(i.geospan) idx = dspan.addSourceSpan(i.geospan)
diidx = _diindices.setdefault(dspan, []) diidx = _diindices.setdefault(dspan, [])
diidx.append(idx) 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 # Step 3.1: Harvest Span indices and create the DIIndices
drawables = [] drawables = []

4
korman/helpers.py

@ -46,8 +46,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):

3
korman/properties/modifiers/anim.py

@ -99,8 +99,9 @@ class PlasmaAnimationModifier(ActionModifier, PlasmaModifierProperties):
start, end = min((start, end)), max((start, end)) start, end = min((start, end)), max((start, end))
else: else:
start, end = None, None 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: if not applicators:
exporter.report.warn(f"Animation '{anim_name}' generated no applicators. Nothing will be exported.") exporter.report.warn(f"Animation '{anim_name}' generated no applicators. Nothing will be exported.")
continue continue

23
korman/properties/prop_anim.py

@ -127,6 +127,29 @@ class PlasmaAnimation(bpy.types.PropertyGroup):
bpy.types.Texture: "plasma_layer.anim_loop_end", 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": { "sdl_var": {
"type": StringProperty, "type": StringProperty,
"property": { "property": {

1
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_normals.COMPAT_ENGINES.add("PLASMA_GAME")
properties_data_mesh.DATA_PT_uv_texture.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_colors.COMPAT_ENGINES.add("PLASMA_GAME")
properties_data_mesh.DATA_PT_vertex_groups.COMPAT_ENGINES.add("PLASMA_GAME")
del properties_data_mesh del properties_data_mesh
def _whitelist_all(mod): def _whitelist_all(mod):

5
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.active = anim.loop and not anim.sdl_var
col.prop_search(anim, "loop_start", action, "pose_markers", icon="PMARKER") col.prop_search(anim, "loop_start", action, "pose_markers", icon="PMARKER")
col.prop_search(anim, "loop_end", 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.separator()
layout.prop(anim, "sdl_var") layout.prop(anim, "sdl_var")

Loading…
Cancel
Save