Files
Nicholas ButzkeandClaude Opus 5 53f175ed6d 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]>
2026-07-27 14:35:06 -04:00

117 lines
4.9 KiB
GDScript

extends RefCounted
class_name SkinSurfaces
## What every surface of a character IS — body, cloth, hair or accessory.
##
## Written at build time by tools/surface_map.py into `<skin>.rig.json`, read
## here. Nothing at runtime re-derives it, which is the point: the question has
## one right answer per model and it is knowable in Blender, where the mesh, the
## weights and the skeleton are all in hand. Asking it again from a material at
## load time is how the mannequin's flat yellow body came to be rendered as a
## black silhouette — "untextured" is not the same question as "is ink".
##
## The heuristic that mistake came from is still here, as `guess()`, and still
## earns its place: it is the fallback for a model imported before the surface
## table existed, or one whose material genuinely says nothing. But it is now
## the last resort rather than the only source.
const BODY := "body"
const CLOTH := "cloth"
const HAIR := "hair"
const ACCESSORY := "accessory"
const LINEWORK := "linework"
## Same threshold as tools/surface_map.INK_LEVEL. The two must agree, or a model
## with a surface table would render differently from one without.
const INK_LEVEL := 0.18
var _by_slot: Dictionary = {} # "mesh|index" -> record
var _by_material: Dictionary = {} # material name -> record
## Materials used by two surfaces the classifier disagreed about. Taila reuses
## `ClothA` on three meshes and `FullBlack` on three more; those agree, so they
## stay usable. One that did not would silently give whichever surface was read
## first, so it is dropped from the material index instead and falls back to the
## slot key.
var _material_conflict: Dictionary = {}
static func from_rig_info(rig_info: Dictionary) -> SkinSurfaces:
var out := SkinSurfaces.new()
for record in rig_info.get("surfaces", []):
if not record is Dictionary:
continue
out._by_slot["%s|%d" % [record.get("mesh", ""),
int(record.get("surface", 0))]] = record
var mat: String = record.get("material", "")
if mat == "":
continue
var seen = out._by_material.get(mat)
if seen != null and seen.get("class", "") != record.get("class", ""):
out._material_conflict[mat] = true
else:
out._by_material[mat] = record
return out
func is_empty() -> bool:
return _by_slot.is_empty()
## The build-time record for one surface, or an empty Dictionary.
##
## The mesh node name and surface index are tried first because they name
## exactly one surface. The material name is the fallback because it is what
## survives best — every character in this game arrives with its meshes called
## `Object_7` through `Object_32`, and a renamed node would take the slot key
## with it while `ClothB` stays `ClothB`.
func lookup(mesh_name: String, surface_index: int, material_name: String) -> Dictionary:
var by_slot = _by_slot.get("%s|%d" % [mesh_name, surface_index])
if by_slot != null:
return by_slot
if material_name != "" and not _material_conflict.has(material_name):
var by_mat = _by_material.get(material_name)
if by_mat != null:
return by_mat
return {}
## (class, detail) for a surface with no build-time record.
##
## This is the pre-surface-table heuristic, kept verbatim for the one job it is
## still right for. It answers a narrow question — is this untextured surface
## part of the model's own DRAWING? — and it answers it from the three things
## that actually distinguish line-work: being black, being drawn inside-out, or
## saying outright that it is an eye card. Anything else is treated as body,
## which is the safe answer because body is ordinary character shading.
static func guess(mat: BaseMaterial3D) -> Array:
if mat == null:
return [BODY, "skin"]
var name := mat.resource_name.to_lower()
if mat.albedo_texture == null:
if name.begins_with("eyes"):
return [BODY, "eyes_highlight" if name.contains("hl") else "eyes_ink"]
if mat.cull_mode == BaseMaterial3D.CULL_FRONT:
return [LINEWORK, "outline_hull"]
var c: Color = mat.albedo_color
if maxf(maxf(c.r, c.g), c.b) < INK_LEVEL:
return [LINEWORK, "outline_hull"]
return [BODY, "skin"]
## (class, detail) for a surface, from the table where it has an entry and from
## the heuristic where it does not.
##
## The cull-mode test is re-run even when the table HAS an entry, because that
## is the one piece of evidence the build side cannot see: glTF has no way to
## say "draw only the backfaces", so an inverted-hull outline arrives in Blender
## indistinguishable from an ordinary surface and only shows itself here.
func resolve(mesh_name: String, surface_index: int, mat: BaseMaterial3D) -> Array:
var material_name := "" if mat == null else mat.resource_name
if mat != null and mat.albedo_texture == null \
and mat.cull_mode == BaseMaterial3D.CULL_FRONT:
return [LINEWORK, "outline_hull"]
var record := lookup(mesh_name, surface_index, material_name)
if record.is_empty():
return guess(mat)
return [record.get("class", BODY), record.get("detail", "")]