Files
Papay-Shooter/debug/cloth_settle_check.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

168 lines
5.6 KiB
GDScript

extends SceneTree
## Dev tool: is the cloth MOVING SANELY, or spasming?
##
## godot --headless --path . -s res://debug/cloth_settle_check.gd -- [speed] [nospring] [glb]
##
## Reports how far each cloth bone ROTATES per frame, in degrees, read from the
## bone's LOCAL pose — which is exactly what SpringBones writes, and is immune to
## the head bobbing or the character travelling.
##
## speed 0 everything should fall towards 0.
## speed 9 a few degrees per frame is cloth. Tens of degrees per frame, at
## frame rate, is the "blur spazzing about".
##
## WARNING: the deg/frame column is LOCAL bone rotation, and for a chain that
## is not the same as visible motion. Correcting a panel root shows up as an
## equal and opposite delta on each of its segments, so a hem that has not
## moved on screen at all can report 12-18 deg/frame. Measured against
## debug/idle_jitter_check.gd, which counts changed PIXELS between
## consecutive idle frames: with collision on 24866 px/frame, with collision
## off 38594 — the collision was the thing this tool accused, and it is
## actually damping the idle rather than driving it. Use the pixel check
## before believing a settling number here.
##
## HOW IT MEASURES, AND WHY THAT MATTERS. Sampling is done by an observer
## SkeletonModifier3D appended AFTER SpringBones, so it sees precisely the pose
## the renderer will use. Two earlier versions of this tool were wrong and both
## sent the investigation the wrong way:
##
## * Reading `get_bone_pose_rotation()` from `_process` reported an identical
## 0.06 deg in EVERY configuration. `_process` runs BEFORE the modifiers, and
## cloth bones carry no animation tracks, so it read the rest pose every time.
## * Calling `force_update_all_bone_transforms()` RE-RUNS the modification
## stack, stepping the solver a second time per frame. A blend applied to the
## final pose write — which cannot fail to reduce motion — moved the reading
## from 32.02 to 32.00 mm/frame. Six real changes in a row read as no-ops.
##
## If a change to the solver does not move these numbers, suspect this file
## before concluding the change did nothing.
const WINDOW := 30
var _frames := 0
var _model: SkinnedPlayerModel = null
var _spring: SpringBones = null
var _obs: Observer = null
var _speed := 0.0
class Observer extends SkeletonModifier3D:
var bones: PackedInt32Array = PackedInt32Array()
var names: Array = []
var prev: Array = []
var worst := 0.0
var worst_name := ""
var sum := 0.0
var n := 0
# Per class, because "the cloth moves" can hide "the hair is frozen".
var hair_sum := 0.0
var hair_n := 0
var hair_worst := 0.0
var skirt_sum := 0.0
var skirt_n := 0
var started := false
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null or bones.is_empty():
return
for i in bones.size():
var q := skel.get_bone_pose_rotation(bones[i])
if started:
var d: float = absf(q.angle_to(prev[i]))
if d > worst:
worst = d
worst_name = names[i]
sum += d
n += 1
if names[i].findn("hair") != -1:
hair_sum += d
hair_n += 1
hair_worst = maxf(hair_worst, d)
elif names[i].findn("skirt") != -1:
skirt_sum += d
skirt_n += 1
prev[i] = q
started = true
func _initialize() -> void:
var path := "res://assets/characters/skins/taila.glb"
for a in OS.get_cmdline_user_args():
if a.begins_with("res://"):
path = a
elif a != "nospring":
_speed = a.to_float()
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
func _setup(skel: Skeleton3D) -> bool:
_spring = skel.get_node_or_null("SpringBones") as SpringBones
if _spring == null:
print("SpringBones not installed")
return false
_spring.fixed_delta = 1.0 / 60.0
if OS.get_cmdline_user_args().has("nospring"):
_spring.active = false
print("springs DISABLED (animation-only baseline)")
var info = JSON.parse_string(
FileAccess.get_file_as_string(_model.model_path.get_basename() + ".rig.json"))
if typeof(info) != TYPE_DICTIONARY:
return false
_obs = Observer.new()
_obs.name = "ClothObserver"
for c in info.get("chains", []):
for nm in c.get("bones", []):
var i := skel.find_bone(String(nm))
if i >= 0:
_obs.bones.append(i)
_obs.names.append(String(nm))
_obs.prev.append(Quaternion.IDENTITY)
# AFTER SpringBones in the tree, so it observes the final pose.
skel.add_child(_obs)
print("tracking %d cloth bones at %.1f m/s" % [_obs.bones.size(), _speed])
return _obs.bones.size() > 0
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 6:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
print("no skeleton")
return true
if _obs == null and not _setup(skel):
return true
_model.update_state("ground", _speed, false)
_model.set_locomotion(0.0, 1.0 if _speed > 0.1 else 0.0, 0.0)
_model.position += Vector3(0, 0, -_speed) / 60.0
if _frames % WINDOW == 0 and _frames > 20:
print("t=%4d worst %6.2f deg/frame (%-18s) mean %5.3f contacts/frame %.1f" % [
_frames, rad_to_deg(_obs.worst), _obs.worst_name,
rad_to_deg(_obs.sum / maxf(_obs.n, 1)),
_spring.debug_collisions_per_frame()])
print(" hair mean %5.3f deg/frame (worst %5.2f) skirt mean %5.3f" % [
rad_to_deg(_obs.hair_sum / maxf(_obs.hair_n, 1)),
rad_to_deg(_obs.hair_worst),
rad_to_deg(_obs.skirt_sum / maxf(_obs.skirt_n, 1))])
_obs.hair_sum = 0.0
_obs.hair_n = 0
_obs.hair_worst = 0.0
_obs.skirt_sum = 0.0
_obs.skirt_n = 0
_obs.worst = 0.0
_obs.sum = 0.0
_obs.n = 0
if _frames > WINDOW * 8:
return true
return false