Files
Papay-Shooter/scenes/maps/level_materials.gd
T
Nicholas ButzkeandClaude Opus 4.8 28dc255d17 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]>
2026-07-21 22:20:02 -04:00

195 lines
8.3 KiB
GDScript

extends Object
class_name LevelMaterials
## Shared cel-shaded materials for code-built levels and characters.
##
## Level geometry gets the toon shader with world-space triplanar grid
## (0.5 m cells) tinted per surface; characters get the same toon shading
## over their own textures via convert_to_toon(), plus an inverted-hull
## outline overlay for the inked silhouette.
const GRID_GRAY := "res://assets/textures/prototype/grid_gray.png"
const GRID_DARK := "res://assets/textures/prototype/grid_dark.png"
const TOON_SHADER := "res://assets/shaders/toon.gdshader"
const OUTLINE_SHADER := "res://assets/shaders/toon_outline.gdshader"
## One texture tile = 2 m of world, so one grid cell = 0.5 m.
const WORLD_UNITS_PER_TILE := 2.0
static var _cache: Dictionary = {}
static var _outline_cache: Dictionary = {}
## A tinted toon grid material for level geometry. Cached per (tint, dark) so
## identical surfaces share one material.
static func tinted(tint: Color, dark: bool = false) -> Material:
var key := "%s|%s" % [tint.to_html(), dark]
if _cache.has(key):
return _cache[key]
var shader: Shader = load(TOON_SHADER)
var mat := ShaderMaterial.new()
mat.shader = shader
var tex_path := GRID_DARK if dark else GRID_GRAY
var tex: Texture2D = load(tex_path) if ResourceLoader.exists(tex_path) else null
if tex:
mat.set_shader_parameter("albedo_texture", tex)
mat.set_shader_parameter("has_texture", true)
mat.set_shader_parameter("use_triplanar", true)
mat.set_shader_parameter("triplanar_tile", WORLD_UNITS_PER_TILE)
else:
mat.set_shader_parameter("has_texture", false)
# The texture is grayscale ~mid value; multiply by ~2x-brightened tint to
# land near the original flat color while keeping the grid contrast.
mat.set_shader_parameter("albedo_color", Color(
minf(tint.r * 1.9, 1.0), minf(tint.g * 1.9, 1.0), minf(tint.b * 1.9, 1.0)))
# Level surfaces are huge; the toon rim/specular reads as a giant soft
# "blob" highlight smeared across floors and walls. Keep those effects
# for characters/props only.
mat.set_shader_parameter("rim_strength", 0.0)
mat.set_shader_parameter("specular_strength", 0.0)
_cache[key] = mat
return mat
static var _flat_cache: Dictionary = {}
## Flat cel color: toon banding with NO grid texture — the full stylized
## look for dressed maps (vs. tinted()'s greybox grid for blockouts).
## Rim/specular stay off (they blob on large level surfaces).
static func flat(tint: Color) -> Material:
var key := tint.to_html()
if _flat_cache.has(key):
return _flat_cache[key]
var mat := ShaderMaterial.new()
mat.shader = load(TOON_SHADER)
mat.set_shader_parameter("has_texture", false)
mat.set_shader_parameter("use_triplanar", false)
mat.set_shader_parameter("albedo_color", tint)
mat.set_shader_parameter("rim_strength", 0.0)
mat.set_shader_parameter("specular_strength", 0.0)
_flat_cache[key] = mat
return mat
## Toon version of an arbitrary material (usually a character's imported
## StandardMaterial3D): keeps its albedo texture/color, swaps the shading.
static func toonify(src: Material) -> Material:
var mat := ShaderMaterial.new()
mat.shader = load(TOON_SHADER)
var tex: Texture2D = null
var col := Color.WHITE
if src is BaseMaterial3D:
tex = src.albedo_texture
col = src.albedo_color
mat.set_shader_parameter("albedo_texture", tex)
mat.set_shader_parameter("has_texture", tex != null)
mat.set_shader_parameter("use_triplanar", false)
mat.set_shader_parameter("albedo_color", col)
# Fully matte characters: NO specular (even a 2% stepped glint reads as
# shine sweeping across hair when the camera moves) and only a whisper
# of rim for silhouette separation.
mat.set_shader_parameter("rim_strength", 0.05)
mat.set_shader_parameter("rim_width", 0.28)
mat.set_shader_parameter("specular_strength", 0.0)
mat.set_shader_parameter("specular_shininess", 64.0)
return mat
## Ink used for the line-work an imported character carries in its own mesh.
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, 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
## hair as the camera moved. Characters get a soft terminator and an
## almost-invisible second step so the painted shading carries the form.
##
## Props and level geometry keep the crisp banding they were calibrated with.
static func apply_character_look(root: Node) -> void:
for mi in root.find_children("*", "MeshInstance3D", true, false):
if not mi.mesh:
continue
for s in range(mi.mesh.get_surface_count()):
var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
if src == null:
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
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
if toon == null:
continue
toon.set_shader_parameter("band_softness", 0.16)
toon.set_shader_parameter("mid_tone", 0.92)
# Shadow tint measured off the Sketchfab reference render: sampling
# Taila's hair there, shadow/midtone lands near (0.63, 0.53, 0.70).
# Green drops hardest, and that is what keeps copper hair COPPER in
# shadow — the level default (0.62, 0.65, 0.78) lifts green above
# red and washes ginger toward a dull brown.
toon.set_shader_parameter("shadow_color", Color(0.64, 0.56, 0.72))
## Swap every mesh surface under `node` to toon shading and add an
## inverted-hull outline overlay. Safe on skinned meshes (material_overlay
## re-renders the same deformed mesh).
static func apply_toon_recursive(node: Node, outline_width: float = 0.005) -> void:
if node is MeshInstance3D:
var mi := node as MeshInstance3D
var surface_count: int = mi.mesh.get_surface_count() if mi.mesh else 0
for s in range(surface_count):
var src := mi.get_active_material(s)
if src and not (src is ShaderMaterial):
mi.set_surface_override_material(s, toonify(src))
if outline_width > 0.0:
mi.material_overlay = outline(outline_width)
for child in node.get_children():
apply_toon_recursive(child, outline_width)
static func outline(width: float = 0.005) -> ShaderMaterial:
var key := "%.4f" % width
if _outline_cache.has(key):
return _outline_cache[key]
var mat := ShaderMaterial.new()
mat.shader = load(OUTLINE_SHADER)
mat.set_shader_parameter("outline_width", width)
_outline_cache[key] = mat
return mat