diff --git a/tools/character/build_player_anims.py b/tools/character/build_player_anims.py new file mode 100644 index 0000000..64227bd --- /dev/null +++ b/tools/character/build_player_anims.py @@ -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===") diff --git a/web/world/models/MODELS.md b/web/world/models/MODELS.md new file mode 100644 index 0000000..2078e54 --- /dev/null +++ b/web/world/models/MODELS.md @@ -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. diff --git a/web/world/models/player_01.glb b/web/world/models/player_01.glb new file mode 100644 index 0000000..231c8f0 Binary files /dev/null and b/web/world/models/player_01.glb differ diff --git a/web/world/models/player_anims.glb b/web/world/models/player_anims.glb new file mode 100644 index 0000000..3528730 Binary files /dev/null and b/web/world/models/player_anims.glb differ