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 several 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. ## ## 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 _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) # baseline from the skeleton's REST transforms 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) # Keyed by MESH as well as material. A model that keeps its per-part # meshes reuses one material across several of them — Taila has # "FullBlack" on three — and keying by material alone compared one # mesh's rest against another mesh's posed span, which reported a # 4.9x stretch on a model that was fine. var mname: String = "%s/%s" % [mi.name, 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) ## `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, bones: PackedInt32Array, weights: PackedFloat32Array, base: int, per: int, rest: bool = false) -> 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 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 ## Is this vertex mostly owned by a LEFT leg bone? func _left_leg_driven(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, bones: PackedInt32Array, weights: PackedFloat32Array, base: int, per: int) -> bool: var best := 0.0 var best_name := "" for k in per: var w: float = weights[base + k] if w <= best: continue var bind: int = bones[base + k] var n: String = skin.get_bind_name(bind) if n == "": var bi: int = bone_of[bind] n = skel.get_bone_name(bi) if bi >= 0 else "" best = w best_name = n if not (best_name.ends_with(".L") or best_name.find(".L.") != -1): return false for hint in ["shin", "thigh", "foot", "toe"]: if best_name.findn(hint) != -1: return true return false 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 # Must be DRIVEN by the left leg, not merely near it. Taila's boots are # a single mesh holding both feet, and at rest the right boot sits # within 0.10 m of the left leg axis — so a purely positional filter # collected both, and the span between them read as a 4.9x "stretch" # the moment the legs separated. if not _left_leg_driven(skel, skin, bone_of, bones, weights, v * per, per): continue 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)) 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, store_rest) 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