tools: measure limb deformation instead of eyeballing it

Adds debug/limb_deform_check.gd, which skins the leg vertices by hand and
compares them against the rest pose across a sweep of run, walk, jump, fall,
slide and dash. It reports lengthwise stretch (bending a knee can only shrink
this, so growth is genuine stretching) and cross-section loss (the linear-blend
"candy wrapper" collapse at a bent joint).

No gameplay or character code changes in this commit. Current readings on
Taila, through the full runtime stack:

  lengthwise stretch   1.05 worst (Body, ClothB, ClothCAndW), 1.03 outline shell
  cross-section        0.93 knee, 0.84 hip, 0.91 ankle

Sanity check on the method: the measured rest span of the leg vertex cloud is
0.818 m against 0.843 m of thigh+shin bone length, so the skinning maths is
producing real positions rather than plausible-looking noise.

That is ordinary linear-blend skinning. Combined with the earlier findings — no
bone pose basis deviating from a pure rotation by more than 0.00001, and no
bone length drifting by more than 0.0000 m — the leg geometry is not squashing
or stretching in any measurable way, so there is nothing here to fix blind.
Committing the instrument so the next round starts from a number.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-22 07:39:42 -04:00
co-authored by Claude Opus 4.8
parent a6e009d24a
commit 204846bf09
2 changed files with 197 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
extends SceneTree
## Dev tool: measure whether a skin's LEGS actually squash or stretch, by
## skinning the vertices ourselves and comparing against the rest pose.
##
## godot --headless --path . -s res://debug/limb_deform_check.gd -- [skin_glb]
##
## Reports two numbers per surface, taken as the worst over a sweep of run,
## walk, jump, fall, slide and dash:
##
## LENGTHWISE the greatest distance between any two leg vertices, over the
## same at rest. Bending a knee can only SHRINK this, so anything
## above ~1.05 is real stretching.
## CROSS-SECTION the average spread of a ring of vertices about its own
## centre, over the same at rest. This is the "candy wrapper"
## collapse that linear-blend skinning causes at a bent joint;
## 0.85-1.00 is normal, well below that is a weighting fault.
##
## Written after four rounds of chasing reported leg "squashing". Renders were
## repeatedly misleading — a slim anime leg at full stride genuinely looks
## stretched — so measure before changing anything, and prefer this over
## eyeballing a screenshot.
var _frames := 0
var _model: SkinnedPlayerModel = null
var _rest_radius := {}
var _min_ratio := {}
var _rest_span := {}
var _max_span := {}
## state, speed
const SWEEP := [["ground", 9.0], ["ground", 3.0], ["air", 6.0],
["air", -8.0], ["slide", 10.0], ["dash", 14.0]]
const FRAMES_PER_STATE := 40
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 < 10:
return false
if _frames == 10:
_measure(true)
return false
var phase: int = clampi((_frames - 20) / 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)
if _frames > 20:
_measure(false)
if _frames > 20 + FRAMES_PER_STATE * SWEEP.size():
_report()
return true
return false
func _report() -> void:
print("\n=== worst LENGTHWISE stretch vs rest (>1.05 = real stretching) ===")
var lk := _max_span.keys()
lk.sort()
for k in lk:
print(" %-14s %.2f (rest %.3f m, worst %.3f m)" % [
k, _max_span[k] / _rest_span[k], _rest_span[k], _max_span[k]])
print("\n=== worst CROSS-SECTION vs rest (1.00 = no loss) ===")
var keys := _min_ratio.keys()
keys.sort()
for k in keys:
print(" %-34s %.2f" % [k, _min_ratio[k]])
func _measure(store_rest: bool) -> void:
var skel: Skeleton3D = _model.skeleton
if not skel:
return
skel.force_update_all_bone_transforms()
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 mat = mi.mesh.surface_get_material(s)
var mname: String = mat.resource_name if mat else "?"
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()
_span(skel, skin, bone_of, mname, verts, bones, weights, per, store_rest)
_rings(skel, skin, bone_of, mname, verts, bones, weights, per, store_rest)
func _skinned(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, p: Vector3,
bones: PackedInt32Array, weights: PackedFloat32Array,
base: int, per: int) -> Vector3:
var q := Vector3.ZERO
for k in per:
var w: float = weights[base + k]
if w <= 0.0:
continue
var bind: int = bones[base + k]
var bi: int = bone_of[bind]
if bi < 0:
continue
q += (skel.get_bone_global_pose(bi) * skin.get_bind_pose(bind) * p) * w
return q
func _span(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, mname: String,
verts: PackedVector3Array, bones: PackedInt32Array,
weights: PackedFloat32Array, per: int, store_rest: bool) -> void:
var th := skel.find_bone("DEF-thigh.L")
var fo := skel.find_bone("DEF-foot.L")
if th < 0 or fo < 0:
return
var ha: Vector3 = skel.get_bone_global_rest(th).origin
var hf: Vector3 = skel.get_bone_global_rest(fo).origin - ha
var lo := Vector3(INF, INF, INF)
var hi := Vector3(-INF, -INF, -INF)
var n := 0
for v in verts.size():
var p: Vector3 = verts[v]
var t: float = clampf((p - ha).dot(hf) / hf.length_squared(), 0.0, 1.0)
if p.distance_to(ha + hf * t) > 0.10:
continue
var q := _skinned(skel, skin, bone_of, p, bones, weights, v * per, per)
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))
n += 1
if n < 4:
return
var span: float = (hi - lo).length()
if store_rest:
_rest_span[mname] = span
elif _rest_span.has(mname) and (not _max_span.has(mname) or span > _max_span[mname]):
_max_span[mname] = span
func _rings(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, mname: String,
verts: PackedVector3Array, bones: PackedInt32Array,
weights: PackedFloat32Array, per: int, store_rest: bool) -> void:
for seg in [
["DEF-thigh.L", "DEF-shin.L", 0.82, 1.0, "knee (thigh side)"],
["DEF-shin.L", "DEF-foot.L", 0.0, 0.18, "knee (shin side)"],
["DEF-thigh.L", "DEF-shin.L", 0.0, 0.18, "hip"],
["DEF-shin.L", "DEF-foot.L", 0.82, 1.0, "ankle"],
]:
var a_i := skel.find_bone(seg[0])
var b_i := skel.find_bone(seg[1])
if a_i < 0 or b_i < 0:
continue
var a: Vector3 = skel.get_bone_global_rest(a_i).origin
var ab: Vector3 = skel.get_bone_global_rest(b_i).origin - a
var sp: Array = []
var c := Vector3.ZERO
for v in verts.size():
var p: Vector3 = verts[v]
var t: float = clampf((p - a).dot(ab) / ab.length_squared(), 0.0, 1.0)
if t < float(seg[2]) or t > float(seg[3]):
continue
if p.distance_to(a + ab * t) > 0.09:
continue
var q := _skinned(skel, skin, bone_of, p, bones, weights, v * per, per)
sp.append(q)
c += q
if sp.size() < 3:
continue
c /= sp.size()
var r := 0.0
for q in sp:
r += (q - c).length()
r /= sp.size()
var key: String = "%-12s %s" % [mname, seg[4]]
if store_rest:
_rest_radius[key] = r
elif _rest_radius.has(key) and _rest_radius[key] > 0.0001:
var ratio: float = r / _rest_radius[key]
if not _min_ratio.has(key) or ratio < _min_ratio[key]:
_min_ratio[key] = ratio
+1
View File
@@ -0,0 +1 @@
uid://d3pq0rn2bc0e6