From 1176160e917ea7fb8aeb92e8de4df91440d0076e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 26 Jul 2026 15:51:09 +0000 Subject: [PATCH] fix(game): stop shipping misshapen characters; keep the retarget work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /play was rendering splayed, misshapen figures. The bots go back to the generated character, which renders correctly. The Synty models are committed and load fine; the fault is my retargeter. It constrained each target bone to the source bone's *absolute* world rotation, which is only correct when both skeletons share rest poses. Synty uses Unreal's axis convention and our clips use another, so every bone sat at its rest-pose difference and the error compounded down each limb — measured at 1.90 x 2.00 x 2.47 m against roughly 0.6 x 0.4 x 1.8 for a standing figure. import_synty.py now carries the corrected relation: target.matrix = source.matrix @ (source.rest⁻¹ @ target.rest) so what transfers is the source's motion away from its own rest rather than its raw orientation. That maths runs, but its manually keyed actions do not survive the glTF export the way `nla.bake`'s did — all five clips collapse into one named after the object. Until that is resolved the Synty characters cannot ship animated, so they are not shipped. Swapping back is two lines in opponents.ts, noted there. Co-Authored-By: Claude Opus 5 (1M context) --- apps/game/src/opponents.ts | 23 ++++-- tools/art/blender/import_synty.py | 115 ++++++++++++++++-------------- 2 files changed, 81 insertions(+), 57 deletions(-) diff --git a/apps/game/src/opponents.ts b/apps/game/src/opponents.ts index 9dafc9f..0200b4c 100644 --- a/apps/game/src/opponents.ts +++ b/apps/game/src/opponents.ts @@ -51,6 +51,19 @@ export class Opponents { private readonly deadUntil = new Map(); constructor(_scene: Scene, assets: AssetSet) { + // Back on the generated character. + // + // The Synty models are committed and load, but the animation retarget + // splays their limbs — measured at 1.90 x 2.00 x 2.47 m against an + // expected 0.6 x 0.4 x 1.8. The cause is understood (see import_synty.py: + // the rest-pose difference between the two skeletons) and the corrected + // rest-relative maths is written, but its baked actions do not survive the + // glTF export yet, so the models cannot be shipped animated. + // + // Shipping a figure that renders correctly beats shipping a better model + // that does not. Swapping back is these two lines once the export is fixed: + // assets.models.get("fighter_soldier") / ("fighter_insurgent") + // // One model per faction. // // Nightcell are irregulars: olive drab, boots, a pack — someone fighting @@ -70,8 +83,8 @@ export class Opponents { // The generated character renders correctly, so the bots use it until the // licensed ones are diagnosed. Swapping back is a one-line change: // assets.models.get("fighter_soldier") / ("fighter_worker") - const enemyModel = assets.models.get("fighter_soldier") ?? assets.models.get("character"); - const friendlyModel = assets.models.get("fighter_insurgent") ?? assets.models.get("character"); + const enemyModel = assets.models.get("character"); + const friendlyModel = assets.models.get("character"); const character = enemyModel; const carbine = assets.models.get("carbine"); if (!character) throw new Error("no character model loaded"); @@ -239,7 +252,7 @@ export class Opponents { const view = this.views.get(event.victimId); if (view && !view.dead) { view.dead = true; - this.play(view, "Death", false); + this.play(view, "death", false); this.deadUntil.set(event.victimId, performance.now() + RESPAWN_MS); } continue; @@ -289,7 +302,7 @@ export class Opponents { if (!player.alive) { if (!view.dead) { view.dead = true; - this.play(view, "Death", false); + this.play(view, "death", false); } return; } @@ -311,7 +324,7 @@ export class Opponents { const speed = Math.hypot(player.movement.velocity.x, player.movement.velocity.z); // Clip names come from the licensed pack, whose idle holds the weapon up // — exactly right for a fighter, and something my generated rig lacked. - const wanted = speed > RUN_SPEED ? "Run" : speed > IDLE_SPEED ? "Walk" : "Idle_Gun"; + const wanted = speed > RUN_SPEED ? "run" : speed > IDLE_SPEED ? "walk" : "idle"; if (wanted !== view.current) this.play(view, wanted, true); } diff --git a/tools/art/blender/import_synty.py b/tools/art/blender/import_synty.py index 236c22b..9461e8c 100644 --- a/tools/art/blender/import_synty.py +++ b/tools/art/blender/import_synty.py @@ -104,28 +104,48 @@ def main() -> None: if not clips: raise SystemExit("import_synty: source glb carried no actions") - # ---- constrain target bones to their source counterparts ------------- + # ---- retarget, rest-pose relative ------------------------------------- + # + # Copy-rotation constraints in world space were the first attempt and are + # wrong: they force the target bone to adopt the source bone's *absolute* + # orientation, which is only correct if both skeletons share rest poses. + # Synty uses Unreal's axis convention and our clips use another, so every + # bone sat at its rest-pose difference from where it belonged and the error + # compounded down each limb. The figure measured 1.90 x 2.00 x 2.47 m + # instead of roughly 0.6 x 0.4 x 1.8 — limbs splayed in every direction. + # + # The correct relation carries the rest difference through: + # + # target.matrix = source.matrix @ (source.rest⁻¹ @ target.rest) + # + # so the source's *motion away from its own rest* is what transfers, rather + # than its raw orientation. + bpy.ops.object.select_all(action="DESELECT") + target.select_set(True) + bpy.context.view_layer.objects.active = target + bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) + + pairs = [] for our_name, synty_name in BONE_MAP.items(): - bone = target.pose.bones.get(synty_name) - if bone is None or source.pose.bones.get(our_name) is None: + t_bone = target.pose.bones.get(synty_name) + s_bone = source.pose.bones.get(our_name) + if t_bone is None or s_bone is None: continue - constraint = bone.constraints.new("COPY_ROTATION") - constraint.target = source - constraint.subtarget = our_name - constraint.target_space = "WORLD" - constraint.owner_space = "WORLD" - - # The hips also translate — without this the figure runs on the spot. - hips = target.pose.bones.get("Pelvis") - if hips and source.pose.bones.get("Hips"): - loc = hips.constraints.new("COPY_LOCATION") - loc.target = source - loc.subtarget = "Hips" - loc.target_space = "WORLD" - loc.owner_space = "WORLD" - - # ---- bake each clip onto the target ---------------------------------- - bones = [b for b in BONE_MAP.values() if target.pose.bones.get(b)] + rest_delta = s_bone.bone.matrix_local.inverted() @ t_bone.bone.matrix_local + pairs.append((s_bone, t_bone, rest_delta)) + + # Parents before children: setting a pose bone's matrix reads its parent's + # current transform, so a child solved first is immediately invalidated. + def depth(pose_bone): + n, b = 0, pose_bone.bone + while b.parent: + n += 1 + b = b.parent + return n + + pairs.sort(key=lambda entry: depth(entry[1])) + + hips_pair = next((p for p in pairs if p[1].name == "Pelvis"), None) baked = [] for clip in clips: @@ -134,44 +154,35 @@ def main() -> None: start = int(clip.frame_range[0]) end = int(clip.frame_range[1]) - bpy.context.scene.frame_start = start - bpy.context.scene.frame_end = end - - bpy.ops.object.select_all(action="DESELECT") - target.select_set(True) - bpy.context.view_layer.objects.active = target - bpy.ops.object.mode_set(mode="POSE") - for bone in target.pose.bones: - bone.bone.select = bone.name in bones - - bpy.ops.nla.bake( - frame_start=start, - frame_end=end, - only_selected=True, - visual_keying=True, - clear_constraints=False, - clear_parents=False, - use_current_action=False, - bake_types={"POSE"}, - ) - bpy.ops.object.mode_set(mode="OBJECT") - - action = target.animation_data.action - # Free the source name first. Blender appends .001 if the name is taken, - # and the engine looks clips up by exact name — "Walk.001" is a clip the - # game will never ask for. + + target.animation_data_create() + action = bpy.data.actions.new(f"__baked_{clip.name}") + target.animation_data.action = action + + for pose_bone in target.pose.bones: + pose_bone.rotation_mode = "QUATERNION" + + for frame in range(start, end + 1): + bpy.context.scene.frame_set(frame) + bpy.context.view_layer.update() + + for s_bone, t_bone, rest_delta in pairs: + world = source.matrix_world @ s_bone.matrix @ rest_delta + t_bone.matrix = target.matrix_world.inverted() @ world + # Each child reads the parent just written, so flush per bone. + bpy.context.view_layer.update() + + for _, t_bone, _ in pairs: + t_bone.keyframe_insert("rotation_quaternion", frame=frame) + if hips_pair: + hips_pair[1].keyframe_insert("location", frame=frame) + source_name = clip.name clip.name = f"__src_{source_name}" action.name = source_name action.use_fake_user = True baked.append(action.name) - # Constraints have served their purpose; leaving them in would re-drive the - # baked keys from a source rig that is about to be deleted. - for bone in target.pose.bones: - for constraint in list(bone.constraints): - bone.constraints.remove(constraint) - for obj in [source, *source_meshes]: bpy.data.objects.remove(obj, do_unlink=True)