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,86 @@
|
||||
extends SceneTree
|
||||
|
||||
## Print every mesh surface of every shipping skin: node name, surface index,
|
||||
## material name, whether it carries a texture, its albedo and cull mode, and
|
||||
## which bones dominate it.
|
||||
##
|
||||
## This exists because the surface table in the rig sidecar is written by Blender
|
||||
## and read by Godot, and the two do not have to agree on what anything is
|
||||
## called. Rather than assume the glTF round trip preserves names and ordering,
|
||||
## this measures what Godot ends up holding, so the sidecar can be keyed on
|
||||
## something that actually survives.
|
||||
##
|
||||
## godot --headless --path . -s res://debug/dump_surfaces.gd
|
||||
|
||||
func _init() -> void:
|
||||
var registry := "res://assets/characters/skins/skins.json"
|
||||
var data = JSON.parse_string(FileAccess.get_file_as_string(registry))
|
||||
for entry in data["skins"]:
|
||||
var path: String = entry.get("model", "")
|
||||
if path == "" or not ResourceLoader.exists(path):
|
||||
continue
|
||||
print("\n=== %s (%s)" % [entry["id"], path])
|
||||
var scene: Node = load(path).instantiate()
|
||||
var skel: Skeleton3D = _first_skeleton(scene)
|
||||
for mi in scene.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.mesh == null:
|
||||
continue
|
||||
var owner_names := _dominant_bones(mi, skel)
|
||||
for s in mi.mesh.get_surface_count():
|
||||
var m: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
|
||||
var mat_name := "<none>" if m == null else m.resource_name
|
||||
var tex := m != null and m.albedo_texture != null
|
||||
var col := Color.WHITE if m == null else m.albedo_color
|
||||
var cull := -1 if m == null else int(m.cull_mode)
|
||||
print(" %-34s s%d mat=%-28s tex=%s albedo=(%.2f,%.2f,%.2f) cull=%d bones=%s"
|
||||
% [mi.name, s, mat_name, "Y" if tex else "n",
|
||||
col.r, col.g, col.b, cull, owner_names])
|
||||
scene.free()
|
||||
quit()
|
||||
|
||||
|
||||
func _first_skeleton(node: Node) -> Skeleton3D:
|
||||
if node is Skeleton3D:
|
||||
return node
|
||||
for c in node.get_children():
|
||||
var found := _first_skeleton(c)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
## The five bones holding the most dominant-weight vertices on this mesh.
|
||||
func _dominant_bones(mi: MeshInstance3D, skel: Skeleton3D) -> String:
|
||||
if mi.skin == null or skel == null or mi.mesh == null:
|
||||
return "<unskinned>"
|
||||
var bone_of := {}
|
||||
for b in mi.skin.get_bind_count():
|
||||
var n := mi.skin.get_bind_name(b)
|
||||
bone_of[b] = skel.find_bone(n) if n != "" else mi.skin.get_bind_bone(b)
|
||||
var tally := {}
|
||||
var arrays: Array = mi.mesh.surface_get_arrays(0)
|
||||
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
||||
if bones.is_empty():
|
||||
return "<no weights>"
|
||||
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||
var per := bones.size() / maxi(1, verts.size())
|
||||
for v in verts.size():
|
||||
var best := -1
|
||||
var best_w := 0.0
|
||||
for k in per:
|
||||
var w := weights[v * per + k]
|
||||
if w > best_w:
|
||||
best_w = w
|
||||
best = bones[v * per + k]
|
||||
if best >= 0 and best_w > 0.25:
|
||||
var bi: int = bone_of.get(best, -1)
|
||||
if bi >= 0:
|
||||
var nm := skel.get_bone_name(bi)
|
||||
tally[nm] = tally.get(nm, 0) + 1
|
||||
var names: Array = tally.keys()
|
||||
names.sort_custom(func(a, b): return tally[a] > tally[b])
|
||||
var out: PackedStringArray = []
|
||||
for i in mini(5, names.size()):
|
||||
out.append("%s:%d" % [names[i], tally[names[i]]])
|
||||
return ", ".join(out)
|
||||
Reference in New Issue
Block a user