Files
Papay-Shooter/scenes/maps/level_materials.gd
T
2026-08-02 02:20:02 -04:00

520 lines
24 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 = {}
## Surface laws: what KIND of thing this is, rather than what colour it is.
##
## A flat cel colour is the style, but a forty-metre wall holding exactly one
## value is not stylised, it is unfinished — the probe measured 0.28 mean
## adjacent-pixel difference, meaning the only structure in the frame was the
## silhouettes. These give a surface its own quiet detail underneath the cel
## bands, all of it procedural and world-space, so nothing needs UVs, textures,
## or an artist.
##
## The laws are deliberately few. Every one of them has to be a thing a cel
## painter would actually draw:
##
## wall Panel seams at storey-ish spacing plus a grade that darkens the
## bottom few metres. The grade is the important half: it is
## painted-in ambient occlusion, it grounds a building at any
## distance, and unlike SSAO it does not vanish when the camera
## pulls back.
## ground Wider seams and no grade — a floor has no "bottom" to darken,
## and paving slabs are bigger than wall panels.
## panel Tight seams for machined props: shutters, containers, kiosks.
## trim Grade only. For kerbs, plinths and bases, where a seam grid
## would fight the shape but contact darkening still helps.
## "" Plain flat colour. Props, vehicles, foliage and anything whose
## silhouette is doing the work — a seam grid crawling over a tree
## or a car reads as dirt, not as construction.
const SURFACE_LAW := {
"wall": {"seam_scale": 2.6, "seam_strength": 0.20, "seam_width": 0.010,
"grade_height": 7.0, "grade_strength": 0.20},
"ground": {"seam_scale": 4.0, "seam_strength": 0.13, "seam_width": 0.006},
"panel": {"seam_scale": 0.9, "seam_strength": 0.24, "seam_width": 0.020},
"trim": {"grade_height": 1.6, "grade_strength": 0.24},
}
static func _apply_law(mat: ShaderMaterial, law: String) -> void:
if law == "" or not SURFACE_LAW.has(law):
return
for param in SURFACE_LAW[law]:
mat.set_shader_parameter(param, SURFACE_LAW[law][param])
## 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).
##
## `law` names a SURFACE_LAW: what the surface is made of, not what colour it
## is. Defaults to plain flat colour, so every existing call site is unchanged.
static func flat(tint: Color, law: String = "") -> Material:
var key := "%s|%s" % [tint.to_html(), law]
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)
_apply_law(mat, law)
_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.
## Is this untextured surface part of the model's own DRAWING, or is it just an
## untextured surface?
##
## "No albedo texture" alone is not the question, and answering it that way made
## every flat-coloured model render as a black silhouette — Quaternius' mannequin
## has two untextured materials, a yellow body and lilac joints, and both were
## being hidden as though they were an outline shell.
##
## What actually distinguishes line-work:
##
## DARK an ink shell or a lash card is black or nearly so. A flat-coloured
## character is any colour at all. This is the discriminator that
## does the work.
## INVERTED the classic inverted-hull outline is drawn front-face-culled so
## only its backfaces show. Nothing else on a character is.
## NAMED eye cards say so — they are kept, not hidden, and need to reach
## the branch below whatever colour they are.
##
## That heuristic now lives in `SkinSurfaces.guess`, where it is the FALLBACK
## rather than the only answer — see below.
## 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.
const CHARACTER_SHADOW := Color(0.64, 0.56, 0.72)
## Per-class art direction. This is what the surface table BUYS: until a
## character could say which of its surfaces were hair and which were a jacket,
## every one of them had to take the same numbers, and those numbers were
## calibrated on skin.
##
## outline how thick the inked silhouette is, in metres of normal offset
## band terminator softness — 0 is a hard cel break, 1 is a smooth ramp
## mid_tone where the second, subtler step sits
## rim backlight strength, for separation from the background
##
## `body` is deliberately identical to what every surface used to get, so the
## look this was calibrated against does not move. The others are departures
## from it, and each one is a departure for a reason:
##
## hair THE thinnest line, and it is not a small difference. A hair mesh
## is dozens of near-parallel strands a few millimetres apart; at
## the body's 5 mm every strand's hull swallows its neighbour and
## the whole head reads as one solid dark cap instead of hair.
## Softest banding too — the painted texture already carries the
## form, and a hard break on top of it slides across as gloss.
## cloth a heavier line and a crisper break. A garment's silhouette is
## most of what separates a character from the background at range,
## and folds need a defined terminator to read as fabric rather
## than as a painted-on costume.
## accessory heaviest and crispest, plus real rim. These are small, rigid and
## usually the most saturated thing on the character; they are
## supposed to pop.
const CHARACTER_LOOK := {
SkinSurfaces.BODY: {"outline": 0.0050, "band": 0.16, "mid_tone": 0.92, "rim": 0.05},
SkinSurfaces.CLOTH: {"outline": 0.0058, "band": 0.13, "mid_tone": 0.90, "rim": 0.06},
SkinSurfaces.HAIR: {"outline": 0.0034, "band": 0.20, "mid_tone": 0.94, "rim": 0.04},
SkinSurfaces.ACCESSORY: {"outline": 0.0068, "band": 0.10, "mid_tone": 0.88, "rim": 0.10},
}
## Second pass for IMPORTED CHARACTER models, run right after
## apply_toon_recursive. `surfaces` is the build-time surface table from the
## rig sidecar; pass null and every surface falls back to the heuristic.
static func apply_character_look(root: Node, surfaces: SkinSurfaces = null) -> void:
for mi in root.find_children("*", "MeshInstance3D", true, false):
if not mi.mesh:
continue
# The outline moves from the instance to the individual surfaces, which
# is the only way it can differ between them — and it has to, because
# Miku's body, face and hair are three surfaces of ONE mesh, so an
# instance-wide overlay can only ever give all three the same weight.
mi.material_overlay = null
for s in range(mi.mesh.get_surface_count()):
var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
if src == null:
continue
var resolved: Array = SkinSurfaces.guess(src) if surfaces == null \
else surfaces.resolve(mi.name, s, src)
var surface_class: String = resolved[0]
var detail: String = resolved[1]
if detail == "outline_hull":
# A duplicated ink 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: the
# per-surface outline below and the screen-space ink_edge pass
# both already draw one. So it is hidden, fully transparent
# rather than deleted, so the surface indices — and therefore the
# mesh's own skin bindings — stay exactly as imported.
var hidden := StandardMaterial3D.new()
hidden.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
hidden.cull_mode = src.cull_mode
hidden.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
hidden.albedo_color = Color(0, 0, 0, 0)
mi.set_surface_override_material(s, hidden)
continue
if detail == "eyes_ink" or detail == "eyes_highlight":
# A lash or iris card: a real facial feature, kept — and 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.
var card := StandardMaterial3D.new()
card.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
card.cull_mode = src.cull_mode
card.albedo_color = Color.WHITE if detail == "eyes_highlight" \
else CHARACTER_INK
mi.set_surface_override_material(s, card)
continue
var toon: ShaderMaterial = mi.get_surface_override_material(s) as ShaderMaterial
if toon == null:
continue
var look: Dictionary = CHARACTER_LOOK.get(
surface_class, CHARACTER_LOOK[SkinSurfaces.BODY])
toon.set_shader_parameter("band_softness", look["band"])
toon.set_shader_parameter("mid_tone", look["mid_tone"])
toon.set_shader_parameter("rim_strength", look["rim"])
toon.set_shader_parameter("shadow_color", CHARACTER_SHADOW)
# Eye irises and highlights are flat art on a curved ball; an ink
# line around them reads as a second pupil.
# The dummy headless renderer has no material backend for an
# outline pass and emits teardown errors while following next_pass
# RIDs. CI needs the animation/scene graph, not pixels.
toon.next_pass = null if detail == "eyes" \
or DisplayServer.get_name() == "headless" \
else outline(look["outline"])
## First-person weapons live in their own viewport and are read at arm's
## length, so they need a different emphasis from distant world props: a broad
## cool rim for silhouette separation and a small stepped metal glint that
## reveals receivers, magazines and sights without turning the gun glossy.
## This is intentionally viewmodel-only; characters stay matte.
const VIEWMODEL_LOOK := {
"dark_metal": {
"rim": 0.12, "rim_width": 0.26, "band": 0.035, "mid": 0.62,
"shadow": Color(0.12, 0.15, 0.25), "cast": 0.34,
"specular": 0.14, "shininess": 104.0, "floor": 0.0,
"ambient": 0.0, "contrast": 1.12, "saturation": 1.02, "gain": 0.96,
"painted_light": 1.15, "vm_shadow": 0.18, "vm_mid": 0.58,
"crease": 0.34, "crease_threshold": 0.09,
},
"light_metal": {
"rim": 0.11, "rim_width": 0.28, "band": 0.045, "mid": 0.70,
"shadow": Color(0.30, 0.36, 0.52), "cast": 0.42,
"specular": 0.10, "shininess": 112.0, "floor": 0.0,
"ambient": 0.0, "contrast": 0.96, "saturation": 0.96, "gain": 0.52,
"painted_light": 0.35, "vm_shadow": 0.16, "vm_mid": 0.50,
"crease": 0.28, "crease_threshold": 0.10,
},
"painted": {
"rim": 0.12, "rim_width": 0.30, "band": 0.050, "mid": 0.72,
"shadow": Color(0.25, 0.30, 0.46), "cast": 0.44,
"specular": 0.035, "shininess": 88.0, "floor": 0.0,
"ambient": 0.0, "contrast": 0.90, "saturation": 1.30, "gain": 0.90,
"painted_light": 0.88, "vm_shadow": 0.14, "vm_mid": 0.48,
"crease": 0.24, "crease_threshold": 0.11,
},
"polymer": {
"rim": 0.09, "rim_width": 0.26, "band": 0.045, "mid": 0.64,
"shadow": Color(0.16, 0.19, 0.29), "cast": 0.38,
"specular": 0.015, "shininess": 72.0, "floor": 0.0,
"ambient": 0.0, "contrast": 0.82, "saturation": 1.02, "gain": 0.88,
"painted_light": 0.88, "vm_shadow": 0.11, "vm_mid": 0.44,
"crease": 0.30, "crease_threshold": 0.10,
},
}
static func _viewmodel_profile(mat: ShaderMaterial) -> String:
var value = mat.get_shader_parameter("albedo_color")
var color := value as Color if value is Color else Color.WHITE
var luma := color.get_luminance()
var chroma := maxf(color.r, maxf(color.g, color.b)) \
- minf(color.r, minf(color.g, color.b))
var textured := bool(mat.get_shader_parameter("has_texture"))
if textured or chroma > 0.045:
return "painted"
if luma < 0.34:
return "dark_metal"
if luma > 0.72:
return "light_metal"
return "polymer"
static func apply_viewmodel_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 toon := mi.get_surface_override_material(s) as ShaderMaterial
if toon == null:
continue
var look: Dictionary = VIEWMODEL_LOOK[_viewmodel_profile(toon)]
toon.set_shader_parameter("rim_strength", look["rim"])
toon.set_shader_parameter("rim_width", look["rim_width"])
toon.set_shader_parameter("band_softness", look["band"])
toon.set_shader_parameter("mid_tone", look["mid"])
toon.set_shader_parameter("shadow_color", look["shadow"])
toon.set_shader_parameter("cast_shadow_depth", look["cast"])
toon.set_shader_parameter("specular_strength", look["specular"])
toon.set_shader_parameter("specular_shininess", look["shininess"])
toon.set_shader_parameter("albedo_floor", look["floor"])
toon.set_shader_parameter("ambient_fill", look["ambient"])
toon.set_shader_parameter("albedo_contrast", look["contrast"])
toon.set_shader_parameter("albedo_saturation", look["saturation"])
toon.set_shader_parameter("albedo_gain", look["gain"])
toon.set_shader_parameter(
"viewmodel_light_strength", look["painted_light"])
toon.set_shader_parameter(
"viewmodel_shadow_tone", look["vm_shadow"])
toon.set_shader_parameter(
"viewmodel_mid_tone", look["vm_mid"])
toon.set_shader_parameter(
"viewmodel_crease_strength", look["crease"])
toon.set_shader_parameter(
"viewmodel_crease_threshold", look["crease_threshold"])
# Set these explicitly rather than relying on shader defaults.
# Runtime-created ShaderMaterials can predate a hot-reloaded shader
# default in the editor cache, which left the fill at black.
toon.set_shader_parameter(
"viewmodel_key_direction", Vector3(-0.42, 0.58, 0.70))
toon.set_shader_parameter(
"viewmodel_key_color", Color(0.98, 0.88, 0.76))
toon.set_shader_parameter(
"viewmodel_fill_color", Color(0.62, 0.68, 0.88))
## 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)
# Some kit surfaces intentionally omit a material. Leaving those
# null is visually equivalent to Godot's white default, but the
# headless renderer cannot query instance shader parameters from a
# null material during scene teardown. Give them an explicit toon
# default so runtime map sweeps remain error-free.
if src == null:
var fallback := StandardMaterial3D.new()
fallback.albedo_color = Color(0.78, 0.78, 0.82)
src = fallback
if src and not (src is ShaderMaterial):
mi.set_surface_override_material(s, toonify(src))
var toon := mi.get_surface_override_material(s) as ShaderMaterial
if toon:
var arrays: Array = mi.mesh.surface_get_arrays(s)
var has_vertex_colors: bool = arrays.size() > Mesh.ARRAY_COLOR \
and arrays[Mesh.ARRAY_COLOR] is PackedColorArray \
and not (arrays[Mesh.ARRAY_COLOR] as PackedColorArray).is_empty()
toon.set_shader_parameter("use_vertex_color", has_vertex_colors)
# Avoid constructing render-only overlay chains on the headless dummy
# backend. The real game renderer still receives the complete ink pass.
if outline_width > 0.0 and DisplayServer.get_name() != "headless":
mi.material_overlay = outline(outline_width)
for child in node.get_children():
apply_toon_recursive(child, outline_width)
## ── The sakura cel look ──────────────────────────────────────────────────────
##
## `flat()` above is the older path: toon.gdshader's three-tone break, calibrated
## against imported character textures that already carry painted shading. These
## build on sakura_cel.gdshader instead — a quantised N-band ramp with a
## hue-shifted shadow — which is what flat-coloured world geometry wants.
const CEL_SHADER := "res://assets/shaders/sakura_cel.gdshader"
## Ramp ids, matching `ramp_id` in sakura_cel.gdshader.
##
## RAMP_3 the default, and what most of a scene should be
## RAMP_2 two hard tones, for small props whose silhouette does the work
## RAMP_4/5 more steps, for large curved masses that would otherwise show
## their band edges as hard bars across a wall
## RAMP_SOFT* high key: the darkest band is 0.71 rather than 0.36, for pale
## masses (blossom, cloud, plaster) that must stay light even on
## the shadow side
enum {
RAMP_2 = 0,
RAMP_3 = 1,
RAMP_4 = 2,
RAMP_5 = 3,
RAMP_SOFT = 4,
RAMP_SOFT3 = 5,
}
static var _cel_cache: Dictionary = {}
static var _unlit_cache: Dictionary = {}
## A cel material. `law` names a SURFACE_LAW as `flat()` does; `faceted` maps to
## the shader's flat_shading and should stay on for anything built out of boxes
## and cylinders, which here is everything.
static func cel(color: Color, ramp: int = RAMP_3, tint: Color = SakuraPalette.TINT,
law: String = "", faceted: bool = true) -> ShaderMaterial:
var key := "%s|%d|%s|%s|%s" % [color.to_html(), ramp, tint.to_html(), law, faceted]
if _cel_cache.has(key):
return _cel_cache[key]
var mat := ShaderMaterial.new()
mat.shader = load(CEL_SHADER)
mat.set_shader_parameter("albedo_color", color)
mat.set_shader_parameter("has_texture", false)
mat.set_shader_parameter("ramp_id", ramp)
mat.set_shader_parameter("shadow_tint", tint)
mat.set_shader_parameter("flat_shading", faceted)
_apply_law(mat, law)
_cel_cache[key] = mat
return mat
## Unlit flat colour — the reference's `flat()`. For sky panels, distant
## silhouettes, sign faces, glass, lit windows and the ink details a cel painter
## would draw rather than light. Anything that takes light instead of being a
## drawing should use cel().
static func unlit(color: Color, double_sided: bool = false) -> StandardMaterial3D:
var key := "%s|%s" % [color.to_html(), double_sided]
if _unlit_cache.has(key):
return _unlit_cache[key]
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = color
if double_sided:
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
_unlit_cache[key] = mat
return mat
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