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
+90
View File
@@ -0,0 +1,90 @@
extends SceneTree
## Dev tool: a skin's RAW import beside the same model with the game's character
## treatment, under identical lighting — the reference shot for tuning
## LevelMaterials.apply_character_look() against the model's source render.
## godot --path . --windowed --resolution 1280x720 \
## -s res://debug/character_look_capture.gd -- <out_dir> [res-path-to-glb]
## Saves look_head.png and look_body.png (left = raw import, right = ours).
const DEFAULT_GLB := "res://assets/characters/skins/taila.glb"
var _frames := 0
var _out_dir := "."
var _glb_path := DEFAULT_GLB
var _cam: Camera3D = null
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
if args.size() > 1:
_glb_path = args[1]
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
# Neutral studio light, matched to the game's tonemap so colours compare.
var env := WorldEnvironment.new()
var e := Environment.new()
e.background_mode = Environment.BG_COLOR
e.background_color = Color(0.30, 0.32, 0.38)
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.85, 0.87, 0.95)
e.ambient_light_energy = 1.1
e.tonemap_mode = Environment.TONE_MAPPER_FILMIC
e.tonemap_white = 2.4
env.environment = e
scene.add_child(env)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-38, 28, 0)
sun.light_energy = 2.0
scene.add_child(sun)
var raw := GLBLoader.load(_glb_path)
if not raw:
printerr("character_look_capture: could not load ", _glb_path)
quit(1)
return
raw.position = Vector3(-0.45, 0, 0)
scene.add_child(raw)
var ours := GLBLoader.load(_glb_path)
ours.position = Vector3(0.45, 0, 0)
scene.add_child(ours)
LevelMaterials.apply_toon_recursive(ours)
LevelMaterials.apply_character_look(ours)
_cam = Camera3D.new()
scene.add_child(_cam)
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 30:
return false
if _frames == 30:
_look(Vector3(0, 1.42, 1.05), Vector3(0, 1.38, 0))
return false
if _frames == 32:
_snap("look_head")
_look(Vector3(0, 1.15, 2.2), Vector3(0, 0.95, 0))
return false
if _frames == 34:
_snap("look_body")
return true
return false
func _look(pos: Vector3, at: Vector3) -> void:
_cam.global_position = pos
_cam.look_at(at, Vector3.UP)
func _snap(tag: String) -> void:
var img := root.get_viewport().get_texture().get_image()
img.save_png(_out_dir + "/" + tag + ".png")
print("character_look_capture: saved ", tag)