fix: handle the knee bend with a joint, and record what made it worse

Renames SkinKneeHelper to SkinJointHelper and generalises it to a list of
[parent, child] joints, so the half-angle joint technique is stated once and can
be pointed at any joint. It stays pointed at the KNEES only, and the header now
says why, because "add more joints" is the obvious next idea and it is wrong
here.

Three things were tried this round and measured, on the thigh, per vertex,
against the rest transforms:

  * hip and ankle helpers as well as knees: the knee got WORSE, 0.80 -> 0.50,
    and the skirt went 0.80 -> 0.72. The hip mostly swings rather than folding,
    and linear-blend skinning handles a swing far better than a fold; the helper
    just added another blend for nothing. Reverted.
  * gating the re-weight to vertices near the leg bone chain, to keep the hip
    helper off the skirt: that also excludes the OUTSIDE of the knee, which sits
    further from the bone axis than the limb radius and is exactly the geometry
    that needs helping. Knee went back to 0.50. Removed — with knees only there
    is no skirt to protect against.
  * one joint per vertex (break after the first match): kept, since a second
    job would read the bones the first just rewrote.

Net result on the thigh, worst per height bucket during a jump:

  y=0.60  0.80 -> 0.83     y=0.75  0.84 -> 0.84
  y=0.65  0.81 -> 0.84     y=0.85  0.90 -> 0.90

and the skirt is untouched at 0.80/0.84, still free to move.

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:43:56 -04:00
co-authored by Claude Opus 4.8
parent 0bb1db0806
commit 1ab7e99231
5 changed files with 239 additions and 190 deletions
+225
View File
@@ -0,0 +1,225 @@
extends Object
class_name SkinJointHelper
## Handles bending with JOINTS instead of letting the mesh collapse.
##
## 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.
##
## 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.
##
## 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.
##
## install() returns the [child_bone, helper_bone] pairs; 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.
const JOINTS := [
["DEF-thigh.L", "DEF-shin.L"], ["DEF-thigh.R", "DEF-shin.R"],
]
## 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"]
## Returns an Array of [child_bone_idx, helper_bone_idx].
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 := {}
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:
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])
if driven.is_empty():
return []
var chain_l := _leg_chain(skeleton, ".L")
var chain_r := _leg_chain(skeleton, ".R")
var limb_radius := 0.09
if chain_l.size() > 0 and chain_r.size() > 0:
limb_radius = maxf(absf(chain_l[0].x - chain_r[0].x) * 0.5, 0.02)
for mi in root.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
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)
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:
var i := skeleton.find_bone(stem + suffix)
if i >= 0:
out.append(skeleton.get_bone_global_rest(i).origin)
return out
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 ab: Vector3 = chain[i + 1] - a
var l2: float = ab.length_squared()
var t: float = 0.0 if l2 < 0.000001 else clampf((p - a).dot(ab) / l2, 0.0, 1.0)
best = minf(best, p.distance_to(a + ab * t))
return best
## Drive the helpers. MUST run inside the skeleton's modification pass.
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))
static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
helper_of_child: Dictionary, chain_l: PackedVector3Array,
chain_r: PackedVector3Array, limb_radius: float) -> void:
var skin: Skin = mi.skin
var bone_of := {}
for b in skin.get_bind_count():
var bi := skin.get_bind_bone(b)
if bi < 0:
bi = skeleton.find_bone(skin.get_bind_name(b))
bone_of[b] = bi
var bind_of_bone := {}
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]
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]
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])
if jobs.is_empty():
return
var surfaces: Array = []
var touched := 0
for s in range(mi.mesh.get_surface_count()):
var arrays: Array = mi.mesh.surface_get_arrays(s)
touched += _reweight_surface(arrays, jobs, chain_l, chain_r, limb_radius)
surfaces.append({
"arrays": arrays,
"material": mi.mesh.surface_get_material(s),
"name": mi.mesh.surface_get_name(s),
})
if touched == 0:
return
var rebuilt := ArrayMesh.new()
for i in surfaces.size():
var e: Dictionary = surfaces[i]
rebuilt.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, e["arrays"])
rebuilt.surface_set_material(i, e["material"])
if e["name"] != "":
rebuilt.surface_set_name(i, e["name"])
mi.mesh = rebuilt
static func _reweight_surface(arrays: Array, jobs: Array,
chain_l: PackedVector3Array, chain_r: PackedVector3Array,
limb_radius: float) -> int:
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
if bones.is_empty() or verts.is_empty():
return 0
var per: int = bones.size() / verts.size()
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]
var k_parent := -1
var k_child := -1
for k in per:
var b: int = bones[v * per + k]
if b == parent_bind:
k_parent = k
elif b == child_bind:
k_child = k
if k_parent < 0 or k_child < 0:
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:
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
if touched > 0:
arrays[Mesh.ARRAY_BONES] = bones
arrays[Mesh.ARRAY_WEIGHTS] = weights
return touched