Browse Source

Export skinned objects (WIP).

pull/436/head
Jrius 3 weeks ago
parent
commit
a6ddffed4e
  1. 38
      korman/exporter/animation.py
  2. 54
      korman/exporter/armature.py
  3. 46
      korman/exporter/convert.py
  4. 198
      korman/exporter/mesh.py
  5. 9
      korman/helpers.py
  6. 1
      korman/render.py

38
korman/exporter/animation.py

@ -60,19 +60,7 @@ class AnimationConverter:
try:
for bone_name, bone in generated_bones.items():
child = exit_stack.enter_context(TemporaryCollectionItem(anim_group.children))
child.child_anim = bone
# Copy animation modifier and its properties if it exists..
# Cheating ? Very much yes. Do not try this at home, kids.
bone.plasma_modifiers["animation"] = arm_bo.plasma_modifiers["animation"]
bone.plasma_modifiers["animation_loop"] = arm_bo.plasma_modifiers["animation_loop"]
# Now copy animation data.
anim_data = bone.animation_data_create()
action = bpy.data.actions.new("{}_action".format(bone.name))
temporary_objects.append(action)
anim_data.action = action
self._exporter().report.warn(str([(i.data_path, i.array_index) for i in armature_action.fcurves]))
fcurves = []
for fcurve in armature_action.fcurves:
match = self._bone_data_path_regex.match(fcurve.data_path)
if not match:
@ -80,6 +68,18 @@ class AnimationConverter:
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))
temporary_objects.append(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
@ -87,6 +87,13 @@ class AnimationConverter:
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
finally:
if do_bake:
bpy.data.actions.remove(armature_action)
@ -152,8 +159,9 @@ class AnimationConverter:
# Do bake, but make sure we don't mess the user's data.
old_action = bo.animation_data.action
try:
frame_start = start if start is not None else bpy.context.scene.frame_start
frame_end = end if end is not None else bpy.context.scene.frame_end
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
return baked_anim

54
korman/exporter/armature.py

@ -23,13 +23,15 @@ 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):
# Creates Blender equivalents to each bone of the armature, adjusting a whole bunch of stuff along the way.
# Yes, this is ugly, but required to get anims to export properly. I tried other ways to export armatures,
# but AFAICT sooner or later you have to implement similar hacks. Might as well create something that the
# 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...
# Don't worry, we'll return a list of temporary objects to clean up after ourselves.
# Obviously the returned temporary_objects will be cleaned up afterwards.
armature = bo.data
pose = bo.pose if armature.pose_position == "POSE" else None
generated_bones = {} # name: Blender empty.
@ -45,11 +47,44 @@ class ArmatureConverter:
return temporary_objects
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, generated_bones, temporary_objects):
bone_empty = bpy.data.objects.new(ArmatureConverter.get_bone_name(bo, bone), None)
bpy.context.scene.objects.link(bone_empty)
bone_empty.plasma_object.enabled = True
if pose is not None:
pose_bone = pose.bones[bone.name]
bone_empty.rotation_mode = pose_bone.rotation_mode
pose_matrix = pose_bone.matrix_basis
else:
pose_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...
@ -59,19 +94,14 @@ class ArmatureConverter:
bone_parent.parent = parent
bone_empty.parent = bone_parent
bone_empty.matrix_local = Matrix.Identity(4)
if pose is not None:
pose_bone = pose.bones[bone.name]
bone_empty.rotation_mode = pose_bone.rotation_mode
pose_matrix = pose_bone.matrix_basis
else:
pose_bone = None
pose_matrix = Matrix.Identity(4)
temporary_objects.append(bone_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_objects.append(bone_empty)
temporary_objects.append(bone_parent)
generated_bones[bone.name] = bone_empty
bone_parent.matrix_local = matrix * bone.matrix_local.to_4x4() * pose_matrix
for child in bone.children:
child_empty = self._export_bone(bo, child, bone_empty, bone.matrix_local.inverted(), pose, generated_bones, temporary_objects)

46
korman/exporter/convert.py

@ -257,28 +257,33 @@ class Exporter:
def _export_actor(self, so, bo):
"""Exports a Coordinate Interface if we need one"""
parent = bo.parent
parent_bone_name = bo.parent_bone
parent_bone_name = bo.parent_bone if parent is not None else None
offset_matrix = None
if parent_bone_name:
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 = Matrix.Translation((0, 0, -parent_bone.length))
offset_matrix = Matrix.Translation(bo.matrix_local.row[1].xyz * parent_bone.length)
if self.has_coordiface(bo):
self._export_coordinate_interface(so, bo, offset_matrix)
# If this object has a parent, then we will need to go upstream and add ourselves to the
# parent's CoordinateInterface... Because life just has to be backwards.
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))
# 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, matrix: Matrix = None):
"""Ensures that the SceneObject has a CoordinateInterface"""
@ -425,6 +430,13 @@ class Exporter:
inc_progress()
def has_coordiface(self, bo):
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:
@ -435,9 +447,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):
@ -490,6 +501,7 @@ 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 and ctx_temporary != temporary:
return handle_temporary(ctx_temporary, parent)

198
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,30 @@ 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)
armatures = []
for armature_mod in self._exporter().armature.get_skin_modifiers(i):
# 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[i.name] = armatures
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
@ -365,6 +385,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 +396,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 +491,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 +608,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 +690,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 +824,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 = []

9
korman/helpers.py

@ -18,6 +18,7 @@ import bpy
from contextlib import contextmanager
import math
from typing import *
from uuid import uuid4
@contextmanager
def bmesh_from_object(bl):
@ -64,12 +65,14 @@ class GoodNeighbor:
@contextmanager
def TemporaryCollectionItem(collection):
item = collection.add()
# Blender may recreate the `item` instance as the collection grows and shrink...
# Assign it a unique name so we know which item to delete later on.
name = item.name = str(uuid4())
try:
yield item
finally:
index = next((i for i, j in enumerate(collection) if j == item), None)
if index is not None:
collection.remove(index)
index = collection.find(name)
collection.remove(index)
class TemporaryObject:
def __init__(self, obj, remove_func):

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

Loading…
Cancel
Save