A third playable character, built with the pipeline skill from a source that was already in the repo: the animation library ships a rigged Mannequin mesh on the exact 53-joint reference skeleton, CC0, so it needed no download and retargets perfectly. 18 clips, 0.3% cross-leg bleed, 7% of verts at four influences — a clean authored-weight import. Licence recorded in mannequin.license.json as the other skins do. It has no cloth chains, correctly: it is a mannequin and has neither hair nor clothes. Importing it turned up two real bugs, both of which would have hit any flat-coloured or single-piece model: - LevelMaterials.apply_character_look treated ANY untextured surface on a character as the model's own outline shell and hid it, so the mannequin rendered as a solid black silhouette — its body and joint materials are untextured flat colours, not ink. _is_line_work() now asks whether the surface is named eyes*, is drawn front-face-culled (the inverted-hull setup), or is near-black. Taila and Miku are unaffected: their materials are textured and never reach that branch. Verified by render. - verify_character.py failed the build for having one mesh. That check cannot tell "the pipeline joined them" from "the artist authored one mesh" — Quaternius' mannequin is one piece on purpose. It is advisory now; the join path's two unambiguous signatures, cross-leg bleed and the 4-influences-everywhere spread, are still hard checks. Also restored Miku's description, which the re-import had blanked. 3 GLB skins selectable (6 with the built-in colour skins). Smoke 0 failures, 11/11 movement tests, cloth idle 0.024-0.078 deg/frame. Co-Authored-By: Claude Opus 5 <[email protected]>
221 lines
9.5 KiB
GDScript
221 lines
9.5 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.
|
|
## 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.
|
|
static func _is_line_work(src: BaseMaterial3D) -> bool:
|
|
if src.resource_name.to_lower().begins_with("eyes"):
|
|
return true
|
|
if src.cull_mode == BaseMaterial3D.CULL_FRONT:
|
|
return true
|
|
var c: Color = src.albedo_color
|
|
return maxf(maxf(c.r, c.g), c.b) < 0.18
|
|
|
|
|
|
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 and _is_line_work(src):
|
|
# Untextured AND dark or inside-out — 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
|