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]>
This commit is contained in:
Nicholas Butzke
2026-07-26 03:07:30 -04:00
co-authored by Claude Opus 5
parent 374d9f9822
commit 0dd9d01ac7
24 changed files with 3322 additions and 170 deletions
+195 -10
View File
@@ -136,7 +136,26 @@ var _cur_ads: float = 0.0
var _cur_slide: float = 0.0
var _cur_wall: float = 0.0
var _owner_visible: bool = false
## Horizontal speed from the last update_state, so the lean can scale with how
## fast the character is really moving.
var _speed: float = 0.0
var _loco_tier: int = 0
var _tier_age: float = 0.0
const POSE_SMOOTH := 10.0
## Separate, slower rate for the whole-body lean. Roughly a 0.36 s time constant,
## so the posture arrives with the clip crossfade instead of a tenth of a second
## ahead of it.
const LEAN_SMOOTH := 4.5
## Ground locomotion tiers, slowest first, and how far below the promoting
## threshold the speed must fall before dropping back a tier.
const LOCO_TIERS := ["Idle", "Walk", "Run", "Sprint"]
const LOCO_HYSTERESIS := 0.78
## A locomotion tier is held at least this long before another change is
## allowed. Hard acceleration genuinely passes through walking pace in about a
## fifth of a second, so without this Walk got 0.19 s — less than half of its own
## 0.40 s crossfade — and was cut off mid-blend by Run. Multi-tier jumps still
## happen in one step, so this delays nothing that was not already a blur.
const MIN_TIER_DWELL := 0.22
func _ready() -> void:
@@ -200,7 +219,8 @@ func load_model(path: String) -> void:
_spring_mod = SpringBones.new()
_spring_mod.name = "SpringBones"
skeleton.add_child(_spring_mod)
var driven := _spring_mod.setup(skeleton, _rig_info)
var driven := _spring_mod.setup(skeleton, _rig_info,
_cloth_hulls(scene))
if driven == 0:
_spring_mod.queue_free()
_spring_mod = null
@@ -231,6 +251,101 @@ func load_model(path: String) -> void:
_play_clip("Idle")
## Collision hulls for the cloth solver, taken from the MESH rather than from
## the sidecar: bone name -> the points that bone drives, in its own rest space.
##
## The sidecar carries ten farthest-point samples per cloth bone, which is a good
## description of a panel's OUTLINE and a poor one of a panel. Farthest-point
## sampling lands on corners, edges and the hem; a thigh comes up through the
## MIDDLE of a panel, between every sample, and the solver reported each frame's
## contacts fully resolved while 158 vertices sat 95 mm inside a leg.
##
## The runtime has the actual mesh, so it does not have to guess. Every vertex a
## cloth bone dominates is binned into a ~16 mm grid and one representative per
## cell is kept, which covers a panel evenly for a bounded number of points —
## unlike keeping every vertex, which would be thousands of collision tests per
## frame for no extra accuracy at the scale a limb is shaped.
const HULL_CELL := 0.020
const HULL_MAX := 14
func _cloth_hulls(scene: Node) -> Dictionary:
var cloth := {}
for c in _rig_info.get("chains", []):
for n in c.get("bones", []):
var bi := skeleton.find_bone(String(n))
if bi >= 0:
cloth[bi] = true
if cloth.is_empty():
return {}
# bone -> cell key -> the vertex nearest that cell's centre.
var cells := {}
for mi in scene.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
continue
var skin: Skin = mi.skin
var bone_of := {}
for b in skin.get_bind_count():
var bi := skin.get_bind_bone(b)
if bi < 0:
bi = skeleton.find_bone(skin.get_bind_name(b))
bone_of[b] = bi
for s in mi.mesh.get_surface_count():
var arrays: Array = mi.mesh.surface_get_arrays(s)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
if bones.is_empty() or verts.is_empty():
continue
var per: int = bones.size() / verts.size()
for v in verts.size():
# A vertex belongs to whichever bone holds the largest share of
# it — that is the bone whose motion actually decides where it
# ends up, and so the bone that has to keep it out of a leg.
var best := 0.0
var bind := -1
for k in per:
var w: float = weights[v * per + k]
if w > best:
best = w
bind = bones[v * per + k]
if bind < 0 or best < 0.5:
continue
var bi: int = bone_of.get(bind, -1)
if not cloth.has(bi):
continue
# The bind pose maps a vertex straight into its bone's rest
# space, which is exactly the frame the solver poses hulls in.
var local: Vector3 = skin.get_bind_pose(bind) * verts[v]
var key := "%d_%d_%d" % [
int(round(local.x / HULL_CELL)),
int(round(local.y / HULL_CELL)),
int(round(local.z / HULL_CELL))]
if not cells.has(bi):
cells[bi] = {}
if not cells[bi].has(key):
cells[bi][key] = local
var out := {}
var total := 0
for bi in cells:
var pts: Array = cells[bi].values()
if pts.size() > HULL_MAX:
# Keep the OUTERMOST cells. What clips is the part of a panel
# furthest from the bone it hangs on, and the grid has already made
# sure those are spread over the whole sheet rather than clustered.
pts.sort_custom(func(a, b): return a.length_squared() > b.length_squared())
pts = pts.slice(0, HULL_MAX)
var packed := PackedVector3Array()
for p in pts:
packed.append(p)
out[skeleton.get_bone_name(bi)] = packed
total += packed.size()
print("SkinnedPlayerModel: cloth hulls from mesh — %d bones, %d points"
% [out.size(), total])
return out
## Read the rig sidecar that tools/retarget.py writes next to the GLB.
##
## Its presence is also the signal that this model kept its OWN skeleton and
@@ -429,6 +544,7 @@ func add_gun_recoil(strength: float = 1.0) -> void:
## Same contract as HumanoidModel.update_state(). Called by the movement
## controller each frame with either local or network-synced state.
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
_speed = speed
if not loaded or not animation_player:
return
@@ -454,12 +570,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
clip = "Dance"
elif is_crouching:
clip = "CrouchWalk" if speed > 0.5 else "Crouch"
elif speed > run_anim_reference_speed * 1.35:
clip = "Sprint"
elif speed > walk_anim_reference_speed * 1.2:
clip = "Run"
elif speed > 0.5:
clip = "Walk"
else:
clip = _loco_clip(speed)
# Armed idle uses the plain Idle clip — the rifle-hold pose layer
# owns the arms, so the odd arms-crossed PistolIdle base reads worse.
"air":
@@ -529,17 +641,36 @@ func set_grapple_target(point_world: Vector3) -> void:
_grapple_point_world = point_world
## How far away each cloth detail level starts, in metres. See SpringBones.lod —
## the solver is expensive enough that only the character being looked at can
## afford the full thing.
const CLOTH_LOD_RANGES := [6.0, 14.0, 28.0]
var _lod_timer: float = 0.0
func _process(delta: float) -> void:
_update_cloth_lod(delta)
if not _pose_mod:
return
var t := 1.0 - exp(-POSE_SMOOTH * delta)
_cur_strafe = lerpf(_cur_strafe, _target_strafe, t)
_cur_fwd = lerpf(_cur_fwd, _target_fwd, t)
# The body lean gets its own, much slower rate, and is scaled by how fast the
# character is ACTUALLY moving rather than by which key is held.
#
# The controller passes a normalised input direction, so `fwd` jumps 0 -> 1
# the instant W is pressed. At the shared rate that planted the full forward
# lean in about a tenth of a second while the Idle->Run crossfade was still
# 0.4 s from finishing — the body snapped into a run posture ahead of the run
# cycle. Tying it to speed means the lean now grows as the character
# accelerates, and lands with the clip.
var lean_t := 1.0 - exp(-LEAN_SMOOTH * delta)
var drive: float = clampf(_speed / maxf(run_anim_reference_speed, 0.01), 0.0, 1.0)
_cur_strafe = lerpf(_cur_strafe, _target_strafe * drive, lean_t)
_cur_fwd = lerpf(_cur_fwd, _target_fwd * drive, lean_t)
_cur_ads = lerpf(_cur_ads, _target_ads, t)
var slide_target := 1.0 if _pose_mod.state == "slide" else 0.0
_cur_slide = lerpf(_cur_slide, slide_target, t)
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
_cur_wall = lerpf(_cur_wall, wall_target, t)
_cur_wall = lerpf(_cur_wall, wall_target, lean_t)
_pose_mod.strafe = _cur_strafe
_pose_mod.fwd = _cur_fwd
_pose_mod.ads = _cur_ads
@@ -611,6 +742,50 @@ func _process(delta: float) -> void:
_pose_mod.reload_phase = rl_target
## Pick the cloth solver's detail level from how far the camera is.
##
## Re-checked a few times a second rather than every frame: the answer changes
## slowly, and the distance query is not free either.
func _update_cloth_lod(delta: float) -> void:
if _spring_mod == null:
return
_lod_timer -= delta
if _lod_timer > 0.0:
return
_lod_timer = 0.25
var cam := get_viewport().get_camera_3d() if is_inside_tree() else null
if cam == null:
return
var d := cam.global_position.distance_to(global_position)
var want := CLOTH_LOD_RANGES.size()
for i in CLOTH_LOD_RANGES.size():
if d < CLOTH_LOD_RANGES[i]:
want = i
break
_spring_mod.lod = want
## Locomotion clip for a ground speed, with HYSTERESIS.
##
## The thresholds used to be a bare elif chain, so a character accelerating from
## a standstill crossed all three in under a second and each crossfade cut off
## the one before it — and any speed hovering on a boundary flickered between
## two clips forever. Dropping back down needs the speed to fall well under the
## threshold that promoted it, so a tier, once entered, is committed to.
func _loco_clip(speed: float) -> String:
_tier_age += get_process_delta_time()
var up := [0.5, walk_anim_reference_speed * 1.2, run_anim_reference_speed * 1.35]
var want := _loco_tier
while want < LOCO_TIERS.size() - 1 and speed > up[want]:
want += 1
while want > 0 and speed < up[want - 1] * LOCO_HYSTERESIS:
want -= 1
if want != _loco_tier and _tier_age >= MIN_TIER_DWELL:
_loco_tier = want
_tier_age = 0.0
return LOCO_TIERS[_loco_tier]
func _play_clip(canonical: String, restart: bool = false) -> void:
if not _anim_tree or not _resolved_clips.has(canonical):
return
@@ -622,6 +797,16 @@ func _play_clip(canonical: String, restart: bool = false) -> void:
_current_clip = clip_name
## Current smoothed forward lean, 0..1. For debug/transition_check.gd.
func get_lean_debug() -> float:
return _cur_fwd
## Clip currently playing. For debug/transition_check.gd.
func current_clip_debug() -> String:
return _current_clip
## Vertical velocity of the body this model is attached to (0 if detached).
func _vertical_speed() -> float:
var p := get_parent()