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
+14
View File
@@ -86,6 +86,8 @@ var loaded: bool = false
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
var _current_clip: String = ""
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
# 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:
print("SkinnedPlayerModel: '%s' — snapped %d cross-leg vertices, dropped %d bridging triangles"
% [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.knee_helpers = _knee_helpers
_pose_mod.name = "ShooterPose"
skeleton.add_child(_pose_mod)
@@ -755,6 +761,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var gun_stock: float = 0.20
# 0..1 through a reload — drives the support hand to the mag well and back.
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.
# 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)
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
# so looking up/down reads on the whole silhouette, not just the head.