Feat/outline thickness and tp weapon hold #22
@@ -2494,5 +2494,180 @@
|
|||||||
"Right toe",
|
"Right toe",
|
||||||
"Right wrist",
|
"Right wrist",
|
||||||
"Spine"
|
"Spine"
|
||||||
|
],
|
||||||
|
"surfaces": [
|
||||||
|
{
|
||||||
|
"mesh": "Object_103",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_Hair_00_HAIR_Instance",
|
||||||
|
"class": "hair",
|
||||||
|
"detail": "hair",
|
||||||
|
"why": "material name says 'hair'",
|
||||||
|
"verts": 19753,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {
|
||||||
|
"hair": 0.619
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_104",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_FaceMouth_00_FACE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "face",
|
||||||
|
"why": "material name says 'face'",
|
||||||
|
"verts": 1053,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_105",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_EyeIris_00_EYE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "eyes",
|
||||||
|
"why": "material name says 'eyes'",
|
||||||
|
"verts": 212,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_106",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_EyeHighlight_00_EYE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "eyes",
|
||||||
|
"why": "material name says 'eyes'",
|
||||||
|
"verts": 26,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_107",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_Face_00_SKIN_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "face",
|
||||||
|
"why": "material name says 'face'",
|
||||||
|
"verts": 2266,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_108",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_EyeWhite_00_EYE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "eyes",
|
||||||
|
"why": "material name says 'eyes'",
|
||||||
|
"verts": 290,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_109",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_FaceBrow_00_FACE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "face",
|
||||||
|
"why": "material name says 'face'",
|
||||||
|
"verts": 50,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_110",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_FaceEyelash_00_FACE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "face",
|
||||||
|
"why": "material name says 'face'",
|
||||||
|
"verts": 18,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_111",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_FaceEyeline_00_FACE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "face",
|
||||||
|
"why": "material name says 'face'",
|
||||||
|
"verts": 118,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_112",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_Body_00_SKIN_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "skin",
|
||||||
|
"why": "material name says 'skin'",
|
||||||
|
"verts": 5870,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {
|
||||||
|
"breast": 0.038
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_113",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_001_03_Bottoms_01_CLOTH_Instance",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 742,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_114",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_004_01_Shoes_01_CLOTH_Instance",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 600,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_115",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_HairBack_00_HAIR_Instance",
|
||||||
|
"class": "hair",
|
||||||
|
"detail": "hair",
|
||||||
|
"why": "material name says 'hair'",
|
||||||
|
"verts": 760,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_116",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_002_02_Tops_01_CLOTH_Instance",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 512,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {
|
||||||
|
"breast": 0.117
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_117",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_010_01_Onepiece_00_CLOTH_Instance",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 52,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {
|
||||||
|
"breast": 0.115
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3119,5 +3119,156 @@
|
|||||||
"J_Bip_R_UpperArm",
|
"J_Bip_R_UpperArm",
|
||||||
"J_Bip_R_UpperLeg",
|
"J_Bip_R_UpperLeg",
|
||||||
"Root"
|
"Root"
|
||||||
|
],
|
||||||
|
"surfaces": [
|
||||||
|
{
|
||||||
|
"mesh": "Object_10",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_001_03_Bottoms_01_CLOTH_Instance",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 742,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_11",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_001_01_Shoes_01_CLOTH_Instance",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 410,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_13",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_FaceMouth_00_FACE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "face",
|
||||||
|
"why": "material name says 'face'",
|
||||||
|
"verts": 1073,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_14",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_EyeIris_00_EYE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "eyes",
|
||||||
|
"why": "material name says 'eyes'",
|
||||||
|
"verts": 268,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_15",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_EyeHighlight_00_EYE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "eyes",
|
||||||
|
"why": "material name says 'eyes'",
|
||||||
|
"verts": 200,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_16",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_Face_00_SKIN_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "face",
|
||||||
|
"why": "material name says 'face'",
|
||||||
|
"verts": 2266,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_17",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_EyeWhite_00_EYE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "eyes",
|
||||||
|
"why": "material name says 'eyes'",
|
||||||
|
"verts": 290,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_18",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_FaceBrow_00_FACE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "face",
|
||||||
|
"why": "material name says 'face'",
|
||||||
|
"verts": 52,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_19",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_FaceEyeline_00_FACE_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "face",
|
||||||
|
"why": "material name says 'face'",
|
||||||
|
"verts": 168,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_21",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_Hair_00_HAIR_Instance",
|
||||||
|
"class": "hair",
|
||||||
|
"detail": "hair",
|
||||||
|
"why": "material name says 'hair'",
|
||||||
|
"verts": 22432,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {
|
||||||
|
"hair": 0.281
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_7",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_Body_00_SKIN_Instance",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "skin",
|
||||||
|
"why": "material name says 'skin'",
|
||||||
|
"verts": 5103,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {
|
||||||
|
"bust": 0.001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_8",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_000_00_HairBack_00_HAIR_Instance",
|
||||||
|
"class": "hair",
|
||||||
|
"detail": "hair",
|
||||||
|
"why": "material name says 'hair'",
|
||||||
|
"verts": 700,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_9",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "N00_007_01_Tops_01_CLOTH_Instance",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 814,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {
|
||||||
|
"bust": 0.061
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -179,5 +179,29 @@
|
|||||||
"DEF-upper_arm.L",
|
"DEF-upper_arm.L",
|
||||||
"DEF-upper_arm.R",
|
"DEF-upper_arm.R",
|
||||||
"root"
|
"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": {}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -179,5 +179,51 @@
|
|||||||
"DEF-upper_arm.L",
|
"DEF-upper_arm.L",
|
||||||
"DEF-upper_arm.R",
|
"DEF-upper_arm.R",
|
||||||
"root"
|
"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": {}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1860,5 +1860,68 @@
|
|||||||
"Toes4_R",
|
"Toes4_R",
|
||||||
"Unused_Noname_004",
|
"Unused_Noname_004",
|
||||||
"Unused_Noname_010"
|
"Unused_Noname_010"
|
||||||
|
],
|
||||||
|
"surfaces": [
|
||||||
|
{
|
||||||
|
"mesh": "Object_262",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "Face",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "face",
|
||||||
|
"why": "material name says 'face'",
|
||||||
|
"verts": 9620,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_263",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "Clothes",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 12846,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {
|
||||||
|
"bust": 0.059
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_264",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "Hair_A",
|
||||||
|
"class": "hair",
|
||||||
|
"detail": "hair",
|
||||||
|
"why": "material name says 'hair'",
|
||||||
|
"verts": 15552,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {
|
||||||
|
"hair": 0.018
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_265",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "Hair_B",
|
||||||
|
"class": "hair",
|
||||||
|
"detail": "hair",
|
||||||
|
"why": "material name says 'hair'",
|
||||||
|
"verts": 5281,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {
|
||||||
|
"hair": 0.094
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_266",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "Iris",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "eyes",
|
||||||
|
"why": "material name says 'eyes'",
|
||||||
|
"verts": 260,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -8018,5 +8018,215 @@
|
|||||||
"DEF-toe.R",
|
"DEF-toe.R",
|
||||||
"DEF-upper_arm.L",
|
"DEF-upper_arm.L",
|
||||||
"DEF-upper_arm.R"
|
"DEF-upper_arm.R"
|
||||||
|
],
|
||||||
|
"surfaces": [
|
||||||
|
{
|
||||||
|
"mesh": "Object_10",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "EyesInvL",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "eyes_ink",
|
||||||
|
"why": "material names itself an eye card",
|
||||||
|
"verts": 140,
|
||||||
|
"textured": false,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_11",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "EyesHL",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "eyes_highlight",
|
||||||
|
"why": "material names itself an eye card",
|
||||||
|
"verts": 80,
|
||||||
|
"textured": false,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_12",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "EyesFullBlack",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "eyes_ink",
|
||||||
|
"why": "material names itself an eye card",
|
||||||
|
"verts": 269,
|
||||||
|
"textured": false,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_14",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "ClothB",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 1658,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {
|
||||||
|
"skirt": 0.698
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_15",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "FullBlack",
|
||||||
|
"class": "linework",
|
||||||
|
"detail": "outline_hull",
|
||||||
|
"why": "material is untextured ink or inside-out",
|
||||||
|
"verts": 2052,
|
||||||
|
"textured": false,
|
||||||
|
"chain_share": {
|
||||||
|
"skirt": 0.698
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_17",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "ClothA",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 706,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_18",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "FullBlack",
|
||||||
|
"class": "linework",
|
||||||
|
"detail": "outline_hull",
|
||||||
|
"why": "material is untextured ink or inside-out",
|
||||||
|
"verts": 786,
|
||||||
|
"textured": false,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_20",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "ClothA",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 124,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_22",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "ClothA",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 106,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_24",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "ClothCAndW",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 78,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_26",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "ClothCAndW",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 312,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_27",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "FullBlack",
|
||||||
|
"class": "linework",
|
||||||
|
"detail": "outline_hull",
|
||||||
|
"why": "material is untextured ink or inside-out",
|
||||||
|
"verts": 312,
|
||||||
|
"textured": false,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_29",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "ClothCAndW",
|
||||||
|
"class": "cloth",
|
||||||
|
"detail": "garment",
|
||||||
|
"why": "material name says 'garment'",
|
||||||
|
"verts": 54,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_31",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "Hair",
|
||||||
|
"class": "hair",
|
||||||
|
"detail": "hair",
|
||||||
|
"why": "material name says 'hair'",
|
||||||
|
"verts": 3479,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {
|
||||||
|
"hair": 0.51,
|
||||||
|
"skirt": 0.002
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_32",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "material",
|
||||||
|
"class": "linework",
|
||||||
|
"detail": "outline_hull",
|
||||||
|
"why": "material is untextured ink or inside-out",
|
||||||
|
"verts": 3814,
|
||||||
|
"textured": false,
|
||||||
|
"chain_share": {
|
||||||
|
"hair": 0.474,
|
||||||
|
"skirt": 0.002
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_7",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "Face",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "face",
|
||||||
|
"why": "material name says 'face'",
|
||||||
|
"verts": 1296,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_8",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "Body",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "skin",
|
||||||
|
"why": "material name says 'skin'",
|
||||||
|
"verts": 1980,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": "Object_9",
|
||||||
|
"surface": 0,
|
||||||
|
"material": "Eyes",
|
||||||
|
"class": "body",
|
||||||
|
"detail": "eyes",
|
||||||
|
"why": "material name says 'eyes'",
|
||||||
|
"verts": 112,
|
||||||
|
"textured": true,
|
||||||
|
"chain_share": {}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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