Lane D: player rig assets — ped copy + anim-only clip pack

Character is a byte copy of 90sDJsim's man_casual_01 ped (already metre-scale,
head bone 1.75 m, bind pose proven in that game). Clips ride beside it in an
anim-only carrier built from the FBX libraries on the M1 Ultra.

The peds cannot round-trip through Blender: they encode metre scale as a node
scale of 0.01 on mixamorig*:Hips with child bones in centimetres, and Blender
bones carry no rest scale, so the glTF importer silently drops it — the rig is
exploded on import (LeftToe_End 96 m below the floor) before any merge happens.
So merging clips onto the ped (PLAN3D 5-D.1) is not viable; we ship the ped
untouched and retarget rotation-only clips onto it at runtime, the same shape
90sDJsim already uses for peds/idle.glb + peds/walk.glb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-16 21:27:04 +10:00
parent 8338c0fa99
commit 027fb99828
4 changed files with 177 additions and 0 deletions

View File

@ -0,0 +1,164 @@
"""Build web/world/models/player_anims.glb -- one anim-only GLB carrying every clip the player needs.
Run on the M1 Ultra (the FBX libraries live there). The OUTPUT glb is committed per the copies rule,
so the game itself never needs this box:
scp tools/character/build_player_anims.py johnking@100.91.239.7:~/Documents/shades-laneD-out/
ssh johnking@100.91.239.7 '/Applications/Blender.app/Contents/MacOS/Blender -b --factory-startup \
-P ~/Documents/shades-laneD-out/build_player_anims.py'
scp johnking@100.91.239.7:~/Documents/shades-laneD-out/player_anims.glb web/world/models/
This is the 90sDJsim peds/idle.glb + peds/walk.glb shape (an anim-only clip carrier, no mesh),
extended to the clip set SHADES needs. The character itself is a byte copy of a ped -- see MODELS.md.
WHY NOT merge the clips onto the ped, character_kit/scripts/merge_anims.py style (which is what
PLAN3D 5-D.1 assumes):
The ped GLBs encode their metre scale as a NODE scale of 0.01 on mixamorig*:Hips, with every child
bone in centimetres (Spine T=+10.05, LeftLeg T=+42.8). Blender bones have no rest scale, so the
glTF importer silently drops that 0.01: straight after import the armature already reads
Hips.head_local=(0,-0.03,0.99) in METRES while HeadTop_End=(0,-4.39,76.88) is in CENTIMETRES, and
LeftToe_End sits 96 m under the floor. The rig is exploded before a single clip is merged -- the
peds cannot round-trip through Blender at all. (merge_anims.py works because its base,
Hum_M_1_mixamo.fbx, is an FBX carrying no such trick -- and that base is exactly why dancer.glb
lands 30x small, head bone at 0.0563 m.)
So: never rebuild the character. Ship the ped untouched and carry the clips beside it.
Bind pose here is deliberately IRRELEVANT: the carrier rig exists only to hold actions. player.js
lifts g.animations off this file, canonicalises the bone namespace, and keeps rotation tracks only
(quaternions are scale-invariant, so a carrier at any scale retargets cleanly onto the ped). This
file's own skeleton is never rendered and its scale is never trusted.
"""
import bpy, os, re, sys
HOME = os.path.expanduser("~")
ANIM = f"{HOME}/Documents/3D=models/animations"
FBX = f"{HOME}/Documents/FBX"
OUT = f"{HOME}/Documents/shades-laneD-out/player_anims.glb"
# (clip name in-game, source fbx). These names are the contract with player.js CLIPS.
# Locomotion is in-place; the game translates the player itself (PLAN3D 5-D.2).
CLIPS = [
("Idle", f"{ANIM}/Happy Idle.fbx"),
("Walk", f"{ANIM}/Walk.fbx"),
("Run", f"{FBX}/Running.fbx"),
("Falling", f"{FBX}/Falling.fbx"), # limb flail; player.js pitches the root itself
("CrouchToStand", f"{FBX}/Crouch To Stand.fbx"),
("Reaction", f"{FBX}/Reaction.fbx"), # stagger on a debris glance
]
PREFIX_RE = re.compile(r"mixamorig\d*:")
def imported(fn):
"""Run an import op; return (new objects, new actions)."""
before_o, before_a = set(bpy.data.objects), set(bpy.data.actions)
fn()
return set(bpy.data.objects) - before_o, list(set(bpy.data.actions) - before_a)
# Blender 4.4+ ("slotted actions") moved fcurves from action.fcurves into
# action.layers[].strips[].channelbags[].fcurves, and dropped action.fcurves outright by 5.0
# (which is why merge_anims.py no longer runs there as written). Support both layouts.
def fcurve_owners(act):
"""Yield (owning collection, fcurve) so callers can both read and remove."""
if hasattr(act, "fcurves"):
for fc in list(act.fcurves):
yield act.fcurves, fc
return
for layer in act.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for fc in list(bag.fcurves):
yield bag.fcurves, fc
def fcurve_groups(act):
if hasattr(act, "groups"):
yield from act.groups
return
for layer in act.layers:
for strip in layer.strips:
for bag in strip.channelbags:
yield from bag.groups
bpy.ops.wm.read_factory_settings(use_empty=True)
print("\n===BUILD===")
carrier = None
prefix = None
valid = set()
actions = []
for name, src in CLIPS:
if not os.path.exists(src):
print(f" ! MISSING {name:<14} {src}")
continue
objs, acts = imported(lambda s=src: bpy.ops.import_scene.fbx(filepath=s, automatic_bone_orientation=True))
if not acts:
print(f" ! NO ACTION {name:<12} {os.path.basename(src)}")
keep = None
if carrier is None: # the first clip donates its armature as the carrier; its meshes are dropped
keep = next((o for o in objs if o.type == "ARMATURE"), None)
if keep is None:
sys.exit(f"FATAL: no armature in {src}")
carrier = keep
m = next((PREFIX_RE.match(b.name) for b in carrier.pose.bones if PREFIX_RE.match(b.name)), None)
if not m:
sys.exit(f"FATAL: carrier has no mixamorig namespace: {[b.name for b in carrier.pose.bones][:4]}")
prefix = m.group(0)
valid = {b.name for b in carrier.pose.bones}
print(f"carrier : {os.path.basename(src)} armature={carrier.name} "
f"bones={len(valid)} namespace={prefix!r}")
for act in acts:
act.name = name
act.use_fake_user = True
kept = dropped = 0
for coll, fc in fcurve_owners(act):
fc.data_path = PREFIX_RE.sub(prefix, fc.data_path) # retarget onto the carrier namespace
bone = re.search(r'pose\.bones\["([^"]+)"\]', fc.data_path)
if bone and bone.group(1) not in valid:
coll.remove(fc)
dropped += 1
else:
kept += 1
for g in fcurve_groups(act):
g.name = PREFIX_RE.sub(prefix, g.name)
actions.append(act)
print(f" + {name:<14} {act.frame_range[1] - act.frame_range[0]:6.1f}f fcurves={kept}"
+ (f" (dropped {dropped} unbindable)" if dropped else ""))
for o in objs: # keep the action (and the carrier); bin every mesh and every donor rig
if o is not keep:
bpy.data.objects.remove(o, do_unlink=True)
if not actions:
sys.exit("FATAL: no clips merged")
# --- one NLA track per action: the glTF exporter emits one animation per track ---
if not carrier.animation_data:
carrier.animation_data_create()
carrier.animation_data.action = None
for act in actions:
tr = carrier.animation_data.nla_tracks.new()
tr.name = act.name
strip = tr.strips.new(act.name, int(act.frame_range[0]), act)
# Blender 4.4+ slotted actions: a strip with no slot exports as an empty animation
if hasattr(strip, "action_slot") and strip.action_slot is None and getattr(act, "slots", None):
strip.action_slot = act.slots[0]
print(f"NLA tracks: {len(carrier.animation_data.nla_tracks)}")
os.makedirs(os.path.dirname(OUT), exist_ok=True)
bpy.ops.object.select_all(action="DESELECT")
carrier.select_set(True)
bpy.ops.export_scene.gltf(
filepath=OUT, export_format="GLB",
use_selection=True, # carrier only -- no meshes reach the file
export_animations=True,
export_skins=False, # nothing is skinned to this rig; it is a clip carrier
export_animation_mode="NLA_TRACKS",
export_apply=False,
)
print(f"exported {OUT} ({os.path.getsize(OUT) // 1024} kB)")
print("===ENDBUILD===")

View File

@ -0,0 +1,13 @@
# Lane D runtime models (copies — see PLAN3D §0 "asset copies rule")
| File | Source (canonical) | Notes |
|---|---|---|
| `player_01.glb` | `90sDJsim/web/world/models/peds/man_casual_01.glb` | **Byte copy, never edited.** Head bone 1.75 m, already metre-scale. |
| `player_anims.glb` | built by `tools/character/build_player_anims.py` on the M1 Ultra | Anim-only carrier: Idle/Walk/Run/Falling/CrouchToStand/Reaction. No mesh, no skin. |
Rebuild the clips: see the header of `tools/character/build_player_anims.py`.
**Do not round-trip `player_01.glb` through Blender.** The peds encode metre scale as a node
scale of 0.01 on `mixamorig*:Hips` with children in centimetres; Blender bones have no rest
scale, so the glTF importer drops it and the skeleton explodes on import (`LeftToe_End` lands
96 m below the floor). Clips are carried beside the ped instead, never merged into it.

Binary file not shown.

Binary file not shown.