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:
co-authored by
Claude Opus 4.8
parent
e707229283
commit
050011666c
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxgs0k8sy6jcg
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user