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
4 changed files with 48 additions and 180 deletions
Showing only changes of commit 28dc255d17 - Show all commits
-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