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
@@ -0,0 +1,70 @@
|
||||
extends SceneTree
|
||||
|
||||
## Does every surface of every shipping skin get classified, and does the
|
||||
## classification survive the trip from Blender into Godot?
|
||||
##
|
||||
## The surface table is written by tools/surface_map.py against the Blender
|
||||
## scene and read by SkinSurfaces against the loaded glTF. Nothing forces the
|
||||
## two to agree on what anything is called, so this checks that they do — by
|
||||
## counting how many surfaces resolve from the TABLE versus how many fell
|
||||
## through to the heuristic. A model whose names stopped matching still renders,
|
||||
## because the fallback catches it; it just quietly loses its per-class art
|
||||
## direction. That is exactly the kind of silent regression this catches.
|
||||
##
|
||||
## godot --headless --path . -s res://debug/surface_class_check.gd
|
||||
|
||||
var _failures: int = 0
|
||||
|
||||
func _init() -> void:
|
||||
var data = JSON.parse_string(FileAccess.get_file_as_string(
|
||||
"res://assets/characters/skins/skins.json"))
|
||||
for entry in data["skins"]:
|
||||
_check_skin(entry["id"], entry.get("model", ""))
|
||||
print("\n%s" % ("FAILED (%d)" % _failures if _failures > 0 else "All skins classified"))
|
||||
quit(1 if _failures > 0 else 0)
|
||||
|
||||
|
||||
func _check_skin(id: String, path: String) -> void:
|
||||
if path == "" or not ResourceLoader.exists(path):
|
||||
return
|
||||
var side := path.get_basename() + ".rig.json"
|
||||
var rig = JSON.parse_string(FileAccess.get_file_as_string(side))
|
||||
if typeof(rig) != TYPE_DICTIONARY:
|
||||
print("%-10s NO SIDECAR" % id)
|
||||
_failures += 1
|
||||
return
|
||||
var surfaces := SkinSurfaces.from_rig_info(rig)
|
||||
var scene: Node = load(path).instantiate()
|
||||
|
||||
var tally := {}
|
||||
var from_table := 0
|
||||
var from_guess := 0
|
||||
var unmatched: PackedStringArray = []
|
||||
for mi in scene.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
|
||||
var mat_name := "" if src == null else src.resource_name
|
||||
if surfaces.lookup(mi.name, s, mat_name).is_empty():
|
||||
from_guess += 1
|
||||
unmatched.append("%s|%d|%s" % [mi.name, s, mat_name])
|
||||
else:
|
||||
from_table += 1
|
||||
var cls: String = surfaces.resolve(mi.name, s, src)[0]
|
||||
tally[cls] = tally.get(cls, 0) + 1
|
||||
scene.free()
|
||||
|
||||
var parts: PackedStringArray = []
|
||||
var keys: Array = tally.keys()
|
||||
keys.sort()
|
||||
for k in keys:
|
||||
parts.append("%s x%d" % [k, tally[k]])
|
||||
print("%-10s %d surfaces — %s (table %d, fallback %d)"
|
||||
% [id, from_table + from_guess, ", ".join(parts), from_table, from_guess])
|
||||
if from_guess > 0:
|
||||
print(" unmatched: %s" % ", ".join(unmatched))
|
||||
_failures += 1
|
||||
if surfaces.is_empty():
|
||||
print(" sidecar has no surface table")
|
||||
_failures += 1
|
||||
Reference in New Issue
Block a user