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
+13 -13
View File
@@ -86,8 +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 = []
## [child_bone, helper_bone] pairs driven every frame by the pose modifier.
var _joint_helpers: Array = []
var is_holding_weapon: bool = false
# Animation blending: locomotion plays full-body through a Transition node;
@@ -170,11 +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)
# Half-angle helper bones at every leg joint. Without them the limb
# pinches at a hard bend — see SkinJointHelper.
_joint_helpers = SkinJointHelper.install(scene, skeleton)
_pose_mod = ShooterPoseModifier.new()
_pose_mod.knee_helpers = _knee_helpers
_pose_mod.joint_helpers = _joint_helpers
_pose_mod.name = "ShooterPose"
skeleton.add_child(_pose_mod)
@@ -761,8 +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 = []
# [child_bone, helper_bone] pairs; see SkinJointHelper.
var joint_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
@@ -821,11 +821,11 @@ 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)
# Joints LAST, and inside the modification pass: each helper has to track
# whatever final rotation its child bone ended up with, or it deforms the
# limb instead of saving it.
if not joint_helpers.is_empty():
SkinJointHelper.update(skel, joint_helpers)
# Upper body follows the camera pitch: distributed over spine/neck/head