fix: hang the skirt from the hips so it stops collapsing at a stride

It was the SKIRT, not the legs. Every leg check I ran hid the skirt to see the
thighs, so I was never looking at what is actually on screen. Rendering the full
model side-on shows it immediately: at rest the skirt is a flared shape with
real volume, and the moment the legs split for a run or a jump it collapses flat
against the body. The lower-body silhouette loses its shape, which reads as the
legs squashing.

Cause: the skirt is weighted to BOTH thighs. At a split stride the two thighs
pull it in opposite directions and linear-blend skinning averages them, so the
skirt is dragged inward and flattened. The earlier repair deliberately left it
that way, on the reasoning that a skirt should be free to drape across both
legs — correct in principle, wrong at these stride angles.

Draping cloth now hangs from the hips: vertices that clear both leg bone chains
keep at most 20% leg influence and the rest goes to DEF-hips, so the skirt holds
its shape and swings as a unit. Limb vertices are still snapped to one leg, so
the two are now modelled differently on purpose.

Bounded ABOVE THE KNEE. The first attempt used distance from the bone chain
alone, and chunky boots sit further from the ankle than the limb radius, so they
read as "drape", lost their leg weight and trailed off the hips — the legs came
out far worse than before. A skirt hem is above the knee; boots and stockings
are not.

Legs unchanged by this: knee cross-section stays 0.99, and the knee helper work
from the previous commit is intact. Taila snaps 4024 vertices, Miku 670.

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 08:20:27 -04:00
co-authored by Claude Opus 4.8
parent 050011666c
commit 20b70e5eda
+86 -4
View File
@@ -41,7 +41,17 @@ class_name SkinLegRepair
## against 0.97-0.99 at the knee). Leg vertices keep at most MAX_TORSO of ## 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 ## hips/spine influence, and the excess goes to the leg bone that already
## dominates them, so the thigh follows its own bone. ## dominates them, so the thigh follows its own bone.
## 4. Drop any triangle still spanning the two legs below the knee. Those are ## 4. Hang DRAPING CLOTH from the hips instead of the legs. The skirt is
## weighted to both thighs so it can follow them, but at a running or
## jumping stride the two thighs pull it in opposite directions and it
## collapses flat against the body — its flared volume vanishes and the
## whole lower body reads as squashed from the side. Cloth that is clear of
## both leg bone chains keeps at most MAX_DRAPE_LEG of leg influence, and
## the rest goes to the hips, so the skirt holds its shape and swings as a
## unit. This is the "model the skirt differently from the legs" case, and
## it is separate from the limb work above: LIMB vertices are snapped to
## one leg, DRAPE vertices are taken off the legs almost entirely.
## 5. 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.
## ##
@@ -53,6 +63,9 @@ const TORSO_BONE_HINTS := ["hips", "spine", "pelvis"]
## The most torso influence a leg vertex may keep. Some is wanted — it is what ## 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. ## rounds the hip off — but past this the thigh stops following its own bone.
const MAX_TORSO := 0.15 const MAX_TORSO := 0.15
## The most LEG influence a draping cloth vertex may keep. A little lets the
## skirt react to the legs; more than this and a split stride tears it flat.
const MAX_DRAPE_LEG := 0.20
## 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
@@ -126,12 +139,13 @@ static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D, knee: float,
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 torso_bone := _torso_map(mi.skin, skeleton)
var hips_slot := _hips_bind(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, torso_bone, knee, chain_l, chain_r, limb_radius) var r := _repair_surface(arrays, side, torso_bone, hips_slot, knee, chain_l, chain_r, limb_radius)
snapped += r[0] snapped += r[0]
removed += r[1] removed += r[1]
surfaces.append({ surfaces.append({
@@ -153,6 +167,18 @@ static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D, knee: float,
return [snapped, removed] return [snapped, removed]
## SKIN BIND index of the hips, which is what draping cloth should hang from.
static func _hips_bind(skin: Skin, skeleton: Skeleton3D) -> int:
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 ""
if n.findn("hips") != -1 or n.findn("pelvis") != -1:
return b
return -1
## Is each bind a torso bone? Keyed by SKIN BIND index, like _side_map. ## Is each bind a torso bone? Keyed by SKIN BIND index, like _side_map.
static func _torso_map(skin: Skin, skeleton: Skeleton3D) -> Array: static func _torso_map(skin: Skin, skeleton: Skeleton3D) -> Array:
var out: Array = [] var out: Array = []
@@ -197,7 +223,7 @@ static func _side_map(skin: Skin, skeleton: Skeleton3D) -> PackedInt32Array:
static func _repair_surface(arrays: Array, side: PackedInt32Array, static func _repair_surface(arrays: Array, side: PackedInt32Array,
torso_bone: Array, knee: float, torso_bone: Array, hips_slot: int, 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]
@@ -290,7 +316,63 @@ 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 — draping cloth hangs from the hips. A skirt pulled by both thighs
# at a split stride collapses flat; taking it off the legs keeps its volume.
if hips_slot >= 0:
for v in verts.size():
var leg_total := 0.0
for k in per:
if side[bones[v * per + k]] != 0:
leg_total += weights[v * per + k]
if leg_total <= MAX_DRAPE_LEG:
continue
# ABOVE THE KNEE ONLY. Distance from the bone chain alone is not a
# safe test down here: chunky footwear sits further from the ankle
# than the limb radius, so a boot reads as "drape" and taking its
# leg weight away makes it trail off the hips. A skirt hem is above
# the knee; boots and stockings are not.
if verts[v].y <= knee:
continue
# Limb vertices were already handled above; this is only for cloth
# that hangs clear of BOTH leg bone chains.
if minf(_dist_to_chain(verts[v], chain_l),
_dist_to_chain(verts[v], chain_r)) <= limb_radius:
continue
var k_hips := -1
for k in per:
if bones[v * per + k] == hips_slot:
k_hips = k
break
if k_hips < 0:
# No hips influence yet: take over the weakest leg slot.
var weakest := -1
var weakest_w := 2.0
for k in per:
if side[bones[v * per + k]] != 0 and weights[v * per + k] < weakest_w:
weakest_w = weights[v * per + k]
weakest = k
if weakest < 0:
continue
bones[v * per + weakest] = hips_slot
weights[v * per + weakest] = 0.0
k_hips = weakest
leg_total -= weakest_w
if leg_total <= MAX_DRAPE_LEG:
continue
var keep: float = MAX_DRAPE_LEG / leg_total
for k in per:
if side[bones[v * per + k]] != 0:
weights[v * per + k] *= keep
weights[v * per + k_hips] += leg_total - MAX_DRAPE_LEG
var total2 := 0.0
for k in per:
total2 += weights[v * per + k]
if total2 > 0.0:
for k in per:
weights[v * per + k] /= total2
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()