fix: Taila reads like her source render — no white rim, matte hair

Two separate causes behind "too glossy with a thin white outline":

1. The model carries its OWN cel line-work as extra, UNTEXTURED surfaces —
   an inverted-hull outline shell plus eye-line/highlight cards (Taila names
   them FullBlack / EyesFullBlack / EyesInvL / EyesHL). They are authored to
   read as flat black, but the glTF import hands them a default near-white
   albedo, and apply_toon_recursive then LIT them. That is the thin white
   rim: a pale, toon-shaded outline shell tracing every hair strand. They now
   render unshaded flat ink (flat white for the "HL" highlight card).

2. The skin textures already have cel shading painted in. Stacking our hard
   3-tone break on top produced a bright stripe that slid across the hair as
   the camera moved — the "gloss". Characters now get a soft terminator and
   an almost-invisible second step, so the painted shading carries the form.

Both live in a new LevelMaterials.apply_character_look(), called only for
imported character GLBs, so props and level geometry keep exactly the crisp
banding they were calibrated with. The one shared change is that the toon
shader's second-step darkness is now the mid_tone uniform, defaulting to the
0.82 that was hardcoded — no change for existing callers.

Character shadow tint (0.64, 0.56, 0.72) is measured off the Sketchfab
reference render rather than guessed: sampling the hair there, shadow/midtone
lands near (0.63, 0.53, 0.70). Green drops hardest, which is what keeps
copper hair copper instead of washing it to brown.

debug/character_look_capture.gd renders the raw import beside our treatment
under matched lighting — the comparison this was tuned against.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-21 17:54:41 -04:00
co-authored by Claude Opus 4.8
parent af8c9831c9
commit d8b0c08611
4 changed files with 156 additions and 3 deletions
+54 -1
View File
@@ -87,7 +87,7 @@ static func toonify(src: Material) -> Material:
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. Cel banding carries all the shape.
# 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)
@@ -95,6 +95,59 @@ static func toonify(src: Material) -> Material:
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. Anime GLBs ship an inverted-hull
## outline shell plus eye-line/eye-highlight cards as extra, UNTEXTURED
## surfaces (Taila names them FullBlack / EyesFullBlack / EyesInvL / EyesHL).
## They are authored to read as flat black — or flat white for a highlight —
## and the glTF import hands them a default near-white albedo. Toon-LIGHTING
## that turns every black shell pale: that was the thin white rim tracing
## 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 flat := StandardMaterial3D.new()
flat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
flat.cull_mode = src.cull_mode
# "HL" marks a highlight card (the glint in the pupil) — that
# one really is meant to be white.
var is_highlight: bool = src.resource_name.to_lower().contains("hl")
flat.albedo_color = Color.WHITE if is_highlight 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).