180 lines
8.2 KiB
Python
180 lines
8.2 KiB
Python
"""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
|
|
# --- M3 verbs, fetched from Mixamo 2026-07-16 (see tools/character/mixamo_wishlist.txt).
|
|
# Substitutions where Mixamo has no such clip: Turning Key → Pulling Lever,
|
|
# Standing Up Ready → Standing Up, Covering Head → Taking Cover.
|
|
# Hammering / Sweeping Floor / Bracing don't exist on Mixamo — skipped.
|
|
("ClimbLadder", f"{FBX}/Climbing Ladder.fbx"),
|
|
("Crank", f"{FBX}/Pulling Lever.fbx"), # turnbuckle work
|
|
("Dig", f"{FBX}/Digging.fbx"),
|
|
("PickUp", f"{FBX}/Picking Up Object.fbx"),
|
|
("Carry", f"{FBX}/Carrying.fbx"),
|
|
("CarryTurn", f"{FBX}/Carrying Turn.fbx"),
|
|
("CarryIdle", f"{FBX}/Box Idle.fbx"),
|
|
("StandUp", f"{FBX}/Standing Up.fbx"),
|
|
("TakeCover", f"{FBX}/Taking Cover.fbx"), # hail/debris shelter
|
|
("StumbleBack", f"{FBX}/Stumble Backwards.fbx"),
|
|
("PlantSeeds", f"{FBX}/Dig And Plant Seeds.fbx"),# garden repair verb
|
|
]
|
|
|
|
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===")
|