Feat/outline thickness and tp weapon hold #22
+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": {
|
||||
"hips": "DEF-hips",
|
||||
"head": "DEF-head",
|
||||
"neck": "DEF-neck",
|
||||
"spine": [
|
||||
"DEF-spine.001",
|
||||
"DEF-spine.002",
|
||||
"DEF-spine.003",
|
||||
"DEF-neck",
|
||||
"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
|
||||
}
|
||||
"roles": {
|
||||
"hips": "DEF-hips",
|
||||
"head": "DEF-head",
|
||||
"neck": "DEF-neck",
|
||||
"spine": [
|
||||
"DEF-spine.001",
|
||||
"DEF-spine.002",
|
||||
"DEF-spine.003",
|
||||
"DEF-neck",
|
||||
"DEF-head"
|
||||
],
|
||||
"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"
|
||||
"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,
|
||||
"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": {
|
||||
"hips": "DEF-hips",
|
||||
"head": "DEF-head",
|
||||
"neck": "DEF-neck",
|
||||
"spine": [
|
||||
"DEF-spine.001",
|
||||
"DEF-spine.002",
|
||||
"DEF-spine.003",
|
||||
"DEF-neck",
|
||||
"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
|
||||
}
|
||||
"roles": {
|
||||
"hips": "DEF-hips",
|
||||
"head": "DEF-head",
|
||||
"neck": "DEF-neck",
|
||||
"spine": [
|
||||
"DEF-spine.001",
|
||||
"DEF-spine.002",
|
||||
"DEF-spine.003",
|
||||
"DEF-neck",
|
||||
"DEF-head"
|
||||
],
|
||||
"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"
|
||||
"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,
|
||||
"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
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
## NAMED eye cards say so — they are kept, not hidden, and need to reach
|
||||
## the branch below whatever colour they are.
|
||||
static func _is_line_work(src: BaseMaterial3D) -> bool:
|
||||
if src.resource_name.to_lower().begins_with("eyes"):
|
||||
return true
|
||||
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
|
||||
##
|
||||
## That heuristic now lives in `SkinSurfaces.guess`, where it is the FALLBACK
|
||||
## rather than the only answer — see below.
|
||||
|
||||
|
||||
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):
|
||||
if not mi.mesh:
|
||||
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()):
|
||||
var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
|
||||
if src == null:
|
||||
continue
|
||||
if src.albedo_texture == null and _is_line_work(src):
|
||||
# Untextured AND dark or inside-out — the model's own line-work.
|
||||
var name := src.resource_name.to_lower()
|
||||
var flat := StandardMaterial3D.new()
|
||||
flat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
flat.cull_mode = src.cull_mode
|
||||
if not name.begins_with("eyes"):
|
||||
# The outline HULL: hide it (see above). Fully transparent
|
||||
# rather than deleted so the surface indices, and therefore
|
||||
# the mesh's own skin bindings, stay exactly as imported.
|
||||
flat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
flat.albedo_color = Color(0, 0, 0, 0)
|
||||
else:
|
||||
# An eye card. "HL" marks the highlight (the glint in the
|
||||
# pupil) — that one really is meant to be white.
|
||||
flat.albedo_color = Color.WHITE if name.contains("hl") \
|
||||
else CHARACTER_INK
|
||||
mi.set_surface_override_material(s, flat)
|
||||
var resolved: Array = SkinSurfaces.guess(src) if surfaces == null \
|
||||
else surfaces.resolve(mi.name, s, src)
|
||||
var surface_class: String = resolved[0]
|
||||
var detail: String = resolved[1]
|
||||
|
||||
if detail == "outline_hull":
|
||||
# A duplicated ink shell. Its skin weights do not track the base
|
||||
# mesh through a deep bend, so during a run it tears into spikes
|
||||
# and stretches sheets between the ankles — that is what made the
|
||||
# ankle cuffs look welded together. It is also redundant: the
|
||||
# per-surface outline below and the screen-space ink_edge pass
|
||||
# both already draw one. So it is hidden, fully transparent
|
||||
# rather than deleted, so the surface indices — and therefore the
|
||||
# mesh's own skin bindings — stay exactly as imported.
|
||||
var hidden := StandardMaterial3D.new()
|
||||
hidden.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
hidden.cull_mode = src.cull_mode
|
||||
hidden.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
hidden.albedo_color = Color(0, 0, 0, 0)
|
||||
mi.set_surface_override_material(s, hidden)
|
||||
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
|
||||
if toon == null:
|
||||
continue
|
||||
toon.set_shader_parameter("band_softness", 0.16)
|
||||
toon.set_shader_parameter("mid_tone", 0.92)
|
||||
# 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.
|
||||
toon.set_shader_parameter("shadow_color", Color(0.64, 0.56, 0.72))
|
||||
var look: Dictionary = CHARACTER_LOOK.get(
|
||||
surface_class, CHARACTER_LOOK[SkinSurfaces.BODY])
|
||||
toon.set_shader_parameter("band_softness", look["band"])
|
||||
toon.set_shader_parameter("mid_tone", look["mid_tone"])
|
||||
toon.set_shader_parameter("rim_strength", look["rim"])
|
||||
toon.set_shader_parameter("shadow_color", CHARACTER_SHADOW)
|
||||
# Eye irises and highlights are flat art on a curved ball; an ink
|
||||
# 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
|
||||
|
||||
@@ -55,6 +55,7 @@ from mathutils import Matrix, Quaternion, Vector
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import gltf_fix
|
||||
import rig_map
|
||||
import surface_map
|
||||
from rig_map import RigRoles, build_map, is_cosmetic, is_segment_of, tokens
|
||||
|
||||
argv = sys.argv
|
||||
@@ -1039,8 +1040,22 @@ def describe_rig(arm, roles, mapping, meshes):
|
||||
for (digit, side), bones in roles.fingers.items():
|
||||
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,
|
||||
"chains": chains, "twist": twist,
|
||||
"surfaces": surface_map.describe_surfaces(
|
||||
meshes, chain_class, driven, role_bone_names),
|
||||
"colliders": _leg_colliders(
|
||||
arm, roles, _dominant_vertices(meshes, arm, min_weight=0.6),
|
||||
_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