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

139 lines
4.7 KiB
GDScript

extends SceneTree
## Dev tool: how well do the sidecar's leg capsules actually enclose the leg?
##
## godot --headless --path . -s res://debug/leg_radius_check.gd -- [skin_glb]
##
## tools/retarget.py sizes each capsule from the MEDIAN distance of the limb's
## own vertices, which by construction leaves half the leg's surface outside the
## collider. Cloth is then pushed out to a shape narrower than the leg it is
## meant to clear, so the solver reports the panel as clear while the thigh is
## visibly through it in the render — the metric and the eye disagree, and the
## eye is right.
##
## Prints, per capsule end, the percentile spread of the real vertex distances
## next to the radius actually shipped.
const PCTS := [0.5, 0.75, 0.85, 0.95, 1.0]
var _frames := 0
var _model: SkinnedPlayerModel = null
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8 or not _model.loaded:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
return true
var info = JSON.parse_string(FileAccess.get_file_as_string(
_model.model_path.get_basename() + ".rig.json"))
if typeof(info) != TYPE_DICTIONARY:
print("no sidecar")
return true
# Every vertex, tagged with the bone that dominates it.
var owned := {} # bone index -> PackedVector3Array of rest positions
for mi in _model.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 = skel.find_bone(skin.get_bind_name(b))
bone_of[b] = bi
for s in range(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():
var bw := 0.0
var bind := -1
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
var bi: int = bone_of[bones[v * per + k]]
if bi < 0 or w <= 0.0:
continue
q += (skel.get_bone_global_rest(bi)
* skin.get_bind_pose(bones[v * per + k]) * verts[v]) * w
if w > bw:
bw = w
bind = bi
if bind < 0 or bw < 0.5:
continue
if not owned.has(bind):
owned[bind] = PackedVector3Array()
owned[bind].append(q)
for c in info.get("colliders", []):
var a_i := skel.find_bone(String(c.get("bone", "")))
var b_i := skel.find_bone(String(c.get("child", "")))
if a_i < 0 or b_i < 0:
continue
var a := skel.get_bone_global_rest(a_i).origin
var b := skel.get_bone_global_rest(b_i).origin
var ab := b - a
var d2 := ab.length_squared()
# The limb is this bone plus any twist segment hanging off it — the same
# grouping tools/retarget.py uses when it sizes the capsule.
var base := String(c.get("bone", ""))
var pts := PackedVector3Array()
for bi in owned:
var n := skel.get_bone_name(bi)
if n == base or n.begins_with(base + "."):
pts.append_array(owned[bi])
# Per-tenth of the limb, so the real taper is visible instead of two
# lumps. The head band is where a thigh stops being a thigh and becomes
# the hip, and that is exactly the band a two-point capsule has to guess.
var bands: Array = []
for _b in 10:
bands.append(PackedFloat32Array())
for p in pts:
var t: float = 0.0 if d2 < 0.000001 else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
bands[clampi(int(t * 10.0), 0, 9)].append(p.distance_to(a + ab * t))
var rh := float(c.get("radius_head", 0.0))
var rt := float(c.get("radius_tail", 0.0))
print("%s (%d verts) shipped head %.4f tail %.4f" % [
base, pts.size(), rh, rt])
for band in 10:
var v: PackedFloat32Array = bands[band]
if v.is_empty():
continue
v.sort()
var i: int = clampi(int(0.88 * (v.size() - 1)), 0, v.size() - 1)
var mid: int = v.size() / 2
var t := (float(band) + 0.5) / 10.0
print(" t %.2f n%-5d p50 %.4f p88 %.4f p100 %.4f capsule %.4f" % [
t, v.size(), v[mid], v[i], v[v.size() - 1], lerpf(rh, rt, t)])
return true
func _spread(v: PackedFloat32Array) -> String:
if v.is_empty():
return "(none)"
var out := ""
for p in PCTS:
var i: int = clampi(int(p * (v.size() - 1)), 0, v.size() - 1)
out += "p%02d %.4f " % [int(p * 100.0), v[i]]
return out