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
+2668
-2493
File diff suppressed because it is too large
Load Diff
+3269
-3118
File diff suppressed because it is too large
Load Diff
@@ -1,183 +1,207 @@
|
|||||||
{
|
{
|
||||||
"roles": {
|
"roles": {
|
||||||
"hips": "DEF-hips",
|
"hips": "DEF-hips",
|
||||||
"head": "DEF-head",
|
"head": "DEF-head",
|
||||||
"neck": "DEF-neck",
|
"neck": "DEF-neck",
|
||||||
"spine": [
|
"spine": [
|
||||||
"DEF-spine.001",
|
"DEF-spine.001",
|
||||||
"DEF-spine.002",
|
"DEF-spine.002",
|
||||||
"DEF-spine.003",
|
"DEF-spine.003",
|
||||||
"DEF-neck",
|
"DEF-neck",
|
||||||
"DEF-head"
|
"DEF-head"
|
||||||
],
|
|
||||||
"shoulder.L": "DEF-shoulder.L",
|
|
||||||
"shoulder.R": "DEF-shoulder.R",
|
|
||||||
"upper_arm.R": "DEF-upper_arm.R",
|
|
||||||
"upper_arm.L": "DEF-upper_arm.L",
|
|
||||||
"forearm.L": "DEF-forearm.L",
|
|
||||||
"forearm.R": "DEF-forearm.R",
|
|
||||||
"thigh.L": "DEF-thigh.L",
|
|
||||||
"thigh.R": "DEF-thigh.R",
|
|
||||||
"foot.R": "DEF-foot.R",
|
|
||||||
"foot.L": "DEF-foot.L",
|
|
||||||
"shin.R": "DEF-shin.R",
|
|
||||||
"shin.L": "DEF-shin.L",
|
|
||||||
"hand.R": "DEF-hand.R",
|
|
||||||
"hand.L": "DEF-hand.L",
|
|
||||||
"toe.R": "DEF-toe.R",
|
|
||||||
"toe.L": "DEF-toe.L"
|
|
||||||
},
|
|
||||||
"fingers": {
|
|
||||||
"thumb.L": [
|
|
||||||
"DEF-thumb.01.L",
|
|
||||||
"DEF-thumb.02.L",
|
|
||||||
"DEF-thumb.03.L"
|
|
||||||
],
|
|
||||||
"index.L": [
|
|
||||||
"DEF-f_index.01.L",
|
|
||||||
"DEF-f_index.02.L",
|
|
||||||
"DEF-f_index.03.L"
|
|
||||||
],
|
|
||||||
"middle.L": [
|
|
||||||
"DEF-f_middle.01.L",
|
|
||||||
"DEF-f_middle.02.L",
|
|
||||||
"DEF-f_middle.03.L"
|
|
||||||
],
|
|
||||||
"ring.L": [
|
|
||||||
"DEF-f_ring.01.L",
|
|
||||||
"DEF-f_ring.02.L",
|
|
||||||
"DEF-f_ring.03.L"
|
|
||||||
],
|
|
||||||
"pinky.L": [
|
|
||||||
"DEF-f_pinky.01.L",
|
|
||||||
"DEF-f_pinky.02.L",
|
|
||||||
"DEF-f_pinky.03.L"
|
|
||||||
],
|
|
||||||
"thumb.R": [
|
|
||||||
"DEF-thumb.01.R",
|
|
||||||
"DEF-thumb.02.R",
|
|
||||||
"DEF-thumb.03.R"
|
|
||||||
],
|
|
||||||
"index.R": [
|
|
||||||
"DEF-f_index.01.R",
|
|
||||||
"DEF-f_index.02.R",
|
|
||||||
"DEF-f_index.03.R"
|
|
||||||
],
|
|
||||||
"middle.R": [
|
|
||||||
"DEF-f_middle.01.R",
|
|
||||||
"DEF-f_middle.02.R",
|
|
||||||
"DEF-f_middle.03.R"
|
|
||||||
],
|
|
||||||
"ring.R": [
|
|
||||||
"DEF-f_ring.01.R",
|
|
||||||
"DEF-f_ring.02.R",
|
|
||||||
"DEF-f_ring.03.R"
|
|
||||||
],
|
|
||||||
"pinky.R": [
|
|
||||||
"DEF-f_pinky.01.R",
|
|
||||||
"DEF-f_pinky.02.R",
|
|
||||||
"DEF-f_pinky.03.R"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"chains": [],
|
|
||||||
"twist": [],
|
|
||||||
"colliders": [
|
|
||||||
{
|
|
||||||
"bone": "DEF-hips",
|
|
||||||
"child": "DEF-spine.001",
|
|
||||||
"from": 0.0,
|
|
||||||
"radius_head": 0.1381,
|
|
||||||
"radius_tail": 0.1381,
|
|
||||||
"radius": 0.1381,
|
|
||||||
"lid": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bone": "DEF-thigh.L",
|
|
||||||
"child": "DEF-shin.L",
|
|
||||||
"from": 0.1,
|
|
||||||
"radius_head": 0.1037,
|
|
||||||
"radius_tail": 0.0733,
|
|
||||||
"radius": 0.0733
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bone": "DEF-thigh.R",
|
|
||||||
"child": "DEF-shin.R",
|
|
||||||
"from": 0.1,
|
|
||||||
"radius_head": 0.1037,
|
|
||||||
"radius_tail": 0.0733,
|
|
||||||
"radius": 0.0733
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bone": "DEF-shin.L",
|
|
||||||
"child": "DEF-foot.L",
|
|
||||||
"from": 0.1,
|
|
||||||
"radius_head": 0.0927,
|
|
||||||
"radius_tail": 0.0516,
|
|
||||||
"radius": 0.0516
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bone": "DEF-shin.R",
|
|
||||||
"child": "DEF-foot.R",
|
|
||||||
"from": 0.1,
|
|
||||||
"radius_head": 0.0927,
|
|
||||||
"radius_tail": 0.0516,
|
|
||||||
"radius": 0.0516
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"weights_authored": true,
|
"shoulder.L": "DEF-shoulder.L",
|
||||||
"driven_bones": [
|
"shoulder.R": "DEF-shoulder.R",
|
||||||
"DEF-f_index.01.L",
|
"upper_arm.R": "DEF-upper_arm.R",
|
||||||
"DEF-f_index.01.R",
|
"upper_arm.L": "DEF-upper_arm.L",
|
||||||
"DEF-f_index.02.L",
|
"forearm.L": "DEF-forearm.L",
|
||||||
"DEF-f_index.02.R",
|
"forearm.R": "DEF-forearm.R",
|
||||||
"DEF-f_index.03.L",
|
"thigh.L": "DEF-thigh.L",
|
||||||
"DEF-f_index.03.R",
|
"thigh.R": "DEF-thigh.R",
|
||||||
"DEF-f_middle.01.L",
|
"foot.R": "DEF-foot.R",
|
||||||
"DEF-f_middle.01.R",
|
"foot.L": "DEF-foot.L",
|
||||||
"DEF-f_middle.02.L",
|
"shin.R": "DEF-shin.R",
|
||||||
"DEF-f_middle.02.R",
|
"shin.L": "DEF-shin.L",
|
||||||
"DEF-f_middle.03.L",
|
"hand.R": "DEF-hand.R",
|
||||||
"DEF-f_middle.03.R",
|
"hand.L": "DEF-hand.L",
|
||||||
"DEF-f_pinky.01.L",
|
"toe.R": "DEF-toe.R",
|
||||||
"DEF-f_pinky.01.R",
|
"toe.L": "DEF-toe.L"
|
||||||
"DEF-f_pinky.02.L",
|
},
|
||||||
"DEF-f_pinky.02.R",
|
"fingers": {
|
||||||
"DEF-f_pinky.03.L",
|
"thumb.L": [
|
||||||
"DEF-f_pinky.03.R",
|
"DEF-thumb.01.L",
|
||||||
"DEF-f_ring.01.L",
|
"DEF-thumb.02.L",
|
||||||
"DEF-f_ring.01.R",
|
"DEF-thumb.03.L"
|
||||||
"DEF-f_ring.02.L",
|
],
|
||||||
"DEF-f_ring.02.R",
|
"index.L": [
|
||||||
"DEF-f_ring.03.L",
|
"DEF-f_index.01.L",
|
||||||
"DEF-f_ring.03.R",
|
"DEF-f_index.02.L",
|
||||||
"DEF-foot.L",
|
"DEF-f_index.03.L"
|
||||||
"DEF-foot.R",
|
],
|
||||||
"DEF-forearm.L",
|
"middle.L": [
|
||||||
"DEF-forearm.R",
|
"DEF-f_middle.01.L",
|
||||||
"DEF-hand.L",
|
"DEF-f_middle.02.L",
|
||||||
"DEF-hand.R",
|
"DEF-f_middle.03.L"
|
||||||
"DEF-head",
|
],
|
||||||
"DEF-hips",
|
"ring.L": [
|
||||||
"DEF-neck",
|
"DEF-f_ring.01.L",
|
||||||
"DEF-shin.L",
|
"DEF-f_ring.02.L",
|
||||||
"DEF-shin.R",
|
"DEF-f_ring.03.L"
|
||||||
"DEF-shoulder.L",
|
],
|
||||||
"DEF-shoulder.R",
|
"pinky.L": [
|
||||||
"DEF-spine.001",
|
"DEF-f_pinky.01.L",
|
||||||
"DEF-spine.002",
|
"DEF-f_pinky.02.L",
|
||||||
"DEF-spine.003",
|
"DEF-f_pinky.03.L"
|
||||||
"DEF-thigh.L",
|
],
|
||||||
"DEF-thigh.R",
|
"thumb.R": [
|
||||||
"DEF-thumb.01.L",
|
"DEF-thumb.01.R",
|
||||||
"DEF-thumb.01.R",
|
"DEF-thumb.02.R",
|
||||||
"DEF-thumb.02.L",
|
"DEF-thumb.03.R"
|
||||||
"DEF-thumb.02.R",
|
],
|
||||||
"DEF-thumb.03.L",
|
"index.R": [
|
||||||
"DEF-thumb.03.R",
|
"DEF-f_index.01.R",
|
||||||
"DEF-toe.L",
|
"DEF-f_index.02.R",
|
||||||
"DEF-toe.R",
|
"DEF-f_index.03.R"
|
||||||
"DEF-upper_arm.L",
|
],
|
||||||
"DEF-upper_arm.R",
|
"middle.R": [
|
||||||
"root"
|
"DEF-f_middle.01.R",
|
||||||
|
"DEF-f_middle.02.R",
|
||||||
|
"DEF-f_middle.03.R"
|
||||||
|
],
|
||||||
|
"ring.R": [
|
||||||
|
"DEF-f_ring.01.R",
|
||||||
|
"DEF-f_ring.02.R",
|
||||||
|
"DEF-f_ring.03.R"
|
||||||
|
],
|
||||||
|
"pinky.R": [
|
||||||
|
"DEF-f_pinky.01.R",
|
||||||
|
"DEF-f_pinky.02.R",
|
||||||
|
"DEF-f_pinky.03.R"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"chains": [],
|
||||||
|
"twist": [],
|
||||||
|
"colliders": [
|
||||||
|
{
|
||||||
|
"bone": "DEF-hips",
|
||||||
|
"child": "DEF-spine.001",
|
||||||
|
"from": 0.0,
|
||||||
|
"radius_head": 0.1381,
|
||||||
|
"radius_tail": 0.1381,
|
||||||
|
"radius": 0.1381,
|
||||||
|
"lid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bone": "DEF-thigh.L",
|
||||||
|
"child": "DEF-shin.L",
|
||||||
|
"from": 0.1,
|
||||||
|
"radius_head": 0.1037,
|
||||||
|
"radius_tail": 0.0733,
|
||||||
|
"radius": 0.0733
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bone": "DEF-thigh.R",
|
||||||
|
"child": "DEF-shin.R",
|
||||||
|
"from": 0.1,
|
||||||
|
"radius_head": 0.1037,
|
||||||
|
"radius_tail": 0.0733,
|
||||||
|
"radius": 0.0733
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bone": "DEF-shin.L",
|
||||||
|
"child": "DEF-foot.L",
|
||||||
|
"from": 0.1,
|
||||||
|
"radius_head": 0.0927,
|
||||||
|
"radius_tail": 0.0516,
|
||||||
|
"radius": 0.0516
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bone": "DEF-shin.R",
|
||||||
|
"child": "DEF-foot.R",
|
||||||
|
"from": 0.1,
|
||||||
|
"radius_head": 0.0927,
|
||||||
|
"radius_tail": 0.0516,
|
||||||
|
"radius": 0.0516
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weights_authored": true,
|
||||||
|
"driven_bones": [
|
||||||
|
"DEF-f_index.01.L",
|
||||||
|
"DEF-f_index.01.R",
|
||||||
|
"DEF-f_index.02.L",
|
||||||
|
"DEF-f_index.02.R",
|
||||||
|
"DEF-f_index.03.L",
|
||||||
|
"DEF-f_index.03.R",
|
||||||
|
"DEF-f_middle.01.L",
|
||||||
|
"DEF-f_middle.01.R",
|
||||||
|
"DEF-f_middle.02.L",
|
||||||
|
"DEF-f_middle.02.R",
|
||||||
|
"DEF-f_middle.03.L",
|
||||||
|
"DEF-f_middle.03.R",
|
||||||
|
"DEF-f_pinky.01.L",
|
||||||
|
"DEF-f_pinky.01.R",
|
||||||
|
"DEF-f_pinky.02.L",
|
||||||
|
"DEF-f_pinky.02.R",
|
||||||
|
"DEF-f_pinky.03.L",
|
||||||
|
"DEF-f_pinky.03.R",
|
||||||
|
"DEF-f_ring.01.L",
|
||||||
|
"DEF-f_ring.01.R",
|
||||||
|
"DEF-f_ring.02.L",
|
||||||
|
"DEF-f_ring.02.R",
|
||||||
|
"DEF-f_ring.03.L",
|
||||||
|
"DEF-f_ring.03.R",
|
||||||
|
"DEF-foot.L",
|
||||||
|
"DEF-foot.R",
|
||||||
|
"DEF-forearm.L",
|
||||||
|
"DEF-forearm.R",
|
||||||
|
"DEF-hand.L",
|
||||||
|
"DEF-hand.R",
|
||||||
|
"DEF-head",
|
||||||
|
"DEF-hips",
|
||||||
|
"DEF-neck",
|
||||||
|
"DEF-shin.L",
|
||||||
|
"DEF-shin.R",
|
||||||
|
"DEF-shoulder.L",
|
||||||
|
"DEF-shoulder.R",
|
||||||
|
"DEF-spine.001",
|
||||||
|
"DEF-spine.002",
|
||||||
|
"DEF-spine.003",
|
||||||
|
"DEF-thigh.L",
|
||||||
|
"DEF-thigh.R",
|
||||||
|
"DEF-thumb.01.L",
|
||||||
|
"DEF-thumb.01.R",
|
||||||
|
"DEF-thumb.02.L",
|
||||||
|
"DEF-thumb.02.R",
|
||||||
|
"DEF-thumb.03.L",
|
||||||
|
"DEF-thumb.03.R",
|
||||||
|
"DEF-toe.L",
|
||||||
|
"DEF-toe.R",
|
||||||
|
"DEF-upper_arm.L",
|
||||||
|
"DEF-upper_arm.R",
|
||||||
|
"root"
|
||||||
|
],
|
||||||
|
"surfaces": [
|
||||||
|
{
|
||||||
|
"mesh": "Mannequin",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "M_Main",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "skin",
|
||||||
|
"why": "no name or weight evidence \u2014 treated as body",
|
||||||
|
"verts": 3391,
|
||||||
|
"textured": false,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Mannequin",
|
||||||
|
"surface": 1,
|
||||||
|
"material": "M_Joints",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "skin",
|
||||||
|
"why": "no name or weight evidence \u2014 treated as body",
|
||||||
|
"verts": 5157,
|
||||||
|
"textured": false,
|
||||||
|
"chain_share": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -1,183 +1,229 @@
|
|||||||
{
|
{
|
||||||
"roles": {
|
"roles": {
|
||||||
"hips": "DEF-hips",
|
"hips": "DEF-hips",
|
||||||
"head": "DEF-head",
|
"head": "DEF-head",
|
||||||
"neck": "DEF-neck",
|
"neck": "DEF-neck",
|
||||||
"spine": [
|
"spine": [
|
||||||
"DEF-spine.001",
|
"DEF-spine.001",
|
||||||
"DEF-spine.002",
|
"DEF-spine.002",
|
||||||
"DEF-spine.003",
|
"DEF-spine.003",
|
||||||
"DEF-neck",
|
"DEF-neck",
|
||||||
"DEF-head"
|
"DEF-head"
|
||||||
],
|
|
||||||
"shoulder.L": "DEF-shoulder.L",
|
|
||||||
"shoulder.R": "DEF-shoulder.R",
|
|
||||||
"upper_arm.L": "DEF-upper_arm.L",
|
|
||||||
"upper_arm.R": "DEF-upper_arm.R",
|
|
||||||
"forearm.L": "DEF-forearm.L",
|
|
||||||
"forearm.R": "DEF-forearm.R",
|
|
||||||
"thigh.L": "DEF-thigh.L",
|
|
||||||
"thigh.R": "DEF-thigh.R",
|
|
||||||
"foot.R": "DEF-foot.R",
|
|
||||||
"foot.L": "DEF-foot.L",
|
|
||||||
"shin.R": "DEF-shin.R",
|
|
||||||
"shin.L": "DEF-shin.L",
|
|
||||||
"hand.R": "DEF-hand.R",
|
|
||||||
"hand.L": "DEF-hand.L",
|
|
||||||
"toe.R": "DEF-toe.R",
|
|
||||||
"toe.L": "DEF-toe.L"
|
|
||||||
},
|
|
||||||
"fingers": {
|
|
||||||
"thumb.L": [
|
|
||||||
"DEF-thumb.01.L",
|
|
||||||
"DEF-thumb.02.L",
|
|
||||||
"DEF-thumb.03.L"
|
|
||||||
],
|
|
||||||
"index.L": [
|
|
||||||
"DEF-f_index.01.L",
|
|
||||||
"DEF-f_index.02.L",
|
|
||||||
"DEF-f_index.03.L"
|
|
||||||
],
|
|
||||||
"middle.L": [
|
|
||||||
"DEF-f_middle.01.L",
|
|
||||||
"DEF-f_middle.02.L",
|
|
||||||
"DEF-f_middle.03.L"
|
|
||||||
],
|
|
||||||
"ring.L": [
|
|
||||||
"DEF-f_ring.01.L",
|
|
||||||
"DEF-f_ring.02.L",
|
|
||||||
"DEF-f_ring.03.L"
|
|
||||||
],
|
|
||||||
"pinky.L": [
|
|
||||||
"DEF-f_pinky.01.L",
|
|
||||||
"DEF-f_pinky.02.L",
|
|
||||||
"DEF-f_pinky.03.L"
|
|
||||||
],
|
|
||||||
"thumb.R": [
|
|
||||||
"DEF-thumb.01.R",
|
|
||||||
"DEF-thumb.02.R",
|
|
||||||
"DEF-thumb.03.R"
|
|
||||||
],
|
|
||||||
"index.R": [
|
|
||||||
"DEF-f_index.01.R",
|
|
||||||
"DEF-f_index.02.R",
|
|
||||||
"DEF-f_index.03.R"
|
|
||||||
],
|
|
||||||
"middle.R": [
|
|
||||||
"DEF-f_middle.01.R",
|
|
||||||
"DEF-f_middle.02.R",
|
|
||||||
"DEF-f_middle.03.R"
|
|
||||||
],
|
|
||||||
"ring.R": [
|
|
||||||
"DEF-f_ring.01.R",
|
|
||||||
"DEF-f_ring.02.R",
|
|
||||||
"DEF-f_ring.03.R"
|
|
||||||
],
|
|
||||||
"pinky.R": [
|
|
||||||
"DEF-f_pinky.01.R",
|
|
||||||
"DEF-f_pinky.02.R",
|
|
||||||
"DEF-f_pinky.03.R"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"chains": [],
|
|
||||||
"twist": [],
|
|
||||||
"colliders": [
|
|
||||||
{
|
|
||||||
"bone": "DEF-hips",
|
|
||||||
"child": "DEF-spine.001",
|
|
||||||
"from": 0.0,
|
|
||||||
"radius_head": 0.1913,
|
|
||||||
"radius_tail": 0.1913,
|
|
||||||
"radius": 0.1913,
|
|
||||||
"lid": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bone": "DEF-thigh.L",
|
|
||||||
"child": "DEF-shin.L",
|
|
||||||
"from": 0.1,
|
|
||||||
"radius_head": 0.1435,
|
|
||||||
"radius_tail": 0.1304,
|
|
||||||
"radius": 0.1304
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bone": "DEF-thigh.R",
|
|
||||||
"child": "DEF-shin.R",
|
|
||||||
"from": 0.1,
|
|
||||||
"radius_head": 0.1084,
|
|
||||||
"radius_tail": 0.1084,
|
|
||||||
"radius": 0.1084
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bone": "DEF-shin.L",
|
|
||||||
"child": "DEF-foot.L",
|
|
||||||
"from": 0.1,
|
|
||||||
"radius_head": 0.0862,
|
|
||||||
"radius_tail": 0.0728,
|
|
||||||
"radius": 0.0728
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bone": "DEF-shin.R",
|
|
||||||
"child": "DEF-foot.R",
|
|
||||||
"from": 0.1,
|
|
||||||
"radius_head": 0.0862,
|
|
||||||
"radius_tail": 0.0728,
|
|
||||||
"radius": 0.0728
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"weights_authored": false,
|
"shoulder.L": "DEF-shoulder.L",
|
||||||
"driven_bones": [
|
"shoulder.R": "DEF-shoulder.R",
|
||||||
"DEF-f_index.01.L",
|
"upper_arm.L": "DEF-upper_arm.L",
|
||||||
"DEF-f_index.01.R",
|
"upper_arm.R": "DEF-upper_arm.R",
|
||||||
"DEF-f_index.02.L",
|
"forearm.L": "DEF-forearm.L",
|
||||||
"DEF-f_index.02.R",
|
"forearm.R": "DEF-forearm.R",
|
||||||
"DEF-f_index.03.L",
|
"thigh.L": "DEF-thigh.L",
|
||||||
"DEF-f_index.03.R",
|
"thigh.R": "DEF-thigh.R",
|
||||||
"DEF-f_middle.01.L",
|
"foot.R": "DEF-foot.R",
|
||||||
"DEF-f_middle.01.R",
|
"foot.L": "DEF-foot.L",
|
||||||
"DEF-f_middle.02.L",
|
"shin.R": "DEF-shin.R",
|
||||||
"DEF-f_middle.02.R",
|
"shin.L": "DEF-shin.L",
|
||||||
"DEF-f_middle.03.L",
|
"hand.R": "DEF-hand.R",
|
||||||
"DEF-f_middle.03.R",
|
"hand.L": "DEF-hand.L",
|
||||||
"DEF-f_pinky.01.L",
|
"toe.R": "DEF-toe.R",
|
||||||
"DEF-f_pinky.01.R",
|
"toe.L": "DEF-toe.L"
|
||||||
"DEF-f_pinky.02.L",
|
},
|
||||||
"DEF-f_pinky.02.R",
|
"fingers": {
|
||||||
"DEF-f_pinky.03.L",
|
"thumb.L": [
|
||||||
"DEF-f_pinky.03.R",
|
"DEF-thumb.01.L",
|
||||||
"DEF-f_ring.01.L",
|
"DEF-thumb.02.L",
|
||||||
"DEF-f_ring.01.R",
|
"DEF-thumb.03.L"
|
||||||
"DEF-f_ring.02.L",
|
],
|
||||||
"DEF-f_ring.02.R",
|
"index.L": [
|
||||||
"DEF-f_ring.03.L",
|
"DEF-f_index.01.L",
|
||||||
"DEF-f_ring.03.R",
|
"DEF-f_index.02.L",
|
||||||
"DEF-foot.L",
|
"DEF-f_index.03.L"
|
||||||
"DEF-foot.R",
|
],
|
||||||
"DEF-forearm.L",
|
"middle.L": [
|
||||||
"DEF-forearm.R",
|
"DEF-f_middle.01.L",
|
||||||
"DEF-hand.L",
|
"DEF-f_middle.02.L",
|
||||||
"DEF-hand.R",
|
"DEF-f_middle.03.L"
|
||||||
"DEF-head",
|
],
|
||||||
"DEF-hips",
|
"ring.L": [
|
||||||
"DEF-neck",
|
"DEF-f_ring.01.L",
|
||||||
"DEF-shin.L",
|
"DEF-f_ring.02.L",
|
||||||
"DEF-shin.R",
|
"DEF-f_ring.03.L"
|
||||||
"DEF-shoulder.L",
|
],
|
||||||
"DEF-shoulder.R",
|
"pinky.L": [
|
||||||
"DEF-spine.001",
|
"DEF-f_pinky.01.L",
|
||||||
"DEF-spine.002",
|
"DEF-f_pinky.02.L",
|
||||||
"DEF-spine.003",
|
"DEF-f_pinky.03.L"
|
||||||
"DEF-thigh.L",
|
],
|
||||||
"DEF-thigh.R",
|
"thumb.R": [
|
||||||
"DEF-thumb.01.L",
|
"DEF-thumb.01.R",
|
||||||
"DEF-thumb.01.R",
|
"DEF-thumb.02.R",
|
||||||
"DEF-thumb.02.L",
|
"DEF-thumb.03.R"
|
||||||
"DEF-thumb.02.R",
|
],
|
||||||
"DEF-thumb.03.L",
|
"index.R": [
|
||||||
"DEF-thumb.03.R",
|
"DEF-f_index.01.R",
|
||||||
"DEF-toe.L",
|
"DEF-f_index.02.R",
|
||||||
"DEF-toe.R",
|
"DEF-f_index.03.R"
|
||||||
"DEF-upper_arm.L",
|
],
|
||||||
"DEF-upper_arm.R",
|
"middle.R": [
|
||||||
"root"
|
"DEF-f_middle.01.R",
|
||||||
|
"DEF-f_middle.02.R",
|
||||||
|
"DEF-f_middle.03.R"
|
||||||
|
],
|
||||||
|
"ring.R": [
|
||||||
|
"DEF-f_ring.01.R",
|
||||||
|
"DEF-f_ring.02.R",
|
||||||
|
"DEF-f_ring.03.R"
|
||||||
|
],
|
||||||
|
"pinky.R": [
|
||||||
|
"DEF-f_pinky.01.R",
|
||||||
|
"DEF-f_pinky.02.R",
|
||||||
|
"DEF-f_pinky.03.R"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"chains": [],
|
||||||
|
"twist": [],
|
||||||
|
"colliders": [
|
||||||
|
{
|
||||||
|
"bone": "DEF-hips",
|
||||||
|
"child": "DEF-spine.001",
|
||||||
|
"from": 0.0,
|
||||||
|
"radius_head": 0.1913,
|
||||||
|
"radius_tail": 0.1913,
|
||||||
|
"radius": 0.1913,
|
||||||
|
"lid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bone": "DEF-thigh.L",
|
||||||
|
"child": "DEF-shin.L",
|
||||||
|
"from": 0.1,
|
||||||
|
"radius_head": 0.1435,
|
||||||
|
"radius_tail": 0.1304,
|
||||||
|
"radius": 0.1304
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bone": "DEF-thigh.R",
|
||||||
|
"child": "DEF-shin.R",
|
||||||
|
"from": 0.1,
|
||||||
|
"radius_head": 0.1084,
|
||||||
|
"radius_tail": 0.1084,
|
||||||
|
"radius": 0.1084
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bone": "DEF-shin.L",
|
||||||
|
"child": "DEF-foot.L",
|
||||||
|
"from": 0.1,
|
||||||
|
"radius_head": 0.0862,
|
||||||
|
"radius_tail": 0.0728,
|
||||||
|
"radius": 0.0728
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bone": "DEF-shin.R",
|
||||||
|
"child": "DEF-foot.R",
|
||||||
|
"from": 0.1,
|
||||||
|
"radius_head": 0.0862,
|
||||||
|
"radius_tail": 0.0728,
|
||||||
|
"radius": 0.0728
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weights_authored": false,
|
||||||
|
"driven_bones": [
|
||||||
|
"DEF-f_index.01.L",
|
||||||
|
"DEF-f_index.01.R",
|
||||||
|
"DEF-f_index.02.L",
|
||||||
|
"DEF-f_index.02.R",
|
||||||
|
"DEF-f_index.03.L",
|
||||||
|
"DEF-f_index.03.R",
|
||||||
|
"DEF-f_middle.01.L",
|
||||||
|
"DEF-f_middle.01.R",
|
||||||
|
"DEF-f_middle.02.L",
|
||||||
|
"DEF-f_middle.02.R",
|
||||||
|
"DEF-f_middle.03.L",
|
||||||
|
"DEF-f_middle.03.R",
|
||||||
|
"DEF-f_pinky.01.L",
|
||||||
|
"DEF-f_pinky.01.R",
|
||||||
|
"DEF-f_pinky.02.L",
|
||||||
|
"DEF-f_pinky.02.R",
|
||||||
|
"DEF-f_pinky.03.L",
|
||||||
|
"DEF-f_pinky.03.R",
|
||||||
|
"DEF-f_ring.01.L",
|
||||||
|
"DEF-f_ring.01.R",
|
||||||
|
"DEF-f_ring.02.L",
|
||||||
|
"DEF-f_ring.02.R",
|
||||||
|
"DEF-f_ring.03.L",
|
||||||
|
"DEF-f_ring.03.R",
|
||||||
|
"DEF-foot.L",
|
||||||
|
"DEF-foot.R",
|
||||||
|
"DEF-forearm.L",
|
||||||
|
"DEF-forearm.R",
|
||||||
|
"DEF-hand.L",
|
||||||
|
"DEF-hand.R",
|
||||||
|
"DEF-head",
|
||||||
|
"DEF-hips",
|
||||||
|
"DEF-neck",
|
||||||
|
"DEF-shin.L",
|
||||||
|
"DEF-shin.R",
|
||||||
|
"DEF-shoulder.L",
|
||||||
|
"DEF-shoulder.R",
|
||||||
|
"DEF-spine.001",
|
||||||
|
"DEF-spine.002",
|
||||||
|
"DEF-spine.003",
|
||||||
|
"DEF-thigh.L",
|
||||||
|
"DEF-thigh.R",
|
||||||
|
"DEF-thumb.01.L",
|
||||||
|
"DEF-thumb.01.R",
|
||||||
|
"DEF-thumb.02.L",
|
||||||
|
"DEF-thumb.02.R",
|
||||||
|
"DEF-thumb.03.L",
|
||||||
|
"DEF-thumb.03.R",
|
||||||
|
"DEF-toe.L",
|
||||||
|
"DEF-toe.R",
|
||||||
|
"DEF-upper_arm.L",
|
||||||
|
"DEF-upper_arm.R",
|
||||||
|
"root"
|
||||||
|
],
|
||||||
|
"surfaces": [
|
||||||
|
{
|
||||||
|
"mesh": "Object_2",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "body",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "skin",
|
||||||
|
"why": "material name says 'skin'",
|
||||||
|
"verts": 1496,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_2",
|
||||||
|
"surface": 1,
|
||||||
|
"material": "body_parts",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "skin",
|
||||||
|
"why": "material name says 'skin'",
|
||||||
|
"verts": 330,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_2",
|
||||||
|
"surface": 2,
|
||||||
|
"material": "hair",
|
||||||
|
"class": "hair",
|
||||||
|
"detail": "hair",
|
||||||
|
"why": "material name says 'hair'",
|
||||||
|
"verts": 500,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_2",
|
||||||
|
"surface": 3,
|
||||||
|
"material": "face",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "face",
|
||||||
|
"why": "material name says 'face'",
|
||||||
|
"verts": 461,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+1922
-1859
File diff suppressed because it is too large
Load Diff
+8226
-8016
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
|||||||
|
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", "")]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://diqctdcqixjqg
|
||||||
@@ -92,6 +92,9 @@ var _joint_helpers: Array = []
|
|||||||
## and leg colliders, written by tools/retarget.py. Empty for a model that was
|
## 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.
|
## rebound onto the library skeleton instead of keeping its own rig.
|
||||||
var _rig_info: Dictionary = {}
|
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 _spring_mod: SpringBones
|
||||||
var is_holding_weapon: bool = false
|
var is_holding_weapon: bool = false
|
||||||
## Which skin this is, so per-character hold tuning can be looked up. Set by
|
## 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()
|
_resolved_clips.clear()
|
||||||
_current_clip = ""
|
_current_clip = ""
|
||||||
_weapon_attachment = null
|
_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)
|
var scene := GLBLoader.load(path)
|
||||||
if not scene:
|
if not scene:
|
||||||
@@ -240,13 +248,16 @@ func load_model(path: String) -> void:
|
|||||||
print("SkinnedPlayerModel: '%s' — %d cloth/hair bones on springs"
|
print("SkinnedPlayerModel: '%s' — %d cloth/hair bones on springs"
|
||||||
% [path.get_file(), driven])
|
% [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)
|
LevelMaterials.apply_toon_recursive(scene)
|
||||||
# ...then the character-only pass: flat line-work + softer banding (see the
|
# ...then the character-only pass, which is where the surface table earns
|
||||||
# function — toon-lighting the model's own outline shell is what put a white
|
# its keep: it says which surfaces are hair, cloth, body or an accessory, so
|
||||||
# rim on every hair strand, and re-banding already-shaded textures read as
|
# each can take its own outline weight and banding instead of all of them
|
||||||
# gloss).
|
# taking numbers calibrated on skin. It also identifies the model's own
|
||||||
LevelMaterials.apply_character_look(scene)
|
# 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:
|
if animation_player:
|
||||||
_index_animations()
|
_index_animations()
|
||||||
_setup_anim_tree(scene)
|
_setup_anim_tree(scene)
|
||||||
@@ -375,6 +386,33 @@ func _load_rig_info(model_path: String) -> Dictionary:
|
|||||||
return parsed
|
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.
|
## Make sure every skinned MeshInstance3D is actually driven by the skeleton.
|
||||||
## A correctly-exported GLB binds automatically, but if one imports with a skin
|
## 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
|
## resource whose `skeleton` NodePath doesn't resolve, the mesh renders its bind
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ch7c3vv6t2gei
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bhafeud8lp38x
|
||||||
@@ -143,53 +143,117 @@ const CHARACTER_INK := Color(0.07, 0.06, 0.09)
|
|||||||
## only its backfaces show. Nothing else on a character is.
|
## only its backfaces show. Nothing else on a character is.
|
||||||
## NAMED eye cards say so — they are kept, not hidden, and need to reach
|
## NAMED eye cards say so — they are kept, not hidden, and need to reach
|
||||||
## the branch below whatever colour they are.
|
## the branch below whatever colour they are.
|
||||||
static func _is_line_work(src: BaseMaterial3D) -> bool:
|
##
|
||||||
if src.resource_name.to_lower().begins_with("eyes"):
|
## That heuristic now lives in `SkinSurfaces.guess`, where it is the FALLBACK
|
||||||
return true
|
## rather than the only answer — see below.
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
for mi in root.find_children("*", "MeshInstance3D", true, false):
|
||||||
if not mi.mesh:
|
if not mi.mesh:
|
||||||
continue
|
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()):
|
for s in range(mi.mesh.get_surface_count()):
|
||||||
var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
|
var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
|
||||||
if src == null:
|
if src == null:
|
||||||
continue
|
continue
|
||||||
if src.albedo_texture == null and _is_line_work(src):
|
var resolved: Array = SkinSurfaces.guess(src) if surfaces == null \
|
||||||
# Untextured AND dark or inside-out — the model's own line-work.
|
else surfaces.resolve(mi.name, s, src)
|
||||||
var name := src.resource_name.to_lower()
|
var surface_class: String = resolved[0]
|
||||||
var flat := StandardMaterial3D.new()
|
var detail: String = resolved[1]
|
||||||
flat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
||||||
flat.cull_mode = src.cull_mode
|
if detail == "outline_hull":
|
||||||
if not name.begins_with("eyes"):
|
# A duplicated ink shell. Its skin weights do not track the base
|
||||||
# The outline HULL: hide it (see above). Fully transparent
|
# mesh through a deep bend, so during a run it tears into spikes
|
||||||
# rather than deleted so the surface indices, and therefore
|
# and stretches sheets between the ankles — that is what made the
|
||||||
# the mesh's own skin bindings, stay exactly as imported.
|
# ankle cuffs look welded together. It is also redundant: the
|
||||||
flat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
# per-surface outline below and the screen-space ink_edge pass
|
||||||
flat.albedo_color = Color(0, 0, 0, 0)
|
# both already draw one. So it is hidden, fully transparent
|
||||||
else:
|
# rather than deleted, so the surface indices — and therefore the
|
||||||
# An eye card. "HL" marks the highlight (the glint in the
|
# mesh's own skin bindings — stay exactly as imported.
|
||||||
# pupil) — that one really is meant to be white.
|
var hidden := StandardMaterial3D.new()
|
||||||
flat.albedo_color = Color.WHITE if name.contains("hl") \
|
hidden.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
else CHARACTER_INK
|
hidden.cull_mode = src.cull_mode
|
||||||
mi.set_surface_override_material(s, flat)
|
hidden.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
hidden.albedo_color = Color(0, 0, 0, 0)
|
||||||
|
mi.set_surface_override_material(s, hidden)
|
||||||
continue
|
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
|
var toon: ShaderMaterial = mi.get_surface_override_material(s) as ShaderMaterial
|
||||||
if toon == null:
|
if toon == null:
|
||||||
continue
|
continue
|
||||||
toon.set_shader_parameter("band_softness", 0.16)
|
var look: Dictionary = CHARACTER_LOOK.get(
|
||||||
toon.set_shader_parameter("mid_tone", 0.92)
|
surface_class, CHARACTER_LOOK[SkinSurfaces.BODY])
|
||||||
# Shadow tint measured off the Sketchfab reference render: sampling
|
toon.set_shader_parameter("band_softness", look["band"])
|
||||||
# Taila's hair there, shadow/midtone lands near (0.63, 0.53, 0.70).
|
toon.set_shader_parameter("mid_tone", look["mid_tone"])
|
||||||
# Green drops hardest, and that is what keeps copper hair COPPER in
|
toon.set_shader_parameter("rim_strength", look["rim"])
|
||||||
# shadow — the level default (0.62, 0.65, 0.78) lifts green above
|
toon.set_shader_parameter("shadow_color", CHARACTER_SHADOW)
|
||||||
# red and washes ginger toward a dull brown.
|
# Eye irises and highlights are flat art on a curved ball; an ink
|
||||||
toon.set_shader_parameter("shadow_color", Color(0.64, 0.56, 0.72))
|
# 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
|
## Swap every mesh surface under `node` to toon shading and add an
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ from mathutils import Matrix, Quaternion, Vector
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
import gltf_fix
|
import gltf_fix
|
||||||
import rig_map
|
import rig_map
|
||||||
|
import surface_map
|
||||||
from rig_map import RigRoles, build_map, is_cosmetic, is_segment_of, tokens
|
from rig_map import RigRoles, build_map, is_cosmetic, is_segment_of, tokens
|
||||||
|
|
||||||
argv = sys.argv
|
argv = sys.argv
|
||||||
@@ -1039,8 +1040,22 @@ def describe_rig(arm, roles, mapping, meshes):
|
|||||||
for (digit, side), bones in roles.fingers.items():
|
for (digit, side), bones in roles.fingers.items():
|
||||||
finger_out["%s.%s" % (digit, side)] = bones
|
finger_out["%s.%s" % (digit, side)] = bones
|
||||||
|
|
||||||
|
# What each surface IS — body, cloth, hair or accessory. Derived from the
|
||||||
|
# chains resolved just above, so the surface table and the cloth solver can
|
||||||
|
# never disagree about which bones are a skirt. See tools/surface_map.py.
|
||||||
|
chain_class = {}
|
||||||
|
for chain in chains:
|
||||||
|
for bone in chain["bones"]:
|
||||||
|
chain_class[bone] = chain["class"]
|
||||||
|
role_bone_names = [n for n in roles_out.values() if isinstance(n, str) and n]
|
||||||
|
for value in roles_out.values():
|
||||||
|
if isinstance(value, list):
|
||||||
|
role_bone_names.extend(value)
|
||||||
|
|
||||||
return {"roles": roles_out, "fingers": finger_out,
|
return {"roles": roles_out, "fingers": finger_out,
|
||||||
"chains": chains, "twist": twist,
|
"chains": chains, "twist": twist,
|
||||||
|
"surfaces": surface_map.describe_surfaces(
|
||||||
|
meshes, chain_class, driven, role_bone_names),
|
||||||
"colliders": _leg_colliders(
|
"colliders": _leg_colliders(
|
||||||
arm, roles, _dominant_vertices(meshes, arm, min_weight=0.6),
|
arm, roles, _dominant_vertices(meshes, arm, min_weight=0.6),
|
||||||
_body_points(meshes)),
|
_body_points(meshes)),
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Say what every surface of a character IS: body, cloth, hair or accessory.
|
||||||
|
|
||||||
|
The pipeline already refuses to join meshes, so a character arrives at runtime
|
||||||
|
as the eighteen separate pieces the artist authored. But nothing recorded what
|
||||||
|
those pieces WERE, so every system downstream had to re-guess from scratch:
|
||||||
|
`LevelMaterials.apply_character_look` decides "is this the model's own ink
|
||||||
|
line-work?" by asking whether the material is untextured and nearly black, and
|
||||||
|
that heuristic has already cost one debugging cycle — it rendered Quaternius'
|
||||||
|
flat-coloured mannequin as a black silhouette, because a yellow body and a
|
||||||
|
lilac joint are untextured too.
|
||||||
|
|
||||||
|
Guessing per-frame from a material is the wrong place to ask the question. The
|
||||||
|
answer is knowable ONCE, at build time, where the mesh, the weights and the
|
||||||
|
skeleton are all in hand:
|
||||||
|
|
||||||
|
* the material NAME is usually explicit, and on VRoid exports it is formal —
|
||||||
|
`N00_000_00_Body_00_SKIN_Instance` carries its own class infix, and every
|
||||||
|
VRoid character in the game uses SKIN / FACE / EYE / HAIR / CLOTH;
|
||||||
|
* the WEIGHTS are decisive when the name says nothing: a surface whose
|
||||||
|
vertices are pulled by the skirt chain is a skirt, whatever it is called;
|
||||||
|
* the MATERIAL FLAGS catch the model's own line-work, which is the one class
|
||||||
|
that is not really a surface of the character at all.
|
||||||
|
|
||||||
|
So the classification is written into `<name>.rig.json` beside the roles and the
|
||||||
|
cloth chains, and the runtime reads it instead of re-deriving it. That is what
|
||||||
|
lets per-class art direction exist at all — a different outline width on hair
|
||||||
|
than on the body, cloth that takes the garment shading, accessories that keep
|
||||||
|
their crisp banding — and it gives the rig lab something to show and to let an
|
||||||
|
artist correct.
|
||||||
|
|
||||||
|
Two ways in:
|
||||||
|
|
||||||
|
# As part of an import — retarget.py calls describe_surfaces() directly.
|
||||||
|
|
||||||
|
# Backfilling a character that is already exported, without re-importing it
|
||||||
|
blender --background --python tools/surface_map.py -- \
|
||||||
|
assets/characters/skins/taila.glb
|
||||||
|
|
||||||
|
The backfill route loads the SHIPPED glb, so the names it records are by
|
||||||
|
construction the names Godot will see. Nothing else is touched: the surface
|
||||||
|
table is merged into the existing sidecar and every other key is left alone.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import rig_map
|
||||||
|
|
||||||
|
|
||||||
|
# ── What a surface can be ────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Four classes, because that is what the rest of the game wants to reason about,
|
||||||
|
# plus one that is not a surface of the character at all.
|
||||||
|
#
|
||||||
|
# body skin, face, eyes — anything that is the character themselves
|
||||||
|
# cloth garments: tops, skirts, shoes, sleeves, capes
|
||||||
|
# hair hair, including the parts of it that are welded to the head
|
||||||
|
# accessory rigid worn props: hats, glasses, bags, jewellery, horns
|
||||||
|
# linework the model's own outline shell — drawn, not worn (see below)
|
||||||
|
#
|
||||||
|
# `detail` narrows it without forcing a new class on anyone who does not care.
|
||||||
|
BODY, CLOTH, HAIR, ACCESSORY, LINEWORK = (
|
||||||
|
"body", "cloth", "hair", "accessory", "linework")
|
||||||
|
|
||||||
|
# Material-name tokens, most specific class first. Order is the whole design
|
||||||
|
# here: `N00_000_00_EyeIris_00_EYE_Instance` carries both "eye" and "iris" and
|
||||||
|
# must not be caught by a later, broader rule, while `FaceEyeline` tokenises to
|
||||||
|
# "face" + "eyeline" and stays a face.
|
||||||
|
NAME_RULES = [
|
||||||
|
({"eye", "eyes", "iris", "pupil", "sclera", "cornea"}, BODY, "eyes"),
|
||||||
|
({"hair", "bang", "bangs", "fringe", "ponytail", "braid", "sidelock"},
|
||||||
|
HAIR, "hair"),
|
||||||
|
({"brow", "eyebrow", "eyeline", "eyelash", "lash", "mouth", "lip", "teeth",
|
||||||
|
"tooth", "tongue", "nose", "face", "facial"}, BODY, "face"),
|
||||||
|
({"cloth", "clothes", "clothing", "garment", "outfit", "costume", "uniform",
|
||||||
|
"top", "tops", "bottom", "bottoms", "skirt", "dress", "onepiece", "shirt",
|
||||||
|
"blouse", "jacket", "coat", "hoodie", "pants", "trousers", "shorts",
|
||||||
|
"socks", "stocking", "stockings", "shoe", "shoes", "boot", "boots",
|
||||||
|
"sleeve", "apron", "cape", "cloak", "scarf", "tie", "necktie", "collar",
|
||||||
|
"ribbon", "frill", "underwear", "bra", "panty", "swimsuit"},
|
||||||
|
CLOTH, "garment"),
|
||||||
|
({"accessory", "acc", "hat", "cap", "helmet", "glasses", "goggle",
|
||||||
|
"goggles", "mask", "bag", "backpack", "belt", "buckle", "earring",
|
||||||
|
"necklace", "choker", "bracelet", "pin", "clip", "badge", "horn", "wing",
|
||||||
|
"weapon", "prop", "item", "jewel", "gem", "strap", "headphone",
|
||||||
|
"headphones", "antenna", "halo"}, ACCESSORY, "accessory"),
|
||||||
|
({"body", "skin", "torso", "chest", "arm", "arms", "leg", "legs", "hand",
|
||||||
|
"hands", "foot", "feet", "head"}, BODY, "skin"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Spring-chain classes, grouped by what the SURFACE they pull should be called.
|
||||||
|
# `spring_class` already tells the solver how a chain should behave; this says
|
||||||
|
# what the geometry hanging off it is. Bust bones move like cloth and belong to
|
||||||
|
# the body, which is exactly why the two questions are asked separately.
|
||||||
|
CHAIN_TO_SURFACE = {
|
||||||
|
"hair": HAIR,
|
||||||
|
"skirt": CLOTH, "cloth": CLOTH, "coat": CLOTH, "cape": CLOTH,
|
||||||
|
"scarf": CLOTH, "sleeve": CLOTH, "ribbon": CLOTH,
|
||||||
|
"breast": BODY, "bust": BODY,
|
||||||
|
"tail": ACCESSORY, "feather": ACCESSORY, "antenna": ACCESSORY,
|
||||||
|
"wing": ACCESSORY, "strap": ACCESSORY,
|
||||||
|
}
|
||||||
|
|
||||||
|
# How much of a surface has to hang off one kind of chain before that decides
|
||||||
|
# it. Deliberately low: VRoid welds the whole cap of the hair to the head bone
|
||||||
|
# and springs only the strands, so kiyoko's hair mesh is 85% head — a majority
|
||||||
|
# rule would call it skin. A surface with a sixth of itself on skirt bones is a
|
||||||
|
# skirt; nothing else on a character has any weight on those bones at all.
|
||||||
|
CHAIN_SHARE = 0.05
|
||||||
|
|
||||||
|
# Darker than this, with no texture, and it is ink rather than a colour. Kept
|
||||||
|
# identical to LevelMaterials._is_line_work, which this replaces — the two must
|
||||||
|
# agree, or a model with no surface table would render differently from one
|
||||||
|
# with it.
|
||||||
|
INK_LEVEL = 0.18
|
||||||
|
|
||||||
|
|
||||||
|
def datablock_name(name):
|
||||||
|
"""A Blender datablock name as the glTF it came from spelled it.
|
||||||
|
|
||||||
|
Blender uniquifies a name it has already seen by appending `.001`, so a
|
||||||
|
second import of the same character in one session gives `ClothA.001`.
|
||||||
|
That, and only that, is stripped.
|
||||||
|
|
||||||
|
Explicitly NOT `rig_map.strip_gltf_suffix`, which removes a trailing
|
||||||
|
`_<digits>` — right for bones, whose names the glTF importer really does
|
||||||
|
suffix with a node index, and catastrophic here: every mesh in this game is
|
||||||
|
called something like `Object_15`, and stripping would collapse all
|
||||||
|
eighteen of Taila's to `Object`.
|
||||||
|
"""
|
||||||
|
return re.sub(r"\.\d{3}$", "", name)
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens(name):
|
||||||
|
"""Material name -> lowercase word tokens, splitting camelCase too.
|
||||||
|
|
||||||
|
`N00_001_03_Bottoms_01_CLOTH_Instance` -> bottoms, cloth, instance (plus
|
||||||
|
the numbers), and `EyesFullBlack` -> eyes, full, black. Without the
|
||||||
|
camelCase split the VRoid names still work and the hand-authored ones do
|
||||||
|
not, and Taila's are all hand-authored.
|
||||||
|
"""
|
||||||
|
spaced = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", name)
|
||||||
|
return [t for t in re.split(r"[^A-Za-z0-9]+", spaced.lower()) if t]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_by_name(material_name):
|
||||||
|
"""(class, detail) from the material name alone, or (None, None)."""
|
||||||
|
toks = set(_tokens(material_name))
|
||||||
|
for stems, cls, detail in NAME_RULES:
|
||||||
|
hit = toks & stems
|
||||||
|
if hit:
|
||||||
|
return cls, detail
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def classify_linework(material_name, textured, albedo, front_culled):
|
||||||
|
"""Is this untextured surface part of the model's own DRAWING?
|
||||||
|
|
||||||
|
Lifted wholesale from the runtime rule it replaces, including its history:
|
||||||
|
"no albedo texture" alone is not the question, and answering it that way
|
||||||
|
hid the mannequin's yellow body and lilac joints as though they were an
|
||||||
|
outline shell. What actually distinguishes line-work is being BLACK, being
|
||||||
|
drawn inside-out, or saying outright that it is an eye card.
|
||||||
|
|
||||||
|
Returns a detail string, or None if this is an ordinary flat-coloured
|
||||||
|
surface that should shade normally.
|
||||||
|
"""
|
||||||
|
if textured:
|
||||||
|
return None
|
||||||
|
lower = material_name.lower()
|
||||||
|
if lower.startswith("eyes"):
|
||||||
|
# A lash or an iris card — a real facial feature, kept. "HL" is the
|
||||||
|
# glint in the pupil, and that one really is meant to be white.
|
||||||
|
return "eyes_highlight" if "hl" in lower else "eyes_ink"
|
||||||
|
if front_culled:
|
||||||
|
return "outline_hull"
|
||||||
|
if max(albedo[0], albedo[1], albedo[2]) < INK_LEVEL:
|
||||||
|
return "outline_hull"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def classify(material_name, textured, albedo, front_culled, chain_shares,
|
||||||
|
cosmetic_share):
|
||||||
|
"""Everything known about one surface -> (class, detail, evidence).
|
||||||
|
|
||||||
|
Name first where it is explicit, because it is the artist's own statement of
|
||||||
|
intent and it survives every transform the pipeline applies. Weights second,
|
||||||
|
because they are the ground truth for anything the name does not say. The
|
||||||
|
fallback is `body`, which is the safe answer: it gets ordinary character
|
||||||
|
shading, which is what an unrecognised surface should get.
|
||||||
|
"""
|
||||||
|
ink = classify_linework(material_name, textured, albedo, front_culled)
|
||||||
|
if ink == "outline_hull":
|
||||||
|
return LINEWORK, ink, "material is untextured ink or inside-out"
|
||||||
|
if ink is not None:
|
||||||
|
return BODY, ink, "material names itself an eye card"
|
||||||
|
|
||||||
|
cls, detail = classify_by_name(material_name)
|
||||||
|
if cls is not None:
|
||||||
|
return cls, detail, "material name says '%s'" % detail
|
||||||
|
|
||||||
|
# Nothing in the name. Ask the skeleton what is pulling this geometry.
|
||||||
|
best = max(chain_shares.items(), key=lambda kv: kv[1], default=(None, 0.0))
|
||||||
|
if best[0] is not None and best[1] >= CHAIN_SHARE:
|
||||||
|
surface_cls = CHAIN_TO_SURFACE.get(best[0], CLOTH)
|
||||||
|
return (surface_cls, best[0],
|
||||||
|
"%.0f%% of it hangs off %s bones" % (best[1] * 100.0, best[0]))
|
||||||
|
if cosmetic_share >= 0.5:
|
||||||
|
return ACCESSORY, "accessory", (
|
||||||
|
"%.0f%% of it is on bones no clip drives" % (cosmetic_share * 100.0))
|
||||||
|
return BODY, "skin", "no name or weight evidence — treated as body"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Measuring one Blender scene ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _dominant_bone_per_vertex(mesh_obj):
|
||||||
|
"""vertex index -> the vertex group holding its largest weight."""
|
||||||
|
gname = {g.index: g.name for g in mesh_obj.vertex_groups}
|
||||||
|
out = {}
|
||||||
|
for v in mesh_obj.data.vertices:
|
||||||
|
best = None
|
||||||
|
for g in v.groups:
|
||||||
|
if best is None or g.weight > best.weight:
|
||||||
|
best = g
|
||||||
|
if best is not None and best.weight > 0.25:
|
||||||
|
out[v.index] = gname.get(best.group, "")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _vertices_per_slot(mesh_obj):
|
||||||
|
"""material slot index -> the set of vertices drawn with it.
|
||||||
|
|
||||||
|
A Blender object with four material slots exports as one glTF mesh with
|
||||||
|
four primitives, which Godot loads as one MeshInstance3D with four
|
||||||
|
surfaces — Miku is exactly this, one object carrying body, body_parts, hair
|
||||||
|
and face. Partitioning by polygon is the only way to tell those apart;
|
||||||
|
reading the object's weights as a whole says all four are the same thing.
|
||||||
|
"""
|
||||||
|
per = {}
|
||||||
|
for poly in mesh_obj.data.polygons:
|
||||||
|
per.setdefault(poly.material_index, set()).update(poly.vertices)
|
||||||
|
return per
|
||||||
|
|
||||||
|
|
||||||
|
def describe_surfaces(meshes, chain_class, driven, role_bones):
|
||||||
|
"""The surface table for one character.
|
||||||
|
|
||||||
|
`chain_class` bone name -> spring class ("hair", "skirt", ...)
|
||||||
|
`driven` bones the animation drives — the body proper
|
||||||
|
`role_bones` resolved anatomy bone names, also body
|
||||||
|
|
||||||
|
Keyed on the MATERIAL name rather than the mesh node name. Node names do not
|
||||||
|
survive as anything meaningful — every character in the game arrives with
|
||||||
|
its meshes called `Object_7` through `Object_32` — while material names come
|
||||||
|
through the glTF round trip intact and are what the artist actually named.
|
||||||
|
The mesh name and surface index are recorded alongside as hints, so a model
|
||||||
|
that reuses one material for two different things can still be told apart.
|
||||||
|
"""
|
||||||
|
body_bones = set(driven) | set(role_bones)
|
||||||
|
out = []
|
||||||
|
for mesh_obj in meshes:
|
||||||
|
slots = _vertices_per_slot(mesh_obj)
|
||||||
|
dominant = _dominant_bone_per_vertex(mesh_obj)
|
||||||
|
mesh_name = datablock_name(mesh_obj.name)
|
||||||
|
# Surface index, not slot index. The exporter emits one primitive per
|
||||||
|
# material slot that any polygon actually uses, in slot order, so an
|
||||||
|
# empty slot in the middle shifts every surface after it down by one.
|
||||||
|
for surface_index, slot_index in enumerate(sorted(slots)):
|
||||||
|
verts = slots[slot_index]
|
||||||
|
mat = (mesh_obj.material_slots[slot_index].material
|
||||||
|
if slot_index < len(mesh_obj.material_slots) else None)
|
||||||
|
mat_name = mat.name if mat else ""
|
||||||
|
|
||||||
|
# Weight evidence, as a share of the vertices that belong clearly
|
||||||
|
# to any one bone — not of all vertices, so a surface that is half
|
||||||
|
# unweighted is not written off as having no evidence.
|
||||||
|
counted = 0
|
||||||
|
shares = {}
|
||||||
|
cosmetic = 0
|
||||||
|
for vi in verts:
|
||||||
|
bone = dominant.get(vi)
|
||||||
|
if not bone:
|
||||||
|
continue
|
||||||
|
counted += 1
|
||||||
|
cls = chain_class.get(bone)
|
||||||
|
if cls:
|
||||||
|
shares[cls] = shares.get(cls, 0) + 1
|
||||||
|
elif bone not in body_bones and rig_map.is_cosmetic(bone):
|
||||||
|
cosmetic += 1
|
||||||
|
denom = float(max(1, counted))
|
||||||
|
shares = {k: v / denom for k, v in shares.items()}
|
||||||
|
|
||||||
|
textured, albedo, front_culled = _material_look(mat)
|
||||||
|
cls, detail, why = classify(
|
||||||
|
mat_name, textured, albedo, front_culled, shares,
|
||||||
|
cosmetic / denom)
|
||||||
|
out.append({
|
||||||
|
"mesh": mesh_name,
|
||||||
|
"surface": surface_index,
|
||||||
|
"material": datablock_name(mat_name),
|
||||||
|
"class": cls,
|
||||||
|
"detail": detail,
|
||||||
|
"why": why,
|
||||||
|
"verts": len(verts),
|
||||||
|
"textured": textured,
|
||||||
|
# Top chain classes pulling this surface, for the rig lab to
|
||||||
|
# show when someone disagrees with the answer above.
|
||||||
|
"chain_share": {k: round(v, 3) for k, v in
|
||||||
|
sorted(shares.items(), key=lambda kv: -kv[1])},
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _material_look(mat):
|
||||||
|
"""(textured, (r, g, b), front_culled) for a Blender material.
|
||||||
|
|
||||||
|
Both routes to the same two facts: a glTF imported by Blender's own
|
||||||
|
importer keeps a Principled BSDF, and one written by gltf_fix may be a
|
||||||
|
plain emission or have no node tree at all.
|
||||||
|
|
||||||
|
`front_culled` is always False here, and honestly so: glTF has no way to say
|
||||||
|
"draw only the backfaces" — it has a single `doubleSided` flag — so an
|
||||||
|
inverted-hull outline cannot survive the round trip as a cull mode, and
|
||||||
|
Blender has no property that would carry it. Every character in the game
|
||||||
|
imports as CULL_BACK or CULL_DISABLED, never CULL_FRONT. The runtime keeps
|
||||||
|
the cull-mode test as a backstop for a model that does arrive that way, so
|
||||||
|
nothing is lost by not being able to see it from here.
|
||||||
|
"""
|
||||||
|
if mat is None:
|
||||||
|
return False, (1.0, 1.0, 1.0), False
|
||||||
|
front_culled = False
|
||||||
|
albedo = tuple(mat.diffuse_color[:3])
|
||||||
|
textured = False
|
||||||
|
if mat.use_nodes and mat.node_tree:
|
||||||
|
for node in mat.node_tree.nodes:
|
||||||
|
if node.type == "TEX_IMAGE" and node.image is not None:
|
||||||
|
textured = True
|
||||||
|
if node.type == "BSDF_PRINCIPLED":
|
||||||
|
base = node.inputs.get("Base Color")
|
||||||
|
if base is not None and not base.is_linked:
|
||||||
|
albedo = tuple(base.default_value[:3])
|
||||||
|
elif node.type == "EMISSION":
|
||||||
|
col = node.inputs.get("Color")
|
||||||
|
if col is not None and not col.is_linked:
|
||||||
|
albedo = tuple(col.default_value[:3])
|
||||||
|
return textured, albedo, front_culled
|
||||||
|
|
||||||
|
|
||||||
|
# ── Backfilling an already-exported character ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _chain_class_from_sidecar(sidecar):
|
||||||
|
"""bone -> spring class, taken from the chains the pipeline already found.
|
||||||
|
|
||||||
|
Backfilling deliberately reuses the sidecar's own answer rather than
|
||||||
|
re-deriving it from the bone names: if the two ever disagreed, the surface
|
||||||
|
table would describe a rig that the solver is not simulating.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
for chain in sidecar.get("chains", []):
|
||||||
|
cls = chain.get("class", "cloth")
|
||||||
|
for bone in chain.get("bones", []):
|
||||||
|
out[bone] = cls
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _role_bone_names(sidecar):
|
||||||
|
names = []
|
||||||
|
for value in sidecar.get("roles", {}).values():
|
||||||
|
if isinstance(value, list):
|
||||||
|
names.extend(value)
|
||||||
|
elif value:
|
||||||
|
names.append(value)
|
||||||
|
for bones in sidecar.get("fingers", {}).values():
|
||||||
|
names.extend(bones)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _is_character_mesh(obj):
|
||||||
|
"""Is this mesh part of the character, or is it riding along in the file?
|
||||||
|
|
||||||
|
Every skin shipped so far carries a stray 42-vertex `Icosphere`: no parent,
|
||||||
|
no vertex groups, no material slot. It comes in from the animation library,
|
||||||
|
and `add_animations.py` and `rig_and_animate.py` both already skip it — by
|
||||||
|
checking `o.name != 'Icosphere'`, which is precisely the kind of hardcoded
|
||||||
|
spelling this project has a rule against.
|
||||||
|
|
||||||
|
Asked by construction instead: a surface of the character is skinned to the
|
||||||
|
character. Anything with no vertex groups is not being deformed by the
|
||||||
|
skeleton, so whatever it is, it is not a body, a garment, hair or a worn
|
||||||
|
accessory — and it has no business in a table that says which of those a
|
||||||
|
surface is.
|
||||||
|
"""
|
||||||
|
return len(obj.vertex_groups) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def backfill(glb_path):
|
||||||
|
"""Load a shipped skin, classify it, merge the table into its sidecar."""
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
sidecar_path = os.path.splitext(glb_path)[0] + ".rig.json"
|
||||||
|
if not os.path.exists(sidecar_path):
|
||||||
|
raise SystemExit("no sidecar beside %s — run the pipeline first"
|
||||||
|
% glb_path)
|
||||||
|
with open(sidecar_path, "r", encoding="utf-8") as f:
|
||||||
|
sidecar = json.load(f)
|
||||||
|
|
||||||
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
bpy.ops.import_scene.gltf(filepath=glb_path)
|
||||||
|
meshes = [o for o in bpy.context.scene.objects
|
||||||
|
if o.type == "MESH" and _is_character_mesh(o)]
|
||||||
|
if not meshes:
|
||||||
|
raise SystemExit("no skinned meshes in %s" % glb_path)
|
||||||
|
|
||||||
|
surfaces = describe_surfaces(
|
||||||
|
meshes,
|
||||||
|
_chain_class_from_sidecar(sidecar),
|
||||||
|
sidecar.get("driven_bones", []),
|
||||||
|
_role_bone_names(sidecar))
|
||||||
|
sidecar["surfaces"] = surfaces
|
||||||
|
with open(sidecar_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(sidecar, f, indent=1)
|
||||||
|
|
||||||
|
tally = {}
|
||||||
|
for s in surfaces:
|
||||||
|
tally[s["class"]] = tally.get(s["class"], 0) + 1
|
||||||
|
print("%s: %d surfaces — %s" % (
|
||||||
|
os.path.basename(glb_path), len(surfaces),
|
||||||
|
", ".join("%s x%d" % kv for kv in sorted(tally.items()))))
|
||||||
|
for s in surfaces:
|
||||||
|
print(" %-14s s%d %-30s %-10s %-14s %s" % (
|
||||||
|
s["mesh"][:14], s["surface"], s["material"][:30], s["class"],
|
||||||
|
s["detail"], s["why"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
argv = sys.argv
|
||||||
|
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||||
|
if not argv:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
for path in argv:
|
||||||
|
backfill(path)
|
||||||
Reference in New Issue
Block a user