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:
parent
8338c0fa99
commit
027fb99828
164
tools/character/build_player_anims.py
Normal file
164
tools/character/build_player_anims.py
Normal 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===")
|
||||||
13
web/world/models/MODELS.md
Normal file
13
web/world/models/MODELS.md
Normal 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.
|
||||||
BIN
web/world/models/player_01.glb
Normal file
BIN
web/world/models/player_01.glb
Normal file
Binary file not shown.
BIN
web/world/models/player_anims.glb
Normal file
BIN
web/world/models/player_anims.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user