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:
Nicholas Butzke
2026-07-27 14:35:06 -04:00
co-authored by Claude Opus 5
parent 33d07b3717
commit 53f175ed6d
16 changed files with 17393 additions and 15884 deletions
+44 -6
View File
@@ -92,6 +92,9 @@ var _joint_helpers: Array = []
## and leg colliders, written by tools/retarget.py. Empty for a model that was
## rebound onto the library skeleton instead of keeping its own rig.
var _rig_info: Dictionary = {}
## What each surface of this model IS — body, cloth, hair, accessory. Read from
## the same sidecar; drives the per-class cel look and answers `surfaces_of()`.
var _surfaces: SkinSurfaces = null
var _spring_mod: SpringBones
var is_holding_weapon: bool = false
## Which skin this is, so per-character hold tuning can be looked up. Set by
@@ -183,6 +186,11 @@ func load_model(path: String) -> void:
_resolved_clips.clear()
_current_clip = ""
_weapon_attachment = null
# Cleared, not left standing: a model with no skeleton never reaches the
# branch that reloads these, and would otherwise be described by the LAST
# character's sidecar.
_rig_info = {}
_surfaces = null
var scene := GLBLoader.load(path)
if not scene:
@@ -240,13 +248,16 @@ func load_model(path: String) -> void:
print("SkinnedPlayerModel: '%s' — %d cloth/hair bones on springs"
% [path.get_file(), driven])
# Cel-shaded look: toon shading over the imported textures + ink outline.
# Cel-shaded look: toon shading over the imported textures...
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)
# ...then the character-only pass, which is where the surface table earns
# its keep: it says which surfaces are hair, cloth, body or an accessory, so
# each can take its own outline weight and banding instead of all of them
# taking numbers calibrated on skin. It also identifies the model's own
# line-work by name and weight rather than by "is it nearly black", which is
# what used to render a flat-coloured model as a black silhouette.
_surfaces = SkinSurfaces.from_rig_info(_rig_info)
LevelMaterials.apply_character_look(scene, _surfaces)
if animation_player:
_index_animations()
_setup_anim_tree(scene)
@@ -375,6 +386,33 @@ func _load_rig_info(model_path: String) -> Dictionary:
return parsed
## Every [mesh, surface index] of this model belonging to one surface class —
## SkinSurfaces.BODY, CLOTH, HAIR or ACCESSORY.
##
## The point of separating a character into a body, garments and hair is that
## the game can then treat them differently, and it can only do that if it can
## ask which is which. This is that question. It is used by the rig lab to let
## an artist isolate a class, and it is what a damage flash on skin only, or a
## hidden hat, or a per-class LOD would be built on.
func surfaces_of(surface_class: String) -> Array:
var out: Array = []
if _surfaces == null:
return out
for mi in find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null:
continue
for s in mi.mesh.get_surface_count():
var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
if _surfaces.resolve(mi.name, s, src)[0] == surface_class:
out.append([mi, s])
return out
## The surface table, for tools that want to show or edit it. May be null.
func surface_table() -> SkinSurfaces:
return _surfaces
## Make sure every skinned MeshInstance3D is actually driven by the skeleton.
## A correctly-exported GLB binds automatically, but if one imports with a skin
## resource whose `skeleton` NodePath doesn't resolve, the mesh renders its bind