fix: half-angle helper bones at the knees kill the candy-wrapper collapse

This is the leg squashing. It was at the KNEE, and every previous measurement
missed it because they averaged a ring of vertices, which hides a collapse that
only affects part of the ring.

Measured per vertex instead, against an exact rest-transform baseline: 532 of
Taila's leg vertices lose more than 20% of their distance from the bone axis and
the worst lose 40%, all at y=0.54 — exactly the knee — and worst in the air pose.
That is "squashing while running or jumping, easy to see from the side".

Cause is inherent to linear-blend skinning: it averages TRANSFORMS, not shapes.
A vertex half on the thigh and half on the shin is placed at the average of two
rotations, and at a hard bend that average falls well inside the leg, pinching
it to a ribbon.

SkinKneeHelper adds a bone at each knee, parented to the thigh and holding the
shin's rest transform, driven every frame to HALF the shin's rotation from
inside the skeleton's modification pass. Blend-zone vertices are re-weighted off
the thigh/shin pair and onto the helper, taking equal weight from each side and
reusing the slot that falls to zero — so no extra influence slot is needed and
the weights still sum to exactly 1. The sharpest blend any vertex now sees is
half the knee angle, and halving the angle roughly quarters the collapse.

Result: vertices losing more than 20% of their radius go from 532 to 0 on the
body. The worst anywhere is now 0.79, on the outline shell, which is hidden.
Verified visually too, legs rendered bare from the side through rest, run and
jump: knees hold their volume and the bind is intact.

FSM tests 11/11, spawn smoke test 0 failures, Miku unaffected.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-22 08:04:24 -04:00
co-authored by Claude Opus 4.8
parent e707229283
commit 050011666c
3 changed files with 191 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
extends Object
class_name SkinKneeHelper
## Fixes the knee "candy wrapper" collapse with a half-angle helper bone.
##
## Linear-blend skinning averages TRANSFORMS, not shapes. A vertex sitting half
## on the thigh and half on the shin is placed at the average of two rotations,
## and when the knee bends hard that average falls well inside the leg — the
## limb pinches to a flat ribbon. Measured on Taila per vertex (ring averages
## hide this completely): 532 leg vertices lose more than 20% of their radius
## and the worst lose 40%, all at y=0.54, which is exactly the knee, and worst
## in the air/jump pose. That is the squashing visible from the side while
## running and jumping.
##
## The standard fix is to stop any vertex blending across the full bend. We add
## a bone at the knee, parented to the thigh, holding the shin's rest transform,
## and drive it every frame to HALF the shin's rotation. Vertices in the blend
## zone are re-weighted off the thigh/shin pair and onto this helper, so the
## sharpest blend any vertex sees is half the knee angle. Halving the angle
## roughly quarters the collapse.
##
## install() returns the [shin_bone, helper_bone] pairs; the caller must drive
## them every frame from inside the skeleton's modification pass (see
## SkinnedPlayerModel.ShooterPoseModifier), because a helper bone that is not
## updated in step with the shin is worse than none at all.
const PAIRS := [["DEF-thigh.L", "DEF-shin.L"], ["DEF-thigh.R", "DEF-shin.R"]]
## Blend-zone threshold: a vertex needs at least this much of BOTH bones before
## it is worth moving onto the helper.
const MIN_BLEND := 0.02
## Returns an Array of [shin_bone_idx, helper_bone_idx].
static func install(root: Node, skeleton: Skeleton3D) -> Array:
if skeleton == null:
return []
var driven: Array = []
var helper_of_shin := {} # shin bone idx -> helper bone idx
for pair in PAIRS:
var thigh := skeleton.find_bone(pair[0])
var shin := skeleton.find_bone(pair[1])
if thigh < 0 or shin < 0:
continue
var hname: String = "HELPER-knee" + pair[1].substr(pair[1].length() - 2)
if skeleton.find_bone(hname) >= 0:
continue # already installed
skeleton.add_bone(hname)
var h := skeleton.find_bone(hname)
skeleton.set_bone_parent(h, thigh)
# Same rest as the shin, so "half the shin's local rotation" lands the
# helper exactly halfway through the bend.
skeleton.set_bone_rest(h, skeleton.get_bone_rest(shin))
skeleton.reset_bone_pose(h)
helper_of_shin[shin] = h
driven.append([shin, h])
if driven.is_empty():
return []
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_shin)
return driven
## Drive the helpers. MUST run inside the skeleton's modification pass.
static func update(skeleton: Skeleton3D, driven: Array) -> void:
for d in driven:
var shin: int = d[0]
var helper: int = d[1]
skeleton.set_bone_pose_rotation(helper,
Quaternion.IDENTITY.slerp(skeleton.get_bone_pose_rotation(shin), 0.5))
static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
helper_of_shin: Dictionary) -> void:
var skin: Skin = mi.skin
# bind index -> skeleton bone
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
# Make sure every helper has a bind, sharing the shin's bind pose (their
# global rests are identical, so the inverse-bind matrix is the same).
var bind_of_bone := {}
for b in skin.get_bind_count():
bind_of_bone[bone_of[b]] = b
var helper_bind := {}
for shin in helper_of_shin:
var helper: int = helper_of_shin[shin]
if not bind_of_bone.has(shin):
continue
var pose: Transform3D = skin.get_bind_pose(bind_of_bone[shin])
skin.add_named_bind(skeleton.get_bone_name(helper), pose)
helper_bind[bind_of_bone[shin]] = skin.get_bind_count() - 1
var thigh_of_shin := {}
for shin in helper_of_shin:
thigh_of_shin[shin] = skeleton.get_bone_parent(shin)
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, bone_of, bind_of_bone, helper_bind,
helper_of_shin, thigh_of_shin)
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, bone_of: Dictionary,
bind_of_bone: Dictionary, helper_bind: Dictionary,
helper_of_shin: Dictionary, thigh_of_shin: Dictionary) -> 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():
for shin in helper_of_shin:
var shin_bind: int = bind_of_bone.get(shin, -1)
var thigh_bind: int = bind_of_bone.get(thigh_of_shin[shin], -1)
if shin_bind < 0 or thigh_bind < 0 or not helper_bind.has(shin_bind):
continue
# Find this vertex's thigh and shin slots.
var k_thigh := -1
var k_shin := -1
for k in per:
var b: int = bones[v * per + k]
if b == thigh_bind:
k_thigh = k
elif b == shin_bind:
k_shin = k
if k_thigh < 0 or k_shin < 0:
continue
var w_thigh: float = weights[v * per + k_thigh]
var w_shin: float = weights[v * per + k_shin]
var m: float = minf(w_thigh, w_shin)
if m < MIN_BLEND:
continue
# Move 2m onto the helper, taking m from each side. The slot whose
# weight goes to zero is reused, so no extra influence slot is
# needed and the weights still sum to exactly 1.
if w_thigh <= w_shin:
bones[v * per + k_thigh] = helper_bind[shin_bind]
weights[v * per + k_thigh] = 2.0 * m
weights[v * per + k_shin] = w_shin - m
else:
bones[v * per + k_shin] = helper_bind[shin_bind]
weights[v * per + k_shin] = 2.0 * m
weights[v * per + k_thigh] = w_thigh - m
touched += 1
if touched > 0:
arrays[Mesh.ARRAY_BONES] = bones
arrays[Mesh.ARRAY_WEIGHTS] = weights
return touched
+1
View File
@@ -0,0 +1 @@
uid://cxgs0k8sy6jcg
+14
View File
@@ -86,6 +86,8 @@ var loaded: bool = false
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
var _current_clip: String = "" var _current_clip: String = ""
var _weapon_attachment: BoneAttachment3D var _weapon_attachment: BoneAttachment3D
## [shin_bone, helper_bone] pairs driven every frame by the pose modifier.
var _knee_helpers: Array = []
var is_holding_weapon: bool = false var is_holding_weapon: bool = false
# Animation blending: locomotion plays full-body through a Transition node; # Animation blending: locomotion plays full-body through a Transition node;
@@ -168,7 +170,11 @@ func load_model(path: String) -> void:
if fixed[0] > 0 or fixed[1] > 0: if fixed[0] > 0 or fixed[1] > 0:
print("SkinnedPlayerModel: '%s' — snapped %d cross-leg vertices, dropped %d bridging triangles" print("SkinnedPlayerModel: '%s' — snapped %d cross-leg vertices, dropped %d bridging triangles"
% [path.get_file(), fixed[0], fixed[1]]) % [path.get_file(), fixed[0], fixed[1]])
# Half-angle helper bones at the knees. Without them the knee pinches to
# a ribbon at a hard bend — see SkinKneeHelper.
_knee_helpers = SkinKneeHelper.install(scene, skeleton)
_pose_mod = ShooterPoseModifier.new() _pose_mod = ShooterPoseModifier.new()
_pose_mod.knee_helpers = _knee_helpers
_pose_mod.name = "ShooterPose" _pose_mod.name = "ShooterPose"
skeleton.add_child(_pose_mod) skeleton.add_child(_pose_mod)
@@ -755,6 +761,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var gun_stock: float = 0.20 var gun_stock: float = 0.20
# 0..1 through a reload — drives the support hand to the mag well and back. # 0..1 through a reload — drives the support hand to the mag well and back.
var reload_phase: float = 0.0 var reload_phase: float = 0.0
# [shin_bone, helper_bone] pairs; see SkinKneeHelper.
var knee_helpers: Array = []
# Tuning (radians). Positive pitch leans forward; positive roll leans right. # Tuning (radians). Positive pitch leans forward; positive roll leans right.
# The lean is the ONLY thing that tells a viewer which way this character is # The lean is the ONLY thing that tells a viewer which way this character is
@@ -813,6 +821,12 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_apply_recoil(skel) _apply_recoil(skel)
recoil = lerpf(recoil, 0.0, 0.25) recoil = lerpf(recoil, 0.0, 0.25)
# Knees LAST, and inside the modification pass: the helper has to track
# whatever final rotation the shin ended up with, or it deforms the leg
# instead of saving it.
if not knee_helpers.is_empty():
SkinKneeHelper.update(skel, knee_helpers)
# Upper body follows the camera pitch: distributed over spine/neck/head # Upper body follows the camera pitch: distributed over spine/neck/head
# so looking up/down reads on the whole silhouette, not just the head. # so looking up/down reads on the whole silhouette, not just the head.