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:
co-authored by
Claude Opus 4.8
parent
ec6b8228da
commit
28dc255d17
@@ -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 +0,0 @@
|
||||
uid://bak2pood5a40t
|
||||
@@ -56,18 +56,18 @@ const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch",
|
||||
"CrouchIdle", "CrouchWalk", "Slide", "WallRun", "WallCling", "Grapple",
|
||||
"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
|
||||
## nothing cuts hard any more — every clip cross-fades. Locomotion gets the
|
||||
## longest fades because Idle<->Walk<->Run<->Sprint switch constantly as speed
|
||||
## drifts across their thresholds, and that is where hard cuts were most
|
||||
## visible.
|
||||
const BLEND_TIMES := {
|
||||
"Dash": 0.10, "Jump": 0.12, "Hit": 0.08, "Land": 0.12,
|
||||
"Slide": 0.16, "Death": 0.15, "Throw": 0.10, "PistolReload": 0.18,
|
||||
"Idle": 0.30, "PistolIdle": 0.30, "Walk": 0.28, "Run": 0.28, "Sprint": 0.28,
|
||||
"CrouchIdle": 0.28, "CrouchWalk": 0.28, "Fall": 0.20, "WallRun": 0.22,
|
||||
"WallCling": 0.20, "Grapple": 0.20,
|
||||
"Dash": 0.14, "Jump": 0.16, "Hit": 0.10, "Land": 0.16,
|
||||
"Slide": 0.22, "Death": 0.20, "Throw": 0.14, "PistolReload": 0.24,
|
||||
"Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40,
|
||||
"CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.28, "WallRun": 0.30,
|
||||
"WallCling": 0.28, "Grapple": 0.28,
|
||||
}
|
||||
|
||||
## 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)
|
||||
else:
|
||||
_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.name = "ShooterPose"
|
||||
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.
|
||||
if _anim_tree:
|
||||
var s := 1.0
|
||||
var is_loco := false
|
||||
match clip:
|
||||
"Walk", "CrouchWalk":
|
||||
s = clampf(speed / walk_anim_reference_speed, 0.7, 1.6)
|
||||
is_loco = true
|
||||
"Run", "Sprint", "WallRun":
|
||||
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)
|
||||
|
||||
|
||||
@@ -743,8 +744,11 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
||||
var reload_phase: float = 0.0
|
||||
|
||||
# Tuning (radians). Positive pitch leans forward; positive roll leans right.
|
||||
const LEAN_ROLL := 0.30
|
||||
const LEAN_PITCH := 0.18
|
||||
# The lean is the ONLY thing that tells a viewer which way this character is
|
||||
# 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_HEAD_UP := 0.7 # head pitch to keep looking forward
|
||||
const SLIDE_LEG_FWD := 0.95 # thighs swing forward so feet lead the slide
|
||||
|
||||
@@ -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
|
||||
## 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
|
||||
## outline shell plus eye-line/eye-highlight cards as extra, UNTEXTURED
|
||||
## surfaces (Taila names them FullBlack / EyesFullBlack / EyesInvL / EyesHL).
|
||||
## They are authored to read as flat black — or flat white for a highlight —
|
||||
## and the glTF import hands them a default near-white albedo. Toon-LIGHTING
|
||||
## that turns every black shell pale: that was the thin white rim tracing
|
||||
## every hair strand.
|
||||
## 1. Their line-work is part of the mesh, as extra UNTEXTURED surfaces, and it
|
||||
## splits into two kinds that need opposite treatment:
|
||||
##
|
||||
## * The body/hair OUTLINE HULL (Taila's "FullBlack" and "material") is a
|
||||
## duplicated shell. Its skin weights do not track the base mesh through a
|
||||
## deep bend, so during a run it tears into spikes and stretches sheets
|
||||
## 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
|
||||
## 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
|
||||
if src.albedo_texture == null:
|
||||
# Untextured surface on a character = the model's own line-work.
|
||||
var name := src.resource_name.to_lower()
|
||||
var flat := StandardMaterial3D.new()
|
||||
flat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
flat.cull_mode = src.cull_mode
|
||||
# "HL" marks a highlight card (the glint in the pupil) — that
|
||||
# one really is meant to be white.
|
||||
var is_highlight: bool = src.resource_name.to_lower().contains("hl")
|
||||
flat.albedo_color = Color.WHITE if is_highlight else CHARACTER_INK
|
||||
if not name.begins_with("eyes"):
|
||||
# The outline HULL: hide it (see above). Fully transparent
|
||||
# rather than deleted so the surface indices, and therefore
|
||||
# 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)
|
||||
continue
|
||||
var toon: ShaderMaterial = mi.get_surface_override_material(s) as ShaderMaterial
|
||||
|
||||
Reference in New Issue
Block a user