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:
co-authored by
Claude Opus 4.8
parent
28dc255d17
commit
f60941d1fb
@@ -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]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://db6vqsk1sku85
|
||||||
@@ -162,6 +162,12 @@ func load_model(path: String) -> void:
|
|||||||
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
||||||
else:
|
else:
|
||||||
_ensure_meshes_bound(scene)
|
_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 = ShooterPoseModifier.new()
|
||||||
_pose_mod.name = "ShooterPose"
|
_pose_mod.name = "ShooterPose"
|
||||||
skeleton.add_child(_pose_mod)
|
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.
|
# Put the GRIP — not the model origin — in the fist.
|
||||||
w.position -= w.transform.basis * grip
|
w.position -= w.transform.basis * grip
|
||||||
|
|
||||||
# Support hand: how far along the barrel the viewmodel's off hand rides.
|
# Barrel length, grip to muzzle. Every weapon marks its own barrel tip.
|
||||||
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.
|
|
||||||
var muzzle_dist: float = WeaponGrips.DEFAULT_MUZZLE_DIST
|
var muzzle_dist: float = WeaponGrips.DEFAULT_MUZZLE_DIST
|
||||||
if "muzzle_flash" in w and w.muzzle_flash:
|
if "muzzle_flash" in w and w.muzzle_flash:
|
||||||
muzzle_dist = maxf(absf((w.muzzle_flash.position - grip).dot(fwd)), 0.1)
|
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)
|
_pose_mod.gun_stock = clampf(muzzle_dist * 0.5, 0.10, 0.40)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user