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]>
376 lines
14 KiB
GDScript
376 lines
14 KiB
GDScript
extends Object
|
|
class_name SkinLegRepair
|
|
|
|
## Keeps the HUMANOID rigid while letting the CLOTHES flow.
|
|
##
|
|
## That split is the whole design rule here, and it decides what every pass below
|
|
## is allowed to touch:
|
|
##
|
|
## * LIMB vertices — anything hugging a leg's own bone chain: skin, stockings,
|
|
## boots — are cleaned up hard. They belong to one leg, they follow it, and
|
|
## they hold their shape. Deforming here is a bug.
|
|
## * DRAPE vertices — the skirt and anything else hanging clear of both leg
|
|
## chains — are LEFT ALONE, so they keep swinging with the body.
|
|
##
|
|
## DO NOT stiffen the skirt onto the hips. It was tried (to stop it flattening at
|
|
## a wide stride) and reverted: it makes the clothes read as a rigid shell, which
|
|
## is worse than the flattening it fixed. Cloth is supposed to move.
|
|
##
|
|
## Current split, from debug/limb_deform_check.gd: the body holds 0.98 of its
|
|
## cross-section at the knee, while the skirt is free at 0.88 — rigid limbs,
|
|
## moving cloth.
|
|
##
|
|
##
|
|
## Stops below-the-knee geometry being dragged by BOTH legs at once.
|
|
##
|
|
## Taila's boots are skinned with weights that bleed across the centre line:
|
|
## 262 vertices in the boot/cuff surface (plus 208 in the model's outline shell
|
|
## and 176 in the body) carry weight from the left AND right leg, the worst at a
|
|
## dead-even 49/51 split. A vertex pulled equally by both feet sits halfway
|
|
## between them and stays there while the legs separate, stretching every
|
|
## triangle around it. That is the "ankle cuffs are linked" stretching, and the
|
|
## elongated boot that reads as the legs being squashed.
|
|
##
|
|
## Everything else about the rig is fine, which is why this took so long to
|
|
## find. Measured through the full runtime stack during a run: no bone's pose
|
|
## basis deviates from a pure rotation by more than 0.00001, no bone's length
|
|
## drifts from its rest offset by more than 0.0000 m, and no below-knee vertex
|
|
## is influenced by any non-leg bone. The skeleton is correct; the weights are
|
|
## not. Freezing the AnimationTree at the rest pose renders the boots perfectly,
|
|
## which is what proves it is a skinning problem rather than a pose one.
|
|
##
|
|
## Three steps:
|
|
##
|
|
## 1. BELOW THE KNEE (height taken from the skeleton's own rest pose, so this
|
|
## scales to any character): snap each vertex to the leg that already
|
|
## dominates it and renormalise, so nothing is pulled in two directions.
|
|
## 2. ABOVE THE KNEE, only for vertices that are part of a LEG rather than
|
|
## draping cloth: same snap, to the nearer leg. 122 vertices on Taila's
|
|
## thigh mesh carry up to 35% of the opposite leg — that is the squashing
|
|
## that survived the below-knee pass and showed up when running and
|
|
## jumping split the legs. Limb membership is decided by distance to the
|
|
## 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
|
|
## left blended, which is what lets a skirt drape across both legs.
|
|
## 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
|
|
## way.
|
|
##
|
|
## Measure with debug/limb_deform_check.gd.
|
|
|
|
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.
|
|
const EPSILON := 0.005
|
|
|
|
|
|
## Returns [vertices_snapped, triangles_removed] so callers can log the result.
|
|
static func repair(root: Node, skeleton: Skeleton3D) -> Array:
|
|
var knee := _knee_height(skeleton)
|
|
if is_nan(knee):
|
|
return [0, 0]
|
|
var chain_l := _leg_chain(skeleton, ".L")
|
|
var chain_r := _leg_chain(skeleton, ".R")
|
|
if chain_l.is_empty() or chain_r.is_empty():
|
|
return [0, 0]
|
|
# "Part of a leg" means within roughly half the gap between the two legs of
|
|
# that leg's bone chain — self-scaling to the character's proportions.
|
|
var limb_radius: float = maxf(absf(chain_l[0].x - chain_r[0].x) * 0.5, 0.02)
|
|
|
|
var snapped_total := 0
|
|
var removed_total := 0
|
|
for mi in root.find_children("*", "MeshInstance3D", true, false):
|
|
if mi.mesh == null or mi.skin == null:
|
|
continue
|
|
# Rebuilding a mesh drops blend shapes, so a skin that uses them (a face
|
|
# rig) is left alone rather than silently losing its expressions.
|
|
if mi.mesh.get_blend_shape_count() > 0:
|
|
continue
|
|
var r := _repair_mesh(mi, skeleton, knee, chain_l, chain_r, limb_radius)
|
|
snapped_total += r[0]
|
|
removed_total += r[1]
|
|
return [snapped_total, removed_total]
|
|
|
|
|
|
## Rest-pose joint positions down one leg, used as a polyline to measure how
|
|
## close a vertex sits to that limb.
|
|
static func _leg_chain(skeleton: Skeleton3D, suffix: String) -> PackedVector3Array:
|
|
var out := PackedVector3Array()
|
|
for stem in ["DEF-thigh", "DEF-shin", "DEF-foot", "DEF-toe"]:
|
|
var i := skeleton.find_bone(stem + suffix)
|
|
if i < 0:
|
|
i = skeleton.find_bone(stem.trim_prefix("DEF-") + suffix)
|
|
if i >= 0:
|
|
out.append(skeleton.get_bone_global_rest(i).origin)
|
|
return out
|
|
|
|
|
|
## Distance from a point to a polyline.
|
|
static func _dist_to_chain(p: Vector3, chain: PackedVector3Array) -> float:
|
|
var best := INF
|
|
for i in range(chain.size() - 1):
|
|
var a: Vector3 = chain[i]
|
|
var b: Vector3 = chain[i + 1]
|
|
var ab: Vector3 = b - a
|
|
var len2: float = ab.length_squared()
|
|
var t: float = 0.0 if len2 < 0.000001 else clampf((p - a).dot(ab) / len2, 0.0, 1.0)
|
|
best = minf(best, p.distance_to(a + ab * t))
|
|
return best
|
|
|
|
|
|
static func _knee_height(skeleton: Skeleton3D) -> float:
|
|
if skeleton == null:
|
|
return NAN
|
|
for n in ["DEF-shin.L", "shin.L", "DEF-shin.R", "shin.R"]:
|
|
var i := skeleton.find_bone(n)
|
|
if i >= 0:
|
|
return skeleton.get_bone_global_rest(i).origin.y
|
|
return NAN
|
|
|
|
|
|
static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D, knee: float,
|
|
chain_l: PackedVector3Array, chain_r: PackedVector3Array,
|
|
limb_radius: float) -> Array:
|
|
var side := _side_map(mi.skin, skeleton)
|
|
var torso_bone := _torso_map(mi.skin, skeleton)
|
|
var surfaces: Array = []
|
|
var snapped := 0
|
|
var removed := 0
|
|
for s in range(mi.mesh.get_surface_count()):
|
|
var arrays: Array = mi.mesh.surface_get_arrays(s)
|
|
var r := _repair_surface(arrays, side, torso_bone, knee, chain_l, chain_r, limb_radius)
|
|
snapped += r[0]
|
|
removed += r[1]
|
|
surfaces.append({
|
|
"arrays": arrays,
|
|
"material": mi.mesh.surface_get_material(s),
|
|
"name": mi.mesh.surface_get_name(s),
|
|
})
|
|
if snapped == 0 and removed == 0:
|
|
return [0, 0]
|
|
|
|
var rebuilt := ArrayMesh.new()
|
|
for i in surfaces.size():
|
|
var entry: Dictionary = surfaces[i]
|
|
rebuilt.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, entry["arrays"])
|
|
rebuilt.surface_set_material(i, entry["material"])
|
|
if entry["name"] != "":
|
|
rebuilt.surface_set_name(i, entry["name"])
|
|
mi.mesh = rebuilt
|
|
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
|
|
## is what ARRAY_BONES stores (not the skeleton's bone index).
|
|
static func _side_map(skin: Skin, skeleton: Skeleton3D) -> PackedInt32Array:
|
|
var out := PackedInt32Array()
|
|
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 ""
|
|
var is_leg := false
|
|
for hint in LEG_BONE_HINTS:
|
|
if n.findn(hint) != -1:
|
|
is_leg = true
|
|
break
|
|
if not is_leg:
|
|
out[b] = 0
|
|
elif n.ends_with(".L"):
|
|
out[b] = -1
|
|
elif n.ends_with(".R"):
|
|
out[b] = 1
|
|
else:
|
|
out[b] = 0
|
|
return out
|
|
|
|
|
|
static func _repair_surface(arrays: Array, side: PackedInt32Array,
|
|
torso_bone: Array, knee: float,
|
|
chain_l: PackedVector3Array, chain_r: PackedVector3Array,
|
|
limb_radius: float) -> Array:
|
|
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
|
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
|
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
|
var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
|
|
if bones.is_empty() or verts.is_empty():
|
|
return [0, 0]
|
|
var per: int = bones.size() / verts.size()
|
|
|
|
# Step 1 — one leg per vertex.
|
|
var vside := PackedInt32Array()
|
|
vside.resize(verts.size())
|
|
var snapped := 0
|
|
for v in verts.size():
|
|
var wl := 0.0
|
|
var wr := 0.0
|
|
for k in per:
|
|
var w: float = weights[v * per + k]
|
|
if w <= EPSILON:
|
|
continue
|
|
match side[bones[v * per + k]]:
|
|
-1: wl += w
|
|
1: wr += w
|
|
if wl <= 0.0 and wr <= 0.0:
|
|
vside[v] = 0
|
|
continue
|
|
var keep: int = -1 if wl >= wr else 1
|
|
vside[v] = keep
|
|
if minf(wl, wr) <= EPSILON:
|
|
continue # already single-legged
|
|
if verts[v].y > knee:
|
|
# Above the knee, only repair vertices that belong to a LEG. Cloth
|
|
# that drapes across both legs sits clear of either bone chain and
|
|
# is left blended so it can keep draping.
|
|
var dl: float = _dist_to_chain(verts[v], chain_l)
|
|
var dr: float = _dist_to_chain(verts[v], chain_r)
|
|
if minf(dl, dr) > limb_radius:
|
|
continue # hugs neither chain — cloth, leave it alone
|
|
# Side comes from WEIGHT, not from which chain is nearer. An earlier
|
|
# version also demanded one chain be 1.25x closer than the other,
|
|
# which skipped everything near the centre line — and that is exactly
|
|
# 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.
|
|
var total := 0.0
|
|
for k in per:
|
|
var b: int = bones[v * per + k]
|
|
if side[b] != 0 and side[b] != keep:
|
|
weights[v * per + k] = 0.0
|
|
total += weights[v * per + k]
|
|
if total > 0.0:
|
|
for k in per:
|
|
weights[v * per + k] /= total
|
|
snapped += 1
|
|
|
|
# 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 — 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
|
|
if not idx.is_empty():
|
|
var keep_idx := PackedInt32Array()
|
|
for t in range(0, idx.size(), 3):
|
|
var a: int = idx[t]
|
|
var b: int = idx[t + 1]
|
|
var c: int = idx[t + 2]
|
|
var below: bool = (verts[a].y + verts[b].y + verts[c].y) / 3.0 < knee
|
|
var has_l: bool = vside[a] == -1 or vside[b] == -1 or vside[c] == -1
|
|
var has_r: bool = vside[a] == 1 or vside[b] == 1 or vside[c] == 1
|
|
if below and has_l and has_r:
|
|
removed += 1
|
|
continue
|
|
keep_idx.append(a)
|
|
keep_idx.append(b)
|
|
keep_idx.append(c)
|
|
if removed > 0:
|
|
arrays[Mesh.ARRAY_INDEX] = keep_idx
|
|
if snapped > 0:
|
|
arrays[Mesh.ARRAY_WEIGHTS] = weights
|
|
return [snapped, removed]
|