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:
co-authored by
Claude Opus 4.8
parent
af8c9831c9
commit
d8b0c08611
@@ -15,6 +15,11 @@ uniform float triplanar_tile = 2.0; // world units per texture tile
|
|||||||
uniform float band_edge : hint_range(-1.0, 1.0) = 0.05; // NdotL where light band starts
|
uniform float band_edge : hint_range(-1.0, 1.0) = 0.05; // NdotL where light band starts
|
||||||
uniform float band_softness : hint_range(0.001, 0.5) = 0.04;
|
uniform float band_softness : hint_range(0.001, 0.5) = 0.04;
|
||||||
uniform float mid_band_edge : hint_range(-1.0, 1.0) = 0.55; // second, brighter band
|
uniform float mid_band_edge : hint_range(-1.0, 1.0) = 0.55; // second, brighter band
|
||||||
|
// How dark the mid band sits below full light. Near 1.0 the second step almost
|
||||||
|
// disappears, which is what textures that already carry painted cel shading
|
||||||
|
// (imported anime characters) need — a hard step on top of painted shading
|
||||||
|
// reads as a glossy stripe sweeping across the hair.
|
||||||
|
uniform float mid_tone : hint_range(0.0, 1.0) = 0.82;
|
||||||
uniform vec4 shadow_color : source_color = vec4(0.62, 0.65, 0.78, 1.0); // cool shadow tint
|
uniform vec4 shadow_color : source_color = vec4(0.62, 0.65, 0.78, 1.0); // cool shadow tint
|
||||||
// Matte-anime defaults: zero specular (any stepped glint reads as shine
|
// Matte-anime defaults: zero specular (any stepped glint reads as shine
|
||||||
// sweeping across hair/cloth when the camera moves), whisper of rim.
|
// sweeping across hair/cloth when the camera moves), whisper of rim.
|
||||||
@@ -69,8 +74,8 @@ void light() {
|
|||||||
float lit = ndotl * ATTENUATION;
|
float lit = ndotl * ATTENUATION;
|
||||||
float band = smoothstep(band_edge - band_softness, band_edge + band_softness, lit);
|
float band = smoothstep(band_edge - band_softness, band_edge + band_softness, lit);
|
||||||
float mid = smoothstep(mid_band_edge - band_softness, mid_band_edge + band_softness, lit);
|
float mid = smoothstep(mid_band_edge - band_softness, mid_band_edge + band_softness, lit);
|
||||||
// 3 tones: shadow tint -> base band (0.82) -> full light.
|
// 3 tones: shadow tint -> base band (mid_tone) -> full light.
|
||||||
float tone = mix(0.82, 1.0, mid);
|
float tone = mix(mid_tone, 1.0, mid);
|
||||||
vec3 shade = mix(shadow_color.rgb, vec3(tone), band);
|
vec3 shade = mix(shadow_color.rgb, vec3(tone), band);
|
||||||
DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR / PI * shade;
|
DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR / PI * shade;
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ func load_model(path: String) -> void:
|
|||||||
|
|
||||||
# Cel-shaded look: toon shading over the imported textures + ink outline.
|
# Cel-shaded look: toon shading over the imported textures + ink outline.
|
||||||
LevelMaterials.apply_toon_recursive(scene)
|
LevelMaterials.apply_toon_recursive(scene)
|
||||||
|
# ...then the character-only pass: flat line-work + softer banding (see the
|
||||||
|
# function — toon-lighting the model's own outline shell is what put a white
|
||||||
|
# rim on every hair strand, and re-banding already-shaded textures read as
|
||||||
|
# gloss).
|
||||||
|
LevelMaterials.apply_character_look(scene)
|
||||||
if animation_player:
|
if animation_player:
|
||||||
_index_animations()
|
_index_animations()
|
||||||
_setup_anim_tree(scene)
|
_setup_anim_tree(scene)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -87,7 +87,7 @@ static func toonify(src: Material) -> Material:
|
|||||||
mat.set_shader_parameter("albedo_color", col)
|
mat.set_shader_parameter("albedo_color", col)
|
||||||
# Fully matte characters: NO specular (even a 2% stepped glint reads as
|
# Fully matte characters: NO specular (even a 2% stepped glint reads as
|
||||||
# shine sweeping across hair when the camera moves) and only a whisper
|
# 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_strength", 0.05)
|
||||||
mat.set_shader_parameter("rim_width", 0.28)
|
mat.set_shader_parameter("rim_width", 0.28)
|
||||||
mat.set_shader_parameter("specular_strength", 0.0)
|
mat.set_shader_parameter("specular_strength", 0.0)
|
||||||
@@ -95,6 +95,59 @@ static func toonify(src: Material) -> Material:
|
|||||||
return mat
|
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
|
## Swap every mesh surface under `node` to toon shading and add an
|
||||||
## inverted-hull outline overlay. Safe on skinned meshes (material_overlay
|
## inverted-hull outline overlay. Safe on skinned meshes (material_overlay
|
||||||
## re-renders the same deformed mesh).
|
## re-renders the same deformed mesh).
|
||||||
|
|||||||
Reference in New Issue
Block a user