fix: hide the model's broken outline hull, reverse the backpedal, slower blends

The "linked ankle cuffs" and the "squashed legs" were the same thing, and it
was never the cloth or the weights: it is the model's OWN outline hull.

Isolating it took rendering the raw GLB in a bright scene and toggling one
surface group at a time. The hull ("FullBlack" and "material", the untextured
surfaces that trace the body and hair silhouettes) is a duplicated shell whose
skin weights do not track the base mesh through a deep bend. During a run it
tears into spikes and stretches sheets across the ankles. Hiding just those
surfaces makes the legs render perfectly clean, with no other change — so the
two previous attempts here were both treating symptoms:

  * re-weighting stray ankle vertices only tore the cloth (reverted last time)
  * deleting cross-leg triangles removed real geometry for no benefit
    (SkinMeshRepair is deleted in this commit — the clean render above was
    produced WITHOUT it)

The hull is also redundant: characters already get an inverted-hull overlay
from apply_toon_recursive AND the screen-space ink_edge pass, so dropping it
costs nothing visually. The EYE cards (EyesFullBlack lashes, EyesInvL, EyesHL
highlight) are real facial features rather than a hull, so those are kept, flat
as before. They are told apart by name prefix, not by guesswork about geometry.

Worth recording: no bone is EVER scaled, in any clip. Measured again here
across the whole Run cycle — worst deviation of a bone's pose basis from a pure
rotation is 0.00000. There is no squash-and-stretch in this rig; it only ever
looked that way because of the shell.

Backpedalling now plays the locomotion cycle in reverse (negative TimeScale)
instead of moon-walking with the forward clip — the library ships no authored
backward run. Directional lean is raised from 0.18/0.30 to 0.30/0.42 rad: with
one forward cycle and no strafe clips, the lean is the only cue for which way
the character is travelling, so it has to be legible rather than subtle.

Blends raised again: base 0.22 -> 0.32, locomotion 0.28 -> 0.40. Verified the
cross-fade genuinely applies rather than snapping — instrumented an idle->run
switch and the thigh ramps gradually over the window instead of stepping on
frame one, with xfade_time reading 0.400 s on the Transition node.

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-21 22:20:02 -04:00
co-authored by Claude Opus 4.8
parent ec6b8228da
commit 28dc255d17
4 changed files with 48 additions and 180 deletions
-153
View File
@@ -1,153 +0,0 @@
extends Object
class_name SkinMeshRepair
## Removes below-the-knee geometry that is welded across BOTH legs.
##
## Taila's ClothCAndW surface carries a ~10 cm strip at ankle height (spanning
## x = -0.05 .. +0.05, straight across the centre line) whose vertices are
## weighted to the left leg on one edge and the right leg on the other. Nothing
## about the rig or the clips is wrong — the foot bones swing independently
## (measured left/right correlation -0.94) and no bone is ever scaled. But a
## triangle with one corner on each leg has no correct pose: the moment the legs
## separate it MUST stretch. It read as the two ankle cuffs being welded
## together.
##
## An earlier version of this pass tried to re-weight those vertices to the
## nearer leg. That was wrong: the strip is real geometry spanning the gap, so
## re-weighting only tore it in half — a visible seam that still stretched.
## Deleting the cross-leg triangles is the only stable answer, and it is safe
## because such a triangle is never legitimate below the knee.
##
## ABOVE the knee it very much is legitimate — the shorts and skirt genuinely
## span from left-thigh to right-thigh weights at the crotch — so the cut is
## limited to below the knee, taken from the skeleton's own rest pose rather
## than a hardcoded height, so it holds for any character's proportions.
const LEG_BONE_HINTS := ["thigh", "shin", "foot", "toe"]
## Returns the number of triangles removed, so callers can log whether a skin
## needed the repair at all.
static func repair(root: Node, skeleton: Skeleton3D) -> int:
var knee_y := _knee_height(skeleton)
if is_nan(knee_y):
return 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 skins that use them (face
# rigs) are left alone rather than silently losing their expressions.
if mi.mesh.get_blend_shape_count() > 0:
continue
removed_total += _repair_mesh(mi, skeleton, knee_y)
return removed_total
## Knee height in skeleton space — the boundary above which cross-leg geometry
## is legitimate.
static func _knee_height(skeleton: Skeleton3D) -> float:
if skeleton == null:
return NAN
var best := NAN
for name in ["DEF-shin.L", "shin.L", "DEF-shin.R", "shin.R"]:
var i := skeleton.find_bone(name)
if i >= 0:
best = skeleton.get_bone_global_rest(i).origin.y
break
return best
static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D,
knee_y: float) -> int:
var side := _side_map(mi.skin, skeleton)
var surfaces: Array = []
var removed_total := 0
for s in range(mi.mesh.get_surface_count()):
var arrays: Array = mi.mesh.surface_get_arrays(s)
removed_total += _repair_surface(arrays, side, knee_y)
surfaces.append({
"arrays": arrays,
"material": mi.mesh.surface_get_material(s),
"name": mi.mesh.surface_get_name(s),
})
if removed_total == 0:
return 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 removed_total
## -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_y: float) -> int:
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
if bones.is_empty() or idx.is_empty() or verts.is_empty():
return 0
var per: int = bones.size() / verts.size()
var vside := PackedInt32Array()
vside.resize(verts.size())
for v in verts.size():
var best_w := 0.0
var best_s := 0
for k in per:
var w: float = weights[v * per + k]
if w > best_w:
best_w = w
best_s = side[bones[v * per + k]]
vside[v] = best_s
var keep := PackedInt32Array()
var removed := 0
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_knee: bool = (verts[a].y + verts[b].y + verts[c].y) / 3.0 < knee_y
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_knee and has_l and has_r:
removed += 1
continue
keep.append(a)
keep.append(b)
keep.append(c)
if removed > 0:
arrays[Mesh.ARRAY_INDEX] = keep
return removed
-1
View File
@@ -1 +0,0 @@
uid://bak2pood5a40t
+19 -15
View File
@@ -56,18 +56,18 @@ const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch",
"CrouchIdle", "CrouchWalk", "Slide", "WallRun", "WallCling", "Grapple", "CrouchIdle", "CrouchWalk", "Slide", "WallRun", "WallCling", "Grapple",
"Dance", "PistolIdle"] "Dance", "PistolIdle"]
const BLEND_TIME := 0.22 const BLEND_TIME := 0.32
## Per-clip blend overrides. Reaction moves still need to read as instant, but ## Per-clip blend overrides. Reaction moves still need to read as instant, but
## nothing cuts hard any more — every clip cross-fades. Locomotion gets the ## nothing cuts hard any more — every clip cross-fades. Locomotion gets the
## longest fades because Idle<->Walk<->Run<->Sprint switch constantly as speed ## longest fades because Idle<->Walk<->Run<->Sprint switch constantly as speed
## drifts across their thresholds, and that is where hard cuts were most ## drifts across their thresholds, and that is where hard cuts were most
## visible. ## visible.
const BLEND_TIMES := { const BLEND_TIMES := {
"Dash": 0.10, "Jump": 0.12, "Hit": 0.08, "Land": 0.12, "Dash": 0.14, "Jump": 0.16, "Hit": 0.10, "Land": 0.16,
"Slide": 0.16, "Death": 0.15, "Throw": 0.10, "PistolReload": 0.18, "Slide": 0.22, "Death": 0.20, "Throw": 0.14, "PistolReload": 0.24,
"Idle": 0.30, "PistolIdle": 0.30, "Walk": 0.28, "Run": 0.28, "Sprint": 0.28, "Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40,
"CrouchIdle": 0.28, "CrouchWalk": 0.28, "Fall": 0.20, "WallRun": 0.22, "CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.28, "WallRun": 0.30,
"WallCling": 0.20, "Grapple": 0.20, "WallCling": 0.28, "Grapple": 0.28,
} }
## Named gameplay actions -> (clip, lock seconds). Networked via the ## Named gameplay actions -> (clip, lock seconds). Networked via the
@@ -162,13 +162,6 @@ 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)
# Below-the-knee geometry welded across both legs can only ever stretch
# as they separate — it made the ankle cuffs look linked. See
# SkinMeshRepair.
var culled := SkinMeshRepair.repair(scene, skeleton)
if culled > 0:
print("SkinnedPlayerModel: removed %d cross-leg triangles from '%s'"
% [culled, path.get_file()])
_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)
@@ -438,11 +431,19 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
# Scale locomotion playback so feet keep up with actual movement speed. # Scale locomotion playback so feet keep up with actual movement speed.
if _anim_tree: if _anim_tree:
var s := 1.0 var s := 1.0
var is_loco := false
match clip: match clip:
"Walk", "CrouchWalk": "Walk", "CrouchWalk":
s = clampf(speed / walk_anim_reference_speed, 0.7, 1.6) s = clampf(speed / walk_anim_reference_speed, 0.7, 1.6)
is_loco = true
"Run", "Sprint", "WallRun": "Run", "Sprint", "WallRun":
s = clampf(speed / run_anim_reference_speed, 0.7, 1.8) s = clampf(speed / run_anim_reference_speed, 0.7, 1.8)
is_loco = true
# Backpedalling: run the cycle BACKWARDS rather than moon-walking with
# the forward clip. The shared library has no authored reverse run, and
# a reversed stride reads correctly for a backpedal.
if is_loco and _cur_fwd < -0.25:
s = -s
_anim_tree.set("parameters/loco_scale/scale", s) _anim_tree.set("parameters/loco_scale/scale", s)
@@ -743,8 +744,11 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var reload_phase: float = 0.0 var reload_phase: float = 0.0
# Tuning (radians). Positive pitch leans forward; positive roll leans right. # Tuning (radians). Positive pitch leans forward; positive roll leans right.
const LEAN_ROLL := 0.30 # The lean is the ONLY thing that tells a viewer which way this character is
const LEAN_PITCH := 0.18 # travelling — the library has one forward locomotion cycle and no strafe
# clips — so it has to be legible, not subtle.
const LEAN_ROLL := 0.42
const LEAN_PITCH := 0.30
const SLIDE_BACK := 0.75 # torso lean-back during slide const SLIDE_BACK := 0.75 # torso lean-back during slide
const SLIDE_HEAD_UP := 0.7 # head pitch to keep looking forward const SLIDE_HEAD_UP := 0.7 # head pitch to keep looking forward
const SLIDE_LEG_FWD := 0.95 # thighs swing forward so feet lead the slide const SLIDE_LEG_FWD := 0.95 # thighs swing forward so feet lead the slide
+29 -11
View File
@@ -102,13 +102,23 @@ const CHARACTER_INK := Color(0.07, 0.06, 0.09)
## Second pass for IMPORTED CHARACTER models (the anime GLB skins), run right ## Second pass for IMPORTED CHARACTER models (the anime GLB skins), run right
## after apply_toon_recursive. Two things those models need that props don't: ## after apply_toon_recursive. Two things those models need that props don't:
## ##
## 1. Their line-work is part of the mesh. Anime GLBs ship an inverted-hull ## 1. Their line-work is part of the mesh, as extra UNTEXTURED surfaces, and it
## outline shell plus eye-line/eye-highlight cards as extra, UNTEXTURED ## splits into two kinds that need opposite treatment:
## surfaces (Taila names them FullBlack / EyesFullBlack / EyesInvL / EyesHL). ##
## They are authored to read as flat black — or flat white for a highlight — ## * The body/hair OUTLINE HULL (Taila's "FullBlack" and "material") is a
## and the glTF import hands them a default near-white albedo. Toon-LIGHTING ## duplicated shell. Its skin weights do not track the base mesh through a
## that turns every black shell pale: that was the thin white rim tracing ## deep bend, so during a run it tears into spikes and stretches sheets
## every hair strand. ## between the ankles — that is what made the ankle cuffs look welded
## together. It is also redundant: characters already get an inverted-hull
## overlay from apply_toon_recursive AND the screen-space ink_edge pass.
## So it is HIDDEN outright, which removes the artefact and the redundancy
## in one go.
##
## * The EYE cards ("EyesFullBlack" lashes, "EyesInvL", "EyesHL" highlight)
## are real facial features, not a hull, and they are kept — flat, because
## the glTF import hands every untextured surface a default near-white
## albedo and toon-LIGHTING the black ones was the thin white rim that used
## to trace every hair strand.
## ##
## 2. Their textures are ALREADY painted with cel shading. Stacking the hard ## 2. Their textures are ALREADY painted with cel shading. Stacking the hard
## 3-tone break on top read as gloss — a bright stripe sliding across the ## 3-tone break on top read as gloss — a bright stripe sliding across the
@@ -126,13 +136,21 @@ static func apply_character_look(root: Node) -> void:
continue continue
if src.albedo_texture == null: if src.albedo_texture == null:
# Untextured surface on a character = the model's own line-work. # Untextured surface on a character = the model's own line-work.
var name := src.resource_name.to_lower()
var flat := StandardMaterial3D.new() var flat := StandardMaterial3D.new()
flat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED flat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
flat.cull_mode = src.cull_mode flat.cull_mode = src.cull_mode
# "HL" marks a highlight card (the glint in the pupil) — that if not name.begins_with("eyes"):
# one really is meant to be white. # The outline HULL: hide it (see above). Fully transparent
var is_highlight: bool = src.resource_name.to_lower().contains("hl") # rather than deleted so the surface indices, and therefore
flat.albedo_color = Color.WHITE if is_highlight else CHARACTER_INK # the mesh's own skin bindings, stay exactly as imported.
flat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
flat.albedo_color = Color(0, 0, 0, 0)
else:
# An eye card. "HL" marks the highlight (the glint in the
# pupil) — that one really is meant to be white.
flat.albedo_color = Color.WHITE if name.contains("hl") \
else CHARACTER_INK
mi.set_surface_override_material(s, flat) mi.set_surface_override_material(s, flat)
continue continue
var toon: ShaderMaterial = mi.get_surface_override_material(s) as ShaderMaterial var toon: ShaderMaterial = mi.get_surface_override_material(s) as ShaderMaterial