251 lines
9.2 KiB
GDScript
251 lines
9.2 KiB
GDScript
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
|