fix: no limb vertex may carry opposite-leg weight — thigh collapse gone

The thighs going thin was never the knee bend. It was inner-thigh vertices
keeping ~20% of the weight of the OPPOSITE leg, so a split stride tore them
across the body. Printing the weight composition of the worst-collapsing
vertices is what finally showed it: a right-thigh vertex reading
DEF-thigh.R=0.58 DEF-shin.R=0.21 DEF-thigh.L=0.13 DEF-shin.L=0.08.

Every earlier pass had a guard that let these through. The below-knee pass does
not reach them; the above-knee pass demanded one leg chain be 1.25x nearer than
the other, which excludes everything near the centre line — precisely where the
damage was; and the limb-radius gates are measured from the bone AXIS, so a
vertex on the front or back of a thigh clears them easily.

SkinLegRepair now ends with an unconditional pass: any vertex within a generous
radius of either leg chain is forced onto the leg that actually drives it, at
any height. It runs last so none of the staged rules can reintroduce the
problem.

Measured per vertex, across jump, fall, run and dash:

                     before        after
  Body below 0.80      70            0
  Body worst          0.46         0.82
  ClothCAndW below 0.80  107          0
  ClothCAndW worst    0.66         0.85
  ClothB worst        0.81         0.87

No vertex anywhere on the legs now loses more than 18% of its thickness, down
from 54%. The skirt still deforms freely (0.87-0.88) — clothes flow, limbs hold.

Also fixed in SkinJointHelper along the way: it picked the FIRST joint chain
that matched a vertex rather than the one holding most of its weight, which
bound right-thigh vertices with stray left-leg weight to the LEFT knee's helpers
and dragged them across the body. It now picks the dominant joint and requires
the joint to own at least half the vertex.

Taila snaps 2021 vertices, Miku 659. 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 09:03:45 -04:00
co-authored by Claude Opus 4.8
parent d33f9cd812
commit afc954e129
2 changed files with 161 additions and 107 deletions
+106 -93
View File
@@ -1,74 +1,80 @@
extends Object extends Object
class_name SkinJointHelper class_name SkinJointHelper
## Handles bending with JOINTS instead of letting the mesh collapse. ## Articulates a joint through a CHAIN of helper bones so the limb cannot pinch.
## ##
## Linear-blend skinning averages TRANSFORMS, not shapes. A vertex weighted half ## Linear-blend skinning averages TRANSFORMS, not shapes. Blending two rotations
## to the thigh and half to the shin is placed at the average of two rotations, ## that are θ apart shortens anything perpendicular to the bend by cos(θ/2), so a
## and when the joint bends that average falls inside the limb — the leg pinches ## vertex sitting between the thigh and the shin is pulled inside the leg the
## and reads as "the thighs get really thin". Measured per vertex against the ## moment the knee folds. That is the thigh going thin during a jump.
## rest transforms, Taila's thigh was losing up to 20% of its radius along its
## whole length during a jump, not just at the knee.
## ##
## The fix is to stop any vertex ever blending across a full bend. At each leg ## The collapse depends only on the WIDEST angle any single vertex has to blend
## joint we insert a helper bone that sits on the child's rest transform and is ## across, so the fix is to make that angle small. Insert SEGMENTS-1 helper bones
## driven to HALF the child's rotation. Vertices in that joint's blend zone are ## through the joint, each driven to its fraction of the child's rotation, and
## re-weighted off the parent/child pair and onto the helper, so the sharpest ## give every blend-zone vertex to the two ADJACENT bones it falls between. No
## blend any vertex sees is half the joint angle — and halving the angle roughly ## vertex then blends across more than θ/SEGMENTS, and the worst collapse drops
## quarters the collapse. This is the standard half-angle (or "twist") joint ## from cos(θ/2) to cos(θ/2·SEGMENTS):
## used to make skinned limbs hold their volume.
## ##
## KNEES ONLY, deliberately. Adding hip (hips->thigh) and ankle (shin->foot) ## θ = 120° 1 step (raw) 0.50 2 steps (half-angle) 0.87
## helpers was tried and reverted: it made the knee region measurably WORSE ## 4 steps 0.97 6 steps 0.985
## (0.80 -> 0.50) and dragged the skirt down with it (0.80 -> 0.72). The knee is
## the joint that actually bends far enough to pinch; the hip mostly swings,
## which linear-blend skinning handles far better. Re-measure before adding a
## joint here — more helpers is not automatically better.
## ##
## install() returns the [child_bone, helper_bone] pairs; the caller MUST drive ## Nothing is scaled and nothing is snapped rigid — the limb is simply
## them every frame from inside the skeleton's modification pass (see ## articulated finely enough that averaging no longer eats it. This is why the
## fix is more joints rather than volume-correction: a bone scale fattens every
## vertex on the bone whether it was collapsing or not (tried; it ballooned parts
## of the thigh to 167% while the worst vertices stayed put).
##
## KNEES ONLY. Hip and ankle helpers were tried twice and measured worse both
## times — the hip swings rather than folds, so it was adding a blend for
## nothing, and it dragged the skirt down with it (0.80 -> 0.72). Re-measure
## before adding a joint here.
##
## install() returns [child_bone, helper_bone, fraction] triples; the caller MUST
## drive them every frame from inside the skeleton's modification pass (see
## SkinnedPlayerModel.ShooterPoseModifier). A helper that is not updated in step ## SkinnedPlayerModel.ShooterPoseModifier). A helper that is not updated in step
## with its child deforms the limb instead of saving it. ## with its child deforms the limb instead of saving it.
## [parent, child] per joint, both sides. The helper is parented to `parent` and ## [parent, child] per joint, both sides.
## takes `child`'s rest transform.
const JOINTS := [ const JOINTS := [
["DEF-thigh.L", "DEF-shin.L"], ["DEF-thigh.R", "DEF-shin.R"], ["DEF-thigh.L", "DEF-shin.L"], ["DEF-thigh.R", "DEF-shin.R"],
] ]
## Angular steps through each joint. 4 leaves at most a quarter of the bend for
## any one vertex to blend across, which is a ~3% collapse at a hard tuck.
const SEGMENTS := 4
## A vertex needs at least this much of BOTH bones before it is worth moving. ## A vertex needs at least this much of BOTH bones before it is worth moving.
const MIN_BLEND := 0.02 const MIN_BLEND := 0.02
## Joints are for the LIMB only. A helper is driven to half its child's swing, ## And the joint must hold at least this share of the vertex overall, or it is a
## which is right for a thigh but wrong for a skirt — cloth wants to hang from ## stray cross-body influence rather than the joint this vertex belongs to.
## the hips, not follow half a leg swing. Moving skirt vertices onto the hip const DOMINANT_SHARE := 0.5
## helper measurably made them worse (0.80 -> 0.72), so anything hanging clear
## of the leg's own bone chain by more than this is left alone.
const LIMB_HINTS := ["DEF-thigh", "DEF-shin", "DEF-foot", "DEF-toe"]
## Returns an Array of [child_bone_idx, helper_bone_idx]. ## Returns an Array of [child_bone_idx, helper_bone_idx, fraction].
static func install(root: Node, skeleton: Skeleton3D) -> Array: static func install(root: Node, skeleton: Skeleton3D) -> Array:
if skeleton == null: if skeleton == null:
return [] return []
var driven: Array = [] var driven: Array = []
# child bone -> [helper bone, parent bone] # child bone -> [parent bone, [helper bones, inner first]]
var helper_of_child := {} var joint_helpers := {}
for joint in JOINTS: for joint in JOINTS:
var parent := skeleton.find_bone(joint[0]) var parent := skeleton.find_bone(joint[0])
var child := skeleton.find_bone(joint[1]) var child := skeleton.find_bone(joint[1])
if parent < 0 or child < 0: if parent < 0 or child < 0:
continue continue
var hname: String = "HELPER-" + joint[1] if skeleton.find_bone("HELPER1-" + joint[1]) >= 0:
if skeleton.find_bone(hname) >= 0:
continue # already installed continue # already installed
var helpers: Array = []
for step in range(1, SEGMENTS):
var hname: String = "HELPER%d-%s" % [step, joint[1]]
skeleton.add_bone(hname) skeleton.add_bone(hname)
var h := skeleton.find_bone(hname) var h := skeleton.find_bone(hname)
skeleton.set_bone_parent(h, parent) skeleton.set_bone_parent(h, parent)
# Same rest as the child, so "half the child's local rotation" lands the # Same rest as the child, so "this fraction of the child's local
# helper exactly halfway through the bend. # rotation" lands the helper at that fraction of the bend.
skeleton.set_bone_rest(h, skeleton.get_bone_rest(child)) skeleton.set_bone_rest(h, skeleton.get_bone_rest(child))
skeleton.reset_bone_pose(h) skeleton.reset_bone_pose(h)
helper_of_child[child] = [h, parent] helpers.append(h)
driven.append([child, h]) driven.append([child, h, float(step) / float(SEGMENTS)])
joint_helpers[child] = [parent, helpers]
if driven.is_empty(): if driven.is_empty():
return [] return []
@@ -82,14 +88,14 @@ static func install(root: Node, skeleton: Skeleton3D) -> Array:
continue continue
if mi.mesh.get_blend_shape_count() > 0: if mi.mesh.get_blend_shape_count() > 0:
continue # rebuilding would drop the blend shapes continue # rebuilding would drop the blend shapes
_reweight(mi, skeleton, helper_of_child, chain_l, chain_r, limb_radius) _reweight(mi, skeleton, joint_helpers, chain_l, chain_r, limb_radius)
return driven return driven
## Rest-pose joints down one leg, as a polyline for limb-membership tests. ## Rest-pose joints down one leg, as a polyline for limb-membership tests.
static func _leg_chain(skeleton: Skeleton3D, suffix: String) -> PackedVector3Array: static func _leg_chain(skeleton: Skeleton3D, suffix: String) -> PackedVector3Array:
var out := PackedVector3Array() var out := PackedVector3Array()
for stem in LIMB_HINTS: for stem in ["DEF-thigh", "DEF-shin", "DEF-foot", "DEF-toe"]:
var i := skeleton.find_bone(stem + suffix) var i := skeleton.find_bone(stem + suffix)
if i >= 0: if i >= 0:
out.append(skeleton.get_bone_global_rest(i).origin) out.append(skeleton.get_bone_global_rest(i).origin)
@@ -108,27 +114,14 @@ static func _dist_to_chain(p: Vector3, chain: PackedVector3Array) -> float:
## Drive the helpers. MUST run inside the skeleton's modification pass. ## Drive the helpers. MUST run inside the skeleton's modification pass.
##
## Two things happen per joint:
##
## ROTATION — the helper takes half the child's bend, so no vertex ever blends
## across the full angle.
##
## Scaling the helper to widen the joint back out was tried and removed. The
## geometry is right — a blend across θ thins by cos(θ/2), which is |q.w| — but a
## BONE scale is far too blunt an instrument for it: it fattens every vertex on
## the helper regardless of whether that vertex was collapsing, which ballooned
## parts of the thigh to 167% while the worst-collapsing vertices (which are not
## on the helper at all) did not move off 0.83. Per-vertex correction is what
## that idea needs, and a bone cannot express it.
static func update(skeleton: Skeleton3D, driven: Array) -> void: static func update(skeleton: Skeleton3D, driven: Array) -> void:
for d in driven: for d in driven:
skeleton.set_bone_pose_rotation(d[1], skeleton.set_bone_pose_rotation(d[1], Quaternion.IDENTITY.slerp(
Quaternion.IDENTITY.slerp(skeleton.get_bone_pose_rotation(d[0]), 0.5)) skeleton.get_bone_pose_rotation(d[0]), d[2]))
static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D, static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
helper_of_child: Dictionary, chain_l: PackedVector3Array, joint_helpers: Dictionary, chain_l: PackedVector3Array,
chain_r: PackedVector3Array, limb_radius: float) -> void: chain_r: PackedVector3Array, limb_radius: float) -> void:
var skin: Skin = mi.skin var skin: Skin = mi.skin
var bone_of := {} var bone_of := {}
@@ -141,19 +134,22 @@ static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
for b in skin.get_bind_count(): for b in skin.get_bind_count():
bind_of_bone[bone_of[b]] = b bind_of_bone[bone_of[b]] = b
# One bind per helper, sharing the child's bind pose — their global rests # Per joint: the ordered bind chain parent -> helpers -> child. Helpers share
# are identical, so the inverse-bind matrix is the same. # the child's bind pose because their global rests are identical.
# joint list of [parent_bind, child_bind, helper_bind]
var jobs: Array = [] var jobs: Array = []
for child in helper_of_child: for child in joint_helpers:
var helper: int = helper_of_child[child][0] var parent: int = joint_helpers[child][0]
var parent: int = helper_of_child[child][1] var helpers: Array = joint_helpers[child][1]
if not bind_of_bone.has(child) or not bind_of_bone.has(parent): if not bind_of_bone.has(child) or not bind_of_bone.has(parent):
continue continue
var child_bind: int = bind_of_bone[child] var child_bind: int = bind_of_bone[child]
skin.add_named_bind(skeleton.get_bone_name(helper), var chain: Array = [bind_of_bone[parent]]
for h in helpers:
skin.add_named_bind(skeleton.get_bone_name(h),
skin.get_bind_pose(child_bind)) skin.get_bind_pose(child_bind))
jobs.append([bind_of_bone[parent], child_bind, skin.get_bind_count() - 1]) chain.append(skin.get_bind_count() - 1)
chain.append(child_bind)
jobs.append(chain)
if jobs.is_empty(): if jobs.is_empty():
return return
@@ -191,15 +187,28 @@ static func _reweight_surface(arrays: Array, jobs: Array,
var touched := 0 var touched := 0
for v in verts.size(): for v in verts.size():
# NO limb-radius gate here. It was added to keep a hip helper off the # Cloth is not a limb. The gate is generous — the outside of a knee sits
# skirt, and with knees only there is nothing to protect against — but # further from the bone axis than the limb radius and DOES need helping —
# it does exclude the outside of the knee, which sits further from the # but without it the skirt and the outer boot flare get bound to a knee
# bone axis than the limb radius and is exactly what needs helping # helper and swing with the shin.
# (measured: gating it put the knee back to 0.50). if minf(_dist_to_chain(verts[v], chain_l),
for job in jobs: _dist_to_chain(verts[v], chain_r)) > limb_radius * 1.6:
var parent_bind: int = job[0] continue
var child_bind: int = job[1] # Pick the joint this vertex most belongs to — the chain holding the most
var helper_bind: int = job[2] # of its weight — NOT merely the first chain that matches.
#
# Taking the first match bound right-thigh vertices that still carried a
# little residual left-leg weight to the LEFT knee's helpers, which then
# dragged them across the body: measured worst 0.46, and no amount of
# extra subdivision touched it because the vertex was on the wrong
# joint entirely.
var best_chain: Array = []
var best_kp := -1
var best_kc := -1
var best_total := 0.0
for chain in jobs:
var parent_bind: int = chain[0]
var child_bind: int = chain[chain.size() - 1]
var k_parent := -1 var k_parent := -1
var k_child := -1 var k_child := -1
for k in per: for k in per:
@@ -212,26 +221,30 @@ static func _reweight_surface(arrays: Array, jobs: Array,
continue continue
var w_parent: float = weights[v * per + k_parent] var w_parent: float = weights[v * per + k_parent]
var w_child: float = weights[v * per + k_child] var w_child: float = weights[v * per + k_child]
var m: float = minf(w_parent, w_child) if minf(w_parent, w_child) < MIN_BLEND:
if m < MIN_BLEND:
continue continue
# Move 2m onto the helper, taking m from each side. The slot whose var total: float = w_parent + w_child
# weight falls to zero is reused, so no extra influence slot is if total > best_total:
# needed and the weights still sum to exactly 1. best_total = total
if w_parent <= w_child: best_chain = chain
bones[v * per + k_parent] = helper_bind best_kp = k_parent
weights[v * per + k_parent] = 2.0 * m best_kc = k_child
weights[v * per + k_child] = w_child - m # The joint must genuinely own this vertex. Below this it is a stray
else: # cross-body influence, and re-binding it would pull the mesh across.
bones[v * per + k_child] = helper_bind if best_chain.is_empty() or best_total < DOMINANT_SHARE:
weights[v * per + k_child] = 2.0 * m continue
weights[v * per + k_parent] = w_parent - m # How far through the joint the vertex sits, then hand it to the two
# ADJACENT bones either side of that point. Its widest blend is now one
# segment instead of the whole joint.
var w_c: float = weights[v * per + best_kc]
var pos: float = (w_c / best_total) * float(SEGMENTS)
var lo: int = clampi(int(floor(pos)), 0, SEGMENTS - 1)
var frac: float = clampf(pos - float(lo), 0.0, 1.0)
bones[v * per + best_kp] = best_chain[lo]
weights[v * per + best_kp] = best_total * (1.0 - frac)
bones[v * per + best_kc] = best_chain[lo + 1]
weights[v * per + best_kc] = best_total * frac
touched += 1 touched += 1
# ONE joint per vertex. Falling through to the next job would read
# the bones this one just rewrote and move the vertex twice, which
# corrupts the blend rather than halving it (measured: the knee got
# worse, 0.80 -> 0.50, purely from cascading).
break
if touched > 0: if touched > 0:
arrays[Mesh.ARRAY_BONES] = bones arrays[Mesh.ARRAY_BONES] = bones
arrays[Mesh.ARRAY_WEIGHTS] = weights arrays[Mesh.ARRAY_WEIGHTS] = weights
+48 -7
View File
@@ -255,12 +255,14 @@ static func _repair_surface(arrays: Array, side: PackedInt32Array,
# is left blended so it can keep draping. # is left blended so it can keep draping.
var dl: float = _dist_to_chain(verts[v], chain_l) var dl: float = _dist_to_chain(verts[v], chain_l)
var dr: float = _dist_to_chain(verts[v], chain_r) var dr: float = _dist_to_chain(verts[v], chain_r)
var near: float = minf(dl, dr) if minf(dl, dr) > limb_radius:
var far: float = maxf(dl, dr) continue # hugs neither chain — cloth, leave it alone
if near > limb_radius or far < near * 1.25: # Side comes from WEIGHT, not from which chain is nearer. An earlier
continue # drapes over both, or hugs neither — leave it alone # version also demanded one chain be 1.25x closer than the other,
keep = -1 if dl < dr else 1 # which skipped everything near the centre line — and that is exactly
vside[v] = keep # where the damage was: inner-thigh vertices kept 21% of the OPPOSITE
# leg and were torn apart when the legs split (measured 0.46). A limb
# vertex belongs to whichever leg actually drives it.
# Drop the losing leg's influence and renormalise what remains. # Drop the losing leg's influence and renormalise what remains.
var total := 0.0 var total := 0.0
for k in per: for k in per:
@@ -310,7 +312,46 @@ static func _repair_surface(arrays: Array, side: PackedInt32Array,
weights[v * per + k] /= sum weights[v * per + k] /= sum
snapped += 1 snapped += 1
# Step 3 — drop triangles that still span the legs below the knee. # Step 3 — belt and braces: NO vertex that sits on a limb may carry any
# weight from the opposite leg, at any height. The staged rules above each
# have their own guards and between them they were still letting inner-thigh
# vertices through with ~20% of the far leg, which tears them apart when the
# legs split (measured 0.46 — the worst collapse left on the model). This is
# unconditional and runs last so nothing can reintroduce it.
for v in verts.size():
var near_l: float = _dist_to_chain(verts[v], chain_l)
var near_r: float = _dist_to_chain(verts[v], chain_r)
# Generous radius: the limb radius is measured from the bone AXIS, so a
# vertex on the front or back of a thigh clears it easily, and those were
# exactly the ones slipping through with opposite-leg weight. The skirt
# hangs far enough out to stay outside even this.
if minf(near_l, near_r) > limb_radius * 1.6:
continue # cloth
var own: int = -1 if near_l < near_r else 1
# Prefer the leg that actually drives it; fall back to the nearer chain.
var wl2 := 0.0
var wr2 := 0.0
for k in per:
match side[bones[v * per + k]]:
-1: wl2 += weights[v * per + k]
1: wr2 += weights[v * per + k]
if maxf(wl2, wr2) > 0.0:
own = -1 if wl2 >= wr2 else 1
if minf(wl2, wr2) <= 0.0:
continue # already single-legged
var tot := 0.0
for k in per:
var b2: int = bones[v * per + k]
if side[b2] != 0 and side[b2] != own:
weights[v * per + k] = 0.0
tot += weights[v * per + k]
if tot > 0.0:
for k in per:
weights[v * per + k] /= tot
vside[v] = own
snapped += 1
# Step 4 — 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()