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

281 lines
9.2 KiB
GDScript

extends SceneTree
## Dev tool: is the skirt STRETCHING around the thigh, or tearing open?
##
## godot --headless --path . -s res://debug/cloth_stretch_check.gd -- [skin_glb]
##
## The collision and drape solve each cloth bone on its own. Neighbouring skirt
## panels therefore get different answers, and the mesh between them has to
## absorb the difference — which linear-blend skinning does by pulling the shared
## edge apart. On screen that reads as the skirt "breaking" open around the thigh
## instead of deforming over it, and no capsule or spring number shows it,
## because every individual bone is behaving.
##
## So measure the MESH: skin every cloth triangle over a movement sweep and
## compare each edge against its own rest length. An edge whose two ends are
## driven by different panels is a SEAM — that is where a tear appears — so those
## are reported separately from edges inside one panel.
##
## Reports, worst over the sweep:
## stretch posed edge length / rest length
## gap how many millimetres that edge grew
const SWEEP := [["ground", 9.0], ["ground", 3.0], ["air", 6.0],
["air", -8.0], ["slide", 10.0], ["dash", 14.0]]
const FRAMES_PER_STATE := 30
## An edge has to grow by more than this to count as a tear rather than noise.
const REPORT_MM := 8.0
var _frames := 0
var _model: SkinnedPlayerModel = null
var _cloth := {} # bone index -> panel family name
var _edges: Array = [] # [mesh, surface, ia, ib, rest_len, family_a, family_b]
var _skins: Array = [] # [mesh, skin, bone_of, verts, bones, weights, per]
var _worst := {} # "famA|famB" -> [stretch, grow_m, bones, rest_m, state]
## The single worst edge seen, kept so _report can dump what actually drives it.
var _peak := 0.0
var _peak_edge: Array = []
var _state := ""
var _ready := false
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)
## Panel a bone belongs to: the chain root's name, with the segments built by
## tools/retarget.py::subdivide_cloth_panels stripped off. Two segments of the
## same panel are meant to bend apart; two different panels are not.
static func _family(bone_name: String) -> String:
var n := bone_name
var cut := n.find(".seg")
return n.substr(0, cut) if cut >= 0 else n
func _build(skel: Skeleton3D) -> void:
var side: String = _model.model_path.get_basename() + ".rig.json"
var info = JSON.parse_string(FileAccess.get_file_as_string(side))
if typeof(info) != TYPE_DICTIONARY:
print("no sidecar")
return
for c in info.get("chains", []):
if String(c.get("class", "")) == "hair":
continue
for n in c.get("bones", []):
var i := skel.find_bone(String(n))
if i >= 0:
_cloth[i] = _family(String(n))
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]
var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
if bones.is_empty() or verts.is_empty() or idx.is_empty():
continue
var per: int = bones.size() / verts.size()
var sk := [mi, skin, bone_of, verts, bones, weights, per]
# Which panel drives each vertex, and its rest position.
var fam := []
var drv := []
var rest := PackedVector3Array()
var any := false
fam.resize(verts.size())
drv.resize(verts.size())
rest.resize(verts.size())
for v in verts.size():
var bw := 0.0
var cw := 0.0
var f := ""
var dn := ""
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
var bind: int = bones[v * per + k]
var bi: int = bone_of[bind]
if bi < 0 or w <= 0.0:
continue
q += (skel.get_bone_global_rest(bi) * skin.get_bind_pose(bind)
* verts[v]) * w
if _cloth.has(bi):
cw += w
if w > bw:
bw = w
f = _cloth[bi]
dn = skel.get_bone_name(bi)
# The cloth chains must actually OWN this vertex. Body surfaces
# carry stray cloth influence — one arm vertex measured 0.54
# forearm, 0.35 skirt — and counting those made the skirt look
# like it was tearing by half a metre when the arm was simply
# moving during a dash.
if cw < 0.75:
f = ""
dn = ""
fam[v] = f
drv[v] = dn
rest[v] = q
if f != "":
any = true
if not any:
continue
_skins.append(sk)
var seen := {}
for t in range(0, idx.size(), 3):
for pair in [[idx[t], idx[t + 1]], [idx[t + 1], idx[t + 2]],
[idx[t + 2], idx[t]]]:
var a: int = mini(pair[0], pair[1])
var b: int = maxi(pair[0], pair[1])
if String(fam[a]) == "" or String(fam[b]) == "":
continue
var key := "%d_%d_%d" % [_skins.size(), a, b]
if seen.has(key):
continue
seen[key] = true
var L := rest[a].distance_to(rest[b])
if L < 0.0005:
continue
_edges.append([_skins.size() - 1, a, b, L,
String(fam[a]), String(fam[b]),
"%s -> %s" % [drv[a], drv[b]]])
var seams := 0
for e in _edges:
if e[4] != e[5]:
seams += 1
print("tracking %d cloth edges, %d of them across a panel seam" % [
_edges.size(), seams])
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
if not _ready:
_build(skel)
_ready = true
if _edges.is_empty():
return true
return false
var phase: int = clampi((_frames - 9) / FRAMES_PER_STATE, 0, SWEEP.size() - 1)
_model.update_state(SWEEP[phase][0], SWEEP[phase][1], false)
_model.set_locomotion(0.0, 1.0, 0.0)
_state = "%s %.0f" % [SWEEP[phase][0], SWEEP[phase][1]]
_measure(skel)
if _frames > 9 + FRAMES_PER_STATE * SWEEP.size():
_report()
return true
return false
func _measure(skel: Skeleton3D) -> void:
# Skin every cloth vertex once, then walk the edges.
var posed: Array = []
for sk in _skins:
var skin: Skin = sk[1]
var bone_of: Dictionary = sk[2]
var verts: PackedVector3Array = sk[3]
var bones: PackedInt32Array = sk[4]
var weights: PackedFloat32Array = sk[5]
var per: int = sk[6]
var out := PackedVector3Array()
out.resize(verts.size())
for v in verts.size():
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
var bind: int = bones[v * per + k]
var bi: int = bone_of[bind]
if bi < 0 or w <= 0.0:
continue
q += (skel.get_bone_global_pose(bi) * skin.get_bind_pose(bind)
* verts[v]) * w
out[v] = q
posed.append(out)
for e in _edges:
var p: PackedVector3Array = posed[e[0]]
var L: float = p[e[1]].distance_to(p[e[2]])
var grow: float = L - float(e[3])
if grow <= 0.0:
continue
var key: String = "%s | %s" % [e[4], e[5]] if e[4] != e[5] else "%s (inside)" % e[4]
var cur: Array = _worst.get(key, [0.0, 0.0])
if grow > cur[1]:
_worst[key] = [L / float(e[3]), grow, e[6], float(e[3]), _state]
if grow > _peak:
_peak = grow
_peak_edge = [e[0], e[1], e[2], float(e[3]), _state]
func _report() -> void:
print("\n=== worst cloth EDGE STRETCH over the sweep ===")
print(" a growing seam is the skirt tearing open between two panels;")
print(" growth inside one panel is the panel itself being stretched.\n")
var keys := _worst.keys()
keys.sort_custom(func(a, b): return _worst[a][1] > _worst[b][1])
var shown := 0
for k in keys:
var w: Array = _worst[k]
if w[1] * 1000.0 < REPORT_MM:
break
print(" %-30s x%6.2f +%6.1f mm rest %5.1f mm %-42s %s" % [
k, w[0], w[1] * 1000.0, w[3] * 1000.0, w[2], w[4]])
shown += 1
if shown >= 24:
break
if shown == 0:
print(" nothing grew by more than %.0f mm" % REPORT_MM)
_dissect()
print("")
## Everything that drives the two ends of the single worst edge. A rigid bone
## cannot change the distance between two points, so an edge that grew while
## both ends report the same DOMINANT bone is being pulled by something else in
## their influence lists — which is the only way to find out what.
func _dissect() -> void:
if _peak_edge.is_empty():
return
var skel: Skeleton3D = _model.skeleton
var sk: Array = _skins[_peak_edge[0]]
var skin: Skin = sk[1]
var bone_of: Dictionary = sk[2]
var bones: PackedInt32Array = sk[4]
var weights: PackedFloat32Array = sk[5]
var per: int = sk[6]
print("
worst single edge: rest %.1f mm, grew %.1f mm, during %s" % [
_peak_edge[3] * 1000.0, _peak * 1000.0, _peak_edge[4]])
for which in [1, 2]:
var v: int = _peak_edge[which]
var line := " vertex %d:" % v
for k in per:
var w: float = weights[v * per + k]
if w <= 0.0001:
continue
var bi: int = bone_of[bones[v * per + k]]
line += " %s %.2f" % [
skel.get_bone_name(bi) if bi >= 0 else "?", w]
print(line)