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