feat: implement automated 3D character pipeline with retargeting and rig management tools

This commit is contained in:
Nicholas Butzke
2026-07-24 20:52:35 -04:00
parent afc954e129
commit 374d9f9822
19 changed files with 3220 additions and 221 deletions
+259
View File
@@ -0,0 +1,259 @@
extends SkeletonModifier3D
class_name SpringBones
## Secondary motion for cloth, hair and accessories.
##
## The animation clips drive the BODY and nothing else — tools/retarget.py
## deliberately exports no tracks at all for skirt, hair or accessory bones (see
## its `export_optimize_animation_keep_anim_armature=False`). This is what moves
## them, and it is the half of the pipeline that makes clothes read as clothes.
##
## Why this rather than skin weights. Linear-blend skinning can only ever make a
## garment a rigid shell of whatever bones it is weighted to: weight a skirt to
## the thighs and it becomes trousers, weight it to the hips and it becomes a
## bell that never moves. Neither is cloth. A skirt is cloth because it LAGS —
## it keeps going when the hips stop, swings out through a turn, and floats on
## the way up through a jump. That is inertia, and inertia has to be integrated,
## not skinned. So the thigh stays solid (authored weights, its own bone) while
## the skirt hanging over it is free to move differently — which is exactly the
## split the model was rigged for and the old pipeline flattened away.
##
## Each bone is a damped spring holding its tip toward where rigidly following
## its parent would have put it:
##
## a = (rest_tip - tip) * w^2 - v * 2*zeta*w + gravity
##
## A real spring rather than the usual Verlet blend, because it is integrated
## against the actual frame delta and so behaves the same at 30 fps and 240.
## The tip is then pinned back to the bone's length (cloth stretches far less
## than it swings) and pushed out of the leg capsules, so a skirt swings AROUND
## a thigh instead of through it.
##
## Bone lengths and the leg capsule radii are MEASURED from the model's own
## geometry at build time and read from <model>.rig.json — a glTF skeleton
## carries no bone tails at all, and Taila's 21 skirt panel bones have no
## children either, so there is nothing in the skeleton itself that says which
## way a panel hangs or how thick a thigh is.
## Per class: w = stiffness as an angular frequency (rad/s), zeta = damping
## ratio (1.0 is critical, lower overshoots), gravity in m/s^2.
##
## Hair is stiffer and lighter than cloth so it settles quickly instead of
## wobbling; a skirt is slacker and heavier so it lags and swings. Gravity is
## modest for both because the AUTHORED rest pose already has the garment
## hanging — this only biases the droop while the body accelerates.
const TUNING := {
"hair": {"w": 16.0, "zeta": 0.34, "gravity": 3.0},
"skirt": {"w": 11.0, "zeta": 0.30, "gravity": 5.0},
"cloth": {"w": 12.0, "zeta": 0.32, "gravity": 4.5},
"cape": {"w": 9.0, "zeta": 0.28, "gravity": 5.5},
"coat": {"w": 11.0, "zeta": 0.30, "gravity": 5.0},
"scarf": {"w": 12.0, "zeta": 0.30, "gravity": 4.0},
"ribbon": {"w": 14.0, "zeta": 0.28, "gravity": 3.5},
"tail": {"w": 13.0, "zeta": 0.30, "gravity": 3.0},
"sleeve": {"w": 14.0, "zeta": 0.34, "gravity": 3.5},
"breast": {"w": 20.0, "zeta": 0.40, "gravity": 2.0},
"bust": {"w": 20.0, "zeta": 0.40, "gravity": 2.0},
}
const DEFAULT_TUNING := {"w": 13.0, "zeta": 0.32, "gravity": 4.0}
## How far a tip may stray from where rigidly following would put it, as a
## fraction of the bone's length. Cloth swings; it does not stretch.
const MAX_STRAY := 0.6
## A frame delta longer than this is a hitch or a load spike. Integrating it
## launches every chain across the map, so it is clamped instead.
const MAX_STEP := 1.0 / 30.0
## Origin jump (metres in one frame) that means a teleport — respawn, or the
## model being reparented — rather than movement. Chains snap instead of whip.
const TELEPORT := 1.5
## Ceiling on tip speed, and on how finely one frame may be subdivided. Both are
## backstops: nothing on a character legitimately moves this fast, and four
## substeps already covers a 30 fps frame at the stiffest tuning here.
const MAX_SPEED := 12.0
const MAX_SUBSTEPS := 4
var _chains: Array = []
var _colliders: Array = []
var _tip: PackedVector3Array = PackedVector3Array()
var _vel: PackedVector3Array = PackedVector3Array()
var _settled: bool = false
var _last_usec: int = 0
## Build from the sidecar written by tools/retarget.py. Returns how many bones
## are being driven, so the caller can log or disable itself when there are none.
func setup(skel: Skeleton3D, info: Dictionary) -> int:
_chains.clear()
_colliders.clear()
if skel == null or info.is_empty():
return 0
var total := 0
for entry in info.get("chains", []):
var parent_name: String = entry.get("root_parent", "")
var parent := skel.find_bone(parent_name) if parent_name != "" else -1
if parent < 0:
continue
var bones := PackedInt32Array()
var tips := PackedVector3Array()
var names: Array = entry.get("bones", [])
var raw_tips: Array = entry.get("tips", [])
for i in names.size():
var idx := skel.find_bone(String(names[i]))
if idx < 0:
continue
var tip := Vector3.ZERO
if i < raw_tips.size():
var t: Array = raw_tips[i]
if t.size() == 3:
tip = Vector3(t[0], t[1], t[2])
if tip.length() < 0.001:
continue # no measurable extent — nothing to swing
bones.append(idx)
tips.append(tip)
if bones.is_empty():
continue
var tune: Dictionary = TUNING.get(String(entry.get("class", "")), DEFAULT_TUNING)
_chains.append({
"parent": parent,
"bones": bones,
"tips": tips,
"w": float(tune["w"]),
"zeta": float(tune["zeta"]),
"gravity": float(tune["gravity"]),
})
total += bones.size()
for c in info.get("colliders", []):
var a := skel.find_bone(String(c.get("bone", "")))
var b := skel.find_bone(String(c.get("child", "")))
if a >= 0 and b >= 0:
_colliders.append({"a": a, "b": b, "r": float(c.get("radius", 0.1))})
_tip.resize(total)
_vel.resize(total)
_settled = false
_last_usec = 0
return total
func _delta() -> float:
var now := Time.get_ticks_usec()
if _last_usec == 0:
_last_usec = now
return 1.0 / 60.0
var dt := float(now - _last_usec) / 1000000.0
_last_usec = now
return clampf(dt, 1.0 / 480.0, MAX_STEP)
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null or _chains.is_empty():
return
var dt := _delta()
var to_world := skel.global_transform
var k := 0
for chain in _chains:
var bones: PackedInt32Array = chain["bones"]
var tips: PackedVector3Array = chain["tips"]
var w: float = chain["w"]
var damp: float = 2.0 * float(chain["zeta"]) * w
var pull: float = w * w
var gravity := Vector3.DOWN * float(chain["gravity"])
# World transform of the bone this chain hangs from, already posed by
# the animation and the shooter pose layer this frame.
var parent_world: Transform3D = to_world * skel.get_bone_global_pose(chain["parent"])
for i in bones.size():
var bone: int = bones[i]
# Where rigidly following the parent would put this bone. Built from
# the REST offset, not the current pose, or last frame's spring
# result would compound into a permanent drift.
var rigid: Transform3D = parent_world * skel.get_bone_rest(bone)
var origin := rigid.origin
var rest_tip: Vector3 = rigid * tips[i]
var arm := rest_tip - origin
var length := arm.length()
if length < 0.0001:
k += 1
continue
var tip := _tip[k]
var vel := _vel[k]
if not _settled or tip.distance_to(rest_tip) > TELEPORT:
tip = rest_tip
vel = Vector3.ZERO
# Substep so the spring can never overshoot, however stiff it is or
# however long the frame was. A single explicit step is only stable
# while w*dt stays small; past that it gains energy every frame and
# the chain flies off the model.
var sub := clampi(int(ceil(dt * w / 0.4)), 1, MAX_SUBSTEPS)
var h := dt / float(sub)
for _s in sub:
vel += ((rest_tip - tip) * pull - vel * damp + gravity) * h
tip += vel * h
# Cloth swings but barely stretches: hold the tip on the bone's
# own sphere.
var offset := tip - origin
tip = origin + (offset if offset.length() > 0.0001 else arm).normalized() * length
# Velocity is taken OUT of the constraint rather than recovered
# from a finite difference afterwards. Dividing a projected
# position change by a wall-clock delta is what produced the
# 1.46 m fling: a short frame turns a millimetre of correction
# into metres per second, and the next frame launches the chain.
var radial := (tip - origin) / length
vel -= radial * vel.dot(radial)
vel = vel.limit_length(MAX_SPEED)
# Cap how far the tip may stray from the rigid pose, so a hard turn
# cannot fold a chain back through the body.
var stray := tip - rest_tip
var limit := length * MAX_STRAY
if stray.length() > limit:
tip = origin + ((rest_tip + stray.normalized() * limit) - origin).normalized() * length
tip = _push_out_of_legs(skel, to_world, tip, origin, length)
_vel[k] = vel
_tip[k] = tip
# Turn the tip direction back into this bone's local rotation.
var swing := Quaternion(arm.normalized(), (tip - origin).normalized())
var world := Transform3D(Basis(swing) * rigid.basis, origin)
var local := parent_world.affine_inverse() * world
skel.set_bone_pose_rotation(bone, local.basis.get_rotation_quaternion())
parent_world = world
k += 1
_settled = true
## Keep a tip outside the leg capsules, so a skirt swings AROUND a thigh rather
## than through it. Radii are measured from the model's own body geometry at
## build time (tools/retarget.py::_leg_colliders).
func _push_out_of_legs(skel: Skeleton3D, to_world: Transform3D, tip: Vector3,
origin: Vector3, length: float) -> Vector3:
for col in _colliders:
var a: Vector3 = to_world * skel.get_bone_global_pose(col["a"]).origin
var b: Vector3 = to_world * skel.get_bone_global_pose(col["b"]).origin
var ab := b - a
var d2 := ab.length_squared()
var t := 0.0 if d2 < 0.000001 else clampf((tip - a).dot(ab) / d2, 0.0, 1.0)
var closest := a + ab * t
var away := tip - closest
var dist := away.length()
var r: float = col["r"]
if dist >= r:
continue
if dist < 0.0001:
away = (tip - origin).cross(ab)
if away.length() < 0.0001:
continue
tip = closest + away.normalized() * r
# Pushing off a capsule moves the tip off its own sphere; put it back.
var offset := tip - origin
if offset.length() > 0.0001:
tip = origin + offset.normalized() * length
return tip