feat(characters): say what every surface IS, and light it accordingly
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
33d07b3717
commit
53f175ed6d
@@ -143,53 +143,117 @@ const CHARACTER_INK := Color(0.07, 0.06, 0.09)
|
||||
## 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.
|
||||
static func _is_line_work(src: BaseMaterial3D) -> bool:
|
||||
if src.resource_name.to_lower().begins_with("eyes"):
|
||||
return true
|
||||
if src.cull_mode == BaseMaterial3D.CULL_FRONT:
|
||||
return true
|
||||
var c: Color = src.albedo_color
|
||||
return maxf(maxf(c.r, c.g), c.b) < 0.18
|
||||
##
|
||||
## That heuristic now lives in `SkinSurfaces.guess`, where it is the FALLBACK
|
||||
## rather than the only answer — see below.
|
||||
|
||||
|
||||
static func apply_character_look(root: Node) -> void:
|
||||
## 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
|
||||
if src.albedo_texture == null and _is_line_work(src):
|
||||
# Untextured AND dark or inside-out — 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)
|
||||
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
|
||||
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))
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user