Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
3 changed files with 208 additions and 6 deletions
Showing only changes of commit f60941d1fb - Show all commits
+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) 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)