Files
Papay-Shooter/characters/spring_bones.gd
T
Nicholas ButzkeandClaude Opus 5 0dd9d01ac7 fix(cloth): solve the garment instead of repairing it five times
The skirt glitched when the character moved and the thigh still came
through it. Both came from the same place: the solver integrated one
spring per bone and then ran four more passes behind it — resolve the
collision against the target, resolve it again against the answer, relax
the cross-panel links and rebuild every pose from the corrected tips,
then walk a separate ancestor "lift" — each writing bone poses the next
read back and partly undid. The lift wrote poses that were never fed back
into the spring state at all, so every frame began by pulling against a
pose the springs did not know about.

Replaced with one position-based solve, the shape Magica Cloth 2's
BoneCloth uses. Every JOINT is a particle, so a bone's head can move;
predict with inertia in the anchor's frame; relax length, bend, backstop,
the cross-panel links and the colliders together; convert to rotations
once at the end. A contact with no rotational leverage is now resolved by
the panel moving, which is what a bodily chain push, an ancestor lift and
a drape weight were each approximating separately.

Measured, at a dead-still idle and over a movement sweep:

  idle jitter (skirt)      0.53 -> 0.025 deg/frame, worst 24 -> 1.9
  settling after a dash    103 -> 18 mm of leg left inside the skirt
  fall / air / walk         82 -> 40, 96 -> 75, 72 -> 76 mm
  run / slide / dash        unchanged, ~95 mm

The idle buzz and the failure to come home after a hard move are gone —
those were the "glitches out". Peak clipping in a run, a slide and a dash
is NOT fixed and is still around 95 mm.

Four things this turned up on the way:

- debug/cloth_clip_check.gd was measuring the animation, not the render.
  Godot restores bone poses after the modifier pass, so reading them with
  force_update_all_bone_transforms() afterwards sees nothing any modifier
  did. It reported the same ~95 mm with collision fully enabled and with
  it commented out. It now observes from inside the pass. Every number
  ever taken from this tool before now was measuring the wrong pose.

- The collision hulls came from ten farthest-point samples per bone,
  which describe a panel's corners and hem and leave its MIDDLE unsampled
  — exactly where a thigh comes through. Built from the real mesh at load
  time instead.

- The drape term is gone. It was there to move a panel the old solver
  could not, and once the solver could, it was worse in every state but a
  walk and cost 20x in stability: its target sat inside the leg the
  collision was pushing out of, so the two ran against each other forever.

- Cost was 10.9 ms per character. The inner loop rebuilt every capsule
  and reallocated the hull array for every (bone, collider, pass). Now
  2.6 ms at full quality with a distance LOD behind it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 03:07:30 -04:00

1239 lines
54 KiB
GDScript

extends SkeletonModifier3D
class_name SpringBones
## Secondary motion for cloth, hair and accessories — a position-based cloth
## solver over the rig's own cosmetic bones.
##
## 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 A PARTICLE SOLVER RATHER THAN A SPRING PER BONE
##
## The previous version integrated one damped spring per bone and then tried to
## repair the result with four more passes stacked behind it: resolve the
## collision against the spring's target, resolve it again against the answer,
## relax the cross-panel links and rebuild every pose from the corrected tips,
## then walk a separate cyclic-descent "lift" up each chain. Each stage wrote
## bone poses the next stage read back and partly undid, and the lift wrote poses
## that were never fed back into the spring state at all — so the spring spent
## the next frame pulling against a pose it did not know about. That is a
## feedback loop, and it is what the skirt "glitching out when it is moved"
## actually was. It also could not stop the thigh coming through, because every
## one of those stages could only ROTATE a bone about its own head, and the
## contacts that matter sit near that head.
##
## This solves the whole garment the way cloth is actually solved — the same
## shape as Magica Cloth 2's BoneCloth, which is what the Hoyoverse-style
## character pipelines use:
##
## 1. Every JOINT of every chain is a particle. Bone i spans particle i to
## particle i+1, so a bone's head is a particle too and can move.
## 2. Predict: integrate each particle with inertia, gravity and wind, in the
## chain anchor's frame so simply travelling costs nothing.
## 3. Relax ALL the constraints together, several Gauss-Seidel iterations:
## bone length, a cone limit against the animated pose, a backstop on how
## far the garment may stray, the horizontal links that hold neighbouring
## panels together, and the leg/body colliders.
## 4. Convert the settled particles into bone rotations ONCE, at the end.
##
## Because collision is a constraint inside that loop rather than a repair pass
## behind it, nothing undoes it and it converges with everything else. And
## because a bone's HEAD is a particle, a contact with no rotational leverage is
## resolved by the whole panel moving — which is what a real skirt does when a
## thigh comes up under it, and what three separate bolt-on stages (a bodily
## chain push, an ancestor lift, a drape weight) were each approximating.
##
## Bone lengths, hull samples and the collider 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, 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 pulling back toward the animated pose (rad/s), zeta =
## damping ratio (1.0 is critical, lower overshoots), gravity in m/s^2, wind =
## how far the class streams in the airflow, as a fraction of its own length.
##
## 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 small because the AUTHORED rest pose already has the garment
## hanging; this only biases the droop while the body accelerates. It is not
## free — a constant force offsets the resting particle by g/w^2, so a large
## value pulls the hem below where it was modelled, straight into the thigh it
## then has to be pushed back out of.
##
## `stray` is the backstop — see MAX_STRAY. It is a per-class number because the
## two classes want opposite things from it. Hair has nothing to get out of the
## way of, so a tight leash is pure benefit and is what stops a long strand
## flailing. A garment has to be able to lie along a thigh that has swung
## horizontal in a slide, which is most of a panel's own length of travel, so the
## same leash on cloth is the clipping.
## `hinge` / `bend` are the swing limits — see MAX_SWING_ROOT. They are per class
## because the two ends of a chain mean different things to different garments. A
## SKIRT hangs from a waistband and hinges there freely: a knee coming up to hip
## height puts a thigh horizontally through where the front panel hangs, and the
## panel has to ride up onto it, which is most of a right angle. HAIR grows out
## of a scalp — a strand that hinges 70 degrees at the root has come off the
## head. Same constraint, opposite settings, and one shared number was wrong for
## both.
const TUNING := {
"hair": {"w": 15.0, "zeta": 0.45, "gravity": 1.6, "wind": 0.30, "stray": 0.45,
"hinge": 26.0, "bend": 52.0},
"skirt": {"w": 12.0, "zeta": 0.48, "gravity": 2.0, "wind": 0.18, "stray": 1.25,
"hinge": 78.0, "bend": 55.0},
"cloth": {"w": 13.0, "zeta": 0.55, "gravity": 1.9, "wind": 0.18, "stray": 1.10,
"hinge": 62.0, "bend": 52.0},
"cape": {"w": 10.0, "zeta": 0.50, "gravity": 2.4, "wind": 0.35, "stray": 1.10,
"hinge": 55.0, "bend": 55.0},
"coat": {"w": 12.0, "zeta": 0.52, "gravity": 2.1, "wind": 0.22, "stray": 1.10,
"hinge": 62.0, "bend": 52.0},
"scarf": {"w": 13.0, "zeta": 0.55, "gravity": 1.8, "wind": 0.35, "stray": 0.70,
"hinge": 40.0, "bend": 58.0},
"ribbon": {"w": 15.0, "zeta": 0.52, "gravity": 1.6, "wind": 0.35, "stray": 0.70,
"hinge": 35.0, "bend": 58.0},
"tail": {"w": 14.0, "zeta": 0.55, "gravity": 1.5, "wind": 0.20, "stray": 0.60,
"hinge": 30.0, "bend": 50.0},
"sleeve": {"w": 15.0, "zeta": 0.60, "gravity": 1.6, "wind": 0.20, "stray": 0.60,
"hinge": 30.0, "bend": 45.0},
"breast": {"w": 21.0, "zeta": 0.65, "gravity": 1.0, "wind": 0.0, "stray": 0.30,
"hinge": 14.0, "bend": 14.0},
"bust": {"w": 21.0, "zeta": 0.65, "gravity": 1.0, "wind": 0.0, "stray": 0.30,
"hinge": 14.0, "bend": 14.0},
}
const DEFAULT_TUNING := {"w": 14.0, "zeta": 0.55, "gravity": 1.8, "wind": 0.2,
"stray": 0.55, "hinge": 40.0, "bend": 52.0}
## The most a bone may turn away from the direction the animated pose gives it —
## GRADED along the chain, tighter where it is anchored and looser at the free
## end. Angles still compound down a chain, so a strand curves well past these
## overall, and how far the garment may travel in total is bounded separately by
## MAX_STRAY.
##
## The ROOT limit is not the 20 degrees Magica Cloth 2's skirt guide suggests,
## and the difference is geometry rather than taste. Their figure is for a chain
## whose first bone is a large share of the skirt; the first segment of one of
## Taila's panels is 49 mm of a 288 mm panel, so 20 degrees there moves the panel
## below it by SEVENTEEN MILLIMETRES. Measured over a movement sweep, the solver
## saw 104 mm of thigh inside the front of the skirt, pushed on it every frame
## and every iteration, and left 95 mm of it — not because it was blind or
## mistuned, but because the joint it had to turn was allowed 17 mm of travel.
##
## The two limits also mean different things. Between segments this is BENDING
## stiffness — how sharply a sheet of cloth may crease — and it belongs tight.
## At the root it is a HINGE at the waistband, and a skirt swinging clear of a
## knee coming up under it really does hinge 40-odd degrees there.
##
## Collision is applied after this within each iteration and may exceed it — the
## same precedence Magica uses ("collision detection has a higher priority than
## this limit").
const MAX_SWING_ROOT := deg_to_rad(42.0)
const MAX_SWING_TIP := deg_to_rad(58.0)
## How far a particle may end up from where the animation alone would have put
## it, as a fraction of the WHOLE chain's length.
##
## The cone limit above is measured against the PARENT, so a four-bone chain can
## legitimately curl to 4x its per-joint limit and the far end of a long strand
## gets thrown a long way by errors that are individually reasonable. This bounds
## the total. It is a hard positional clamp and it is the single thing that turns
## "hair spazzing about" into hair — Magica calls it Max Distance and it is the
## first parameter their setup guide reaches for.
##
## Measured against the whole garment, NOT against the chain hanging above each
## joint. Scaling it per joint sounds right and throttles exactly the wrong
## place: the second segment of one of Taila's skirt panels has only 98 mm of
## chain above it, which allowed it 44 mm of travel while the thigh under it
## needed it to move 100 mm — so the leg went through the front of the skirt and
## every collider measurement said the solver had fired. The garment is one piece
## of cloth and the whole of it has the same room to move.
##
## Overridden per class in TUNING; this is only the fallback for a class with no
## entry of its own.
const MAX_STRAY := 0.55
## Solver iterations. Gauss-Seidel over length, cone, backstop, collision and the
## cross-panel links. Four is enough for the chains here (four bones deep); the
## extra two buy the collision convergence that stops a thigh showing through on
## the frames where a leg sweeps hard.
const ITERATIONS := 3
## Collision-and-length-only passes after the main loop. See the solve.
const SETTLE_PASSES := 1
## Detail level, set by the owner from how much the viewer can actually see —
## 0 is the full solve, 3 is the cheapest.
##
## This is not optional polish. The solver relaxes 127 joints against five
## capsules several times a frame, in GDScript, and collision is three quarters
## of that: a full-quality character costs about 2.6 ms, which is a sixth of a
## 60 fps frame for ONE of them. A shooter has several on screen and only the
## nearest is being looked at closely enough for a hem to matter.
##
## Each level drops a pass or the collision, in the order that costs the least
## visually — the shape constraints hold the silhouette on their own, so a
## distant character still has a skirt that swings; it just stops being carefully
## kept off a thigh nobody can resolve at that range.
const LOD_ITERS := [3, 2, 2, 1]
const LOD_SETTLE := [1, 1, 0, 0]
const LOD_COLLIDE := [true, true, true, false]
var lod: int = 0: set = _set_lod
var _iters: int = ITERATIONS
var _settles: int = SETTLE_PASSES
var _collide: bool = true
func _set_lod(v: int) -> void:
lod = clampi(v, 0, LOD_ITERS.size() - 1)
_iters = LOD_ITERS[lod]
_settles = LOD_SETTLE[lod]
_collide = LOD_COLLIDE[lod]
## Inverse mass of a chain's ROOT joint, relative to the joints below it.
##
## Not zero. A contact against the top of a thigh has almost no lever on the bone
## that owns it, so no rotation can clear it — which is what defeated every
## earlier round of tuning. Letting the root carry a little of the correction
## lets the whole panel ride up over the leg instead, rigidly, so the waistband
## seam cannot be stretched open by it. Small, and hard-clamped by ROOT_STRAY, so
## the garment can never detach from the body.
const ROOT_INV_MASS := 0.25
const ROOT_STRAY := 0.045
## 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
## Speed at which the wind term reaches full strength.
const WIND_SPEED := 9.0
## WHY THERE IS NO "DRAPE" TERM HERE ANY MORE.
##
## There used to be one: each cloth bone measured how much of it rested on each
## leg, and took a share of that leg's motion before the solver ran, so a thigh
## arrived with the skirt already moving out of its way. Hoyoverse-style rigs do
## carry a partial constraint from the leg onto the upper skirt bones, and the
## idea is sound — but it was here to paper over a solver that could not push a
## panel off a thigh at all, and it cost far more than it bought once that was
## fixed. Measured over the movement sweep in debug/cloth_clip_check.gd, with it
## against without:
##
## run 101 -> 92 mm fall 82 -> 49 mm
## air 96 -> 88 mm dash 136 -> 95 mm
## slide 97 -> 96 mm idle after a dash 103 -> 20 mm
##
## It was worse in every state but a walk, and that last row is the one that
## mattered: WITH the drape the garment never came home after a hard move, it
## just sat displaced. It also cost an order of magnitude in stability — 0.48
## deg/frame of skirt movement at a dead-still idle against 0.05 without, because
## the target it aimed at sat inside the leg the collision was pushing out of, so
## the two ran against each other forever. That is what the skirt "glitching out"
## was, and it is gone with the term that caused it.
##
## The mesh-neighbour data it used is still read: the horizontal links below need
## it, and that is what it was always most useful for.
## HORIZONTAL links between neighbouring cloth chains.
##
## A skirt's bones are linked vertically by the parent-child hierarchy and not at
## all sideways, so every panel solves as if the ones sewn to it did not exist.
## Magica Cloth 2's BoneCloth skirt guide calls connecting them "the most
## important work when expressing a skirt", and notes that without it "the
## accuracy of collision detection will be significantly reduced" — which is
## exactly what was happening here: each panel individually satisfied its
## constraints and the garment as a whole came apart.
##
## Relaxed inside the same iteration loop as everything else, so a panel pushed
## off a thigh takes the ones sewn to it with it.
const LINK_STIFF := 0.45
## The same link resisting COMPRESSION. Much weaker: see the solve loop.
const LINK_SQUASH := 0.12
## Most links per bone, strongest first. A skirt vertex is shared with a handful
## of neighbours; linking every one of them just stiffens the garment into a
## bell.
const LINK_MAX := 4
## Classes a leg may carry. Cloth that hangs over the hips — not hair.
## Ceiling on how fast a particle may travel relative to its anchor, as a
## multiple of the bone length it hangs from. Bounds the tip speed to something
## proportional to the bone instead of a flat number that means nothing to a
## 20 mm hair segment and everything to a 140 mm skirt hem.
const MAX_RATE := 14.0
## Ceiling on how fast a chain's anchor is believed to be travelling, and how
## quickly that estimate may change. Backstops against a bad frame delta.
const MAX_CARRY := 20.0
const CARRY_SMOOTH := 0.15
const MAX_SUBSTEPS := 4
## Overlap a contact is allowed to keep before the solver acts on it.
##
## The rest-clearance table is measured in the AUTHORED rest pose, and a
## character never stands in it — the idle clip alone moves the legs enough that
## cloth hanging against a thigh grazes its capsule every frame. Without slop
## those grazes are real contacts and the idle buzzes. Six millimetres is well
## under anything the eye can find on a hem and it makes the idle silent.
const CONTACT_SLOP := 0.006
## Fractions along a bone tested against the colliders when the sidecar carries
## no hull for it. A skirt panel is a sheet, so testing only its tip lets the
## middle of the panel clip.
const SAMPLES := [0.35, 0.7, 1.0]
## How much of a collision correction the bone's HEAD takes when the contact sits
## at the head end. 1.0 would let a panel shear off its own waistband.
const COLLIDE_HEAD_SHARE := 0.85
var _chains: Array = []
var _colliders: Array = []
## [particle a, particle b, rest distance] for every horizontal link. Indices are
## into the flat particle arrays below.
var _links: Array = []
## Joint particles: one per bone plus a final tip, per chain. Kept in the chain
## ANCHOR's frame, so simply travelling through the world cancels exactly and the
## solver only ever deals with the motion that should actually move cloth.
var _q: PackedVector3Array = PackedVector3Array()
var _v: PackedVector3Array = PackedVector3Array()
## Scratch, world space, rebuilt every frame. Kept as members so the per-frame
## solve does not reallocate.
var _pw: PackedVector3Array = PackedVector3Array() # working positions
var _ref: PackedVector3Array = PackedVector3Array() # animated reference
var _rdir: PackedVector3Array = PackedVector3Array() # reference bone dirs
var _rbasis: Array = [] # animated bone bases
var _imass: PackedFloat32Array = PackedFloat32Array()
## The collider capsules in world space, rebuilt ONCE per frame.
##
## They were being rebuilt inside the innermost loop — for every bone, against
## every collider, on every relaxation pass — which is two skeleton queries and
## two transform multiplies about five thousand times a frame for a result that
## cannot change while the solve is running. That alone was most of the 10.9 ms
## per character this used to cost.
var _cap_a: PackedVector3Array = PackedVector3Array()
var _cap_b: PackedVector3Array = PackedVector3Array()
var _cap_rh: PackedFloat32Array = PackedFloat32Array()
var _cap_rt: PackedFloat32Array = PackedFloat32Array()
## Scratch for one bone's posed hull points, reused instead of reallocated.
var _pts: PackedVector3Array = PackedVector3Array()
## Where each chain's anchor was last frame, to measure how fast it is carrying
## the cloth through the world.
var _anchor_prev: PackedVector3Array = PackedVector3Array()
var _carry_prev: PackedVector3Array = PackedVector3Array()
var _time: float = 0.0
var _settled: bool = false
## Force a fixed timestep instead of the engine clock. 0 = normal. Set by
## debug/cloth_settle_check.gd so settling can be measured at a REAL frame rate:
## headless runs uncapped, where the clamped sub-millisecond delta makes every
## chain look motionless whatever the tuning.
var fixed_delta: float = 0.0
## Diagnostics for the debug scripts — how many collider pushes fired, and how
## deep the worst contact on each bone got.
var _hits: int = 0
var _hit_frames: int = 0
var _hit_depth: Dictionary = {}
var _applied: Dictionary = {}
var _left: Dictionary = {}
var debug_bone: int = -1
var debug_swing: float = 0.0
var debug_len: float = 0.0
var debug_origin: Vector3 = Vector3.ZERO
var _calls: int = 0
var _frames_seen: Dictionary = {}
var _dt_min: float = 999.0
var _dt_max: float = 0.0
## How many times the solver ran per rendered frame, and the delta spread.
func debug_rate() -> String:
var n: int = maxi(_frames_seen.size(), 1)
var out := "calls/frame %.2f dt %.5f..%.5f" % [float(_calls) / n, _dt_min, _dt_max]
_calls = 0
_frames_seen.clear()
_dt_min = 999.0
_dt_max = 0.0
return out
## bone index -> [total correction applied (mm), deepest overlap seen (mm)].
func debug_effort_report() -> Dictionary:
var out := {}
for b in _applied:
out[b] = [_applied[b] * 1000.0, _left.get(b, 0.0) * 1000.0]
_applied.clear()
_left.clear()
return out
## bone index -> worst penetration (metres) seen since the last call.
func debug_hit_report() -> Dictionary:
var d := _hit_depth.duplicate()
_hit_depth.clear()
return d
## Average collider pushes per frame since the last call. A chain that is
## settling should report ~0; a steady non-zero count means the solver and the
## colliders are fighting, which no amount of damping will settle.
func debug_collisions_per_frame() -> float:
var v := float(_hits) / maxf(_hit_frames, 1.0)
_hits = 0
_hit_frames = 0
return v
## 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.
##
## `hull_override` is bone name -> the points, in that bone's own rest space,
## that the collision should test. It comes from the MESH ITSELF at load time
## (see SkinnedPlayerModel._cloth_hulls) and it replaces the ten farthest-point
## samples the sidecar carries. Farthest-point sampling spans a panel's corners
## and hem and leaves its MIDDLE unsampled, which is precisely where a thigh
## comes up through a skirt: measured over a movement sweep the solver reported
## every contact resolved while 158 vertices were 95 mm inside a leg, because
## not one of them was a point it was looking at.
func setup(skel: Skeleton3D, info: Dictionary, hull_override: Dictionary = {}) -> int:
_chains.clear()
_links.clear()
_colliders.clear()
if skel == null or info.is_empty():
return 0
# Colliders first: each chain's per-bone clearance is measured against them.
for c in info.get("colliders", []):
var ca := skel.find_bone(String(c.get("bone", "")))
var cb := skel.find_bone(String(c.get("child", "")))
if ca >= 0 and cb >= 0:
var tail := float(c.get("radius_tail", c.get("radius", 0.1)))
_colliders.append({
"a": ca, "b": cb,
# How far down the bone the capsule starts. The top of a thigh is
# hip, not limb — see tools/retarget.py::_leg_colliders.
"from": float(c.get("from", 0.0)),
"lid": bool(c.get("lid", false)),
"rh": float(c.get("radius_head", tail)),
"rt": tail,
})
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 hulls: Array = []
var names: Array = entry.get("bones", [])
var raw_tips: Array = entry.get("tips", [])
var raw_hulls: Array = entry.get("hulls", [])
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
# Chains must stay a strict parent->child run: the particle chain
# below assumes bone i+1 hangs off bone i, and a gap would put a
# distance constraint across a joint that is not there.
if bones.size() > 0 and skel.get_bone_parent(idx) != bones[bones.size() - 1]:
break
bones.append(idx)
tips.append(tip)
var hull: PackedVector3Array = hull_override.get(
skel.get_bone_name(idx), PackedVector3Array())
if hull.is_empty() and i < raw_hulls.size():
for h in raw_hulls[i]:
if h.size() == 3:
hull.append(Vector3(h[0], h[1], h[2]))
hulls.append(hull)
if bones.is_empty():
continue
var cls := String(entry.get("class", ""))
var tune: Dictionary = TUNING.get(cls, DEFAULT_TUNING)
# Rest LINK geometry: particle i sits at bone i's head, particle n at the
# last bone's tip. So the link below bone i reaches to the next bone's
# head, which is NOT the same vector as that bone's mesh tip — the last
# segment of a panel runs on past its own child joint to the hem.
var seg := PackedFloat32Array()
var segdir := PackedVector3Array()
var span := 0.0
for i in bones.size():
var v: Vector3 = tips[i] if i == bones.size() - 1 \
else skel.get_bone_rest(bones[i + 1]).origin
if v.length() < 0.0005:
v = tips[i]
seg.append(v.length())
segdir.append(v.normalized())
span += v.length()
_chains.append({
"parent": parent,
"bones": bones,
"tips": tips,
"seg": seg,
"segdir": segdir,
"span": span,
"hulls": hulls,
"class": cls,
# Everything collides now, including hair. It used to be garments
# only, because a collision push happened AFTER the integrator and so
# was deaf to every spring parameter — long back hair reaching past
# the hips got shoved out of a thigh and hauled back every frame at
# stride frequency, which is the blur. Inside the relaxation there is
# no such fight, and hair that does not collide simply passes through
# the body: measured 373 mm of leg inside the hair over a movement
# sweep, against 183 mm for the skirt that did collide.
"radii": _rest_clearances(skel, bones, tips, hulls),
"names": names,
"neighbours": entry.get("neighbours", []),
"w": float(tune["w"]),
"zeta": float(tune["zeta"]),
"gravity": float(tune["gravity"]),
"wind": float(tune.get("wind", 0.0)),
"stray": float(tune.get("stray", MAX_STRAY)),
"hinge": deg_to_rad(float(tune.get("hinge",
rad_to_deg(MAX_SWING_ROOT)))),
"bend": deg_to_rad(float(tune.get("bend", rad_to_deg(MAX_SWING_TIP)))),
})
total += bones.size()
# Flat particle layout: n+1 particles per chain.
var k := 0
for ci in _chains.size():
_chains[ci]["base"] = k
k += (_chains[ci]["bones"] as PackedInt32Array).size() + 1
_build_links(skel)
print("SpringBones: %d chains, %d particles, %d collision hull points" % [
_chains.size(), k, _hull_points()])
_q.resize(k)
_v.resize(k)
_pw.resize(k)
_ref.resize(k)
_rdir.resize(k)
_imass.resize(k)
_rbasis.resize(k)
_cap_a.resize(_colliders.size())
_cap_b.resize(_colliders.size())
_cap_rh.resize(_colliders.size())
_cap_rt.resize(_colliders.size())
for c in _colliders.size():
_cap_rh[c] = float(_colliders[c]["rh"])
_cap_rt[c] = float(_colliders[c]["rt"])
_anchor_prev.resize(_chains.size())
_carry_prev.resize(_chains.size())
# Inverse mass: heavier (stiffer) near the anchor, lighter toward the free
# end, so a correction prefers to move the hem rather than the waistband.
for ci in _chains.size():
var base: int = _chains[ci]["base"]
var n: int = (_chains[ci]["bones"] as PackedInt32Array).size()
for i in n + 1:
var along := 0.0 if n < 1 else float(i) / float(n)
_imass[base + i] = ROOT_INV_MASS if i == 0 else lerpf(0.55, 1.0, along)
_settled = false
return total
## Total collision sample points across every chain — the solver's real cost.
func _hull_points() -> int:
var n := 0
for c in _chains:
for h: PackedVector3Array in c["hulls"]:
n += h.size() if h.size() > 0 else SAMPLES.size()
return n
## The two ends of a collider capsule, with its `from` offset applied — so every
## place that tests against a limb agrees on where that limb starts.
func _capsule(skel: Skeleton3D, to_world: Transform3D, col: Dictionary) -> Array:
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
return [a.lerp(b, float(col["from"])), b]
## Same, in the skeleton's REST pose.
func _capsule_rest(skel: Skeleton3D, col: Dictionary) -> Array:
var a := skel.get_bone_global_rest(col["a"]).origin
var b := skel.get_bone_global_rest(col["b"]).origin
return [a.lerp(b, float(col["from"])), b]
## Per (bone, collider) capsule radius, capped so the REST pose never collides.
##
## The authored rest pose is by definition not clipping — the artist modelled the
## skirt over these legs. But a capsule sized from the thigh's own vertices is
## fat enough (0.11 m at Taila's hip) to swallow the skirt bones that hang right
## against it, and those bones then get shoved out and pulled straight back in
## every single frame: a limit cycle that never decays. That is cloth and hair
## "never settling", and it also leaves the collider saturated and useless
## against the clipping it exists to prevent.
##
## Capping to just inside the rest clearance makes the rest pose a valid state,
## so idle is quiet and a push only ever means the leg has genuinely swung into
## the cloth.
##
## The cap is PER POINT, not per bone. Scaling a whole bone's radius by its worst
## point switches collision off entirely for every skirt panel whose top
## naturally hangs against the thigh — which is all the ones that matter.
##
## And it is measured against the capsule radius WHERE THE POINT RESTS, not
## against the widest radius anywhere on the limb. A leg tapers 2:1 from hip to
## knee, so comparing every point to the hip radius declares the whole skirt
## already inside the capsule and hands all of it the reduced allowance meant for
## the waistband alone.
func _rest_clearances(skel: Skeleton3D, bones: PackedInt32Array,
tips: PackedVector3Array, hulls: Array) -> Array:
var out: Array = []
for i in bones.size():
var rest := skel.get_bone_global_rest(bones[i])
var origin := rest.origin
var tip: Vector3 = rest * tips[i]
var pts := _sample_points(rest, origin, tip, hulls[i])
var n := _colliders.size() * pts.size()
var caps := PackedFloat32Array()
caps.resize(n)
# How far this bone's geometry reaches from its own head, for the
# broad-phase skip in _collide_bone.
var span := 0.0
for p: Vector3 in pts:
span = maxf(span, p.distance_to(origin))
for c in _colliders.size():
var col: Dictionary = _colliders[c]
var ends := _capsule_rest(skel, col)
var a: Vector3 = ends[0]
var b: Vector3 = ends[1]
var ab := b - a
var d2 := ab.length_squared()
for j in pts.size():
var t: float = 0.0 if d2 < 0.000001 \
else clampf((pts[j] - a).dot(ab) / d2, 0.0, 1.0)
var d: float = (pts[j] - (a + ab * t)).length()
# A limb tapers 2:1 hip to knee, so measure against the radius
# WHERE THE POINT IS.
var local: float = lerpf(float(col["rh"]), float(col["rt"]), t)
caps[c * pts.size() + j] = maxf(local, d * 0.9) if d >= local else d * 0.9
out.append({"cap": caps, "span": span})
return out
## Distance links between bones of DIFFERENT chains that share mesh vertices.
##
## The garment's own topology decides what is sewn to what — `neighbours` in the
## sidecar is the weight each pair of cloth bones shares over the same vertices,
## which is the artist's answer and not a guess from names or rest distance.
##
## Only cross-chain pairs: within a chain the particle links already hold the
## bones together, and re-stating that here only fights them.
func _build_links(skel: Skeleton3D) -> void:
_links.clear()
# bone -> [chain, index]; the particle at that bone's TIP is base+index+1.
var where := {}
for ci in _chains.size():
var bones: PackedInt32Array = _chains[ci]["bones"]
for i in bones.size():
where[bones[i]] = [ci, i]
var seen := {}
for ci in _chains.size():
var chain: Dictionary = _chains[ci]
if String(chain["class"]) == "hair":
continue # hair strands hang free; linking them stiffens them into rope
var bones: PackedInt32Array = chain["bones"]
var names: Array = chain["names"]
var nbrs: Array = chain["neighbours"]
for i in bones.size():
var at: int = names.find(skel.get_bone_name(bones[i]))
if at < 0 or at >= nbrs.size():
continue
var nb: Dictionary = nbrs[at]
# Strongest few only — see LINK_MAX.
var ranked: Array = nb.keys()
ranked.sort_custom(func(a, b): return float(nb[a]) > float(nb[b]))
var made := 0
for other_name in ranked:
if made >= LINK_MAX:
break
var oi := skel.find_bone(String(other_name))
if oi < 0 or not where.has(oi) or where[oi][0] == ci:
continue
var pa: int = int(chain["base"]) + i + 1
var ob: Array = where[oi]
var pb: int = int(_chains[ob[0]]["base"]) + int(ob[1]) + 1
var key := "%d_%d" % [mini(pa, pb), maxi(pa, pb)]
if seen.has(key):
continue
seen[key] = true
made += 1
# Rest separation of the two TIPS, which is what the links hold.
var oc: Dictionary = _chains[ob[0]]
var wa: Vector3 = skel.get_bone_global_rest(bones[i]) \
* (chain["tips"] as PackedVector3Array)[i]
var wb: Vector3 = skel.get_bone_global_rest(oi) \
* (oc["tips"] as PackedVector3Array)[ob[1]]
_links.append([pa, pb, wa.distance_to(wb)])
print("SpringBones: %d horizontal links between cloth chains" % _links.size())
static func _sample_points(xform: Transform3D, origin: Vector3, tip: Vector3,
hull: PackedVector3Array) -> Array:
var out: Array = []
if hull.size() > 0:
for h in hull:
out.append(xform * h)
return out
for s in SAMPLES:
out.append(origin.lerp(tip, s))
return out
static func _seg_point_distance(p: Vector3, a: Vector3, b: Vector3) -> float:
var ab := b - a
var d2 := ab.length_squared()
var t := 0.0 if d2 < 0.000001 else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
return p.distance_to(a + ab * t)
## The ENGINE's frame delta, never a wall clock.
##
## `Time.get_ticks_usec()` deltas measured 0.002 s to 0.033 s inside a single
## second — a 16x spread. That alone is survivable, but the anchor velocity
## divides a position change by it, and that position change came from the
## animation advancing by the ENGINE's delta. When the two disagree the computed
## velocity is wrong by that ratio, so the wind and the damping reference are
## wrong by 16x frame to frame.
func _delta() -> float:
if fixed_delta > 0.0:
return fixed_delta
var dt := get_physics_process_delta_time() if Engine.is_in_physics_frame() \
else get_process_delta_time()
return clampf(dt, 1.0 / 480.0, MAX_STEP)
# ── The solve ─────────────────────────────────────────────────────────────────
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null or _chains.is_empty():
return
var dt := _delta()
_calls += 1
_frames_seen[Engine.get_process_frames()] = true
_dt_min = minf(_dt_min, dt)
_dt_max = maxf(_dt_max, dt)
_time += dt
_hit_frames += 1
var to_world := skel.global_transform
for c in _colliders.size():
var ends := _capsule(skel, to_world, _colliders[c])
_cap_a[c] = ends[0]
_cap_b[c] = ends[1]
# 1. Where the animation alone would put every joint this frame, including
# the share of each leg's swing the cloth resting on it is carried by.
var anchors: Array = []
for ci in _chains.size():
anchors.append(_build_reference(skel, to_world, ci))
# 2. Integrate. The state lives in the anchor's frame, so travelling at a
# steady speed excites nothing and only real acceleration, rotation, wind
# and gravity move the cloth.
for ci in _chains.size():
_predict(skel, ci, anchors[ci], dt)
# 3. Relax everything together.
#
# LINKS FIRST, chains second, so the last thing to touch any particle in an
# iteration is its collision. Order is not cosmetic here. With the chains
# first, every iteration ended by pulling neighbouring panels back toward
# their rest separation — straight back into the leg that had just been
# cleared. Measured on a slide: 93 mm of thigh inside a front panel went in,
# 95 mm came out, and with the links switched off entirely the same frame
# solved down to 27 mm. Collision outranks the garment's own shape, which is
# also the precedence Magica Cloth 2 documents.
for _it in _iters:
for L in _links:
var a: int = L[0]
var b: int = L[1]
var d: Vector3 = _pw[b] - _pw[a]
var cur := d.length()
if cur < 0.00001:
continue
var wa: float = _imass[a]
var wb: float = _imass[b]
var sum := wa + wb
if sum <= 0.0001:
continue
# Cloth is stiff in TENSION and nearly free in COMPRESSION — a
# garment gathers and folds happily but does not tear. Pulling
# equally hard on both is what made these links fight the legs: a
# panel riding up over a thigh opens away from the ones sewn beside
# it, and a symmetric link reads that as the garment coming apart.
var rest: float = float(L[2])
var k: float = LINK_STIFF if cur > rest else LINK_SQUASH
var move: Vector3 = d * ((cur - rest) / cur * k / sum)
_pw[a] += move * wa
_pw[b] -= move * wb
for ci in _chains.size():
_solve_chain(skel, to_world, ci, true)
# A short tail of COLLISION AND BONE LENGTH ONLY.
#
# The cone limit and the backstop are shape constraints — they say what the
# garment should look like — and re-imposing them after each collision means
# the two argue instead of converging: quadrupling the main iteration count
# moved a 92 mm overlap to 76 mm and stalled, which is a standing fight
# rather than slow relaxation. These last passes let the collision finish,
# holding only the constraint that cannot be given up (a bone has a length).
# Shape has already been decided above and a few millimetres of overlap is
# all that is left to clear, so nothing visible is given away by it.
for _t in _settles:
for ci in _chains.size():
_solve_chain(skel, to_world, ci, false)
# What the relaxation actually converged to, measured but not corrected. Read
# alongside the same measurement taken off the finished skeleton, this is the
# only way to tell "the constraints could not agree" from "they agreed and
# the answer was lost on the way into the pose".
if _want_residual:
for ci in _chains.size():
_measure_residual(skel, to_world, ci)
# 4. Settled particles -> bone poses, and back into the anchor's frame.
for ci in _chains.size():
_write_poses(skel, anchors[ci], ci)
_settled = true
## Deepest remaining overlap per bone, from the settled particles. Diagnostic
## only — nothing is corrected here.
func _measure_residual(skel: Skeleton3D, to_world: Transform3D, ci: int) -> void:
var chain: Dictionary = _chains[ci]
var bones: PackedInt32Array = chain["bones"]
var seg: PackedFloat32Array = chain["seg"]
var hulls: Array = chain["hulls"]
var radii: Array = chain["radii"]
var base: int = chain["base"]
for i in bones.size():
if i >= radii.size():
continue
var caps: PackedFloat32Array = radii[i].get("cap", PackedFloat32Array())
if caps.is_empty():
continue
var a := base + i
var d: Vector3 = _pw[a + 1] - _pw[a]
if d.length() < 0.00001:
continue
var u := d.normalized()
var posed := Transform3D(Basis(Quaternion(_rdir[a], u)) * (_rbasis[a] as Basis), _pw[a])
var pts := _sample_points(posed, _pw[a], _pw[a] + u * seg[i], hulls[i])
var worst := 0.0
for c in _colliders.size():
var col: Dictionary = _colliders[c]
var ends := _capsule(skel, to_world, col)
var ca: Vector3 = ends[0]
var ab: Vector3 = ends[1] - ca
var d2 := ab.length_squared()
for j in pts.size():
var p: Vector3 = pts[j]
var t: float = 0.0 if d2 < 0.000001 \
else clampf((p - ca).dot(ab) / d2, 0.0, 1.0)
var kk := c * pts.size() + j
var allow: float = caps[kk] if kk < caps.size() else 1.0
var r: float = minf(lerpf(float(col["rh"]), float(col["rt"]), t), allow)
worst = maxf(worst, (r - CONTACT_SLOP) - p.distance_to(ca + ab * t))
if worst > 0.0:
_resid[bones[i]] = maxf(_resid.get(bones[i], 0.0), worst)
## Turn on the residual measurement above. Off by default — it is a second full
## collision sweep and the game does not need it.
var _want_residual: bool = false
var _resid: Dictionary = {}
func debug_residual_report() -> Dictionary:
_want_residual = true
var d := _resid.duplicate()
_resid.clear()
return d
## The chain's animated pose for this frame: every joint where rigidly following
## the body would put it: every joint where rigidly following the animation
## would leave it.
##
## Returns the anchor transform, and fills _ref / _rdir / _rbasis for the chain.
func _build_reference(skel: Skeleton3D, to_world: Transform3D, ci: int) -> Transform3D:
var chain: Dictionary = _chains[ci]
var bones: PackedInt32Array = chain["bones"]
var segdir: PackedVector3Array = chain["segdir"]
var seg: PackedFloat32Array = chain["seg"]
var base: int = chain["base"]
var anchor: Transform3D = to_world * skel.get_bone_global_pose(chain["parent"])
var pw := anchor
for i in bones.size():
# Built from the REST offset, not the current pose, or last frame's
# result would compound into a permanent drift.
var rigid: Transform3D = pw * skel.get_bone_rest(bones[i])
_ref[base + i] = rigid.origin
_rbasis[base + i] = rigid.basis
_rdir[base + i] = (rigid.basis * segdir[i]).normalized()
pw = Transform3D(rigid.basis, rigid.origin + _rdir[base + i] * seg[i])
_ref[base + bones.size()] = pw.origin
return anchor
## Integrate one chain's particles and seed the working positions.
func _predict(skel: Skeleton3D, ci: int, anchor: Transform3D, dt: float) -> void:
var chain: Dictionary = _chains[ci]
var bones: PackedInt32Array = chain["bones"]
var base: int = chain["base"]
var n: int = bones.size()
var w: float = chain["w"]
var pull: float = w * w
var damp: float = 2.0 * float(chain["zeta"]) * w
var gravity := Vector3.DOWN * float(chain["gravity"])
var seg: PackedFloat32Array = chain["seg"]
# How fast the chain's anchor is travelling through the world — the reference
# the wind is measured against. Low-passed: a raw per-call difference
# alternates violently when the solver is stepped more than once for the same
# animation frame, and the wind force then flips sign every other step.
var carry := (anchor.origin - _anchor_prev[ci]) / dt if _settled else Vector3.ZERO
carry = _carry_prev[ci].lerp(carry.limit_length(MAX_CARRY), CARRY_SMOOTH)
_carry_prev[ci] = carry
_anchor_prev[ci] = anchor.origin
var inv_anchor := anchor.affine_inverse()
var reset := not _settled
if not reset:
# A teleport — respawn, or the model being reparented. Snap, do not whip.
var got: Vector3 = anchor * _q[base + n]
if got.distance_to(_ref[base + n]) > TELEPORT:
reset = true
# Cloth streams backwards through the air it is moving into. A STEADY wind
# gives a steady deflection, which is hair that blows back once and then
# hangs there rigid — travelling at a constant speed excites nothing else now
# that the solver works in the anchor's frame — so the gust varies. Two
# incommensurate rates so the pattern does not read as a loop, and a
# per-chain phase so strands do not move in lockstep.
var wind_dir := Vector3.ZERO
var wind_mag := 0.0
if carry.length() > 0.05:
var ph: float = float(ci) * 1.7
var gust := minf(carry.length() / WIND_SPEED, 1.0) * float(chain["wind"])
var back := -carry.normalized()
var side := back.cross(Vector3.UP)
side = side.normalized() if side.length() > 0.001 else Vector3.ZERO
var t := 1.0 + 0.45 * sin(_time * 6.3 + ph) + 0.25 * sin(_time * 10.7 + ph * 1.6)
wind_dir = back * t + side * (0.35 * sin(_time * 8.1 + ph))
wind_mag = pull * gust
# Substep so the pull 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.
var sub := clampi(int(ceil(dt * w / 0.4)), 1, MAX_SUBSTEPS)
var h := dt / float(sub)
if reset:
_q[base] = inv_anchor * _ref[base]
_v[base] = Vector3.ZERO
# The root joint is not pinned outright — see ROOT_INV_MASS. It keeps a
# fraction of last frame's bodily shift and relaxes back toward the body, so
# a panel that had to ride up over a thigh comes home once the leg has gone
# by instead of stepping back the instant the contact clears.
_pw[base] = _ref[base] \
+ ((anchor * _q[base]) - _ref[base]).limit_length(ROOT_STRAY) * 0.55
for i in range(1, n + 1):
var k := base + i
if reset:
_q[k] = inv_anchor * _ref[k]
_v[k] = Vector3.ZERO
var p: Vector3 = anchor * _q[k]
var vel: Vector3 = anchor.basis * _v[k]
var length: float = seg[i - 1]
for _s in sub:
var acc := (_ref[k] - p) * pull + gravity + wind_dir * (wind_mag * length)
vel += acc * h
vel *= exp(-damp * h)
vel = vel.limit_length(length * MAX_RATE)
p += vel * h
_pw[k] = p
_v[k] = anchor.basis.inverse() * vel
## One Gauss-Seidel sweep over a chain: bone length, cone limit, backstop,
## colliders. Runs top-down so each bone sees where its parent has just gone.
func _solve_chain(skel: Skeleton3D, to_world: Transform3D, ci: int,
shape: bool) -> void:
var chain: Dictionary = _chains[ci]
var bones: PackedInt32Array = chain["bones"]
var seg: PackedFloat32Array = chain["seg"]
var hulls: Array = chain["hulls"]
var radii: Array = chain["radii"]
var base: int = chain["base"]
var n: int = bones.size()
var far: float = float(chain["span"]) * float(chain["stray"])
var hinge: float = chain["hinge"]
var bend: float = chain["bend"]
# How far the bone ABOVE has swung out of the animated pose. Each bone's cone
# limit is measured from its own reference carried through this, so the chain
# curls instead of every joint being pinned to the animation independently.
var carry_swing := Quaternion.IDENTITY
for i in n:
var a := base + i
var b := base + i + 1
var length: float = seg[i]
# 1. Bone length. Symmetric, so a correction below propagates upward —
# which is the whole reason the joints are particles.
var d: Vector3 = _pw[b] - _pw[a]
var cur := d.length()
if cur > 0.00001:
var wa: float = _imass[a]
var wb: float = _imass[b]
var sum := wa + wb
if sum > 0.0001:
var fix: Vector3 = d * ((cur - length) / cur / sum)
_pw[a] += fix * wa
_pw[b] -= fix * wb
else:
_pw[b] = _pw[a] + _rdir[a] * length
# 2. Cone limit, measured from where this bone would point if it FOLLOWED
# ITS PARENT — not from where the animation alone put it.
#
# This is the difference between a chain that can curl and one that
# cannot. Measuring every joint against the animated pose pins the
# whole strand within one cone of it: the root may swing its 42
# degrees, and then its child, which has physically been carried along
# by that swing, is told it is already 42 degrees out of place and
# must come back. Nothing compounds, so a four-bone panel is no more
# mobile than a one-bone one, and a skirt that has to lie along a
# thigh in a slide simply cannot get there — measured, the solver
# reported the contact every frame and left 99 mm of leg inside the
# hem.
#
# Carrying the parent's actual swing onto the child's reference makes
# the limit mean what it should: how far this bone may bend RELATIVE
# TO THE ONE ABOVE IT, which is bending stiffness, which is a property
# of cloth. Total travel stays bounded by the backstop below.
var ref_dir: Vector3 = (carry_swing * _rdir[a]).normalized()
if shape:
var dir: Vector3 = _pw[b] - _pw[a]
if dir.length() > 0.00001:
dir = dir.normalized()
var along := 0.0 if n < 2 else float(i) / float(n - 1)
var limit: float = lerpf(hinge, bend, minf(along * 2.0, 1.0))
var ang := ref_dir.angle_to(dir)
if ang > limit:
dir = ref_dir.slerp(dir, limit / ang).normalized()
_pw[b] = _pw[a] + dir * length
# 3. Backstop: however the cone limits compound, the joint stays
# within a fraction of the whole garment of where the animation
# put it. See MAX_STRAY.
var stray: Vector3 = _pw[b] - _ref[b]
if stray.length() > far:
_pw[b] = _ref[b] + stray.normalized() * far
# 4. Colliders, against the REAL GEOMETRY this bone drives.
if _collide and i < radii.size():
_collide_bone(skel, to_world, bones[i], a, b, length,
hulls[i], radii[i])
# The root's own leash back to the body, re-applied as soon as the bone
# hanging off it has had its say — the bones below solve against wherever
# the root ended up, so letting it drift and only clamping at the end
# would have them chasing a position the garment is not allowed to hold.
if i == 0:
var off0: Vector3 = _pw[base] - _ref[base]
if off0.length() > ROOT_STRAY:
_pw[base] = _ref[base] + off0.normalized() * ROOT_STRAY
# What this bone finally settled on, for its child's cone limit.
var got: Vector3 = _pw[b] - _pw[a]
carry_swing = Quaternion(_rdir[a], got.normalized()) \
if got.length() > 0.00001 else Quaternion.IDENTITY
## Push one bone's mesh hull out of every collider by moving its two joints.
##
## The bone stick is not what clips — a skirt panel is a wide sheet, and measured
## over a movement sweep the bones sat ~1 mm clear of the legs while the thigh
## was 85 mm inside the skirt MESH. So the test is against the hull samples from
## the sidecar, which are the actual vertices this bone drives.
##
## The correction is shared between the bone's head and its tip by where the
## contact sits along the bone, which makes it a rigid motion of the panel rather
## than a hinge. A contact at the head end therefore lifts the WHOLE panel — the
## case no amount of rotation could ever reach, because rotating about a point
## moves that point not at all.
func _collide_bone(skel: Skeleton3D, to_world: Transform3D, bone: int,
a: int, b: int, length: float, hull: PackedVector3Array,
rec: Dictionary) -> void:
var caps: PackedFloat32Array = rec.get("cap", PackedFloat32Array())
if caps.is_empty() or _colliders.is_empty():
return
var dir: Vector3 = _pw[b] - _pw[a]
if dir.length() < 0.00001:
return
var wa: float = _imass[a]
var wb: float = _imass[b]
if wa + wb <= 0.0001:
return
var span: float = maxf(float(rec.get("span", length)), length)
# Per-BONE broad phase, before the hull is posed at all. Most cloth bones are
# nowhere near a limb on most frames — Taila's hair is 48 of the 127 — and
# posing a couple of dozen hull points for them, nine passes a frame, was
# pure waste.
var near := false
for c in _colliders.size():
if _seg_point_distance(_pw[a], _cap_a[c], _cap_b[c]) <= span + maxf(_cap_rh[c], _cap_rt[c]):
near = true
break
if not near:
return
var n := _fill_points(a, b, length, hull)
if n == 0:
return
for c in _colliders.size():
var ca: Vector3 = _cap_a[c]
var cb: Vector3 = _cap_b[c]
var ab := cb - ca
var d2 := ab.length_squared()
var rh: float = _cap_rh[c]
var rt: float = _cap_rt[c]
# Broad phase. Nothing this bone drives can reach further than `span`
# from its own head, so a limb further away than that plus its own radius
# cannot be touching it. Skips most of the (bone, collider) pairs.
if _seg_point_distance(_pw[a], ca, cb) > span + maxf(rh, rt):
continue
# ONE correction per limb, for the DEEPEST point on the bone.
#
# Not one per point. The hull is a couple of dozen samples spread over a
# panel, and a thigh inside it violates most of them at once — applying
# every violation in turn adds up to many times the one overlap that
# actually exists and flings the panel off the character. The deepest
# point is the one that has to clear; the rest are the same contact seen
# from nearby, and the next iteration re-measures whatever is left.
var deepest := 0.0
var push := Vector3.ZERO
var at := Vector3.ZERO
for j in n:
var p: Vector3 = _pts[j]
var t: float = 0.0 if d2 < 0.000001 else clampf((p - ca).dot(ab) / d2, 0.0, 1.0)
var kk := c * n + j
var allow: float = caps[kk] if kk < caps.size() else 1.0
var r: float = minf(lerpf(rh, rt, t), allow)
var away := p - (ca + ab * t)
var dist := away.length()
if dist < 0.0001:
continue
var pen := (r - CONTACT_SLOP) - dist
if pen > deepest:
deepest = pen
push = (away / dist) * pen
at = p
if deepest <= 0.0:
continue
_hits += 1
_hit_depth[bone] = maxf(_hit_depth.get(bone, 0.0), deepest)
_left[bone] = maxf(_left.get(bone, 0.0), deepest)
_applied[bone] = _applied.get(bone, 0.0) + deepest
# Where along the bone that point hangs decides which end carries the
# correction. A contact at the HEAD end therefore lifts the whole panel
# — the case no rotation can ever reach, because rotating about a point
# moves that point not at all.
var u := (_pw[b] - _pw[a]).normalized()
var s: float = clampf((at - _pw[a]).dot(u) / maxf(length, 0.0001), 0.0, 1.0)
var share_a := (1.0 - s) * COLLIDE_HEAD_SHARE
var share_b := s + (1.0 - s) * (1.0 - COLLIDE_HEAD_SHARE)
# Normalise so THE CONTACT POINT moves by the full overlap — not so the
# two endpoint moves add up to it.
#
# The point rides at fraction `s` along the bone, so it travels
# (1-s)*head + s*tip. Splitting the push into two shares that sum to it
# therefore delivers only about half of it to the place that is actually
# inside the leg, and a contact in the middle of a bone converged at
# roughly half the rate it should: measured, quadrupling the iteration
# count moved a 92 mm overlap to 76 mm and no further.
var eff: float = (1.0 - s) * share_a * wa + s * share_b * wb
if eff <= 0.0001:
continue
_pw[a] += push * (share_a * wa / eff)
_pw[b] += push * (share_b * wb / eff)
# Re-read, so the next limb sees where the bone has just gone.
if _fill_points(a, b, length, hull) == 0:
return
## Pose one bone's collision hull into the scratch buffer. Returns how many
## points were written. Kept out of the collider loop and off the heap: this runs
## for every cloth bone on every relaxation pass, and building a fresh Array of
## Vector3 each time was a large share of the solver's whole cost.
func _fill_points(a: int, b: int, length: float,
hull: PackedVector3Array) -> int:
var dir: Vector3 = _pw[b] - _pw[a]
if dir.length() < 0.00001:
return 0
var u := dir.normalized()
# The bone's basis under the correction it has taken so far: the animated
# basis, swung onto the direction the particles settled on.
var basis: Basis = Basis(Quaternion(_rdir[a], u)) * (_rbasis[a] as Basis)
var head: Vector3 = _pw[a]
if hull.size() > 0:
if _pts.size() < hull.size():
_pts.resize(hull.size())
for i in hull.size():
_pts[i] = head + basis * hull[i]
return hull.size()
if _pts.size() < SAMPLES.size():
_pts.resize(SAMPLES.size())
for i in SAMPLES.size():
_pts[i] = head + u * (length * float(SAMPLES[i]))
return SAMPLES.size()
## Turn the settled particles into bone rotations, and store back the joint
## positions the skeleton ACTUALLY got.
##
## The feedback matters as much as the write. A joint's head is fixed by its
## parent's rotation, so the pose the skeleton ends up in is never quite the pose
## the constraints asked for. Integrating from what the solver WANTED rather than
## from what was rendered leaves a standing gap between the two, and that gap is
## a force nothing in the model agreed to — it is what made the old multi-pass
## version oscillate, because its final ancestor-lift pass wrote poses that were
## never fed back into the spring state at all.
func _write_poses(skel: Skeleton3D, anchor: Transform3D, ci: int) -> void:
var chain: Dictionary = _chains[ci]
var bones: PackedInt32Array = chain["bones"]
var segdir: PackedVector3Array = chain["segdir"]
var seg: PackedFloat32Array = chain["seg"]
var base: int = chain["base"]
var n: int = bones.size()
var inv_anchor := anchor.affine_inverse()
# Whatever bodily shift the colliders asked of the chain root. Every segment
# below it inherits this through the hierarchy, so the panel travels as one
# piece and the mesh it drives cannot be stretched by it — which is the one
# way translating cloth is safe where a weight gradient is not.
var shift: Vector3 = (_pw[base] - _ref[base]).limit_length(ROOT_STRAY)
var pw := anchor
for i in n:
var bone: int = bones[i]
var rigid: Transform3D = pw * skel.get_bone_rest(bone)
if i == 0:
rigid.origin += shift
# This joint's head is now final; record where it really landed.
_q[base + i] = inv_anchor * rigid.origin
# The bone's own rest line, and where the particles want it to point.
var rest_dir: Vector3 = (rigid.basis * segdir[i]).normalized()
var want: Vector3 = _pw[base + i + 1] - rigid.origin
var world := rigid
if want.length() > 0.00001 and rest_dir.length_squared() > 0.5:
world.basis = Basis(Quaternion(rest_dir, want.normalized())) * rigid.basis
var local := pw.affine_inverse() * world
skel.set_bone_pose_rotation(bone, local.basis.get_rotation_quaternion())
if i == 0:
skel.set_bone_pose_position(bone, local.origin)
pw = world
# The free tip, from the last bone's achieved orientation.
var tip_dir: Vector3 = (pw.basis * segdir[n - 1]).normalized()
_q[base + n] = inv_anchor * (pw.origin + tip_dir * seg[n - 1])