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).
This commit is contained in:
2026-08-20 17:07:44 +01:00
parent fc0afd7534
commit 70d4312f4a
27 changed files with 865 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
# Baked-skirt-gait spike — design note
**Status: SPIKE, not production.** Ozan 2026-08-20: prove that an offline cloth
sim of a skirt against the run cycle can be baked onto the `skirt_*` strand ring
and shipped as a layered animation clip. Script: `bake_skirt_gait.py` (this dir).
## What it does
1. Loads `Regular_Female_SkirtRig24.glb` (24 strands × 5 segments) and the long
tapa skirt `Female_Tapa_Skirt_Regular.gltf`, re-pointing the garment's skin at
the body rig.
2. Loads `Jog_Fwd_Loop` from `UAL1.glb` — this is the game's actual `run` clip
(`PlayerController.cs`: UAL1 `Jog_Fwd_Loop` → alias `run`). The pack's other
119 actions and its mannequin are discarded in-memory. A CYCLES fmodifier
loops it; scene fps is set to 30 *before* import so seconds map to frames.
3. Cloth-sims the garment against the animated body, reusing
`build_pugu_tifi.py`'s idioms (pin group at the waistband, body as collider
8 mm outer, tension/compression 8, shear 4, bending 0.06, 6 mm contact).
One addition the drape-only pipeline didn't need: **the waistband pin group is
pelvis-weighted and the Armature modifier sits above Cloth**, so the pinned
edge *rides the animated hip* instead of being nailed to world space.
20 settle frames + 3 looped cycles; the last cycle is captured.
4. Bakes cloth → bones (mapping below), blends the loop seam, writes a
skirt-bones-only action, renders sim-vs-bake proof frames, and exports one GLB
containing one animation with **only `skirt_*` rotation tracks**.
## The cloth→bone mapping
- **Rest-pose binning.** Each garment vertex is assigned once, from its rest
position, to a (strand, boundary-knot) cell: strand by azimuth around the
pelvis axis, knot by nearest of the strand's 6 boundary heights (5 segment
heads + hem tail). Same conventions as `skirt_garment_weights.py` — azimuth
and ring heights read off the rig, never bone-name order.
- **Per-frame targets.** For each simulated frame, a cell's world-space centroid
becomes the target position of that strand's boundary knot (in armature
space). ~12 verts/cell on this garment; empty cells fall back to "hang
straight down from the parent joint".
- **Chain fit, rotation-only.** Per strand, per frame: the root head stays glued
to the pelvis-carried rest position (the waistband is pinned there — the cloth
top *is* pelvis motion). Each segment takes the minimal rotation from its
parent-propagated rest orientation that aims head→tail at its target centroid;
twist is inherited down the chain so strands don't spin. The pose-space basis
quaternion comes out analytically
(`basis = (parent_pose @ parent_rest⁻¹ @ rest)⁻¹ @ M_desired`) — no
constraints, no `bake_action`, no per-bone depsgraph updates.
- **No bone scale, no bone translation.** Bone lengths are fixed; cloth
compression shows up as segment curl, never stretch. This keeps the clip
compatible with anything that assumes rigid strand segments (the spring rig's
collision particles included).
- **Loop seam.** Quat sign continuity is enforced per bone, then the last 6
frames of the cycle are slerp-blended toward frame 0. The game wraps last
key → first (`LoopMode.Linear`), so the seam is smooth by construction.
## Runtime integration options (for the game side to pick)
1. **Override per gait (recommended starting point).** When the locomotion state
machine enters run/walk, `SkirtSpringRig` stops simulating and the skeleton
plays the baked `skirt_*` clip on the same bones, synced to (or just
cross-faded with) the body clip. Cheapest possible runtime cost (zero sim),
deterministic silhouette, and the clip loops with the gait. Cost: a crossfade
pop unless blended; no reaction to collisions while overridden.
2. **Additive/under-layer.** Keep the spring sim running but feed the baked pose
as its rest/target (or as a low-weight animated base under the springs).
Keeps collision response, regains baked flair. More machinery: spring sim
needs a "goal pose" input it doesn't currently have.
3. **Replace the sim during gaits only** (hybrid of 1): sim for idle/dance (low
amplitude, collision matters), baked for run/walk/sprint (high amplitude,
where the sim visibly struggles). This matches how the industry uses baked
cloth and is option 1 with a state gate.
Not evaluated: retargeting one baked clip across bodies. The bake is
body-specific (ring radii are measured per body; the cloth is fitted per body) —
per-body bakes are the honest answer, same doctrine as the garment generator.
## What breaks / changes with the 16×4 male ring
Nothing structural — strand/segment counts are read off the rig at runtime, so
the same script bakes 16×4. Three real differences:
- **Fewer, longer segments** (4 vs 5, non-uniform fracs) → each segment spans
more cloth column; the centroid fit smooths out more fold detail. Expect
slightly stiffer-looking hem on the male ring.
- **Wider strand spacing** (16 vs 24 around the ring) → each strand owns a wider
azimuth wedge; lateral fold corners between strands get more visible. The
`AZ_SPREAD` raised-cosine weighting that fixed piecewise-linear hem folds in
`skirt_garment_weights.py` is the analogous fix on the *weights* side; the
bake itself doesn't care.
- The male long tapa sits on different bodies (Superhero's quads) — the cloth
sim, not the bake, owns that problem.
## Verified / not verified
**Verified (run 5, `run5.log`, `renders/`):** the sim holds together at run
amplitude (20 settle + 3 cycles, waistband riding the pelvis, legs pushing the
cloth — `sim_f01`/`sim_f15` show two genuinely different phases, cloth flaring
and lifting over the advancing thigh), and the bake tracks the sim's gross
motion phase-for-phase (`baked_f01` vs `sim_f01` is the same silhouette; the
skirt visibly rides and flares through the cycle, `baked_f01..f28` × 3 views).
No gross leg poke-through in any rendered phase. Exported
`out/SkirtJogFwd.glb`: one animation named `SkirtJogFwd`, 120 channels,
**rotation-only, all `skirt_*` targets**, 0.933 s (one gait cycle), 225 KB.
**Bake fidelity:** the 24×5 rotation-only centroid fit smooths fine folds — the
baked skirt is lumpier than the raw sim and the hem reads slightly ragged at
peak lift (`baked_f14`). Crumple near the front waistband is the *sim's* own
behavior at the pinned edge, not a bake artifact (visible in `sim_f01` too).
**Not verified:** in-game playback (the game side has no loader for this yet —
that is integration, out of spike scope); gait transitions; the 16×4 male ring
(same code path, never executed); walk/sprint clips; other garments.
As-built lessons from the spike runs (full log: `run5.log`):
- **The shipped garment GLTF has per-face vertices** (flat-shaded; every quad is
its own island). A cloth sim on it shreds into confetti — the script
`remove_doubles`-welds the imported mesh before anything else touches it.
- **Pin per island, not per mesh.** The rope ring's back-rise sits ~5 cm above
the tapa tube's top row; one pin line off the mesh top leaves the tube
unpinned and the whole skirt falls to the floor. Pinning is computed per
connected island (rope island fully pinned, tube top band pinned).
- **Pinned verts must be pelvis-only.** The garment's inherited pelvis/thigh
blend would drag the pinned waistband with the stride; the script zeroes all
other groups on pinned verts.
- **glTF export with `export_force_sampling=True` (the default) exports TRS for
all 185 bones**, not just the animated ones — 555 tracks including the body.
`export_force_sampling=False` + `ACTIVE_ACTIONS` yields exactly 120 tracks,
all `skirt_*` rotations. The exporter also won't name the animation on this
path; the script renames it in the GLB JSON post-export.
- **Blender 5.1 has no `Action.fcurves`** — slotted actions only
(`slots.new``layers.new``strips.new(type="KEYFRAME")`
`channelbags.new(slot=...)``bag.fcurves.new(..., group_name=...)`), and the
object must bind `action_slot` (same for NLA strips).
- **Never `scene.frame_set` backward into a cached cloth range** to grab a
still — it re-evaluates stale state (run 4 produced two byte-identical
"different phase" sim renders). Sim stills are shot inside the capture loop.
## Open problems / unknowns
- **Sync contract.** The clip is one gait cycle keyed 1:1 with the body clip's
cycle. If the game ever plays `run` at a scaled speed, the skirt layer must
scale identically or the cloth will fight the legs.
- **Gait transitions.** Run→walk→idle crossfades of the skirt layer are
unproven; a naive crossfade of two phase-locked clips can momentarily halve
the flare amplitude.
- **Collision deafness during override.** With the spring sim off, nothing
pushes the skirt off world geometry (stairs, sitting). The bake only knows
the legs.
- **Multi-garment.** One clip per (body, garment, gait) — the tapa long/short
and piupiu each need their own bake. Matrix is small today; worth a driver
config if it grows.
- **Wind/idle sway.** Baked clips are dead when the character stands still;
the spring sim (or a third, subtle idle bake) still owns that.
@@ -0,0 +1,626 @@
#!/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")
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

+81
View File
@@ -0,0 +1,81 @@
️ blendkit: Verbose is enabled [14:15:34.159, addon_updater.py:150]
️ blendkit: Read in JSON settings from file [14:15:34.160, addon_updater.py:150]
️ blendkit: Ports reordered so first port is now 62485 (previous index was 0) [14:15:34.162, client_lib.py:156]
BlenderMCP: cannot start server in background mode (blender -b) - commands would never execute
BlenderMCP: run Blender with a GUI, or use a virtual display: xvfb-run -a blender
BlenderMCP addon registered
Created history step 74546992-0996-484d-9ad9-72034ae322a5
️ blendkit: Created new search history step [14:15:34.166, search.py:2698]
️ blendkit: No temporarily enabled addons to clean up [14:15:34.166, download.py:419]
[skirt_gait] body = /Users/behcetozanbozkurt/Documents/tinqs-ltd/ariki-game/assets/quaternius/derived-bodies/Regular_Female_SkirtRig24.glb
[skirt_gait] garment = /Users/behcetozanbozkurt/Documents/tinqs-ltd/ariki-game/assets/quaternius/outfits/chamorro/Female_Tapa_Skirt_Regular.gltf
[skirt_gait] anim = /Users/behcetozanbozkurt/Documents/tinqs-ltd/ariki-game/assets/quaternius/anim/UAL1.glb :: Jog_Fwd_Loop
️ blendkit: Unregistering Blendkit add-on [14:15:34.174, __init__.py:2973]
️ blendkit: No temporarily enabled addons to clean up [14:15:34.174, download.py:419]
BlenderMCP server stopped
BlenderMCP addon unregistered
14:15:34 | INFO: Data are loaded, start creating Blender stuff
14:15:34 | INFO: Blender create Mesh node Face
14:15:34 | INFO: Blender create Mesh node Face.001
14:15:34 | INFO: Blender create Mesh node Female_Regular.001
14:15:34 | INFO: glTF import finished in 0.07s
[skirt_gait] body: rig=Armature bones=185 main mesh=Female_Regular (7310 verts)
[skirt_gait] ring: 24 strands x 5 segments; strand 0 chain: ['skirt_00', 'skirt_00_01', 'skirt_00_02', 'skirt_00_03', 'skirt_00_04']
[skirt_gait] ring top z=0.952 hem z=0.523 knots: ['0.952', '0.899', '0.829', '0.733', '0.628', '0.523']
14:15:34 | INFO: Data are loaded, start creating Blender stuff
14:15:34 | INFO: Blender create Mesh node Female_Tapa_Skirt_Regular
14:15:34 | INFO: glTF import finished in 0.04s
[skirt_gait] garment: Female_Tapa_Skirt_Regular (2304 verts), carrier armature: Armature.001
[skirt_gait] welded garment: 2304 -> 612 verts
[skirt_gait] garment rest z span: 1.119 .. 0.403
[skirt_gait] garment islands: [396, 216] verts, pin lines: ['1.025', '1.079']
[skirt_gait] bins: 120 cells, 0 empty; waistband pin verts: 184
14:15:34 | INFO: Data are loaded, start creating Blender stuff
14:15:35 | INFO: Blender create Mesh node Mannequin
14:15:37 | INFO: glTF import finished in 2.48s
[skirt_gait] clip 'Jog_Fwd_Loop': frames 0..28 -> cycle 28f @ 30fps (0.933s)
[skirt_gait] sim: settle 20f + 3 cycles = frames 1..104
[skirt_gait] render engine: BLENDER_EEVEE
/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/bake_skirt_gait.py:377: DeprecationWarning: 'World.use_nodes' is expected to be removed in Blender 6.0
world.use_nodes = True
[skirt_gait] simulating frames 1..104; capture 77..104
00:08.729 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/sim_f01_front45.png'
00:09.112 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/sim_f01_side.png'
00:10.202 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/sim_f15_front45.png'
00:10.606 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/sim_f15_side.png'
[skirt_gait] sim done
[skirt_gait] fitting bone chains to cloth targets...
[skirt_gait] writing action 'SkirtJogFwd': 120 bones x 28 frames
00:11.817 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f01_front45.png'
00:12.209 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f01_side.png'
00:12.622 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f01_back45.png'
00:13.031 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f07_front45.png'
00:13.427 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f07_side.png'
00:13.830 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f07_back45.png'
00:14.231 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f14_front45.png'
00:14.627 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f14_side.png'
00:15.023 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f14_back45.png'
00:15.416 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f21_front45.png'
00:15.804 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f21_side.png'
00:16.211 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f21_back45.png'
00:16.612 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f26_front45.png'
00:17.011 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f26_side.png'
00:17.415 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f26_back45.png'
00:17.822 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f28_front45.png'
00:18.215 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f28_side.png'
00:18.611 render | Saved: '/Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/renders/baked_f28_back45.png'
[skirt_gait] baked renders done
Info: Saved as "work.blend"
INFO Draco mesh compression is available, use library at /Applications/Blender.app/Contents/Resources/5.1/scripts/addons_core/io_scene_gltf2/libextern_draco.dylib
14:15:52 | INFO: Starting glTF 2.0 export
14:15:52 | INFO: Finished glTF 2.0 export in 0.15310096740722656 s
[skirt_gait] EXPORTED /Users/behcetozanbozkurt/Documents/tinqs-ltd/animation/clothing/skirt_gait_spike/out/SkirtJogFwd.glb (229572 bytes)
[skirt_gait] verify: animations=['Animation'] tracks=120 dur=0.933s non-skirt targets=[]
[skirt_gait] renamed animation -> 'SkirtJogFwd'
[skirt_gait] VERIFY OK: skirt-only tracks
[skirt_gait] DONE
Blender 5.1.2 (hash ec6e62d40fa9 built 2026-05-19 01:30:33)
Blender quit
exit=0