Browse Source

Merge 3ed604f400 into 22892ef702

pull/436/merge
Jrius 7 days 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
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

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

206
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 = []

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

3
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

23
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": {

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

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.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")

Loading…
Cancel
Save