fix: stop the boots being dragged by both legs; keep the support hand on the gun

Found the real cause of the leg stretching, after three wrong guesses.

720 vertices on Taila are weighted to BOTH legs at once below the knee — 262 in
the boot/cuff surface, 208 in the outline shell, 176 in the body, the worst at a
dead-even 49/51 split. A vertex pulled equally by both feet sits halfway between
them and STAYS there while the legs separate, stretching every triangle around
it. That is the band between the ankles and the elongated boot that reads as the
legs being squashed. SkinLegRepair snaps each below-knee vertex to whichever leg
already dominates it, renormalises, then drops the few triangles still spanning
the two legs (48 on Taila, 0 on Miku, which needs 214 vertices snapped).

What finally located it was reproducing the artefact through the FULL runtime
stack in a bright scene. Every previous "clean" render had bypassed the game's
animation stack — playing the clip straight off the GLB's AnimationPlayer with
no AnimationTree and no pose modifier — so it could never show the bug. With the
real stack the boot visibly stretches, and freezing the AnimationTree at the
rest pose renders it perfectly, which proves the pose is fine and the skinning
is not. Confirming measurements, all through the runtime stack during a run:
no bone's pose basis deviates from a pure rotation by more than 0.00001, no
bone's length drifts from its rest offset by more than 0.0000 m, and no
below-knee vertex is influenced by any non-leg bone. There is no squash-and-
stretch in this rig; there never was.

The two earlier attempts at this are both superseded: re-weighting by
triangle-neighbour majority (tore the cloth) and deleting cross-leg triangles
(too narrow — 24 triangles against 720 bad vertices). Hiding the model's own
outline hull, from the last commit, stays: that hull is genuinely broken and
redundant, it just was not the whole story.

Weapon support hand: gun_fore was a single constant 0.35 m for every weapon, so
on a short gun it hung the off hand out past the muzzle with nothing to hold —
that is the "not supported by the arms" case. It is now clamped to 80% of the
weapon's own grip-to-muzzle distance, which only bites on the short guns (MP7
0.35 -> 0.20). All six weapons now sit in the shoulder with both hands on them.

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 01:25:04 -04:00
co-authored by Claude Opus 4.8
parent 28dc255d17
commit f60941d1fb
3 changed files with 208 additions and 6 deletions
+188
View File
@@ -0,0 +1,188 @@
extends Object
class_name SkinLegRepair
## Stops below-the-knee geometry being dragged by BOTH legs at once.
##
## Taila's boots are skinned with weights that bleed across the centre line:
## 262 vertices in the boot/cuff surface (plus 208 in the model's outline shell
## and 176 in the body) carry weight from the left AND right leg, the worst at a
## dead-even 49/51 split. A vertex pulled equally by both feet sits halfway
## between them and stays there while the legs separate, stretching every
## triangle around it. That is the "ankle cuffs are linked" stretching, and the
## elongated boot that reads as the legs being squashed.
##
## Everything else about the rig is fine, which is why this took so long to
## find. Measured through the full runtime stack during a run: no bone's pose
## basis deviates from a pure rotation by more than 0.00001, no bone's length
## drifts from its rest offset by more than 0.0000 m, and no below-knee vertex
## is influenced by any non-leg bone. The skeleton is correct; the weights are
## not. Freezing the AnimationTree at the rest pose renders the boots perfectly,
## which is what proves it is a skinning problem rather than a pose one.
##
## Two steps, both limited to BELOW THE KNEE (taken from the skeleton's own rest
## pose, so it scales to any character). Above the knee, cross-leg weighting is
## legitimate — the skirt and shorts really do span both thighs.
##
## 1. Snap each vertex to the leg that already dominates it and renormalise,
## so nothing is pulled in two directions.
## 2. Drop any triangle still spanning the two legs afterwards. Those are the
## midline band between the ankles, which has no correct pose either way.
const LEG_BONE_HINTS := ["thigh", "shin", "foot", "toe"]
## Ignore influences below this — they are rounding, not real weighting.
const EPSILON := 0.005
## Returns [vertices_snapped, triangles_removed] so callers can log the result.
static func repair(root: Node, skeleton: Skeleton3D) -> Array:
var knee := _knee_height(skeleton)
if is_nan(knee):
return [0, 0]
var snapped_total := 0
var removed_total := 0
for mi in root.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
continue
# Rebuilding a mesh drops blend shapes, so a skin that uses them (a face
# rig) is left alone rather than silently losing its expressions.
if mi.mesh.get_blend_shape_count() > 0:
continue
var r := _repair_mesh(mi, skeleton, knee)
snapped_total += r[0]
removed_total += r[1]
return [snapped_total, removed_total]
static func _knee_height(skeleton: Skeleton3D) -> float:
if skeleton == null:
return NAN
for n in ["DEF-shin.L", "shin.L", "DEF-shin.R", "shin.R"]:
var i := skeleton.find_bone(n)
if i >= 0:
return skeleton.get_bone_global_rest(i).origin.y
return NAN
static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D,
knee: float) -> Array:
var side := _side_map(mi.skin, skeleton)
var surfaces: Array = []
var snapped := 0
var removed := 0
for s in range(mi.mesh.get_surface_count()):
var arrays: Array = mi.mesh.surface_get_arrays(s)
var r := _repair_surface(arrays, side, knee)
snapped += r[0]
removed += r[1]
surfaces.append({
"arrays": arrays,
"material": mi.mesh.surface_get_material(s),
"name": mi.mesh.surface_get_name(s),
})
if snapped == 0 and removed == 0:
return [0, 0]
var rebuilt := ArrayMesh.new()
for i in surfaces.size():
var entry: Dictionary = surfaces[i]
rebuilt.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, entry["arrays"])
rebuilt.surface_set_material(i, entry["material"])
if entry["name"] != "":
rebuilt.surface_set_name(i, entry["name"])
mi.mesh = rebuilt
return [snapped, removed]
## -1 left leg, +1 right leg, 0 anything else — keyed by SKIN BIND index, which
## is what ARRAY_BONES stores (not the skeleton's bone index).
static func _side_map(skin: Skin, skeleton: Skeleton3D) -> PackedInt32Array:
var out := PackedInt32Array()
out.resize(skin.get_bind_count())
for b in skin.get_bind_count():
var n := skin.get_bind_name(b)
if n == "":
var bone := skin.get_bind_bone(b)
n = skeleton.get_bone_name(bone) if bone >= 0 else ""
var is_leg := false
for hint in LEG_BONE_HINTS:
if n.findn(hint) != -1:
is_leg = true
break
if not is_leg:
out[b] = 0
elif n.ends_with(".L"):
out[b] = -1
elif n.ends_with(".R"):
out[b] = 1
else:
out[b] = 0
return out
static func _repair_surface(arrays: Array, side: PackedInt32Array,
knee: float) -> Array:
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
if bones.is_empty() or verts.is_empty():
return [0, 0]
var per: int = bones.size() / verts.size()
# Step 1 — one leg per vertex.
var vside := PackedInt32Array()
vside.resize(verts.size())
var snapped := 0
for v in verts.size():
var wl := 0.0
var wr := 0.0
for k in per:
var w: float = weights[v * per + k]
if w <= EPSILON:
continue
match side[bones[v * per + k]]:
-1: wl += w
1: wr += w
if wl <= 0.0 and wr <= 0.0:
vside[v] = 0
continue
var keep: int = -1 if wl >= wr else 1
vside[v] = keep
if verts[v].y > knee:
continue
if minf(wl, wr) <= EPSILON:
continue # already single-legged
# Drop the losing leg's influence and renormalise what remains.
var total := 0.0
for k in per:
var b: int = bones[v * per + k]
if side[b] != 0 and side[b] != keep:
weights[v * per + k] = 0.0
total += weights[v * per + k]
if total > 0.0:
for k in per:
weights[v * per + k] /= total
snapped += 1
# Step 2 — drop triangles that still span the legs below the knee.
var removed := 0
if not idx.is_empty():
var keep_idx := PackedInt32Array()
for t in range(0, idx.size(), 3):
var a: int = idx[t]
var b: int = idx[t + 1]
var c: int = idx[t + 2]
var below: bool = (verts[a].y + verts[b].y + verts[c].y) / 3.0 < knee
var has_l: bool = vside[a] == -1 or vside[b] == -1 or vside[c] == -1
var has_r: bool = vside[a] == 1 or vside[b] == 1 or vside[c] == 1
if below and has_l and has_r:
removed += 1
continue
keep_idx.append(a)
keep_idx.append(b)
keep_idx.append(c)
if removed > 0:
arrays[Mesh.ARRAY_INDEX] = keep_idx
if snapped > 0:
arrays[Mesh.ARRAY_WEIGHTS] = weights
return [snapped, removed]
+1
View File
@@ -0,0 +1 @@
uid://db6vqsk1sku85
+19 -6
View File
@@ -162,6 +162,12 @@ func load_model(path: String) -> void:
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
else:
_ensure_meshes_bound(scene)
# Boots weighted to BOTH legs get dragged into the gap and stretch as
# the legs separate. See SkinLegRepair.
var fixed := SkinLegRepair.repair(scene, skeleton)
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]])
_pose_mod = ShooterPoseModifier.new()
_pose_mod.name = "ShooterPose"
skeleton.add_child(_pose_mod)
@@ -664,15 +670,22 @@ func _measure_weapon(w: Node3D) -> void:
# Put the GRIP — not the model origin — in the fist.
w.position -= w.transform.basis * grip
# Support hand: how far along the barrel the viewmodel's off hand rides.
var fore: float = absf((support - grip).dot(fwd))
# Stock: not authored anywhere, so derive it from the barrel length. Half
# the grip-to-muzzle distance behind the grip lands the butt in the
# shoulder pocket for every gun in the set.
# Barrel length, grip to muzzle. Every weapon marks its own barrel tip.
var muzzle_dist: float = WeaponGrips.DEFAULT_MUZZLE_DIST
if "muzzle_flash" in w and w.muzzle_flash:
muzzle_dist = maxf(absf((w.muzzle_flash.position - grip).dot(fwd)), 0.1)
_pose_mod.gun_fore = clampf(fore, 0.14, 0.45)
# Support hand: where the viewmodel's off hand rides — but NEVER past the
# muzzle. That offset is one constant for all weapons, so on a short gun
# (the MP7's barrel is only 0.30 m) it used to hang the support hand out in
# front of the barrel with nothing to hold, which is why some weapons did
# not look supported by the arms.
var fore: float = absf((support - grip).dot(fwd))
_pose_mod.gun_fore = clampf(minf(fore, muzzle_dist * 0.8), 0.12, 0.45)
# Stock: not authored anywhere, so derive it from the barrel. Half the
# grip-to-muzzle distance behind the grip lands the butt in the shoulder
# pocket for every gun in the set.
_pose_mod.gun_stock = clampf(muzzle_dist * 0.5, 0.10, 0.40)