fix: repair thigh weights too, and keep the gun shouldered at low-ready

Legs: the previous pass deliberately stopped at the knee, on the reasoning that
cross-leg weighting above it is legitimate for skirts. It is — but the LEGS
themselves are up there too, and 122 vertices on Taila's thigh mesh carry up to
35% of the opposite leg. Running and jumping are exactly what splits the legs
far enough for that to show, which is why the cuffs came good while the legs
kept squashing.

The repair now runs above the knee as well, but only on vertices that belong to
a leg. Limb membership is decided by distance to that leg's own bone chain, not
by height and not by surface name: a thigh vertex hugs its bone, while a skirt
vertex hangs clear of both chains and is left blended so it can still drape over
the two legs. The radius is half the gap between the thigh bones, so it scales
with the character rather than being a magic number. Taila now snaps 1217
vertices (was 720), Miku 370 (was 214).

Weapons: at low-ready the stock was dropped 0.20 m below the shoulder joint down
to the ribs, with the muzzle pitched 22 degrees down. That pulls the whole
weapon off the shoulder and out of the supporting arm — it read as dangling
from the hands rather than being carried. Low-ready now stays in the shoulder
pocket and merely relaxes out of it (0.07 m drop), with a shallow 9 degree
muzzle tilt. 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 02:08:07 -04:00
co-authored by Claude Opus 4.8
parent f60941d1fb
commit a6e009d24a
2 changed files with 76 additions and 17 deletions
+69 -15
View File
@@ -19,14 +19,22 @@ class_name SkinLegRepair
## 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.
## Three steps:
##
## 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.
## 1. BELOW THE KNEE (height taken from the skeleton's own rest pose, so this
## scales to any character): snap each vertex to the leg that already
## dominates it and renormalise, so nothing is pulled in two directions.
## 2. ABOVE THE KNEE, only for vertices that are part of a LEG rather than
## draping cloth: same snap, to the nearer leg. 122 vertices on Taila's
## thigh mesh carry up to 35% of the opposite leg — that is the squashing
## that survived the below-knee pass and showed up when running and
## jumping split the legs. Limb membership is decided by distance to the
## leg's own bone chain, NOT by height or by surface name: a thigh vertex
## hugs its bone, while a skirt vertex hangs well clear of both and is
## left blended, which is what lets a skirt drape across both legs.
## 3. Drop any triangle still spanning the two legs below the knee. 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.
@@ -38,6 +46,14 @@ static func repair(root: Node, skeleton: Skeleton3D) -> Array:
var knee := _knee_height(skeleton)
if is_nan(knee):
return [0, 0]
var chain_l := _leg_chain(skeleton, ".L")
var chain_r := _leg_chain(skeleton, ".R")
if chain_l.is_empty() or chain_r.is_empty():
return [0, 0]
# "Part of a leg" means within roughly half the gap between the two legs of
# that leg's bone chain — self-scaling to the character's proportions.
var limb_radius: float = maxf(absf(chain_l[0].x - chain_r[0].x) * 0.5, 0.02)
var snapped_total := 0
var removed_total := 0
for mi in root.find_children("*", "MeshInstance3D", true, false):
@@ -47,12 +63,38 @@ static func repair(root: Node, skeleton: Skeleton3D) -> Array:
# 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)
var r := _repair_mesh(mi, skeleton, knee, chain_l, chain_r, limb_radius)
snapped_total += r[0]
removed_total += r[1]
return [snapped_total, removed_total]
## Rest-pose joint positions down one leg, used as a polyline to measure how
## close a vertex sits to that limb.
static func _leg_chain(skeleton: Skeleton3D, suffix: String) -> PackedVector3Array:
var out := PackedVector3Array()
for stem in ["DEF-thigh", "DEF-shin", "DEF-foot", "DEF-toe"]:
var i := skeleton.find_bone(stem + suffix)
if i < 0:
i = skeleton.find_bone(stem.trim_prefix("DEF-") + suffix)
if i >= 0:
out.append(skeleton.get_bone_global_rest(i).origin)
return out
## Distance from a point to a polyline.
static func _dist_to_chain(p: Vector3, chain: PackedVector3Array) -> float:
var best := INF
for i in range(chain.size() - 1):
var a: Vector3 = chain[i]
var b: Vector3 = chain[i + 1]
var ab: Vector3 = b - a
var len2: float = ab.length_squared()
var t: float = 0.0 if len2 < 0.000001 else clampf((p - a).dot(ab) / len2, 0.0, 1.0)
best = minf(best, p.distance_to(a + ab * t))
return best
static func _knee_height(skeleton: Skeleton3D) -> float:
if skeleton == null:
return NAN
@@ -63,15 +105,16 @@ static func _knee_height(skeleton: Skeleton3D) -> float:
return NAN
static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D,
knee: float) -> Array:
static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D, knee: float,
chain_l: PackedVector3Array, chain_r: PackedVector3Array,
limb_radius: 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)
var r := _repair_surface(arrays, side, knee, chain_l, chain_r, limb_radius)
snapped += r[0]
removed += r[1]
surfaces.append({
@@ -119,8 +162,9 @@ static func _side_map(skin: Skin, skeleton: Skeleton3D) -> PackedInt32Array:
return out
static func _repair_surface(arrays: Array, side: PackedInt32Array,
knee: float) -> Array:
static func _repair_surface(arrays: Array, side: PackedInt32Array, knee: float,
chain_l: PackedVector3Array, chain_r: PackedVector3Array,
limb_radius: float) -> Array:
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
@@ -148,10 +192,20 @@ static func _repair_surface(arrays: Array, side: PackedInt32Array,
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
if verts[v].y > knee:
# Above the knee, only repair vertices that belong to a LEG. Cloth
# that drapes across both legs sits clear of either bone chain and
# is left blended so it can keep draping.
var dl: float = _dist_to_chain(verts[v], chain_l)
var dr: float = _dist_to_chain(verts[v], chain_r)
var near: float = minf(dl, dr)
var far: float = maxf(dl, dr)
if near > limb_radius or far < near * 1.25:
continue # drapes over both, or hugs neither — leave it alone
keep = -1 if dl < dr else 1
vside[v] = keep
# Drop the losing leg's influence and renormalise what remains.
var total := 0.0
for k in per: