627 lines
26 KiB
Python
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")
|