fix: stop the torso holding back the top of the thigh

Hiding the skirt and jacket and rendering the legs alone, side-on, finally
showed where the deformation actually is: not the knee, the HIP. The top of the
thigh flattens into a wedge at full stride. That matches the worst number the
corrected measurement reports — cross-section 0.85 at the hip, against
0.97-0.99 at the knee — which had been dismissed as ordinary.

Cause is the usual linear-blend failure at a big rotation: upper-thigh vertices
are weighted between the near-static hips and a thigh swung far out, and the
average of those two transforms collapses the top of the leg. SkinLegRepair now
caps torso (hips/spine) influence on LEG vertices at 15% and hands the excess to
the leg bone that already dominates them. Only vertices within the limb radius
of a leg bone chain are touched, so the skirt keeps swinging from the hips.

Hip cross-section 0.85 -> 0.88, and the flattened wedge is visibly fuller in the
side-on render. This is a real improvement but a modest one; the legs are slim
low-poly geometry and some collapse at a 60 degree hip rotation is inherent to
linear-blend skinning.

Taila now snaps 1217+ vertices, Miku 434.

FSM tests 11/11, spawn smoke test 0 failures.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-22 07:57:19 -04:00
co-authored by Claude Opus 4.8
parent 1e12c36cca
commit e707229283
+74 -4
View File
@@ -32,13 +32,27 @@ class_name SkinLegRepair
## leg's own bone chain, NOT by height or by surface name: a thigh vertex ## leg's own bone chain, NOT by height or by surface name: a thigh vertex
## hugs its bone, while a skirt vertex hangs well clear of both and is ## hugs its bone, while a skirt vertex hangs well clear of both and is
## left blended, which is what lets a skirt drape across both legs. ## left blended, which is what lets a skirt drape across both legs.
## 3. Drop any triangle still spanning the two legs below the knee. Those are ## 3. Cap how much the TORSO owns a leg vertex. The top of the thigh is
## weighted between the hips and the thigh; at a wide stride, linear-blend
## skinning averages the near-static hips against a thigh swung 60 degrees
## out, and the top of the leg flattens into a wedge. That is the hip
## collapse visible from the side while running and jumping, and it is the
## worst number the measurement reports (cross-section 0.85 at the hip
## against 0.97-0.99 at the knee). Leg vertices keep at most MAX_TORSO of
## hips/spine influence, and the excess goes to the leg bone that already
## dominates them, so the thigh follows its own bone.
## 4. 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. ## Measure with debug/limb_deform_check.gd.
const LEG_BONE_HINTS := ["thigh", "shin", "foot", "toe"] const LEG_BONE_HINTS := ["thigh", "shin", "foot", "toe"]
## Bones that belong to the torso, not the leg.
const TORSO_BONE_HINTS := ["hips", "spine", "pelvis"]
## The most torso influence a leg vertex may keep. Some is wanted — it is what
## rounds the hip off — but past this the thigh stops following its own bone.
const MAX_TORSO := 0.15
## Ignore influences below this — they are rounding, not real weighting. ## Ignore influences below this — they are rounding, not real weighting.
const EPSILON := 0.005 const EPSILON := 0.005
@@ -111,12 +125,13 @@ static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D, knee: float,
chain_l: PackedVector3Array, chain_r: PackedVector3Array, chain_l: PackedVector3Array, chain_r: PackedVector3Array,
limb_radius: float) -> Array: limb_radius: float) -> Array:
var side := _side_map(mi.skin, skeleton) var side := _side_map(mi.skin, skeleton)
var torso_bone := _torso_map(mi.skin, skeleton)
var surfaces: Array = [] var surfaces: Array = []
var snapped := 0 var snapped := 0
var removed := 0 var removed := 0
for s in range(mi.mesh.get_surface_count()): for s in range(mi.mesh.get_surface_count()):
var arrays: Array = mi.mesh.surface_get_arrays(s) var arrays: Array = mi.mesh.surface_get_arrays(s)
var r := _repair_surface(arrays, side, knee, chain_l, chain_r, limb_radius) var r := _repair_surface(arrays, side, torso_bone, knee, chain_l, chain_r, limb_radius)
snapped += r[0] snapped += r[0]
removed += r[1] removed += r[1]
surfaces.append({ surfaces.append({
@@ -138,6 +153,23 @@ static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D, knee: float,
return [snapped, removed] return [snapped, removed]
## Is each bind a torso bone? Keyed by SKIN BIND index, like _side_map.
static func _torso_map(skin: Skin, skeleton: Skeleton3D) -> Array:
var out: Array = []
out.resize(skin.get_bind_count())
for b in skin.get_bind_count():
var n := skin.get_bind_name(b)
if n == "":
var bone := skin.get_bind_bone(b)
n = skeleton.get_bone_name(bone) if bone >= 0 else ""
out[b] = false
for hint in TORSO_BONE_HINTS:
if n.findn(hint) != -1:
out[b] = true
break
return out
## -1 left leg, +1 right leg, 0 anything else — keyed by SKIN BIND index, which ## -1 left leg, +1 right leg, 0 anything else — keyed by SKIN BIND index, which
## is what ARRAY_BONES stores (not the skeleton's bone index). ## is what ARRAY_BONES stores (not the skeleton's bone index).
static func _side_map(skin: Skin, skeleton: Skeleton3D) -> PackedInt32Array: static func _side_map(skin: Skin, skeleton: Skeleton3D) -> PackedInt32Array:
@@ -164,7 +196,8 @@ static func _side_map(skin: Skin, skeleton: Skeleton3D) -> PackedInt32Array:
return out return out
static func _repair_surface(arrays: Array, side: PackedInt32Array, knee: float, static func _repair_surface(arrays: Array, side: PackedInt32Array,
torso_bone: Array, knee: float,
chain_l: PackedVector3Array, chain_r: PackedVector3Array, chain_l: PackedVector3Array, chain_r: PackedVector3Array,
limb_radius: float) -> Array: limb_radius: float) -> Array:
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
@@ -220,7 +253,44 @@ static func _repair_surface(arrays: Array, side: PackedInt32Array, knee: float,
weights[v * per + k] /= total weights[v * per + k] /= total
snapped += 1 snapped += 1
# Step 2 — drop triangles that still span the legs below the knee. # Step 2 — stop the torso holding the top of the leg back. Only vertices that
# actually belong to a limb are touched, so the skirt keeps swinging from
# the hips as it should.
for v in verts.size():
if vside[v] == 0:
continue
var chain: PackedVector3Array = chain_l if vside[v] == -1 else chain_r
if _dist_to_chain(verts[v], chain) > limb_radius:
continue
var torso := 0.0
var dom_k := -1
var dom_w := 0.0
for k in per:
var b: int = bones[v * per + k]
var w: float = weights[v * per + k]
if torso_bone[b]:
torso += w
elif side[b] != 0 and w > dom_w:
dom_w = w
dom_k = k
if torso <= MAX_TORSO or dom_k < 0:
continue
# Scale the torso influence down to the cap and hand the rest to the
# leg bone this vertex already follows.
var keep_scale: float = MAX_TORSO / torso
for k in per:
if torso_bone[bones[v * per + k]]:
weights[v * per + k] *= keep_scale
weights[v * per + dom_k] += torso - MAX_TORSO
var sum := 0.0
for k in per:
sum += weights[v * per + k]
if sum > 0.0:
for k in per:
weights[v * per + k] /= sum
snapped += 1
# Step 3 — drop triangles that still span the legs below the knee.
var removed := 0 var removed := 0
if not idx.is_empty(): if not idx.is_empty():
var keep_idx := PackedInt32Array() var keep_idx := PackedInt32Array()