A character has arrived as separate body, garment and hair meshes since the
pipeline stopped joining them — but nothing recorded which was which, so every
system downstream re-guessed from the material. That guess ("untextured and
nearly black means ink") had already rendered the mannequin's flat yellow body
as a black silhouette once.
The question is answerable once, at build time, where the mesh, the weights and
the skeleton are all in hand. tools/surface_map.py answers it three ways, in
order of how much it trusts them: the material name, which on VRoid exports is
formal and on hand-authored models is still explicit; the weights, which are
decisive when the name says nothing — a surface pulled by the skirt chain is a
skirt whatever it is called; and the material flags, which catch the model's own
line-work. The answer goes in the rig sidecar next to the roles and the chains,
and SkinSurfaces reads it.
All eighteen of Taila's surfaces, and every surface of the other five skins,
now resolve from the table with nothing falling through to the heuristic
(debug/surface_class_check.gd). The heuristic stays as the fallback, which is
the one job it was ever right for.
What that buys immediately is per-class art direction, which was impossible
while every surface had to take numbers calibrated on skin. Hair takes a much
thinner line — at the body's 5 mm each strand's hull swallows its neighbour and
the head reads as a solid dark cap. Cloth takes a heavier line and a crisper
terminator, because a garment's silhouette is most of what separates a character
from the background at range. Accessories take the heaviest. `body` is unchanged
on purpose, so the look this was all calibrated against does not move.
That required moving the outline from the instance to the surface: Miku's body,
face and hair are three surfaces of ONE mesh, so an instance-wide overlay could
only ever give all three the same weight.
Two things found on the way, fixed here because they are one line each: the
surface classifier skips meshes with no vertex groups, which drops the stray
42-vertex Icosphere that rides inside every shipped skin — two older tools
already skipped it by spelling its name — and load_model now clears _rig_info,
which a model with no skeleton used to inherit from the last character loaded.
Co-Authored-By: Claude Opus 5 <[email protected]>
285 lines
13 KiB
GDScript
285 lines
13 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.
|
|
##
|
|
## 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.
|
|
toon.next_pass = null if detail == "eyes" \
|
|
else outline(look["outline"])
|
|
|
|
|
|
## 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
|