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:
co-authored by
Claude Opus 4.8
parent
d33f9cd812
commit
afc954e129
+113
-100
@@ -1,74 +1,80 @@
|
||||
extends Object
|
||||
extends Object
|
||||
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
|
||||
## to the thigh and half to the shin is placed at the average of two rotations,
|
||||
## and when the joint bends that average falls inside the limb — the leg pinches
|
||||
## and reads as "the thighs get really thin". Measured per vertex against the
|
||||
## 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.
|
||||
## Linear-blend skinning averages TRANSFORMS, not shapes. Blending two rotations
|
||||
## that are θ apart shortens anything perpendicular to the bend by cos(θ/2), so a
|
||||
## vertex sitting between the thigh and the shin is pulled inside the leg the
|
||||
## moment the knee folds. That is the thigh going thin during a jump.
|
||||
##
|
||||
## The fix is to stop any vertex ever blending across a full bend. At each leg
|
||||
## joint we insert a helper bone that sits on the child's rest transform and is
|
||||
## driven to HALF the child's rotation. Vertices in that joint's blend zone are
|
||||
## re-weighted off the parent/child pair and onto the helper, so the sharpest
|
||||
## blend any vertex sees is half the joint angle — and halving the angle roughly
|
||||
## quarters the collapse. This is the standard half-angle (or "twist") joint
|
||||
## used to make skinned limbs hold their volume.
|
||||
## The collapse depends only on the WIDEST angle any single vertex has to blend
|
||||
## across, so the fix is to make that angle small. Insert SEGMENTS-1 helper bones
|
||||
## through the joint, each driven to its fraction of the child's rotation, and
|
||||
## give every blend-zone vertex to the two ADJACENT bones it falls between. No
|
||||
## vertex then blends across more than θ/SEGMENTS, and the worst collapse drops
|
||||
## from cos(θ/2) to cos(θ/2·SEGMENTS):
|
||||
##
|
||||
## KNEES ONLY, deliberately. Adding hip (hips->thigh) and ankle (shin->foot)
|
||||
## helpers was tried and reverted: it made the knee region measurably WORSE
|
||||
## (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.
|
||||
## θ = 120° 1 step (raw) 0.50 2 steps (half-angle) 0.87
|
||||
## 4 steps 0.97 6 steps 0.985
|
||||
##
|
||||
## install() returns the [child_bone, helper_bone] pairs; the caller MUST drive
|
||||
## them every frame from inside the skeleton's modification pass (see
|
||||
## Nothing is scaled and nothing is snapped rigid — the limb is simply
|
||||
## 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
|
||||
## with its child deforms the limb instead of saving it.
|
||||
|
||||
## [parent, child] per joint, both sides. The helper is parented to `parent` and
|
||||
## takes `child`'s rest transform.
|
||||
## [parent, child] per joint, both sides.
|
||||
const JOINTS := [
|
||||
["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.
|
||||
const MIN_BLEND := 0.02
|
||||
## Joints are for the LIMB only. A helper is driven to half its child's swing,
|
||||
## which is right for a thigh but wrong for a skirt — cloth wants to hang from
|
||||
## the hips, not follow half a leg swing. Moving skirt vertices onto the hip
|
||||
## 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"]
|
||||
## And the joint must hold at least this share of the vertex overall, or it is a
|
||||
## stray cross-body influence rather than the joint this vertex belongs to.
|
||||
const DOMINANT_SHARE := 0.5
|
||||
|
||||
|
||||
## 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:
|
||||
if skeleton == null:
|
||||
return []
|
||||
var driven: Array = []
|
||||
# child bone -> [helper bone, parent bone]
|
||||
var helper_of_child := {}
|
||||
# child bone -> [parent bone, [helper bones, inner first]]
|
||||
var joint_helpers := {}
|
||||
for joint in JOINTS:
|
||||
var parent := skeleton.find_bone(joint[0])
|
||||
var child := skeleton.find_bone(joint[1])
|
||||
if parent < 0 or child < 0:
|
||||
continue
|
||||
var hname: String = "HELPER-" + joint[1]
|
||||
if skeleton.find_bone(hname) >= 0:
|
||||
if skeleton.find_bone("HELPER1-" + joint[1]) >= 0:
|
||||
continue # already installed
|
||||
skeleton.add_bone(hname)
|
||||
var h := skeleton.find_bone(hname)
|
||||
skeleton.set_bone_parent(h, parent)
|
||||
# Same rest as the child, so "half the child's local rotation" lands the
|
||||
# helper exactly halfway through the bend.
|
||||
skeleton.set_bone_rest(h, skeleton.get_bone_rest(child))
|
||||
skeleton.reset_bone_pose(h)
|
||||
helper_of_child[child] = [h, parent]
|
||||
driven.append([child, h])
|
||||
var helpers: Array = []
|
||||
for step in range(1, SEGMENTS):
|
||||
var hname: String = "HELPER%d-%s" % [step, joint[1]]
|
||||
skeleton.add_bone(hname)
|
||||
var h := skeleton.find_bone(hname)
|
||||
skeleton.set_bone_parent(h, parent)
|
||||
# Same rest as the child, so "this fraction of the child's local
|
||||
# rotation" lands the helper at that fraction of the bend.
|
||||
skeleton.set_bone_rest(h, skeleton.get_bone_rest(child))
|
||||
skeleton.reset_bone_pose(h)
|
||||
helpers.append(h)
|
||||
driven.append([child, h, float(step) / float(SEGMENTS)])
|
||||
joint_helpers[child] = [parent, helpers]
|
||||
if driven.is_empty():
|
||||
return []
|
||||
|
||||
@@ -82,14 +88,14 @@ static func install(root: Node, skeleton: Skeleton3D) -> Array:
|
||||
continue
|
||||
if mi.mesh.get_blend_shape_count() > 0:
|
||||
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
|
||||
|
||||
|
||||
## Rest-pose joints down one leg, as a polyline for limb-membership tests.
|
||||
static func _leg_chain(skeleton: Skeleton3D, suffix: String) -> 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)
|
||||
if i >= 0:
|
||||
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.
|
||||
##
|
||||
## 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:
|
||||
for d in driven:
|
||||
skeleton.set_bone_pose_rotation(d[1],
|
||||
Quaternion.IDENTITY.slerp(skeleton.get_bone_pose_rotation(d[0]), 0.5))
|
||||
skeleton.set_bone_pose_rotation(d[1], Quaternion.IDENTITY.slerp(
|
||||
skeleton.get_bone_pose_rotation(d[0]), d[2]))
|
||||
|
||||
|
||||
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:
|
||||
var skin: Skin = mi.skin
|
||||
var bone_of := {}
|
||||
@@ -141,19 +134,22 @@ static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
|
||||
for b in skin.get_bind_count():
|
||||
bind_of_bone[bone_of[b]] = b
|
||||
|
||||
# One bind per helper, sharing the child's bind pose — their global rests
|
||||
# are identical, so the inverse-bind matrix is the same.
|
||||
# joint list of [parent_bind, child_bind, helper_bind]
|
||||
# Per joint: the ordered bind chain parent -> helpers -> child. Helpers share
|
||||
# the child's bind pose because their global rests are identical.
|
||||
var jobs: Array = []
|
||||
for child in helper_of_child:
|
||||
var helper: int = helper_of_child[child][0]
|
||||
var parent: int = helper_of_child[child][1]
|
||||
for child in joint_helpers:
|
||||
var parent: int = joint_helpers[child][0]
|
||||
var helpers: Array = joint_helpers[child][1]
|
||||
if not bind_of_bone.has(child) or not bind_of_bone.has(parent):
|
||||
continue
|
||||
var child_bind: int = bind_of_bone[child]
|
||||
skin.add_named_bind(skeleton.get_bone_name(helper),
|
||||
skin.get_bind_pose(child_bind))
|
||||
jobs.append([bind_of_bone[parent], child_bind, skin.get_bind_count() - 1])
|
||||
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))
|
||||
chain.append(skin.get_bind_count() - 1)
|
||||
chain.append(child_bind)
|
||||
jobs.append(chain)
|
||||
if jobs.is_empty():
|
||||
return
|
||||
|
||||
@@ -191,15 +187,28 @@ static func _reweight_surface(arrays: Array, jobs: Array,
|
||||
var touched := 0
|
||||
|
||||
for v in verts.size():
|
||||
# NO limb-radius gate here. It was added to keep a hip helper off the
|
||||
# skirt, and with knees only there is nothing to protect against — but
|
||||
# it does exclude the outside of the knee, which sits further from the
|
||||
# bone axis than the limb radius and is exactly what needs helping
|
||||
# (measured: gating it put the knee back to 0.50).
|
||||
for job in jobs:
|
||||
var parent_bind: int = job[0]
|
||||
var child_bind: int = job[1]
|
||||
var helper_bind: int = job[2]
|
||||
# Cloth is not a limb. The gate is generous — the outside of a knee sits
|
||||
# further from the bone axis than the limb radius and DOES need helping —
|
||||
# but without it the skirt and the outer boot flare get bound to a knee
|
||||
# helper and swing with the shin.
|
||||
if minf(_dist_to_chain(verts[v], chain_l),
|
||||
_dist_to_chain(verts[v], chain_r)) > limb_radius * 1.6:
|
||||
continue
|
||||
# Pick the joint this vertex most belongs to — the chain holding the most
|
||||
# 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_child := -1
|
||||
for k in per:
|
||||
@@ -212,26 +221,30 @@ static func _reweight_surface(arrays: Array, jobs: Array,
|
||||
continue
|
||||
var w_parent: float = weights[v * per + k_parent]
|
||||
var w_child: float = weights[v * per + k_child]
|
||||
var m: float = minf(w_parent, w_child)
|
||||
if m < MIN_BLEND:
|
||||
if minf(w_parent, w_child) < MIN_BLEND:
|
||||
continue
|
||||
# Move 2m onto the helper, taking m from each side. The slot whose
|
||||
# weight falls to zero is reused, so no extra influence slot is
|
||||
# needed and the weights still sum to exactly 1.
|
||||
if w_parent <= w_child:
|
||||
bones[v * per + k_parent] = helper_bind
|
||||
weights[v * per + k_parent] = 2.0 * m
|
||||
weights[v * per + k_child] = w_child - m
|
||||
else:
|
||||
bones[v * per + k_child] = helper_bind
|
||||
weights[v * per + k_child] = 2.0 * m
|
||||
weights[v * per + k_parent] = w_parent - m
|
||||
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
|
||||
var total: float = w_parent + w_child
|
||||
if total > best_total:
|
||||
best_total = total
|
||||
best_chain = chain
|
||||
best_kp = k_parent
|
||||
best_kc = k_child
|
||||
# The joint must genuinely own this vertex. Below this it is a stray
|
||||
# cross-body influence, and re-binding it would pull the mesh across.
|
||||
if best_chain.is_empty() or best_total < DOMINANT_SHARE:
|
||||
continue
|
||||
# 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
|
||||
if touched > 0:
|
||||
arrays[Mesh.ARRAY_BONES] = bones
|
||||
arrays[Mesh.ARRAY_WEIGHTS] = weights
|
||||
|
||||
@@ -255,12 +255,14 @@ static func _repair_surface(arrays: Array, side: PackedInt32Array,
|
||||
# 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)
|
||||
var near: float = minf(dl, dr)
|
||||
var far: float = maxf(dl, dr)
|
||||
if near > limb_radius or far < near * 1.25:
|
||||
continue # drapes over both, or hugs neither — leave it alone
|
||||
keep = -1 if dl < dr else 1
|
||||
vside[v] = keep
|
||||
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:
|
||||
@@ -310,7 +312,46 @@ static func _repair_surface(arrays: Array, side: PackedInt32Array,
|
||||
weights[v * per + k] /= sum
|
||||
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
|
||||
if not idx.is_empty():
|
||||
var keep_idx := PackedInt32Array()
|
||||
|
||||
Reference in New Issue
Block a user