tools: give the limb deform check a baseline that validates itself

The measurement was comparing skinned lengths against BIND-POSE lengths. A
character is never in bind pose, so ordinary posing registered as deformation:
with the animation frozen it still reported 0.42x-2.56x edge ratios, and during
a run it reported edges collapsing to 0.08x and blowing out to 5.2x. Those
numbers were an artefact of the metric, not the model.

The baseline is now built from the skeleton's REST transforms, so every ratio
reads exactly 1.00 on an unposed model and the tool validates itself. Corrected
readings on Taila through the full runtime stack, worst over run, walk, jump,
fall, slide and dash:

  lengthwise stretch  1.07 Body, 1.08 ClothB, 1.09 ClothCAndW, 1.03 shell
  cross-section       0.97-0.99 knee, 0.85 hip, 0.92-0.94 ankle

That is ordinary linear-blend skinning.

Also records, in the header, the two ways this measurement was previously wrong,
because both nearly produced a bad "fix":

  * the bind-pose baseline above;
  * flagging vertices weighted across "non-adjacent" leg bones with
    max_slot - min_slot >= 2, which flags shin+foot+toe — a perfectly normal
    contiguous run. A repair pass built on that was written and reverted before
    it shipped; it was stripping legitimate toe and shin influences.

Checks that did come back clean and are worth not repeating: bone pose bases are
pure rotations to 0.00001, bone lengths never drift from their rest offsets, and
of 4019 coincident-vertex groups not one carries mismatched weights, so there
are no split seams tearing at the joints.

No character or gameplay code changed. skin_leg_repair.gd is byte-identical in
behaviour to the last commit; only its header comment gained a pointer to this
tool.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-22 07:51:18 -04:00
co-authored by Claude Opus 4.8
parent 204846bf09
commit 1e12c36cca
2 changed files with 28 additions and 9 deletions
+2
View File
@@ -35,6 +35,8 @@ class_name SkinLegRepair
## 3. Drop any triangle still spanning the two legs below the knee. Those are ## 3. Drop any triangle still spanning the two legs below the knee. Those are
## the midline band between the ankles, which has no correct pose either ## the midline band between the ankles, which has no correct pose either
## way. ## way.
##
## Measure with debug/limb_deform_check.gd.
const LEG_BONE_HINTS := ["thigh", "shin", "foot", "toe"] const LEG_BONE_HINTS := ["thigh", "shin", "foot", "toe"]
## Ignore influences below this — they are rounding, not real weighting. ## Ignore influences below this — they are rounding, not real weighting.
+26 -9
View File
@@ -16,10 +16,23 @@ extends SceneTree
## collapse that linear-blend skinning causes at a bent joint; ## collapse that linear-blend skinning causes at a bent joint;
## 0.85-1.00 is normal, well below that is a weighting fault. ## 0.85-1.00 is normal, well below that is a weighting fault.
## ##
## Written after four rounds of chasing reported leg "squashing". Renders were ## Written after several rounds of chasing reported leg "squashing". Renders
## repeatedly misleading — a slim anime leg at full stride genuinely looks ## were repeatedly misleading — a slim anime leg at full stride genuinely looks
## stretched — so measure before changing anything, and prefer this over ## stretched — so measure before changing anything.
## eyeballing a screenshot. ##
## READ THIS BEFORE TRUSTING ANY NUMBER YOU ADD HERE. Two earlier versions of
## this measurement were themselves wrong and nearly caused a bad "fix":
##
## * Comparing skinned lengths against BIND-POSE lengths. The character is
## never in bind pose, so ordinary posing showed up as 0.4x-2.5x
## "deformation" even with the animation frozen.
## * Flagging vertices weighted across "non-adjacent" leg bones by testing
## max_slot - min_slot >= 2. That flags shin+foot+toe, which is a perfectly
## normal contiguous run.
##
## So the baseline here is the skeleton's REST transforms, which makes every
## ratio exactly 1.00 when the pose is the rest pose — the tool validates
## itself. If you add a metric, prove it reads 1.00 on an unposed model first.
var _frames := 0 var _frames := 0
var _model: SkinnedPlayerModel = null var _model: SkinnedPlayerModel = null
@@ -51,7 +64,7 @@ func _process(_delta: float) -> bool:
if _frames < 10: if _frames < 10:
return false return false
if _frames == 10: if _frames == 10:
_measure(true) _measure(true) # baseline from the skeleton's REST transforms
return false return false
var phase: int = clampi((_frames - 20) / FRAMES_PER_STATE, 0, SWEEP.size() - 1) var phase: int = clampi((_frames - 20) / FRAMES_PER_STATE, 0, SWEEP.size() - 1)
_model.update_state(SWEEP[phase][0], SWEEP[phase][1], false) _model.update_state(SWEEP[phase][0], SWEEP[phase][1], false)
@@ -107,9 +120,12 @@ func _measure(store_rest: bool) -> void:
_rings(skel, skin, bone_of, mname, verts, bones, weights, per, store_rest) _rings(skel, skin, bone_of, mname, verts, bones, weights, per, store_rest)
## `rest` skins against the skeleton's rest transforms instead of its current
## pose, which is what makes the baseline exact: ratios come out 1.00 on an
## unposed model, so a wrong reading is visible immediately.
func _skinned(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, p: Vector3, func _skinned(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, p: Vector3,
bones: PackedInt32Array, weights: PackedFloat32Array, bones: PackedInt32Array, weights: PackedFloat32Array,
base: int, per: int) -> Vector3: base: int, per: int, rest: bool = false) -> Vector3:
var q := Vector3.ZERO var q := Vector3.ZERO
for k in per: for k in per:
var w: float = weights[base + k] var w: float = weights[base + k]
@@ -119,7 +135,8 @@ func _skinned(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, p: Vector3,
var bi: int = bone_of[bind] var bi: int = bone_of[bind]
if bi < 0: if bi < 0:
continue continue
q += (skel.get_bone_global_pose(bi) * skin.get_bind_pose(bind) * p) * w var m: Transform3D = skel.get_bone_global_rest(bi) if rest else skel.get_bone_global_pose(bi)
q += (m * skin.get_bind_pose(bind) * p) * w
return q return q
@@ -140,7 +157,7 @@ func _span(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, mname: String,
var t: float = clampf((p - ha).dot(hf) / hf.length_squared(), 0.0, 1.0) var t: float = clampf((p - ha).dot(hf) / hf.length_squared(), 0.0, 1.0)
if p.distance_to(ha + hf * t) > 0.10: if p.distance_to(ha + hf * t) > 0.10:
continue continue
var q := _skinned(skel, skin, bone_of, p, bones, weights, v * per, per) var q := _skinned(skel, skin, bone_of, p, bones, weights, v * per, per, store_rest)
lo = Vector3(minf(lo.x, q.x), minf(lo.y, q.y), minf(lo.z, q.z)) lo = Vector3(minf(lo.x, q.x), minf(lo.y, q.y), minf(lo.z, q.z))
hi = Vector3(maxf(hi.x, q.x), maxf(hi.y, q.y), maxf(hi.z, q.z)) hi = Vector3(maxf(hi.x, q.x), maxf(hi.y, q.y), maxf(hi.z, q.z))
n += 1 n += 1
@@ -177,7 +194,7 @@ func _rings(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, mname: String,
continue continue
if p.distance_to(a + ab * t) > 0.09: if p.distance_to(a + ab * t) > 0.09:
continue continue
var q := _skinned(skel, skin, bone_of, p, bones, weights, v * per, per) var q := _skinned(skel, skin, bone_of, p, bones, weights, v * per, per, store_rest)
sp.append(q) sp.append(q)
c += q c += q
if sp.size() < 3: if sp.size() < 3: