Files
ozan 70d4312f4a feat(clothing): skirt gait bake spike — Blender cloth sim baked to strand bones
bake_skirt_gait.py: headless Blender CLOTH sim of the long tifi against the
UAL1 Jog_Fwd_Loop run cycle, baked onto the skirt_* ring as a rotation-only
clip (out/SkirtJogFwd.glb, 0.933s loop, 120 tracks). NOTES.md: cloth→bone
centroid-fit mapping, runtime integration options (override/additive/hybrid),
Blender 5.1 slotted-action + export_force_sampling gotchas. Renders: sim vs
bake phase proofs. Game-side loader lives in the ariki-game clothing bed
(Run Baked).
2026-08-20 17:07:44 +01:00

627 lines
26 KiB
Python

#!/usr/bin/env python3
"""bake_skirt_gait.py — SPIKE: bake a cloth-simulated skirt onto the skirt_* ring.
Headless Blender. Simulates the long tapa skirt against a SkirtRig body running
the game's own run cycle (UAL1 Jog_Fwd_Loop == the game's "run" clip), then bakes
the simulated cloth motion onto the skirt_* strand bones and exports ONE looped
cycle as a glTF animation containing ONLY skirt_* rotation tracks — so the game
can layer it over the body clip instead of (or under) the runtime spring sim.
/Applications/Blender.app/Contents/MacOS/Blender --background \
--python clothing/skirt_gait_spike/bake_skirt_gait.py -- \
[--clip Jog_Fwd_Loop] [--cycles 3] [--out clothing/skirt_gait_spike/out]
Stages: import -> bin -> cloth-sim -> fit chains -> skirt-only action -> renders
(sim A/B + baked turntable) -> export GLB -> verify tracks. See NOTES.md.
Bake mapping (cloth -> bones), in one paragraph: every garment vertex is binned
ONCE from its REST position into (strand, boundary) cells — strand by azimuth
around the pelvis axis, boundary by nearest ring-knot height, the same
conventions as skirt_garment_weights.py. Per simulated frame each cell's world
centroid becomes the target point for that strand's boundary knot. Bone pose is
then solved analytically down each chain: root head stays glued to the pelvis
(the waistband is pinned there), each segment takes the minimal rotation from
its parent-propagated rest orientation that aims head->tail at its target
centroid, twist carried from the parent so strands don't spin. Rotation-only:
bone lengths never stretch, cloth compression shows up as curl, not scale.
"""
import argparse
import json
import math
import os
import struct
import sys
import bpy
from mathutils import Matrix, Vector
ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM_ROOT = os.path.dirname(os.path.dirname(HERE))
GAME = os.environ.get("ARIKI_GAME_ROOT") or os.path.join(
os.path.dirname(ANIM_ROOT), "ariki-game")
ap = argparse.ArgumentParser()
ap.add_argument("--body", default=os.path.join(
GAME, "assets/quaternius/derived-bodies/Regular_Female_SkirtRig24.glb"))
ap.add_argument("--garment", default=os.path.join(
GAME, "assets/quaternius/outfits/chamorro/Female_Tapa_Skirt_Regular.gltf"))
ap.add_argument("--anim-pack", default=os.path.join(
GAME, "assets/quaternius/anim/UAL1.glb"))
ap.add_argument("--clip", default="Jog_Fwd_Loop")
ap.add_argument("--cycles", type=int, default=3)
ap.add_argument("--settle", type=int, default=20)
ap.add_argument("--fps", type=int, default=30)
ap.add_argument("--out", default=os.path.join(HERE, "out"))
ap.add_argument("--renders", default=os.path.join(HERE, "renders"))
ap.add_argument("--clip-name", default=None,
help="exported animation name (default derived from --clip)")
ap.add_argument("--skip-renders", action="store_true")
ARGS = ap.parse_args(ARGS)
FPS = ARGS.fps
PIN_BAND = 0.04 # top 4 cm of the garment is the pinned waistband
SEAM_BLEND = 6 # frames at the cycle tail blended toward frame 0 (loop seam)
def log(msg):
print(f"[skirt_gait] {msg}", flush=True)
def find_armature():
for o in bpy.data.objects:
if o.type == "ARMATURE":
return o
return None
def iter_fcurves(act):
"""Blender 5.x: actions are slotted/layered; there is no Action.fcurves."""
for layer in act.layers:
for strip in layer.strips:
for bag in strip.channelbags:
yield from bag.fcurves
def action_frame_range(act):
lo, hi = 1e9, -1e9
for fc in iter_fcurves(act):
for kp in fc.keyframe_points:
lo = min(lo, kp.co.x)
hi = max(hi, kp.co.x)
return (int(round(lo)), int(round(hi)))
def set_active_action(rig, act):
ad = rig.animation_data_create()
ad.action = act
try:
if hasattr(ad, "action_slot") and len(act.slots):
ad.action_slot = act.slots[0]
except Exception as e:
log(f" action_slot bind skipped: {e}")
def push_nla(rig, act, name):
ad = rig.animation_data_create()
tr = ad.nla_tracks.new()
tr.name = name
st = tr.strips.new(name, 1, act)
try:
if hasattr(st, "action_slot") and len(act.slots):
st.action_slot = act.slots[0]
except Exception as e:
log(f" nla slot bind skipped: {e}")
return tr
def new_keyframe_action(name, rig):
"""Create a slotted action bound to `rig`; return (action, fcurve-new)."""
act = bpy.data.actions.new(name)
slot = act.slots.new("OBJECT", name)
layer = act.layers.new("Layer")
strip = layer.strips.new(type="KEYFRAME")
bag = strip.channelbags.new(slot=slot)
ad = rig.animation_data_create()
ad.action = act
ad.action_slot = slot
def fc_new(data_path, index, action_group):
return bag.fcurves.new(data_path=data_path, index=index,
group_name=action_group)
return act, fc_new
def smooth01(x):
x = max(0.0, min(1.0, x))
return x * x * (3 - 2 * x)
# ─────────────────────────────────────────────────────────────────────────────
log(f"body = {ARGS.body}")
log(f"garment = {ARGS.garment}")
log(f"anim = {ARGS.anim_pack} :: {ARGS.clip}")
os.makedirs(ARGS.out, exist_ok=True)
os.makedirs(ARGS.renders, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.render.fps = FPS # BEFORE the anim import, so times map to frames
# ── import body ──────────────────────────────────────────────────────────────
bpy.ops.import_scene.gltf(filepath=ARGS.body)
rig = find_armature()
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
body = max(meshes, key=lambda m: len(m.data.vertices))
log(f"body: rig={rig.name} bones={len(rig.data.bones)} "
f"main mesh={body.name} ({len(body.data.vertices)} verts)")
# ── ring geometry (armature space, Blender Z-up) ─────────────────────────────
pelvis_bone = rig.data.bones["pelvis"]
pelvis_head = pelvis_bone.head_local.copy()
strands = {}
for b in rig.data.bones:
if not b.name.startswith("skirt_"):
continue
parts = b.name.split("_")
if len(parts) < 2 or not parts[1].isdigit():
continue
strands.setdefault(int(parts[1]), []).append(b.name)
for k in strands:
def depth(nm):
d, b = 0, rig.data.bones[nm]
while b.parent and b.parent.name.startswith("skirt_"):
d += 1
b = b.parent
return d
strands[k] = sorted(strands[k], key=depth)
K = sorted(strands)
N_SEG = len(strands[K[0]])
log(f"ring: {len(K)} strands x {N_SEG} segments; strand 0 chain: {strands[K[0]]}")
knots = {}
azimuths = {}
for k in K:
chain = strands[k]
zs = [rig.data.bones[nm].head_local.z for nm in chain]
zs.append(rig.data.bones[chain[-1]].tail_local.z)
knots[k] = zs
h = rig.data.bones[chain[0]].head_local
azimuths[k] = math.atan2(h.y - pelvis_head.y, h.x - pelvis_head.x)
ring_top = knots[K[0]][0]
log(f"ring top z={ring_top:.3f} hem z={knots[K[0]][-1]:.3f} "
f"knots: {[f'{z:.3f}' for z in knots[K[0]]]}")
# ── import garment, retarget its skin to the body rig ─────────────────────────
pre = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=ARGS.garment)
new_objs = [o for o in bpy.data.objects if o not in pre]
garment = next(o for o in new_objs if o.type == "MESH")
gar_arm = next((o for o in new_objs if o.type == "ARMATURE"), None)
log(f"garment: {garment.name} ({len(garment.data.vertices)} verts), "
f"carrier armature: {gar_arm.name if gar_arm else None}")
for m in garment.modifiers:
if m.type == "ARMATURE":
m.object = rig
if not any(m.type == "ARMATURE" for m in garment.modifiers):
garment.modifiers.new("Armature", "ARMATURE").object = rig
if gar_arm:
bpy.data.objects.remove(gar_arm, do_unlink=True)
garment.parent = rig
# WELD: the exported garment has per-face verts (flat-shaded islands of ONE
# quad each — run 3: the cloth sim shredded it into confetti and per-island
# pinning meant nothing). remove_doubles reconnects the tube and the rope as
# two real islands. Weights are (re)assigned after this, nothing to preserve.
import bmesh
bm = bmesh.new()
bm.from_mesh(garment.data)
before = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4)
bm.to_mesh(garment.data)
bm.free()
garment.data.update()
log(f"welded garment: {before} -> {len(garment.data.vertices)} verts")
# Garment rest verts in ARMATURE space (no action assigned yet -> rest pose).
g2r = rig.matrix_world.inverted() @ garment.matrix_world
rest_arm = [g2r @ v.co for v in garment.data.vertices]
top_z = max(p.z for p in rest_arm)
log(f"garment rest z span: {top_z:.3f} .. {min(p.z for p in rest_arm):.3f}")
# ── bin verts: (strand by azimuth, boundary by nearest knot) — rest positions ──
def nearest_strand(az):
return min(K, key=lambda k: abs((az - azimuths[k] + math.pi)
% (2 * math.pi) - math.pi))
# Pin per ISLAND, not per mesh: the garment is the tapa tube PLUS the separate
# rope ring, and the rope's back-rise rides ~5 cm above the tube's top row. A
# single pin line off the mesh top leaves the tube's top row unpinned and the
# whole skirt falls to the floor (run 2 — sim renders showed only the rope).
adj = [set() for _ in rest_arm]
for e in garment.data.edges:
adj[e.vertices[0]].add(e.vertices[1])
adj[e.vertices[1]].add(e.vertices[0])
island_of = [-1] * len(rest_arm)
islands = []
for i in range(len(rest_arm)):
if island_of[i] >= 0:
continue
idx = len(islands)
stack, members = [i], []
island_of[i] = idx
while stack:
v = stack.pop()
members.append(v)
for w in adj[v]:
if island_of[w] < 0:
island_of[w] = idx
stack.append(w)
islands.append(members)
pin_line_by_island = {idx: max(rest_arm[v].z for v in members) - PIN_BAND
for idx, members in enumerate(islands)}
log(f"garment islands: {[len(m) for m in islands]} verts, "
f"pin lines: {[f'{z:.3f}' for z in pin_line_by_island.values()]}")
bins = {}
n_pinned = 0
pin_idxs = []
for i, p in enumerate(rest_arm):
az = math.atan2(p.y - pelvis_head.y, p.x - pelvis_head.x)
k = nearest_strand(az)
j = min(range(N_SEG + 1), key=lambda jj: abs(p.z - knots[k][jj]))
if j == 0:
j = 1 # above-ring waistband verts inform the first boundary
bins.setdefault((k, j), []).append(i)
if p.z >= pin_line_by_island[island_of[i]]:
n_pinned += 1
pin_idxs.append(i)
empty = [(k, j) for k in K for j in range(1, N_SEG + 1) if not bins.get((k, j))]
log(f"bins: {len(bins)} cells, {len(empty)} empty; waistband pin verts: {n_pinned}")
# ── import the anim pack, keep only the wanted clip ──────────────────────────
pre_actions = set(bpy.data.actions)
bpy.ops.import_scene.gltf(filepath=ARGS.anim_pack)
pack_objs = [o for o in bpy.data.objects if o not in pre and o is not garment]
pack_actions = [a for a in bpy.data.actions if a not in pre_actions]
run_act = next((a for a in pack_actions if a.name == ARGS.clip), None)
if run_act is None:
cand = [a.name for a in pack_actions if ARGS.clip.split("_")[0] in a.name]
raise SystemExit(f"[skirt_gait] FATAL: clip '{ARGS.clip}' not found; "
f"similar: {cand[:10]}")
run_act.use_fake_user = True
f0, f1 = action_frame_range(run_act)
CYCLE = f1 - f0
log(f"clip '{run_act.name}': frames {f0}..{f1} -> cycle {CYCLE}f @ {FPS}fps "
f"({CYCLE / FPS:.3f}s)")
for fc in iter_fcurves(run_act):
if not any(m.type == "CYCLES" for m in fc.modifiers):
fc.modifiers.new("CYCLES")
for o in pack_objs:
bpy.data.objects.remove(o, do_unlink=True)
for a in pack_actions:
if a is not run_act:
bpy.data.actions.remove(a)
set_active_action(rig, run_act)
scene.frame_start = 1
sim_end = ARGS.settle + ARGS.cycles * CYCLE
scene.frame_end = sim_end
log(f"sim: settle {ARGS.settle}f + {ARGS.cycles} cycles = frames 1..{sim_end}")
# ── cloth setup (build_pugu_tifi.py idioms) ───────────────────────────────────
# Waistband: pinned, and pelvis-weighted so the pin FOLLOWS the animated hip
# (Armature modifier sits above Cloth; pinned verts track the deformed input).
pin_vg = garment.vertex_groups.get("ClothPin") or garment.vertex_groups.new(name="ClothPin")
pelvis_vg = garment.vertex_groups.get("pelvis") or garment.vertex_groups.new(name="pelvis")
# Pinned verts must ride the pelvis ALONE: zero their other weights first, or
# the inherited thigh blend drags the waistband with the stride.
for vg0 in garment.vertex_groups:
if vg0.name not in ("ClothPin", "pelvis"):
vg0.add(pin_idxs, 0.0, "REPLACE")
pin_vg.add(pin_idxs, 1.0, "REPLACE")
pelvis_vg.add(pin_idxs, 1.0, "REPLACE")
arm_mod = next(m for m in garment.modifiers if m.type == "ARMATURE")
bpy.context.view_layer.objects.active = garment
while garment.modifiers.find(arm_mod.name) > 0:
bpy.ops.object.modifier_move_up(modifier=arm_mod.name)
body.modifiers.new("Collision", "COLLISION")
body.collision.thickness_outer = 0.008
cloth = garment.modifiers.new("Cloth", "CLOTH")
cs = cloth.settings
cs.vertex_group_mass = "ClothPin"
cs.quality = 8
cs.mass = 0.25
cs.tension_stiffness = 8.0
cs.compression_stiffness = 8.0
cs.shear_stiffness = 4.0
cs.bending_stiffness = 0.06
cloth.collision_settings.collision_quality = 4
cloth.collision_settings.distance_min = 0.006
cloth.collision_settings.use_self_collision = False
# ── render rig (set up BEFORE the sim: sim stills are shot DURING the capture
# loop — jumping scene.frame_set BACKWARD into a cached cloth range re-evaluates
# stale state and produced two byte-identical "different phase" stills in run 4)
cam = None
if not ARGS.skip_renders:
cam_data = bpy.data.cameras.new("Cam")
cam = bpy.data.objects.new("Cam", cam_data)
scene.collection.objects.link(cam)
sun_data = bpy.data.lights.new("Sun", "SUN")
sun_data.energy = 3.0
sun = bpy.data.objects.new("Sun", sun_data)
sun.rotation_euler = (math.radians(50), 0, math.radians(30))
scene.collection.objects.link(sun)
gnd = bpy.data.meshes.new("Ground")
gnd.from_pydata([(-3, -3, 0), (3, -3, 0), (3, 3, 0), (-3, 3, 0)], [],
[(0, 1, 2, 3)])
gnd_obj = bpy.data.objects.new("Ground", gnd)
scene.collection.objects.link(gnd_obj)
for eng in ("BLENDER_EEVEE", "BLENDER_EEVEE_NEXT", "BLENDER_WORKBENCH"):
try:
scene.render.engine = eng
break
except Exception:
continue
log(f"render engine: {scene.render.engine}")
scene.render.resolution_x = 640
scene.render.resolution_y = 760
world = bpy.data.worlds.new("W")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs[0].default_value = (0.7, 0.7, 0.75, 1)
world.node_tree.nodes["Background"].inputs[1].default_value = 0.6
scene.world = world
VIEWS = {"front45": math.radians(30), "side": math.radians(120),
"back45": math.radians(240)}
def aim(cam_obj, az):
r, h = 2.6, 1.15
cam_obj.location = (r * math.cos(az), r * math.sin(az), h)
look = Vector((0, 0, 0.80)) - cam_obj.location
cam_obj.rotation_euler = look.to_track_quat("-Z", "Y").to_euler()
def render(tag, f, view):
scene.frame_set(f)
aim(cam, VIEWS[view])
scene.camera = cam
scene.render.filepath = os.path.join(
ARGS.renders, f"{tag}_f{f - cap0 + 1:02d}_{view}.png")
bpy.ops.render.render(write_still=True)
# ── simulate, capturing the LAST cycle ────────────────────────────────────────
cap0 = sim_end - CYCLE + 1
frames = list(range(cap0, sim_end + 1))
SIM_SHOTS = {cap0, cap0 + CYCLE // 2}
log(f"simulating frames 1..{sim_end}; capture {cap0}..{sim_end}")
r2a = rig.matrix_world.inverted()
pelvis_pb = rig.pose.bones["pelvis"]
pelvis_rest_inv = pelvis_bone.matrix_local.inverted()
targets = [] # [frame][(k,j)] -> Vector (armature space)
pelvis_delta = [] # [frame] -> Matrix (pelvis rest->pose, armature space)
for f in range(1, sim_end + 1):
scene.frame_set(f)
if f < cap0:
continue
deps = bpy.context.evaluated_depsgraph_get()
ev = garment.evaluated_get(deps)
me = ev.to_mesh()
mw = ev.matrix_world
acc = {}
for (k, j), idxs in bins.items():
s = Vector((0, 0, 0))
for i in idxs:
s += mw @ me.vertices[i].co
acc[(k, j)] = r2a @ (s / len(idxs))
ev.to_mesh_clear()
targets.append(acc)
pelvis_delta.append(pelvis_pb.matrix @ pelvis_rest_inv)
if cam is not None and f in SIM_SHOTS:
render("sim", f, "front45")
render("sim", f, "side")
scene.frame_set(f) # render() re-set the frame; stay in sync
log("sim done")
# ── fit strand chains to the cloth targets (analytic, rotation-only) ─────────
def fit_frame(ti):
acc, d_pel = targets[ti], pelvis_delta[ti]
out = {}
for k in K:
chain = strands[k]
d_par_rot = d_pel.to_3x3() # inherited orientation delta
root_rest = rig.data.bones[chain[0]].matrix_local
parent_chain = d_pel @ root_rest # parent_pose @ parent_rest^-1 @ rest
H = parent_chain.to_translation() # root head carried by pelvis
for s, nm in enumerate(chain):
bone = rig.data.bones[nm]
T = acc.get((k, s + 1))
if T is None: # empty cell: hang straight down
T = H + d_par_rot @ Vector((0, 0, -(knots[k][s] - knots[k][s + 1])))
want = T - H
if want.length < 1e-6:
want = d_par_rot @ Vector((0, 0, -1))
want.normalize()
rest_rot = bone.matrix_local.to_3x3()
O = d_par_rot @ rest_rot
q = (O @ Vector((0, 1, 0))).rotation_difference(want)
R = q.to_matrix() @ O
M = Matrix.Translation(H) @ R.to_4x4()
basis = (parent_chain.inverted() @ M).to_quaternion()
out[nm] = basis
H = H + R @ Vector((0, bone.length, 0))
d_par_rot = R @ rest_rot.inverted()
parent_chain = M
return out
log("fitting bone chains to cloth targets...")
pose_seq = [fit_frame(ti) for ti in range(len(frames))]
for nm in pose_seq[0]: # quaternion sign continuity
for ti in range(1, len(pose_seq)):
if pose_seq[ti][nm].dot(pose_seq[ti - 1][nm]) < 0:
pose_seq[ti][nm].negate()
for ti in range(len(frames) - SEAM_BLEND, len(frames)): # loop seam blend
w = smooth01((ti - (len(frames) - SEAM_BLEND - 1)) / SEAM_BLEND)
for nm in pose_seq[ti]:
pose_seq[ti][nm] = pose_seq[ti][nm].slerp(pose_seq[0][nm], w)
# ── write the skirt-only action ───────────────────────────────────────────────
clip_name = ARGS.clip_name or "Skirt" + "".join(
w.title() for w in ARGS.clip.split("_") if w.lower() != "loop")
log(f"writing action '{clip_name}': {len(pose_seq[0])} bones x {len(frames)} frames")
rig.animation_data_clear()
skirt_act, fc_new = new_keyframe_action(clip_name, rig)
for pb in rig.pose.bones:
if pb.name in pose_seq[0]:
pb.rotation_mode = "QUATERNION"
curves = {}
for nm in pose_seq[0]:
dp = f'pose.bones["{nm}"].rotation_quaternion'
curves[nm] = [fc_new(data_path=dp, index=ch, action_group=nm) for ch in range(4)]
for ti in range(len(frames)):
for nm, fcs in curves.items():
q = pose_seq[ti][nm]
for ch in range(4):
fcs[ch].keyframe_points.insert(ti + 1, q[ch], options={"FAST"})
for fcs in curves.values():
for fc in fcs:
fc.update()
# ── renders of the BAKED result (the eyeball gate; sim stills were shot
# during the capture loop above) ─────────────────────────────────────────────
if not ARGS.skip_renders:
# ── switch the garment from cloth-sim to SKIRT-RING SKINNING ──
garment.modifiers.remove(cloth)
body.modifiers.remove(next(m for m in body.modifiers if m.type == "COLLISION"))
if "ClothPin" in garment.vertex_groups:
garment.vertex_groups.remove(garment.vertex_groups["ClothPin"])
# classic 2-strand x 2-segment weights (skirt_garment_weights.py conventions)
garment.vertex_groups.clear()
vg = {nm: garment.vertex_groups.new(name=nm)
for k in K for nm in strands[k]}
vg["pelvis"] = garment.vertex_groups.new(name="pelvis")
BAND = 0.02
for i, p in enumerate(rest_arm):
if p.z >= ring_top - BAND:
vg["pelvis"].add([i], 1.0, "REPLACE")
continue
az = math.atan2(p.y - pelvis_head.y, p.x - pelvis_head.x)
ks = sorted(K, key=lambda k: abs((az - azimuths[k] + math.pi)
% (2 * math.pi) - math.pi))[:2]
d = abs((azimuths[ks[1]] - azimuths[ks[0]] + math.pi) % (2 * math.pi) - math.pi)
off = (az - azimuths[ks[0]] + math.pi) % (2 * math.pi) - math.pi
frac = min(1.0, max(0.0, abs(off) / d)) if d > 1e-9 else 0.0
# height -> continuous segment position over the strand's knots
zs = knots[ks[0]]
fseg = float(N_SEG - 1)
for sgi in range(N_SEG - 1):
if p.z > zs[sgi + 1]:
h = zs[sgi] - zs[sgi + 1]
fseg = sgi + (0.0 if h < 1e-9
else max(0.0, min(1.0, (zs[sgi] - p.z) / h)))
break
i0 = min(N_SEG - 1, int(fseg))
i1 = min(N_SEG - 1, i0 + 1)
fs = fseg - i0
for kk, azw in ((ks[0], 1.0 - frac), (ks[1], frac)):
if azw <= 0.0:
continue
for si, sw in ((i0, 1.0 - fs), (i1, fs)):
if sw > 0.0:
vg[strands[kk][si]].add([i], azw * sw, "REPLACE")
# body runs the gait; skirt ring runs the bake (disjoint channels, NLA)
set_active_action(rig, run_act)
push_nla(rig, skirt_act, "skirt_bake")
scene.frame_start = 1
scene.frame_end = CYCLE
cap0 = 1 # render tag math: actions now keyed at 1..CYCLE
phases = [1, CYCLE // 4, CYCLE // 2, 3 * CYCLE // 4, CYCLE - 2, CYCLE]
for f in phases:
for view in VIEWS:
render("baked", f, view)
log("baked renders done")
# ── export the skirt-only clip GLB (armature only, active action) ────────────
# Same scene — a file reload would purge skirt_act with the rest of bpy.data.
# Save the work blend first (scratch/debug), then strip it down to rig+action.
bpy.context.preferences.filepaths.save_version = 0 # working-files rule: no .blend1
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(ARGS.out, "work.blend"))
ad = rig.animation_data
for tr in list(ad.nla_tracks):
ad.nla_tracks.remove(tr)
set_active_action(rig, skirt_act)
if run_act is not None:
bpy.data.actions.remove(run_act)
for o in [o for o in bpy.data.objects if o is not rig]:
bpy.data.objects.remove(o, do_unlink=True)
for pb in rig.pose.bones:
pb.rotation_mode = "QUATERNION"
scene.frame_start = 1
scene.frame_end = CYCLE
rig2 = rig
glb_out = os.path.join(ARGS.out, f"{clip_name}.glb")
bpy.ops.object.select_all(action="DESELECT")
rig2.select_set(True)
try:
bpy.ops.export_scene.gltf(
filepath=glb_out, export_format="GLB", use_selection=True,
export_animations=True, export_animation_mode="ACTIVE_ACTIONS",
# sampling exports TRS for EVERY bone (run 3: 555 tracks incl. body);
# direct fcurve export keeps it to the skirt_* rotation tracks only.
export_force_sampling=False,
export_skins=False, export_yup=True)
except TypeError as e:
log(f"export mode fallback: {e}")
bpy.ops.export_scene.gltf(
filepath=glb_out, export_format="GLB", use_selection=True,
export_animations=True, export_skins=False, export_yup=True)
log(f"EXPORTED {glb_out} ({os.path.getsize(glb_out)} bytes)")
# ── verify the GLB: skirt-only tracks, right duration ─────────────────────────
with open(glb_out, "rb") as fh:
blob = fh.read()
clen, _ = struct.unpack_from("<II", blob, 12)
gj = json.loads(blob[20:20 + clen])
anims = gj.get("animations", [])
node_names = [n.get("name", "") for n in gj.get("nodes", [])]
bad, ntracks, dur = [], 0, 0.0
for a in anims:
for chn in a["channels"]:
ntracks += 1
nm = node_names[chn["target"]["node"]]
if not nm.startswith("skirt_"):
bad.append(nm)
dur = max(dur, gj["accessors"][a["samplers"][chn["sampler"]]["input"]]
.get("max", [0])[0])
log(f"verify: animations={[a.get('name') for a in anims]} tracks={ntracks} "
f"dur={dur:.3f}s non-skirt targets={sorted(set(bad))[:5]}")
if bad or not anims:
log("VERIFY FAILED — see above")
sys.exit(2)
# The exporter won't name the animation after the action on this path (comes
# out 'Animation') — rename it in the GLB JSON chunk so the clip is addressable.
if anims and anims[0].get("name") != clip_name:
gj["animations"][0]["name"] = clip_name
payload = json.dumps(gj, separators=(",", ":")).encode()
payload += b" " * (-len(payload) % 4)
rest = blob[20 + clen:] # BIN chunk, untouched
out = blob[:12] + struct.pack("<II", len(payload), 0x4E4F534A) + payload + rest
out = out[:8] + struct.pack("<I", len(out)) + out[12:]
with open(glb_out, "wb") as fh:
fh.write(out)
log(f"renamed animation -> '{clip_name}'")
log("VERIFY OK: skirt-only tracks")
log("DONE")