feat: implement automated 3D character pipeline with retargeting and rig management tools
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"roles": {
|
||||
"hips": "DEF-hips",
|
||||
"head": "DEF-head",
|
||||
"neck": "DEF-neck",
|
||||
"spine": [
|
||||
"DEF-spine.001",
|
||||
"DEF-spine.002",
|
||||
"DEF-spine.003",
|
||||
"DEF-neck",
|
||||
"DEF-head"
|
||||
],
|
||||
"forearm.R": "DEF-forearm.R",
|
||||
"toe.R": "DEF-toe.R",
|
||||
"upper_arm.R": "DEF-upper_arm.R",
|
||||
"shin.L": "DEF-shin.L",
|
||||
"shin.R": "DEF-shin.R",
|
||||
"toe.L": "DEF-toe.L",
|
||||
"hand.R": "DEF-hand.R",
|
||||
"forearm.L": "DEF-forearm.L",
|
||||
"foot.R": "DEF-foot.R",
|
||||
"upper_arm.L": "DEF-upper_arm.L",
|
||||
"thigh.R": "DEF-thigh.R",
|
||||
"hand.L": "DEF-hand.L",
|
||||
"shoulder.R": "DEF-shoulder.R",
|
||||
"thigh.L": "DEF-thigh.L",
|
||||
"foot.L": "DEF-foot.L",
|
||||
"shoulder.L": "DEF-shoulder.L"
|
||||
},
|
||||
"chains": [],
|
||||
"twist": [],
|
||||
"colliders": [
|
||||
{
|
||||
"bone": "DEF-thigh.L",
|
||||
"child": "DEF-shin.L",
|
||||
"radius": 0.1245
|
||||
},
|
||||
{
|
||||
"bone": "DEF-thigh.R",
|
||||
"child": "DEF-shin.R",
|
||||
"radius": 0.1079
|
||||
},
|
||||
{
|
||||
"bone": "DEF-shin.L",
|
||||
"child": "DEF-foot.L",
|
||||
"radius": 0.0762
|
||||
},
|
||||
{
|
||||
"bone": "DEF-shin.R",
|
||||
"child": "DEF-foot.R",
|
||||
"radius": 0.0762
|
||||
}
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
{
|
||||
"id": "taila",
|
||||
"name": "Taila",
|
||||
"description": "Original anime character by Partaevil (CC-BY)",
|
||||
"description": "",
|
||||
"model": "res://assets/characters/skins/taila.glb",
|
||||
"unlocked": true
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,853 @@
|
||||
{
|
||||
"roles": {
|
||||
"hips": "DEF-spine",
|
||||
"head": "DEF-spine.006",
|
||||
"neck": "DEF-spine.005",
|
||||
"spine": [
|
||||
"DEF-spine.001",
|
||||
"DEF-spine.002",
|
||||
"DEF-spine.003",
|
||||
"DEF-spine.004",
|
||||
"DEF-spine.005",
|
||||
"DEF-spine.006"
|
||||
],
|
||||
"thigh.R": "DEF-thigh.R",
|
||||
"hand.L": "DEF-hand.L",
|
||||
"toe.L": "DEF-toe.L",
|
||||
"hand.R": "DEF-hand.R",
|
||||
"toe.R": "DEF-toe.R",
|
||||
"shoulder.R": "DEF-shoulder.R",
|
||||
"thigh.L": "DEF-thigh.L",
|
||||
"shin.L": "DEF-shin.L",
|
||||
"upper_arm.R": "DEF-upper_arm.R",
|
||||
"upper_arm.L": "DEF-upper_arm.L",
|
||||
"foot.R": "DEF-foot.R",
|
||||
"forearm.R": "DEF-forearm.R",
|
||||
"shin.R": "DEF-shin.R",
|
||||
"foot.L": "DEF-foot.L",
|
||||
"forearm.L": "DEF-forearm.L",
|
||||
"shoulder.L": "DEF-shoulder.L"
|
||||
},
|
||||
"chains": [
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.006",
|
||||
"bones": [
|
||||
"DEF-hair",
|
||||
"DEF-hair.001",
|
||||
"DEF-hair.002"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.0,
|
||||
0.06838,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.04506,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
-0.01032,
|
||||
0.00854,
|
||||
0.01182
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.006",
|
||||
"bones": [
|
||||
"DEF-hair.003",
|
||||
"DEF-hair.004"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
-0.0,
|
||||
0.06123,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
-0.00017,
|
||||
0.04439,
|
||||
-0.00029
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.006",
|
||||
"bones": [
|
||||
"DEF-hair.007",
|
||||
"DEF-hair.005"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.0,
|
||||
0.04186,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
0.00621,
|
||||
0.02144,
|
||||
0.00318
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.006",
|
||||
"bones": [
|
||||
"DEF-hair.008",
|
||||
"DEF-hair.009",
|
||||
"DEF-hair.010",
|
||||
"DEF-hair.011"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
-0.0,
|
||||
0.08774,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.05443,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
-0.0,
|
||||
0.03074,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
-0.00258,
|
||||
0.02087,
|
||||
0.00039
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.006",
|
||||
"bones": [
|
||||
"DEF-hair.L.008",
|
||||
"DEF-hair.L.009",
|
||||
"DEF-hair.L.010",
|
||||
"DEF-hair.L.011"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
-0.0,
|
||||
0.15518,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.1139,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0683,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
-0.00184,
|
||||
0.09275,
|
||||
-0.00073
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.006",
|
||||
"bones": [
|
||||
"DEF-hair.L.012",
|
||||
"DEF-hair.L.013",
|
||||
"DEF-hair.L.014"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.0,
|
||||
0.105,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.05073,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
-0.0067,
|
||||
0.04095,
|
||||
0.00485
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.006",
|
||||
"bones": [
|
||||
"DEF-hair.L.018",
|
||||
"DEF-hair.L.019",
|
||||
"DEF-hair.L.020",
|
||||
"DEF-hair.L.021"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.0,
|
||||
0.06312,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
-0.0,
|
||||
0.05056,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.02034,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0048,
|
||||
0.02461,
|
||||
-0.00168
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.006",
|
||||
"bones": [
|
||||
"DEF-hair.L.022",
|
||||
"DEF-hair.L.023",
|
||||
"DEF-hair.L.024",
|
||||
"DEF-hair.L.025"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.0,
|
||||
0.06757,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.05187,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
-0.0,
|
||||
0.02554,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
-0.00718,
|
||||
0.05819,
|
||||
-0.00047
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.006",
|
||||
"bones": [
|
||||
"DEF-hair.R.008",
|
||||
"DEF-hair.R.009",
|
||||
"DEF-hair.R.010",
|
||||
"DEF-hair.R.011"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.0,
|
||||
0.15518,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
-0.0,
|
||||
0.1139,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0683,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
0.00252,
|
||||
-0.00932,
|
||||
0.00663
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.006",
|
||||
"bones": [
|
||||
"DEF-hair.R.012",
|
||||
"DEF-hair.R.013",
|
||||
"DEF-hair.R.014"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.0,
|
||||
0.105,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
-0.0,
|
||||
0.05073,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0059,
|
||||
0.03897,
|
||||
0.00457
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.004",
|
||||
"bones": [
|
||||
"DEF-hair.L",
|
||||
"DEF-hair.L.001",
|
||||
"DEF-hair.L.002",
|
||||
"DEF-hair.L.003"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
-0.0,
|
||||
0.11762,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.18049,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
-0.0,
|
||||
0.10823,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
-0.04057,
|
||||
-0.18138,
|
||||
-0.1173
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.004",
|
||||
"bones": [
|
||||
"DEF-hair.L.004",
|
||||
"DEF-hair.L.005",
|
||||
"DEF-hair.L.006",
|
||||
"DEF-hair.L.007"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.0,
|
||||
0.13941,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
-0.0,
|
||||
0.20008,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
-0.0,
|
||||
0.10005,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
-0.05539,
|
||||
-0.26952,
|
||||
-0.08576
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.004",
|
||||
"bones": [
|
||||
"DEF-hair.R",
|
||||
"DEF-hair.R.001",
|
||||
"DEF-hair.R.002",
|
||||
"DEF-hair.R.003"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
-0.0,
|
||||
0.11761,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.18049,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.10823,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
0.00673,
|
||||
0.04894,
|
||||
-0.00199
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "hair",
|
||||
"root_parent": "DEF-spine.004",
|
||||
"bones": [
|
||||
"DEF-hair.R.004",
|
||||
"DEF-hair.R.005",
|
||||
"DEF-hair.R.006",
|
||||
"DEF-hair.R.007"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.0,
|
||||
0.13941,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.20008,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
-0.0,
|
||||
0.10005,
|
||||
-0.0
|
||||
],
|
||||
[
|
||||
0.108,
|
||||
-0.20337,
|
||||
-0.03794
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine.001",
|
||||
"bones": [
|
||||
"DEF-skirt"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.00968,
|
||||
0.19273,
|
||||
-0.02416
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine.001",
|
||||
"bones": [
|
||||
"DEF-skirt.003"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.00768,
|
||||
0.07108,
|
||||
-0.00326
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine.001",
|
||||
"bones": [
|
||||
"DEF-skirt.L"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.00938,
|
||||
0.15041,
|
||||
0.0127
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine.001",
|
||||
"bones": [
|
||||
"DEF-skirt.L.003"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.04759,
|
||||
0.0342,
|
||||
0.00628
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine.001",
|
||||
"bones": [
|
||||
"DEF-skirt.R"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
-0.03068,
|
||||
0.21535,
|
||||
-0.00882
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine.001",
|
||||
"bones": [
|
||||
"DEF-skirt.R.003"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
-0.01829,
|
||||
0.13101,
|
||||
0.02073
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine.001",
|
||||
"bones": [
|
||||
"DEF-skirt.007"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.04149,
|
||||
0.08297,
|
||||
0.01074
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.002"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
-0.02029,
|
||||
0.0589,
|
||||
-0.05819
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.011"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.02937,
|
||||
0.14504,
|
||||
-0.07502
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.006"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.00091,
|
||||
0.03548,
|
||||
-0.00392
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.005"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.02723,
|
||||
0.16522,
|
||||
-0.072
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.L.002"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.00968,
|
||||
0.11247,
|
||||
0.00454
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.L.001"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.08352,
|
||||
0.19687,
|
||||
-0.07977
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.L.005"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
-0.00431,
|
||||
0.06347,
|
||||
0.00967
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.L.004"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.12519,
|
||||
0.17009,
|
||||
0.08382
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.R.002"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
-0.00968,
|
||||
0.11247,
|
||||
0.00454
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.R.001"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
-0.1032,
|
||||
0.21865,
|
||||
-0.0877
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.R.005"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
-0.06487,
|
||||
0.03189,
|
||||
0.02494
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.R.004"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.04541,
|
||||
0.19105,
|
||||
-0.00365
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.010"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.03727,
|
||||
0.10017,
|
||||
0.00882
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"class": "skirt",
|
||||
"root_parent": "DEF-spine",
|
||||
"bones": [
|
||||
"DEF-skirt.009"
|
||||
],
|
||||
"tips": [
|
||||
[
|
||||
0.04312,
|
||||
0.09673,
|
||||
-0.03854
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"twist": [
|
||||
{
|
||||
"bone": "DEF-upper_arm.L.001",
|
||||
"parent": "DEF-upper_arm.L",
|
||||
"child": "DEF-forearm.L"
|
||||
},
|
||||
{
|
||||
"bone": "DEF-forearm.L.001",
|
||||
"parent": "DEF-forearm.L",
|
||||
"child": "DEF-hand.L"
|
||||
},
|
||||
{
|
||||
"bone": "DEF-upper_arm.R.001",
|
||||
"parent": "DEF-upper_arm.R",
|
||||
"child": "DEF-forearm.R"
|
||||
},
|
||||
{
|
||||
"bone": "DEF-forearm.R.001",
|
||||
"parent": "DEF-forearm.R",
|
||||
"child": "DEF-hand.R"
|
||||
},
|
||||
{
|
||||
"bone": "DEF-thigh.L.001",
|
||||
"parent": "DEF-thigh.L",
|
||||
"child": "DEF-shin.L"
|
||||
},
|
||||
{
|
||||
"bone": "DEF-shin.L.001",
|
||||
"parent": "DEF-shin.L",
|
||||
"child": "DEF-foot.L"
|
||||
},
|
||||
{
|
||||
"bone": "DEF-thigh.R.001",
|
||||
"parent": "DEF-thigh.R",
|
||||
"child": "DEF-shin.R"
|
||||
},
|
||||
{
|
||||
"bone": "DEF-shin.R.001",
|
||||
"parent": "DEF-shin.R",
|
||||
"child": "DEF-foot.R"
|
||||
}
|
||||
],
|
||||
"colliders": [
|
||||
{
|
||||
"bone": "DEF-thigh.L",
|
||||
"child": "DEF-shin.L",
|
||||
"radius": 0.1543
|
||||
},
|
||||
{
|
||||
"bone": "DEF-thigh.R",
|
||||
"child": "DEF-shin.R",
|
||||
"radius": 0.1519
|
||||
},
|
||||
{
|
||||
"bone": "DEF-shin.L",
|
||||
"child": "DEF-foot.L",
|
||||
"radius": 0.0739
|
||||
},
|
||||
{
|
||||
"bone": "DEF-shin.R",
|
||||
"child": "DEF-foot.R",
|
||||
"radius": 0.0739
|
||||
}
|
||||
],
|
||||
"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-shin.L",
|
||||
"DEF-shin.R",
|
||||
"DEF-shoulder.L",
|
||||
"DEF-shoulder.R",
|
||||
"DEF-spine",
|
||||
"DEF-spine.001",
|
||||
"DEF-spine.002",
|
||||
"DEF-spine.003",
|
||||
"DEF-spine.004",
|
||||
"DEF-spine.005",
|
||||
"DEF-spine.006",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -34,10 +34,15 @@ class_name SkinJointHelper
|
||||
## SkinnedPlayerModel.ShooterPoseModifier). A helper that is not updated in step
|
||||
## with its child deforms the limb instead of saving it.
|
||||
|
||||
## [parent, child] per joint, both sides.
|
||||
const JOINTS := [
|
||||
["DEF-thigh.L", "DEF-shin.L"], ["DEF-thigh.R", "DEF-shin.R"],
|
||||
]
|
||||
## The CHILD bone of each joint to subdivide. The parent is whatever the
|
||||
## skeleton says it is, not a second hardcoded name.
|
||||
##
|
||||
## It used to be a [parent, child] pair of ["DEF-thigh.L", "DEF-shin.L"], which
|
||||
## silently did nothing on a rig with limb twist bones: Taila's shin hangs off
|
||||
## DEF-thigh.L.001, so the knee vertices are weighted across THAT and the shin,
|
||||
## and the pass found no vertex holding both named bones. The knee measured 0.76
|
||||
## with this "installed" and doing nothing at all.
|
||||
const JOINT_CHILDREN := ["DEF-shin.L", "DEF-shin.R", "shin.L", "shin.R"]
|
||||
## Angular steps through each joint. 4 leaves at most a quarter of the bend for
|
||||
## any one vertex to blend across, which is a ~3% collapse at a hard tuck.
|
||||
const SEGMENTS := 4
|
||||
@@ -55,16 +60,18 @@ static func install(root: Node, skeleton: Skeleton3D) -> Array:
|
||||
var driven: Array = []
|
||||
# child bone -> [parent bone, [helper bones, inner first]]
|
||||
var joint_helpers := {}
|
||||
for joint in JOINTS:
|
||||
var parent := skeleton.find_bone(joint[0])
|
||||
var child := skeleton.find_bone(joint[1])
|
||||
if parent < 0 or child < 0:
|
||||
for child_name in JOINT_CHILDREN:
|
||||
var child := skeleton.find_bone(child_name)
|
||||
if child < 0:
|
||||
continue
|
||||
if skeleton.find_bone("HELPER1-" + joint[1]) >= 0:
|
||||
var parent := skeleton.get_bone_parent(child)
|
||||
if parent < 0:
|
||||
continue
|
||||
if skeleton.find_bone("HELPER1-" + child_name) >= 0:
|
||||
continue # already installed
|
||||
var helpers: Array = []
|
||||
for step in range(1, SEGMENTS):
|
||||
var hname: String = "HELPER%d-%s" % [step, joint[1]]
|
||||
var hname: String = "HELPER%d-%s" % [step, child_name]
|
||||
skeleton.add_bone(hname)
|
||||
var h := skeleton.find_bone(hname)
|
||||
skeleton.set_bone_parent(h, parent)
|
||||
|
||||
@@ -88,6 +88,11 @@ var _current_clip: String = ""
|
||||
var _weapon_attachment: BoneAttachment3D
|
||||
## [child_bone, helper_bone] pairs driven every frame by the pose modifier.
|
||||
var _joint_helpers: Array = []
|
||||
## Contents of <model>.rig.json — resolved bone roles, cloth chains, twist pairs
|
||||
## 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 = {}
|
||||
var _spring_mod: SpringBones
|
||||
var is_holding_weapon: bool = false
|
||||
|
||||
# Animation blending: locomotion plays full-body through a Transition node;
|
||||
@@ -164,19 +169,44 @@ func load_model(path: String) -> void:
|
||||
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
||||
else:
|
||||
_ensure_meshes_bound(scene)
|
||||
# Boots weighted to BOTH legs get dragged into the gap and stretch as
|
||||
# the legs separate. See SkinLegRepair.
|
||||
_rig_info = _load_rig_info(path)
|
||||
# `weights_authored` is MEASURED at build time, not inferred from which
|
||||
# pipeline branch ran: a model that arrives unrigged still gets a
|
||||
# sidecar, and its nearest-bone weights still need the repair below.
|
||||
if not _rig_info.get("weights_authored", false):
|
||||
# Weights were solved, not painted — every vertex went to its
|
||||
# nearest four bones with no idea which limb it belongs to.
|
||||
# SkinLegRepair exists solely to undo that, and it is destructive
|
||||
# (it snaps weights and deletes triangles), so a model that kept its
|
||||
# ARTIST weights must never be put through it.
|
||||
var fixed := SkinLegRepair.repair(scene, skeleton)
|
||||
if fixed[0] > 0 or fixed[1] > 0:
|
||||
print("SkinnedPlayerModel: '%s' — snapped %d cross-leg vertices, dropped %d bridging triangles"
|
||||
% [path.get_file(), fixed[0], fixed[1]])
|
||||
# Half-angle helper bones at every leg joint. Without them the limb
|
||||
# pinches at a hard bend — see SkinJointHelper.
|
||||
# Joint subdivision runs for EVERY model, however it was rigged. It is
|
||||
# not a weight repair: linear-blend skinning collapses any joint by
|
||||
# cos(angle/2) no matter how good the weights are, and dropping it from
|
||||
# the authored-weight path measured 0.77 at the knee against 0.99 with
|
||||
# it. See SkinJointHelper.
|
||||
_joint_helpers = SkinJointHelper.install(scene, skeleton)
|
||||
_pose_mod = ShooterPoseModifier.new()
|
||||
_pose_mod.joint_helpers = _joint_helpers
|
||||
_pose_mod.roles = _rig_info.get("roles", {})
|
||||
_pose_mod.name = "ShooterPose"
|
||||
skeleton.add_child(_pose_mod)
|
||||
# Cloth and hair last, so the springs react to the FINAL body pose —
|
||||
# animation plus the shooter lean/slide layer.
|
||||
if not _rig_info.is_empty():
|
||||
_spring_mod = SpringBones.new()
|
||||
_spring_mod.name = "SpringBones"
|
||||
skeleton.add_child(_spring_mod)
|
||||
var driven := _spring_mod.setup(skeleton, _rig_info)
|
||||
if driven == 0:
|
||||
_spring_mod.queue_free()
|
||||
_spring_mod = null
|
||||
else:
|
||||
print("SkinnedPlayerModel: '%s' — %d cloth/hair bones on springs"
|
||||
% [path.get_file(), driven])
|
||||
|
||||
# Cel-shaded look: toon shading over the imported textures + ink outline.
|
||||
LevelMaterials.apply_toon_recursive(scene)
|
||||
@@ -201,6 +231,23 @@ func load_model(path: String) -> void:
|
||||
_play_clip("Idle")
|
||||
|
||||
|
||||
## Read the rig sidecar that tools/retarget.py writes next to the GLB.
|
||||
##
|
||||
## Its presence is also the signal that this model kept its OWN skeleton and
|
||||
## authored weights, which is what decides whether the load-time weight repair
|
||||
## below is needed at all.
|
||||
func _load_rig_info(model_path: String) -> Dictionary:
|
||||
var side := model_path.get_basename() + ".rig.json"
|
||||
if not FileAccess.file_exists(side):
|
||||
return {}
|
||||
var text := FileAccess.get_file_as_string(side)
|
||||
var parsed = JSON.parse_string(text)
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
push_warning("SkinnedPlayerModel: could not parse '%s'" % side)
|
||||
return {}
|
||||
return parsed
|
||||
|
||||
|
||||
## 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
|
||||
@@ -782,6 +829,10 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
||||
|
||||
var _idx: Dictionary = {}
|
||||
var _resolved := false
|
||||
## Role -> actual bone name for THIS rig, from <model>.rig.json. Empty when
|
||||
## the model was rebound onto the library skeleton, where the names below
|
||||
## already match.
|
||||
var roles: Dictionary = {}
|
||||
|
||||
func _resolve() -> void:
|
||||
var skel := get_skeleton()
|
||||
@@ -789,8 +840,29 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
||||
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
|
||||
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
|
||||
"DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"]
|
||||
# The names above are the LIBRARY skeleton's. A model that kept its own
|
||||
# rig names things differently and three of them simply do not exist on
|
||||
# it — Taila's hips are DEF-spine, her head is DEF-spine.006, and she has
|
||||
# no bone with "neck" in its name at all. Unresolved, every lean, aim
|
||||
# pitch and slide head-lift below silently did nothing.
|
||||
var alias := {}
|
||||
if not roles.is_empty():
|
||||
var neck: String = roles.get("neck", "")
|
||||
var head: String = roles.get("head", "")
|
||||
var torso: Array = []
|
||||
for n in roles.get("spine", []):
|
||||
if n != neck and n != head:
|
||||
torso.append(n)
|
||||
for i in mini(torso.size(), SPINE.size() - 1):
|
||||
alias[SPINE[i + 1]] = torso[i]
|
||||
for n in names:
|
||||
_idx[n] = skel.find_bone(n)
|
||||
# Canonical names are the role keys with the DEF- prefix, so the
|
||||
# limbs, hips, neck and head all map straight through.
|
||||
var actual: String = alias.get(n, roles.get(n.trim_prefix("DEF-"), n))
|
||||
var b := skel.find_bone(actual)
|
||||
if b < 0:
|
||||
b = skel.find_bone(n)
|
||||
_idx[n] = b
|
||||
_resolved = true
|
||||
|
||||
func _process_modification() -> void:
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
extends SkeletonModifier3D
|
||||
class_name SpringBones
|
||||
|
||||
## Secondary motion for cloth, hair and accessories.
|
||||
##
|
||||
## The animation clips drive the BODY and nothing else — tools/retarget.py
|
||||
## deliberately exports no tracks at all for skirt, hair or accessory bones (see
|
||||
## its `export_optimize_animation_keep_anim_armature=False`). This is what moves
|
||||
## them, and it is the half of the pipeline that makes clothes read as clothes.
|
||||
##
|
||||
## Why this rather than skin weights. Linear-blend skinning can only ever make a
|
||||
## garment a rigid shell of whatever bones it is weighted to: weight a skirt to
|
||||
## the thighs and it becomes trousers, weight it to the hips and it becomes a
|
||||
## bell that never moves. Neither is cloth. A skirt is cloth because it LAGS —
|
||||
## it keeps going when the hips stop, swings out through a turn, and floats on
|
||||
## the way up through a jump. That is inertia, and inertia has to be integrated,
|
||||
## not skinned. So the thigh stays solid (authored weights, its own bone) while
|
||||
## the skirt hanging over it is free to move differently — which is exactly the
|
||||
## split the model was rigged for and the old pipeline flattened away.
|
||||
##
|
||||
## Each bone is a damped spring holding its tip toward where rigidly following
|
||||
## its parent would have put it:
|
||||
##
|
||||
## a = (rest_tip - tip) * w^2 - v * 2*zeta*w + gravity
|
||||
##
|
||||
## A real spring rather than the usual Verlet blend, because it is integrated
|
||||
## against the actual frame delta and so behaves the same at 30 fps and 240.
|
||||
## The tip is then pinned back to the bone's length (cloth stretches far less
|
||||
## than it swings) and pushed out of the leg capsules, so a skirt swings AROUND
|
||||
## a thigh instead of through it.
|
||||
##
|
||||
## Bone lengths and the leg capsule radii are MEASURED from the model's own
|
||||
## geometry at build time and read from <model>.rig.json — a glTF skeleton
|
||||
## carries no bone tails at all, and Taila's 21 skirt panel bones have no
|
||||
## children either, so there is nothing in the skeleton itself that says which
|
||||
## way a panel hangs or how thick a thigh is.
|
||||
|
||||
## Per class: w = stiffness as an angular frequency (rad/s), zeta = damping
|
||||
## ratio (1.0 is critical, lower overshoots), gravity in m/s^2.
|
||||
##
|
||||
## Hair is stiffer and lighter than cloth so it settles quickly instead of
|
||||
## wobbling; a skirt is slacker and heavier so it lags and swings. Gravity is
|
||||
## modest for both because the AUTHORED rest pose already has the garment
|
||||
## hanging — this only biases the droop while the body accelerates.
|
||||
const TUNING := {
|
||||
"hair": {"w": 16.0, "zeta": 0.34, "gravity": 3.0},
|
||||
"skirt": {"w": 11.0, "zeta": 0.30, "gravity": 5.0},
|
||||
"cloth": {"w": 12.0, "zeta": 0.32, "gravity": 4.5},
|
||||
"cape": {"w": 9.0, "zeta": 0.28, "gravity": 5.5},
|
||||
"coat": {"w": 11.0, "zeta": 0.30, "gravity": 5.0},
|
||||
"scarf": {"w": 12.0, "zeta": 0.30, "gravity": 4.0},
|
||||
"ribbon": {"w": 14.0, "zeta": 0.28, "gravity": 3.5},
|
||||
"tail": {"w": 13.0, "zeta": 0.30, "gravity": 3.0},
|
||||
"sleeve": {"w": 14.0, "zeta": 0.34, "gravity": 3.5},
|
||||
"breast": {"w": 20.0, "zeta": 0.40, "gravity": 2.0},
|
||||
"bust": {"w": 20.0, "zeta": 0.40, "gravity": 2.0},
|
||||
}
|
||||
const DEFAULT_TUNING := {"w": 13.0, "zeta": 0.32, "gravity": 4.0}
|
||||
|
||||
## How far a tip may stray from where rigidly following would put it, as a
|
||||
## fraction of the bone's length. Cloth swings; it does not stretch.
|
||||
const MAX_STRAY := 0.6
|
||||
## A frame delta longer than this is a hitch or a load spike. Integrating it
|
||||
## launches every chain across the map, so it is clamped instead.
|
||||
const MAX_STEP := 1.0 / 30.0
|
||||
## Origin jump (metres in one frame) that means a teleport — respawn, or the
|
||||
## model being reparented — rather than movement. Chains snap instead of whip.
|
||||
const TELEPORT := 1.5
|
||||
## Ceiling on tip speed, and on how finely one frame may be subdivided. Both are
|
||||
## backstops: nothing on a character legitimately moves this fast, and four
|
||||
## substeps already covers a 30 fps frame at the stiffest tuning here.
|
||||
const MAX_SPEED := 12.0
|
||||
const MAX_SUBSTEPS := 4
|
||||
|
||||
var _chains: Array = []
|
||||
var _colliders: Array = []
|
||||
var _tip: PackedVector3Array = PackedVector3Array()
|
||||
var _vel: PackedVector3Array = PackedVector3Array()
|
||||
var _settled: bool = false
|
||||
var _last_usec: int = 0
|
||||
|
||||
|
||||
## Build from the sidecar written by tools/retarget.py. Returns how many bones
|
||||
## are being driven, so the caller can log or disable itself when there are none.
|
||||
func setup(skel: Skeleton3D, info: Dictionary) -> int:
|
||||
_chains.clear()
|
||||
_colliders.clear()
|
||||
if skel == null or info.is_empty():
|
||||
return 0
|
||||
|
||||
var total := 0
|
||||
for entry in info.get("chains", []):
|
||||
var parent_name: String = entry.get("root_parent", "")
|
||||
var parent := skel.find_bone(parent_name) if parent_name != "" else -1
|
||||
if parent < 0:
|
||||
continue
|
||||
var bones := PackedInt32Array()
|
||||
var tips := PackedVector3Array()
|
||||
var names: Array = entry.get("bones", [])
|
||||
var raw_tips: Array = entry.get("tips", [])
|
||||
for i in names.size():
|
||||
var idx := skel.find_bone(String(names[i]))
|
||||
if idx < 0:
|
||||
continue
|
||||
var tip := Vector3.ZERO
|
||||
if i < raw_tips.size():
|
||||
var t: Array = raw_tips[i]
|
||||
if t.size() == 3:
|
||||
tip = Vector3(t[0], t[1], t[2])
|
||||
if tip.length() < 0.001:
|
||||
continue # no measurable extent — nothing to swing
|
||||
bones.append(idx)
|
||||
tips.append(tip)
|
||||
if bones.is_empty():
|
||||
continue
|
||||
var tune: Dictionary = TUNING.get(String(entry.get("class", "")), DEFAULT_TUNING)
|
||||
_chains.append({
|
||||
"parent": parent,
|
||||
"bones": bones,
|
||||
"tips": tips,
|
||||
"w": float(tune["w"]),
|
||||
"zeta": float(tune["zeta"]),
|
||||
"gravity": float(tune["gravity"]),
|
||||
})
|
||||
total += bones.size()
|
||||
|
||||
for c in info.get("colliders", []):
|
||||
var a := skel.find_bone(String(c.get("bone", "")))
|
||||
var b := skel.find_bone(String(c.get("child", "")))
|
||||
if a >= 0 and b >= 0:
|
||||
_colliders.append({"a": a, "b": b, "r": float(c.get("radius", 0.1))})
|
||||
|
||||
_tip.resize(total)
|
||||
_vel.resize(total)
|
||||
_settled = false
|
||||
_last_usec = 0
|
||||
return total
|
||||
|
||||
|
||||
func _delta() -> float:
|
||||
var now := Time.get_ticks_usec()
|
||||
if _last_usec == 0:
|
||||
_last_usec = now
|
||||
return 1.0 / 60.0
|
||||
var dt := float(now - _last_usec) / 1000000.0
|
||||
_last_usec = now
|
||||
return clampf(dt, 1.0 / 480.0, MAX_STEP)
|
||||
|
||||
|
||||
func _process_modification() -> void:
|
||||
var skel := get_skeleton()
|
||||
if skel == null or _chains.is_empty():
|
||||
return
|
||||
var dt := _delta()
|
||||
var to_world := skel.global_transform
|
||||
var k := 0
|
||||
|
||||
for chain in _chains:
|
||||
var bones: PackedInt32Array = chain["bones"]
|
||||
var tips: PackedVector3Array = chain["tips"]
|
||||
var w: float = chain["w"]
|
||||
var damp: float = 2.0 * float(chain["zeta"]) * w
|
||||
var pull: float = w * w
|
||||
var gravity := Vector3.DOWN * float(chain["gravity"])
|
||||
# World transform of the bone this chain hangs from, already posed by
|
||||
# the animation and the shooter pose layer this frame.
|
||||
var parent_world: Transform3D = to_world * skel.get_bone_global_pose(chain["parent"])
|
||||
|
||||
for i in bones.size():
|
||||
var bone: int = bones[i]
|
||||
# Where rigidly following the parent would put this bone. Built from
|
||||
# the REST offset, not the current pose, or last frame's spring
|
||||
# result would compound into a permanent drift.
|
||||
var rigid: Transform3D = parent_world * skel.get_bone_rest(bone)
|
||||
var origin := rigid.origin
|
||||
var rest_tip: Vector3 = rigid * tips[i]
|
||||
var arm := rest_tip - origin
|
||||
var length := arm.length()
|
||||
if length < 0.0001:
|
||||
k += 1
|
||||
continue
|
||||
|
||||
var tip := _tip[k]
|
||||
var vel := _vel[k]
|
||||
if not _settled or tip.distance_to(rest_tip) > TELEPORT:
|
||||
tip = rest_tip
|
||||
vel = Vector3.ZERO
|
||||
|
||||
# Substep so the spring can never overshoot, however stiff it is or
|
||||
# however long the frame was. A single explicit step is only stable
|
||||
# while w*dt stays small; past that it gains energy every frame and
|
||||
# the chain flies off the model.
|
||||
var sub := clampi(int(ceil(dt * w / 0.4)), 1, MAX_SUBSTEPS)
|
||||
var h := dt / float(sub)
|
||||
for _s in sub:
|
||||
vel += ((rest_tip - tip) * pull - vel * damp + gravity) * h
|
||||
tip += vel * h
|
||||
# Cloth swings but barely stretches: hold the tip on the bone's
|
||||
# own sphere.
|
||||
var offset := tip - origin
|
||||
tip = origin + (offset if offset.length() > 0.0001 else arm).normalized() * length
|
||||
# Velocity is taken OUT of the constraint rather than recovered
|
||||
# from a finite difference afterwards. Dividing a projected
|
||||
# position change by a wall-clock delta is what produced the
|
||||
# 1.46 m fling: a short frame turns a millimetre of correction
|
||||
# into metres per second, and the next frame launches the chain.
|
||||
var radial := (tip - origin) / length
|
||||
vel -= radial * vel.dot(radial)
|
||||
vel = vel.limit_length(MAX_SPEED)
|
||||
|
||||
# Cap how far the tip may stray from the rigid pose, so a hard turn
|
||||
# cannot fold a chain back through the body.
|
||||
var stray := tip - rest_tip
|
||||
var limit := length * MAX_STRAY
|
||||
if stray.length() > limit:
|
||||
tip = origin + ((rest_tip + stray.normalized() * limit) - origin).normalized() * length
|
||||
|
||||
tip = _push_out_of_legs(skel, to_world, tip, origin, length)
|
||||
_vel[k] = vel
|
||||
_tip[k] = tip
|
||||
|
||||
# Turn the tip direction back into this bone's local rotation.
|
||||
var swing := Quaternion(arm.normalized(), (tip - origin).normalized())
|
||||
var world := Transform3D(Basis(swing) * rigid.basis, origin)
|
||||
var local := parent_world.affine_inverse() * world
|
||||
skel.set_bone_pose_rotation(bone, local.basis.get_rotation_quaternion())
|
||||
parent_world = world
|
||||
k += 1
|
||||
|
||||
_settled = true
|
||||
|
||||
|
||||
## Keep a tip outside the leg capsules, so a skirt swings AROUND a thigh rather
|
||||
## than through it. Radii are measured from the model's own body geometry at
|
||||
## build time (tools/retarget.py::_leg_colliders).
|
||||
func _push_out_of_legs(skel: Skeleton3D, to_world: Transform3D, tip: Vector3,
|
||||
origin: Vector3, length: float) -> Vector3:
|
||||
for col in _colliders:
|
||||
var a: Vector3 = to_world * skel.get_bone_global_pose(col["a"]).origin
|
||||
var b: Vector3 = to_world * skel.get_bone_global_pose(col["b"]).origin
|
||||
var ab := b - a
|
||||
var d2 := ab.length_squared()
|
||||
var t := 0.0 if d2 < 0.000001 else clampf((tip - a).dot(ab) / d2, 0.0, 1.0)
|
||||
var closest := a + ab * t
|
||||
var away := tip - closest
|
||||
var dist := away.length()
|
||||
var r: float = col["r"]
|
||||
if dist >= r:
|
||||
continue
|
||||
if dist < 0.0001:
|
||||
away = (tip - origin).cross(ab)
|
||||
if away.length() < 0.0001:
|
||||
continue
|
||||
tip = closest + away.normalized() * r
|
||||
# Pushing off a capsule moves the tip off its own sphere; put it back.
|
||||
var offset := tip - origin
|
||||
if offset.length() > 0.0001:
|
||||
tip = origin + offset.normalized() * length
|
||||
return tip
|
||||
@@ -0,0 +1 @@
|
||||
uid://deskxxpcexwyt
|
||||
@@ -108,7 +108,12 @@ func _measure(store_rest: bool) -> void:
|
||||
bone_of[b] = bi
|
||||
for s in range(mi.mesh.get_surface_count()):
|
||||
var mat = mi.mesh.surface_get_material(s)
|
||||
var mname: String = mat.resource_name if mat else "?"
|
||||
# Keyed by MESH as well as material. A model that keeps its per-part
|
||||
# meshes reuses one material across several of them — Taila has
|
||||
# "FullBlack" on three — and keying by material alone compared one
|
||||
# mesh's rest against another mesh's posed span, which reported a
|
||||
# 4.9x stretch on a model that was fine.
|
||||
var mname: String = "%s/%s" % [mi.name, mat.resource_name if mat else "?"]
|
||||
var arrays: Array = mi.mesh.surface_get_arrays(s)
|
||||
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||
@@ -140,6 +145,31 @@ func _skinned(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, p: Vector3,
|
||||
return q
|
||||
|
||||
|
||||
## Is this vertex mostly owned by a LEFT leg bone?
|
||||
func _left_leg_driven(skel: Skeleton3D, skin: Skin, bone_of: Dictionary,
|
||||
bones: PackedInt32Array, weights: PackedFloat32Array,
|
||||
base: int, per: int) -> bool:
|
||||
var best := 0.0
|
||||
var best_name := ""
|
||||
for k in per:
|
||||
var w: float = weights[base + k]
|
||||
if w <= best:
|
||||
continue
|
||||
var bind: int = bones[base + k]
|
||||
var n: String = skin.get_bind_name(bind)
|
||||
if n == "":
|
||||
var bi: int = bone_of[bind]
|
||||
n = skel.get_bone_name(bi) if bi >= 0 else ""
|
||||
best = w
|
||||
best_name = n
|
||||
if not (best_name.ends_with(".L") or best_name.find(".L.") != -1):
|
||||
return false
|
||||
for hint in ["shin", "thigh", "foot", "toe"]:
|
||||
if best_name.findn(hint) != -1:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _span(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, mname: String,
|
||||
verts: PackedVector3Array, bones: PackedInt32Array,
|
||||
weights: PackedFloat32Array, per: int, store_rest: bool) -> void:
|
||||
@@ -157,6 +187,13 @@ func _span(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, mname: String,
|
||||
var t: float = clampf((p - ha).dot(hf) / hf.length_squared(), 0.0, 1.0)
|
||||
if p.distance_to(ha + hf * t) > 0.10:
|
||||
continue
|
||||
# Must be DRIVEN by the left leg, not merely near it. Taila's boots are
|
||||
# a single mesh holding both feet, and at rest the right boot sits
|
||||
# within 0.10 m of the left leg axis — so a purely positional filter
|
||||
# collected both, and the span between them read as a 4.9x "stretch"
|
||||
# the moment the legs separated.
|
||||
if not _left_leg_driven(skel, skin, bone_of, bones, weights, v * per, per):
|
||||
continue
|
||||
var q := _skinned(skel, skin, bone_of, p, bones, weights, v * per, per, store_rest)
|
||||
lo = Vector3(minf(lo.x, q.x), minf(lo.y, q.y), minf(lo.z, q.z))
|
||||
hi = Vector3(maxf(hi.x, q.x), maxf(hi.y, q.y), maxf(hi.z, q.z))
|
||||
|
||||
+195
-95
@@ -1,127 +1,227 @@
|
||||
# Character Pipeline: Sketchfab → Rigged → Animated → In-Game
|
||||
# Character Pipeline: source model → rigged → animated → in-game
|
||||
|
||||
One command turns a Sketchfab model into a playable character skin:
|
||||
One command turns a model into a playable character skin:
|
||||
|
||||
```bash
|
||||
python tools/pipeline.py --uid <sketchfab-uid> --name space_marine
|
||||
```
|
||||
|
||||
That downloads the model, auto-rigs it in Blender, merges the shared
|
||||
animation library onto it, writes `assets/characters/skins/space_marine.glb`,
|
||||
and registers it in `skins.json`. Restart the game — the skin is in the main
|
||||
menu dropdown, fully animated in first AND third person, synced in multiplayer.
|
||||
That downloads the model, moves the shared animation library onto it, writes
|
||||
`assets/characters/skins/space_marine.glb` plus a `.rig.json` sidecar, verifies
|
||||
the result, and registers it in `skins.json`. Restart the game — the skin is in
|
||||
the main menu dropdown, fully animated in first AND third person, synced in
|
||||
multiplayer.
|
||||
|
||||
## Prerequisites (one-time setup)
|
||||
## The one rule: keep the model's own rig
|
||||
|
||||
1. **Sketchfab API token** — from <https://sketchfab.com/settings/password>.
|
||||
Put it in the env var `SKETCHFAB_API_TOKEN`, or in a file named
|
||||
`.sketchfab_token` in the project root (gitignored).
|
||||
2. **Blender 3.6+** — on PATH, or set `BLENDER_PATH` to `blender.exe`.
|
||||
3. **Animation library** — already bundled. `assets/characters/animations/_library.glb`
|
||||
is the CC0 Quaternius Universal Animation Library (13 game-relevant clips
|
||||
mapped in `LIBRARY_CLIP_MAP`). `tools/autorig.py` rigs every character to
|
||||
this library's skeleton (fitting the arm bones to the model's actual pose),
|
||||
and `merge_animations.py` retargets each clip onto that fitted rest. To use a
|
||||
different/larger set instead, see "Swapping the animation library" below.
|
||||
**If a model arrives with a skeleton, that skeleton is what ships.** Its bones,
|
||||
its artist-painted weights, its separate per-part meshes, and its dedicated
|
||||
skirt/hair bone chains all survive; only the ANIMATION is moved onto it.
|
||||
|
||||
This is the whole point, and the pipeline used to do the opposite. The old route
|
||||
(`strip_rig.py` → `autorig.py` → `merge_animations.py`) discarded any foreign
|
||||
skeleton, joined every mesh into one, and rebound the result by weighting each
|
||||
vertex to its nearest four bone segments. It did that to work around a *naming*
|
||||
problem — the retarget matched bones by exact name — and the cost was the entire
|
||||
asset. Measured on the shipped `taila.glb` against the source it was built from:
|
||||
|
||||
| | source model | old pipeline output |
|
||||
|---|---|---|
|
||||
| vertices pulled by BOTH legs | 17 (0.1%) | **2817 (16%)**, worst a dead 50/50 |
|
||||
| vertices at the full 4 influences | 26% | **86%** |
|
||||
| meshes (body / cloth / hair separable) | 18 | **1** |
|
||||
| skirt bone chains | 21 bones | **0** |
|
||||
| hair bone chains | ~50 bones | **0** |
|
||||
| limb twist bones | 8 | **0** |
|
||||
|
||||
A vertex pulled equally by both legs sits between them and stays there while
|
||||
they separate, stretching every triangle around it — that is the squashing and
|
||||
the "elongated boot". `characters/skin_leg_repair.gd` exists solely to undo this
|
||||
at load time, by snapping weights and deleting triangles on a mesh it has to
|
||||
rebuild every spawn.
|
||||
|
||||
The naming problem is now solved properly, in `tools/rig_map.py`, so nothing has
|
||||
to be thrown away.
|
||||
|
||||
## The pipeline, step by step
|
||||
|
||||
Each step is also runnable on its own:
|
||||
|
||||
| Step | Tool | What it does |
|
||||
|---|---|---|
|
||||
| 1. Find | `python tools/sketchfab_import.py search "anime robot" --rigged` | Search downloadable models (license shown per result) |
|
||||
| 2. Download | `python tools/sketchfab_import.py download <uid>` | GLB + license/attribution JSON into `assets/characters/incoming/` |
|
||||
| 3. Auto-rig | `blender --background --python tools/autorig.py -- in.glb out.glb` | Fits the skeleton (incl. arm bones) to the mesh, binds smooth nearest-bone weights |
|
||||
| 4. Animate | `blender --background --python tools/merge_animations.py -- rigged.glb assets/characters/animations out.glb` | Retargets the clip library onto the fitted rest, strips root motion, canonical names |
|
||||
| 5. Register | (automatic in pipeline.py) | Copies to `skins/`, adds entry to `skins.json` |
|
||||
| 2. Download | `python tools/sketchfab_import.py download <uid>` | GLB + license JSON into `assets/characters/incoming/` |
|
||||
| 3. Retarget | `blender --background --python tools/retarget.py -- in.glb assets/characters/animations out.glb` | Keeps the rig; moves the clip library onto it |
|
||||
| 3b. *(unrigged only)* | `blender --background --python tools/autorig.py -- in.glb rigged.glb` | Fits the library skeleton and solves weights — lossy, see below |
|
||||
| 4. Verify | `blender --background --python tools/verify_character.py -- out.glb` | Gates the build on the defects listed below |
|
||||
| 5. Register | (automatic in `pipeline.py`) | Copies to `skins/`, adds an entry to `skins.json` |
|
||||
|
||||
`tools/pipeline.py` chains all of it. Useful flags:
|
||||
`pipeline.py` chains all of it and picks the path automatically — it reads the
|
||||
glTF container to see whether a `skins` array is present. Useful flags:
|
||||
|
||||
- `--input file.glb` instead of `--uid` for local files (GLB/FBX/OBJ).
|
||||
- `--rigged` — skip auto-rig for models that already have a skeleton
|
||||
(Mixamo/AccuRig/Tripo output). Bones get renamed to Mixamo convention
|
||||
automatically when recognizable.
|
||||
- `--height 1.6` — target character height in meters.
|
||||
- `--input file.glb` instead of `--uid` for local files.
|
||||
- `--rigged` — force the keep-the-rig path (needed for FBX, which cannot be probed).
|
||||
- `--rebind` — force the lossy path. Last resort.
|
||||
- `--height 1.6` — target character height in metres.
|
||||
|
||||
## Auto-rigging: what to use when
|
||||
### How the retarget works
|
||||
|
||||
Mixamo has **no public API** (and Adobe has said one isn't coming), so full
|
||||
automation needs an alternative. In order of preference:
|
||||
`tools/rig_map.py` resolves both skeletons to ROLES and pairs them up, so bone
|
||||
names never have to match. It works structurally wherever a name would lie:
|
||||
|
||||
1. **Built-in autorig (`tools/autorig.py`)** — heuristic skeleton fit +
|
||||
Blender automatic weights, fully automated, zero cost. Works well for
|
||||
normal-proportioned upright humanoids. Weakest on flowing
|
||||
dresses/capes/extreme proportions.
|
||||
2. **Mixamo web (manual, ~2 min/model)** — upload FBX/OBJ at
|
||||
<https://www.mixamo.com>, place 7 markers, download rigged FBX "without
|
||||
animations", then `pipeline.py --input rigged.fbx --rigged --name x`.
|
||||
Best weight quality for the effort.
|
||||
3. **Reallusion AccuRig** (free desktop app) — better auto-weights than
|
||||
Mixamo for tricky meshes; export FBX and feed with `--rigged`.
|
||||
4. **UniRig / Tripo AI / Meshy / Anything World** — ML auto-riggers. UniRig is
|
||||
open source (VAST/Tripo); Tripo, Meshy, and Anything World offer paid HTTP
|
||||
APIs if you ever want a fully hosted rigging step with no Blender at all.
|
||||
- **Hips** is found as the base of the longest non-limb, non-cosmetic chain —
|
||||
not by looking for "hips". Rigify calls it `DEF-spine`.
|
||||
- **The head** is wherever that chain ends. A stock Rigify rig has no bone with
|
||||
"head" in its name at all; the head is `DEF-spine.006`.
|
||||
- **Chains of different lengths** are matched by normalised position, so a
|
||||
6-bone spine is driven by a 5-bone one.
|
||||
|
||||
All roads lead to the same place: a GLB with Mixamo bone names, which is what
|
||||
`merge_animations.py` and the game expect.
|
||||
`tools/retarget.py` then bakes each clip as a rest-relative delta —
|
||||
`R_world = src_pose · src_rest⁻¹`, applied as `R_world · tgt_rest` — rather than
|
||||
copying absolute world orientation, which would force the library's bone roll
|
||||
onto a mesh bound with a different one and twist every limb by a constant offset.
|
||||
|
||||
It also **rebuilds parenting**. A Rigify DEF-rig exports its chain roots
|
||||
parented straight to the armature root (Rigify drives them by constraint, not
|
||||
hierarchy), so on import the thighs, skirt and hair all hang off the root and
|
||||
would float in place while the body moves. Orphans are re-attached by anatomy
|
||||
where it is known and by rest geometry otherwise. **Cloth may only ever attach
|
||||
to the trunk, never to a limb** — anchor a skirt panel to the nearest bone and
|
||||
16 of Taila's 21 land on a thigh, where the panel rides one leg like a trouser
|
||||
leg.
|
||||
|
||||
### What is deliberately NOT driven
|
||||
|
||||
Skirt, hair, twist and face bones get **no animation tracks at all**. They rest
|
||||
relative to their parents and belong to the runtime instead. That split — clips
|
||||
animate the body, physics animates the cloth — is what makes clothes read as
|
||||
clothes, and it is why the exported clips only carry the ~53 bones they need
|
||||
(`taila.glb` went from 3.5 MB to 2.9 MB even after regaining its textures).
|
||||
|
||||
## Secondary motion (`characters/spring_bones.gd`)
|
||||
|
||||
Skin weights can only ever make a garment a rigid shell of whatever it is
|
||||
weighted to: weight a skirt to the thighs and it becomes trousers, weight it to
|
||||
the hips and it becomes a bell that never moves. Neither is cloth. A skirt is
|
||||
cloth because it LAGS. That is inertia, and it has to be integrated, not skinned.
|
||||
|
||||
Each cloth bone is a damped spring holding its tip toward where rigidly
|
||||
following its parent would have put it, then pinned to the bone's length and
|
||||
pushed out of the leg capsules so a skirt swings AROUND a thigh rather than
|
||||
through it. Bone tip directions and capsule radii are **measured from the
|
||||
model's own geometry at build time** and stored in the sidecar — a glTF skeleton
|
||||
carries no bone tails, and Taila's skirt bones have no children either, so
|
||||
nothing in the skeleton says which way a panel hangs or how thick a thigh is.
|
||||
|
||||
Tuning per class (hair stiffer and lighter, skirt slacker and heavier) lives in
|
||||
`SpringBones.TUNING`.
|
||||
|
||||
### `<model>.rig.json`
|
||||
|
||||
Written next to every built GLB, so the runtime never re-guesses anatomy:
|
||||
|
||||
- `roles` — resolved bone per role. `ShooterPoseModifier` reads this; without it
|
||||
its hips/neck/head lookups silently missed on any rig that names them
|
||||
differently, and every lean and aim-pitch did nothing.
|
||||
- `chains` — cloth/hair chains, with a measured tip vector per bone.
|
||||
- `colliders` — leg capsules with measured radii.
|
||||
- `twist` — limb twist bones.
|
||||
- `weights_authored` — **measured**, not inferred from which branch ran. This is
|
||||
what decides whether `SkinLegRepair` runs at load time. A model that arrives
|
||||
unrigged still gets a sidecar, and its solved weights still need the repair.
|
||||
|
||||
Raw `.glb` **and `.json`** must be in the export include filter, or the sidecar
|
||||
is missing from a build and every character loses its cloth.
|
||||
|
||||
## When a model has no skeleton
|
||||
|
||||
Then there is no authored weighting to keep and `autorig.py` fits the library
|
||||
skeleton with nearest-bone weights. This is genuinely lossy and the verifier
|
||||
reports it as warnings rather than failures, because no better result is
|
||||
available:
|
||||
|
||||
```
|
||||
[WARN] cross-leg blending is limited to draping cloth — 590 verts (21.2%)
|
||||
[WARN] influences look authored, not solved — 78% of verts carry 4 influences
|
||||
[WARN] model keeps its per-part meshes — 1 meshes
|
||||
```
|
||||
|
||||
`miku` is such a model. `SkinLegRepair` stays on for it at load time.
|
||||
|
||||
Prefer, in order: a model that ships rigged → **Mixamo web** (upload FBX/OBJ,
|
||||
place 7 markers, download rigged "without animations") → **Reallusion AccuRig**
|
||||
→ UniRig / Tripo / Meshy. All of them produce a rig this pipeline will keep.
|
||||
|
||||
## Materials
|
||||
|
||||
Anime models are very often exported "unlit": `KHR_materials_unlit`, a **black**
|
||||
`baseColorFactor`, and the real texture wired to `emissiveTexture`. Renderers
|
||||
honouring the unlit extension use base colour and ignore emission — so Blender
|
||||
reads black, never references the images, and imports with `bpy.data.images`
|
||||
*empty*. The character comes out a silhouette, and there is no node graph left
|
||||
to patch afterwards.
|
||||
|
||||
`tools/gltf_fix.py` rewrites the container **before** import: emissive becomes
|
||||
base colour, the unlit flag is dropped. The game shades characters with its own
|
||||
toon material off ALBEDO anyway.
|
||||
|
||||
## Swapping the animation library
|
||||
|
||||
`merge_animations.py` supports two library layouts in
|
||||
`assets/characters/animations/`:
|
||||
`assets/characters/animations/_library.glb` is the CC0 Quaternius Universal
|
||||
Animation Library. Clips map through `LIBRARY_CLIP_MAP` in `tools/retarget.py`
|
||||
(18 mapped: Idle, Walk, Run, Sprint, Jump, Fall, Land, CrouchIdle, CrouchWalk,
|
||||
Dash, Death, Hit, Dance, Grapple, PistolIdle/Shoot/Reload, Throw).
|
||||
|
||||
**A. Multi-clip library GLB (the bundled default).** If `_library.glb` exists,
|
||||
its clips are merged and mapped through `LIBRARY_CLIP_MAP` in
|
||||
`tools/merge_animations.py`. The bundled file is the CC0 Quaternius Universal
|
||||
Animation Library (13 clips: Idle, Walk, Run, Sprint, Jump, Fall, Land,
|
||||
CrouchIdle, CrouchWalk, Dash, Death, Hit, Dance). **Important:** `autorig.py`
|
||||
rigs characters to *this GLB's own skeleton*, so if you replace `_library.glb`
|
||||
you must re-rig characters against the new one (the pipeline does this
|
||||
automatically). To pull in more of Quaternius's 120-clip set, drop in the Pro
|
||||
`_library.glb` and add rows to `LIBRARY_CLIP_MAP`.
|
||||
Characters no longer have to be rigged to the library's skeleton, so **replacing
|
||||
`_library.glb` does not require re-rigging anything** — just rebuild the skins.
|
||||
Missing clips are fine: the game falls back along sensible chains
|
||||
(`Slide → CrouchIdle → Idle`, `WallRun → Run` — see `CLIP_FALLBACKS` in
|
||||
`characters/skinned_player_model.gd`).
|
||||
|
||||
**B. Loose one-clip-per-file (Mixamo).** Delete `_library.glb` and instead add
|
||||
FBX/GLB files named by clip (`idle.fbx` → `Idle`, `crouch_walk.fbx` →
|
||||
`CrouchWalk`). Download from mixamo.com as *FBX Binary, Without Skin, 30 fps*
|
||||
("In Place" variants). In this mode characters must be on the Mixamo skeleton
|
||||
(rig via Mixamo web / AccuRig and pass `--rigged`, or let the heuristic
|
||||
`build_mixamo_armature` fit one).
|
||||
## Measuring, not eyeballing
|
||||
|
||||
Missing clips are fine either way: the game falls back along sensible chains
|
||||
(`Slide → CrouchIdle → Idle`, `WallRun → Run`, etc. — see `CLIP_FALLBACKS` in
|
||||
`characters/skinned_player_model.gd`). A model with just Idle/Walk/Run still
|
||||
animates in every movement state.
|
||||
- `blender --background --python tools/verify_character.py -- <glb>` — the build
|
||||
gate. Every check corresponds to a defect this project actually shipped.
|
||||
- `godot --headless --path . -s res://debug/limb_deform_check.gd -- <glb>` —
|
||||
skins the mesh itself and reports lengthwise stretch and cross-section loss
|
||||
against the skeleton's REST pose, so an unposed model reads exactly 1.00 and a
|
||||
wrong metric is visible immediately. Read its header before trusting a number
|
||||
you add: three earlier versions of this measurement were themselves wrong and
|
||||
nearly caused bad "fixes".
|
||||
- `godot --path . --windowed --resolution 1280x720 -s res://debug/anim_capture.gd -- <out_dir> <skin_id>`
|
||||
— renders every movement state front and side.
|
||||
|
||||
Current Taila, worst over a run/walk/jump/fall/slide/dash sweep: knee
|
||||
cross-section 0.85–0.86, everything else 0.89–1.00, worst stretch 1.16.
|
||||
|
||||
## How it works in-game
|
||||
|
||||
- **`SkinManager` (autoload)** reads `assets/characters/skins/skins.json` at
|
||||
boot. Selected skin persists per-user and is synced to other players via
|
||||
`synced_skin_id`.
|
||||
- **`SkinnedPlayerModel`** loads the GLB at runtime, maps canonical clip
|
||||
names, sets loop modes, blends between clips (0.15 s), and scales
|
||||
locomotion playback speed to actual movement speed.
|
||||
- **First person (owner):** the model renders shadows-only for the owner (the
|
||||
camera sits inside the head, so drawing the mesh would show its inside). It's
|
||||
still fully animated and casts a real shadow; other players see the full body.
|
||||
- **Third person:** press **V** (`toggle_camera_view`) to swap to an
|
||||
over-the-shoulder `SpringArm3D` camera and reveal your own animated model —
|
||||
the easiest way to eyeball that a new skin's animations look right. Firing
|
||||
still uses the first-person camera, so aim is unchanged. Press V again to
|
||||
return to first person.
|
||||
- **Other players** always see the full third-person model, driven by the
|
||||
synced movement state, with the current weapon attached to the hand bone.
|
||||
- **Licensing:** every Sketchfab download writes `<name>.license.json`.
|
||||
CC-BY models require crediting the author — keep these files and surface
|
||||
them in your credits screen before shipping.
|
||||
- **`SkinManager`** (autoload) reads `skins.json` at boot; the selection persists
|
||||
per user and syncs via `synced_skin_id`.
|
||||
- **`SkinnedPlayerModel`** loads the GLB, reads the sidecar, installs
|
||||
`ShooterPoseModifier` (lean / ADS / slide / wall-run / weapon hold) then
|
||||
`SpringBones` — in that order, so the springs react to the FINAL body pose.
|
||||
- **`SkinJointHelper`** runs for every model regardless of rig. It is not a
|
||||
weight repair: linear-blend skinning collapses any joint by cos(θ/2) however
|
||||
good the weights are. It subdivides the knee through helper bones. It resolves
|
||||
the joint's parent from the SKELETON — hardcoding `DEF-thigh.L` meant it
|
||||
silently did nothing on a rig with twist bones, and the knee measured 0.76
|
||||
instead of 0.85.
|
||||
- **First person (owner):** the model renders shadows-only (the camera sits
|
||||
inside the head). Press **V** to swap to the over-the-shoulder camera.
|
||||
- **Licensing:** every Sketchfab download writes `<name>.license.json`. CC-BY
|
||||
models require crediting the author — surface these in a credits screen.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- *Model T-poses in game* — the GLB has no animations; re-run
|
||||
`merge_animations.py` and check it printed `Merged N clips`.
|
||||
- *Mesh deforms badly at shoulders/hips* — heuristic rig didn't fit; rig via
|
||||
Mixamo web or AccuRig and re-run with `--rigged`.
|
||||
- *Character slides while walking* — clips exported with root motion; re-run
|
||||
without `--keep-root-motion` (stripping is the default).
|
||||
- *Skin missing in exported build* — raw `.glb` files must be included in the
|
||||
export: Project → Export → Resources → include filter `*.glb, *.json`.
|
||||
- *Character is a black silhouette* — unlit materials; see **Materials**. Check
|
||||
the built GLB actually has images.
|
||||
- *Model T-poses* — the GLB has no animations, or the retarget produced a frozen
|
||||
rest pose. `verify_character.py` catches both.
|
||||
- *Skirt rides one leg* — a cloth bone got parented to a limb. Cloth must anchor
|
||||
to the trunk only.
|
||||
- *Cloth is rigid* — the sidecar is missing (check the export filter) or the
|
||||
clips are keying cloth bones (`verify_character.py` checks this).
|
||||
- *Cloth flies off the model* — a spring instability. `SpringBones` substeps and
|
||||
clamps for exactly this; do not remove those guards.
|
||||
- *Limbs squash at a stride* — measure with `limb_deform_check.gd` before
|
||||
changing anything. Renders are repeatedly misleading; a slim anime leg at full
|
||||
stride genuinely looks stretched.
|
||||
|
||||
+6
-1
@@ -6,7 +6,12 @@ runnable=true
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
; Character skins are loaded as RAW files at runtime (GLBLoader reads the bytes
|
||||
; and parses them with GLTFDocument), and each one needs its .rig.json sidecar
|
||||
; for cloth chains, leg colliders and bone roles. Neither is a Godot resource,
|
||||
; so without this filter an exported build ships characters with no secondary
|
||||
; motion at all — or no character.
|
||||
include_filter="*.glb, *.json"
|
||||
exclude_filter=""
|
||||
export_path="./Papaya-Shooter.exe"
|
||||
patches=PackedStringArray()
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Rewrite glTF materials that hide their albedo in the emissive slot.
|
||||
|
||||
Anime models are very often exported "unlit": `KHR_materials_unlit`, a BLACK
|
||||
`baseColorFactor`, and the actual texture wired to `emissiveTexture`. Renderers
|
||||
that honour the unlit extension are supposed to use base colour and ignore
|
||||
emission — so Blender reads black, never references the images at all, and
|
||||
imports the model with `bpy.data.images` empty. The textures are not lost on
|
||||
export; they are never loaded. Taila goes through the whole pipeline and comes
|
||||
out a silhouette.
|
||||
|
||||
Patching Blender's node graph afterwards cannot fix this, because by then there
|
||||
is nothing to patch — so the file is normalised BEFORE it is imported:
|
||||
emissive becomes base colour, and the unlit flag is dropped. The game shades
|
||||
these characters with its own toon material off ALBEDO anyway.
|
||||
|
||||
Pure stdlib, so it runs inside Blender's Python or out of it.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
|
||||
_MAGIC = 0x46546C67
|
||||
_JSON = 0x4E4F534A
|
||||
_BIN = 0x004E4942
|
||||
|
||||
|
||||
def _read_glb(path):
|
||||
with open(path, "rb") as f:
|
||||
magic, version, _total = struct.unpack("<III", f.read(12))
|
||||
if magic != _MAGIC:
|
||||
raise ValueError(f"not a GLB: {path}")
|
||||
doc = None
|
||||
chunks = []
|
||||
while True:
|
||||
header = f.read(8)
|
||||
if len(header) < 8:
|
||||
break
|
||||
length, ctype = struct.unpack("<II", header)
|
||||
data = f.read(length)
|
||||
if ctype == _JSON:
|
||||
doc = json.loads(data.decode("utf-8"))
|
||||
chunks.append((ctype, data))
|
||||
if doc is None:
|
||||
raise ValueError(f"GLB has no JSON chunk: {path}")
|
||||
return version, doc, chunks
|
||||
|
||||
|
||||
def _write_glb(path, version, doc, chunks):
|
||||
out = []
|
||||
for ctype, data in chunks:
|
||||
if ctype == _JSON:
|
||||
data = json.dumps(doc, separators=(",", ":")).encode("utf-8")
|
||||
data += b" " * ((4 - len(data) % 4) % 4)
|
||||
else:
|
||||
data += b"\0" * ((4 - len(data) % 4) % 4)
|
||||
out.append((ctype, data))
|
||||
total = 12 + sum(8 + len(d) for _c, d in out)
|
||||
with open(path, "wb") as f:
|
||||
f.write(struct.pack("<III", _MAGIC, version, total))
|
||||
for ctype, data in out:
|
||||
f.write(struct.pack("<II", len(data), ctype))
|
||||
f.write(data)
|
||||
|
||||
|
||||
def _is_black(colour):
|
||||
return colour is not None and max(colour[:3]) <= 0.001
|
||||
|
||||
|
||||
def normalize_unlit(in_path, out_path):
|
||||
"""Move emissive albedo into base colour. Returns how many materials changed.
|
||||
|
||||
Only touches materials that are actually broken this way — a black base
|
||||
colour with something in emission. A material that already has a proper
|
||||
base colour texture is left exactly as it is.
|
||||
"""
|
||||
version, doc, chunks = _read_glb(in_path)
|
||||
changed = 0
|
||||
for mat in doc.get("materials", []):
|
||||
pbr = mat.setdefault("pbrMetallicRoughness", {})
|
||||
base_factor = pbr.get("baseColorFactor", [1.0, 1.0, 1.0, 1.0])
|
||||
has_base_tex = "baseColorTexture" in pbr
|
||||
emissive_tex = mat.get("emissiveTexture")
|
||||
emissive_factor = mat.get("emissiveFactor", [0.0, 0.0, 0.0])
|
||||
if has_base_tex or not _is_black(base_factor):
|
||||
continue
|
||||
if emissive_tex is None and _is_black(emissive_factor):
|
||||
continue # genuinely black material — leave it alone
|
||||
if emissive_tex is not None:
|
||||
pbr["baseColorTexture"] = emissive_tex
|
||||
mat.pop("emissiveTexture", None)
|
||||
alpha = base_factor[3] if len(base_factor) > 3 else 1.0
|
||||
pbr["baseColorFactor"] = [emissive_factor[0], emissive_factor[1],
|
||||
emissive_factor[2], alpha]
|
||||
mat["emissiveFactor"] = [0.0, 0.0, 0.0]
|
||||
# Unlit would tell the importer to ignore everything but base colour;
|
||||
# the game lights these with its own toon shader.
|
||||
ext = mat.get("extensions", {})
|
||||
ext.pop("KHR_materials_unlit", None)
|
||||
if ext:
|
||||
mat["extensions"] = ext
|
||||
else:
|
||||
mat.pop("extensions", None)
|
||||
changed += 1
|
||||
|
||||
if changed:
|
||||
used = doc.get("extensionsUsed", [])
|
||||
still = any("KHR_materials_unlit" in m.get("extensions", {})
|
||||
for m in doc.get("materials", []))
|
||||
if not still and "KHR_materials_unlit" in used:
|
||||
used.remove("KHR_materials_unlit")
|
||||
if used:
|
||||
doc["extensionsUsed"] = used
|
||||
else:
|
||||
doc.pop("extensionsUsed", None)
|
||||
_write_glb(out_path, version, doc, chunks)
|
||||
return changed
|
||||
|
||||
|
||||
def prepare(in_path, work_dir):
|
||||
"""Return a path safe to import: the original, or a normalised copy."""
|
||||
if os.path.splitext(in_path)[1].lower() != ".glb":
|
||||
return in_path
|
||||
candidate = os.path.join(
|
||||
work_dir, os.path.splitext(os.path.basename(in_path))[0] + ".albedo.glb")
|
||||
try:
|
||||
changed = normalize_unlit(in_path, candidate)
|
||||
except (OSError, ValueError, KeyError, IndexError) as e:
|
||||
print(f"WARNING: could not normalise materials in '{in_path}' ({e})")
|
||||
return in_path
|
||||
if changed:
|
||||
print(f"Moved emissive albedo into base colour on {changed} materials "
|
||||
"(model was exported unlit)")
|
||||
return candidate
|
||||
return in_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python tools/gltf_fix.py <in.glb> <out.glb>")
|
||||
sys.exit(1)
|
||||
n = normalize_unlit(sys.argv[1], sys.argv[2])
|
||||
print(f"{n} materials rewritten")
|
||||
+66
-20
@@ -25,6 +25,7 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@@ -56,6 +57,32 @@ def find_blender() -> str:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def has_skeleton(path: str) -> bool:
|
||||
"""Does this glTF already carry a skin? Read straight out of the container
|
||||
so the check costs nothing — launching Blender just to ask takes seconds.
|
||||
Non-glTF formats can't be probed this way; pass --rigged for those.
|
||||
"""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
try:
|
||||
if ext == ".gltf":
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return bool(json.load(f).get("skins"))
|
||||
if ext != ".glb":
|
||||
return False
|
||||
with open(path, "rb") as f:
|
||||
magic, _ver, total = struct.unpack("<III", f.read(12))
|
||||
if magic != 0x46546C67:
|
||||
return False
|
||||
while f.tell() < total:
|
||||
length, ctype = struct.unpack("<II", f.read(8))
|
||||
chunk = f.read(length)
|
||||
if ctype == 0x4E4F534A: # JSON
|
||||
return bool(json.loads(chunk.decode("utf-8")).get("skins"))
|
||||
except (OSError, ValueError, struct.error) as e:
|
||||
print(f"WARNING: could not probe '{path}' for a skeleton ({e})")
|
||||
return False
|
||||
|
||||
|
||||
def run(cmd: list[str], step: str) -> None:
|
||||
print(f"\n=== {step} ===")
|
||||
print(" ".join(f'"{c}"' if " " in c else c for c in cmd))
|
||||
@@ -98,7 +125,11 @@ def main() -> None:
|
||||
p.add_argument("--name", required=True, help="skin id (snake_case)")
|
||||
p.add_argument("--display-name", help="name shown in menus (default: from --name)")
|
||||
p.add_argument("--description", default="", help="skin description")
|
||||
p.add_argument("--rigged", action="store_true", help="input is already rigged — skip autorig")
|
||||
p.add_argument("--rigged", action="store_true",
|
||||
help="force the keep-the-rig path (auto-detected for glTF)")
|
||||
p.add_argument("--rebind", action="store_true",
|
||||
help="discard the source rig and fit the library skeleton "
|
||||
"(last resort — destroys authored weights and cloth bones)")
|
||||
p.add_argument("--height", type=float, default=1.75, help="target character height in meters")
|
||||
p.add_argument("--keep-root-motion", action="store_true", help="don't strip hips motion from clips")
|
||||
p.add_argument("--anim-dir", default=ANIM_DIR, help="animation library directory")
|
||||
@@ -131,29 +162,44 @@ def main() -> None:
|
||||
blender = find_blender()
|
||||
print(f"Using Blender: {blender}")
|
||||
|
||||
# 2. Auto-rig (or pass through if already rigged).
|
||||
rigged_path = os.path.join(STAGING, f"{name}_rigged.glb")
|
||||
if args.rigged:
|
||||
rigged_path = input_path
|
||||
print("Skipping autorig (--rigged)")
|
||||
else:
|
||||
run([blender, "--background", "--python", os.path.join(TOOLS, "autorig.py"),
|
||||
"--", input_path, rigged_path, str(args.height)],
|
||||
"Auto-rig (Blender)")
|
||||
|
||||
# 3. Merge the shared animation library.
|
||||
if not os.path.isdir(args.anim_dir) or not any(
|
||||
f.lower().endswith((".fbx", ".glb", ".gltf")) for f in os.listdir(args.anim_dir)):
|
||||
print(f"ERROR: animation library is empty: {args.anim_dir}")
|
||||
print("Fill it with one clip per file (idle.fbx, run.fbx, ...) — see docs/ASSET_SOURCES.md")
|
||||
# 2. Decide whether the model already has a skeleton worth keeping.
|
||||
#
|
||||
# Keeping it is strongly preferred and is now the default. The old route
|
||||
# (strip_rig -> autorig) discarded the source skeleton, joined every mesh
|
||||
# into one, and rebound with nearest-bone weights — which is what put 2817
|
||||
# both-legs-at-once vertices into the shipped Taila and cost her the skirt
|
||||
# and hair bone chains outright. --rebind still exists for a model that
|
||||
# genuinely has no usable rig, but it is the lossy path.
|
||||
rigged = args.rigged or (not args.rebind and has_skeleton(input_path))
|
||||
if not os.path.isdir(args.anim_dir) or not os.path.exists(
|
||||
os.path.join(args.anim_dir, "_library.glb")):
|
||||
print(f"ERROR: animation library not found: {args.anim_dir}/_library.glb")
|
||||
print("See docs/ASSET_SOURCES.md")
|
||||
sys.exit(1)
|
||||
|
||||
final_path = os.path.join(SKINS_DIR, f"{name}.glb")
|
||||
merge_cmd = [blender, "--background", "--python", os.path.join(TOOLS, "merge_animations.py"),
|
||||
"--", rigged_path, args.anim_dir, final_path]
|
||||
if rigged:
|
||||
print("Model is rigged — keeping its skeleton, weights and cloth chains")
|
||||
cmd = [blender, "--background", "--python", os.path.join(TOOLS, "retarget.py"),
|
||||
"--", input_path, args.anim_dir, final_path, "--height", str(args.height)]
|
||||
if args.keep_root_motion:
|
||||
merge_cmd.append("--keep-root-motion")
|
||||
run(merge_cmd, "Merge animation library (Blender)")
|
||||
cmd.append("--keep-root-motion")
|
||||
run(cmd, "Retarget animation library onto the model's own rig (Blender)")
|
||||
else:
|
||||
print("No skeleton found — fitting the library rig (authored weights unavailable)")
|
||||
rigged_path = os.path.join(STAGING, f"{name}_rigged.glb")
|
||||
run([blender, "--background", "--python", os.path.join(TOOLS, "autorig.py"),
|
||||
"--", input_path, rigged_path, str(args.height)],
|
||||
"Auto-rig (Blender)")
|
||||
cmd = [blender, "--background", "--python", os.path.join(TOOLS, "retarget.py"),
|
||||
"--", rigged_path, args.anim_dir, final_path, "--height", str(args.height)]
|
||||
if args.keep_root_motion:
|
||||
cmd.append("--keep-root-motion")
|
||||
run(cmd, "Retarget animation library (Blender)")
|
||||
|
||||
# 3. Gate on the checks that encode every way this has gone wrong before.
|
||||
run([blender, "--background", "--python", os.path.join(TOOLS, "verify_character.py"),
|
||||
"--", final_path], "Verify the built character (Blender)")
|
||||
|
||||
# 4. Carry the license file along if the model came from Sketchfab.
|
||||
lic_src = os.path.splitext(input_path)[0] + ".license.json"
|
||||
|
||||
@@ -0,0 +1,887 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Put the animation library onto a character WITHOUT touching how it deforms.
|
||||
|
||||
This replaces the old strip_rig -> autorig -> merge_animations route, which
|
||||
solved a naming problem by destroying the asset. That route threw away the
|
||||
character's skeleton, joined every mesh into one blob, and rebound the result
|
||||
with nearest-four-bones Euclidean weights. Measured on the shipped taila.glb it
|
||||
produced 2817 vertices pulled by BOTH legs (worst a dead 50/50 split) and 86%
|
||||
of all vertices carrying the full four influences — while the ORIGINAL file it
|
||||
was built from had zero cross-leg bleed, one mesh per material, and dedicated
|
||||
bone chains for the skirt and the hair. Every runtime "repair" in
|
||||
characters/skin_leg_repair.gd exists to undo damage done right here.
|
||||
|
||||
So: keep the character's own rig, weights, per-part meshes and cloth chains,
|
||||
and move the ANIMATION onto it instead.
|
||||
|
||||
1. Rebuild parenting. A Rigify DEF-rig exports its chain roots parented
|
||||
straight to the armature root, because Rigify drives them by constraint
|
||||
rather than hierarchy. Left that way, rotating the hips would leave the
|
||||
legs, skirt and hair floating in place. Orphans are re-attached by
|
||||
anatomy where it is known and by rest geometry otherwise — and cloth may
|
||||
only ever attach to the trunk, never to a limb, or a skirt would ride one
|
||||
thigh.
|
||||
2. Retarget by ROLE, not by name (see tools/rig_map.py).
|
||||
3. Bake each clip as a rest-relative delta:
|
||||
|
||||
R_world = src_pose_rot * src_rest_rot^-1 (what the clip does)
|
||||
tgt_rot = R_world * tgt_rest_rot (done to THIS rig)
|
||||
|
||||
Copying absolute world orientation instead — which is what the old
|
||||
constraint bake did — forces the library's bone roll onto a mesh that was
|
||||
bound with a different one, and twists every limb by a constant offset.
|
||||
4. Drive ONLY the body. Skirt, hair, twist and face bones are left with no
|
||||
keys at all, so they rest relative to their parents and are free for the
|
||||
spring solver at runtime. That split — clips animate the body, physics
|
||||
animates the cloth — is the whole point.
|
||||
|
||||
Usage:
|
||||
blender --background --python tools/retarget.py -- \
|
||||
<character.glb> <animations_dir> <output.glb> [--height 1.75] [--keep-root-motion]
|
||||
|
||||
Writes <output>.rig.json beside the GLB: resolved bone roles, cloth chains and
|
||||
twist pairs, so the runtime never has to re-guess the skeleton's anatomy.
|
||||
"""
|
||||
import bpy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from mathutils import Matrix, Quaternion, Vector
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import gltf_fix
|
||||
import rig_map
|
||||
from rig_map import RigRoles, build_map, is_cosmetic, is_segment_of, tokens
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if len(argv) < 3:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
CHARACTER, ANIM_DIR, OUTPUT = argv[0], argv[1], argv[2]
|
||||
STRIP_ROOT_MOTION = "--keep-root-motion" not in argv
|
||||
TARGET_HEIGHT = 1.75
|
||||
if "--height" in argv:
|
||||
TARGET_HEIGHT = float(argv[argv.index("--height") + 1])
|
||||
OVERRIDES = {}
|
||||
if "--bone-map" in argv:
|
||||
with open(argv[argv.index("--bone-map") + 1], "r", encoding="utf-8") as f:
|
||||
OVERRIDES = json.load(f)
|
||||
|
||||
# Library clip -> the game's canonical clip name. Unmapped clips are skipped so
|
||||
# character GLBs stay small.
|
||||
LIBRARY_CLIP_MAP = {
|
||||
"Idle_Loop": "Idle",
|
||||
"Walk_Loop": "Walk",
|
||||
"Jog_Fwd_Loop": "Run",
|
||||
"Sprint_Loop": "Sprint",
|
||||
"Jump_Start": "Jump",
|
||||
"Jump_Loop": "Fall",
|
||||
"Jump_Land": "Land",
|
||||
"Crouch_Idle_Loop": "CrouchIdle",
|
||||
"Crouch_Fwd_Loop": "CrouchWalk",
|
||||
"Roll": "Dash",
|
||||
"Death01": "Death",
|
||||
"Hit_Chest": "Hit",
|
||||
"Dance_Loop": "Dance",
|
||||
"Swim_Fwd_Loop": "Grapple",
|
||||
"Pistol_Idle_Loop": "PistolIdle",
|
||||
"Pistol_Shoot": "PistolShoot",
|
||||
"Pistol_Reload": "PistolReload",
|
||||
"Sword_Attack": "Throw",
|
||||
}
|
||||
|
||||
UP = Vector((0.0, 0.0, 1.0))
|
||||
|
||||
# Which cosmetic chains get secondary motion. A face-shape or eye chain is
|
||||
# cosmetic but must never swing, so this is deliberately narrower than
|
||||
# rig_map.COSMETIC.
|
||||
SPRING_CLASSES = {"hair", "skirt", "cloth", "ribbon", "tail", "cape", "coat",
|
||||
"scarf", "sleeve", "breast", "bust", "feather", "strap",
|
||||
"antenna", "wing"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- import
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.actions):
|
||||
for b in list(blocks):
|
||||
if b.users == 0:
|
||||
blocks.remove(b)
|
||||
|
||||
|
||||
def import_any(path):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in (".glb", ".gltf"):
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
elif ext == ".fbx":
|
||||
bpy.ops.import_scene.fbx(filepath=path)
|
||||
else:
|
||||
print(f"ERROR: unsupported character format {ext}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def skinned_meshes(arm):
|
||||
"""Meshes actually driven by this armature.
|
||||
|
||||
Anything else in the file is scene dressing — Sketchfab models routinely
|
||||
ship a display base or a diorama, and those must not become part of the
|
||||
player.
|
||||
"""
|
||||
out = []
|
||||
for o in [o for o in bpy.data.objects if o.type == "MESH"]:
|
||||
driven = any(m.type == "ARMATURE" and m.object is arm for m in o.modifiers)
|
||||
if driven or (o.parent is arm and o.vertex_groups):
|
||||
out.append(o)
|
||||
return out
|
||||
|
||||
|
||||
def strip_import_suffixes(arm, meshes):
|
||||
"""`DEF-thigh.L_16` -> `DEF-thigh.L`.
|
||||
|
||||
The glTF importer appends the node index to every bone name. Those names
|
||||
ship in the exported GLB and are what the game code matches on, so clean
|
||||
them up here rather than teaching every consumer about the suffix.
|
||||
"""
|
||||
renames = {}
|
||||
taken = set(b.name for b in arm.data.bones)
|
||||
for bone in arm.data.bones:
|
||||
clean = rig_map.strip_gltf_suffix(bone.name)
|
||||
if clean != bone.name and clean not in taken:
|
||||
renames[bone.name] = clean
|
||||
taken.discard(bone.name)
|
||||
taken.add(clean)
|
||||
for old, new in renames.items():
|
||||
arm.data.bones[old].name = new
|
||||
# Vertex groups are matched to bones by NAME and are not renamed for us.
|
||||
for m in meshes:
|
||||
for vg in m.vertex_groups:
|
||||
new = renames.get(vg.name)
|
||||
if new and new not in m.vertex_groups:
|
||||
vg.name = new
|
||||
print(f"Cleaned {len(renames)} bone names")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- hierarchy
|
||||
|
||||
|
||||
def _seg_distance(p, a, b):
|
||||
ab = b - a
|
||||
d2 = ab.dot(ab)
|
||||
t = 0.0 if d2 < 1e-12 else max(0.0, min(1.0, (p - a).dot(ab) / d2))
|
||||
return (p - (a + ab * t)).length
|
||||
|
||||
|
||||
def rebuild_hierarchy(arm, roles):
|
||||
"""Re-attach chain roots that exported parented to the armature root.
|
||||
|
||||
Anatomy first (a thigh belongs to the hips, a forearm to the upper arm),
|
||||
rest geometry second. Cloth and hair are only ever allowed to attach to the
|
||||
TRUNK: pick anchors by raw proximity and a skirt panel hanging beside a leg
|
||||
attaches to that thigh and rides it like a trouser leg.
|
||||
"""
|
||||
trunk = [roles.hips] + list(roles.spine)
|
||||
trunk = [n for n in trunk if n]
|
||||
limb = roles.limb
|
||||
|
||||
def anat(role, side):
|
||||
return limb.get((role, side))
|
||||
|
||||
fixed = {}
|
||||
for side in ("L", "R"):
|
||||
chest = trunk[-3] if len(trunk) >= 3 else (trunk[-1] if trunk else None)
|
||||
pairs = [
|
||||
(("thigh", side), roles.hips),
|
||||
(("shin", side), anat("thigh", side)),
|
||||
(("foot", side), anat("shin", side)),
|
||||
(("toe", side), anat("foot", side)),
|
||||
(("shoulder", side), chest),
|
||||
(("upper_arm", side), anat("shoulder", side) or chest),
|
||||
(("forearm", side), anat("upper_arm", side)),
|
||||
(("hand", side), anat("forearm", side)),
|
||||
]
|
||||
for key, parent in pairs:
|
||||
name = limb.get(key)
|
||||
if name and parent:
|
||||
fixed[name] = parent
|
||||
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
eb = arm.data.edit_bones
|
||||
|
||||
# Where each anchor bone actually EXTENDS TO, from authored data only.
|
||||
#
|
||||
# glTF stores joints as nodes with no tail, so the tails Blender reports are
|
||||
# invented — every one of Taila's skirt bones comes back 0.78 m long. Using
|
||||
# them, DEF-spine's phantom tail runs straight down through the whole skirt
|
||||
# and every panel measures ~0.05 m from "the hips", which beat each panel's
|
||||
# real 0.15 m link to its own chain root and flattened all 7 chains.
|
||||
# A bone's true extent is the head of the next bone along.
|
||||
span = {}
|
||||
for i, name in enumerate(trunk):
|
||||
nxt = trunk[i + 1] if i + 1 < len(trunk) else None
|
||||
span[name] = (eb[name].head,
|
||||
eb[nxt].head if nxt and nxt in eb else eb[name].tail)
|
||||
for name in limb.values():
|
||||
if name not in eb:
|
||||
continue
|
||||
kids = [c for c in eb[name].children if not is_cosmetic(c.name)]
|
||||
span[name] = (eb[name].head, kids[0].head if kids else eb[name].tail)
|
||||
|
||||
def anchor_distance(point, name):
|
||||
a, b = span.get(name, (eb[name].head, eb[name].tail))
|
||||
return _seg_distance(point, a, b)
|
||||
|
||||
def is_root_like(bone):
|
||||
return bone is None or any(
|
||||
t in ("root", "master", "armature", "scene", "rootjoint")
|
||||
for t in tokens(bone.name))
|
||||
|
||||
def descendants(bone):
|
||||
out = {bone.name}
|
||||
stack = list(bone.children)
|
||||
while stack:
|
||||
b = stack.pop()
|
||||
out.add(b.name)
|
||||
stack.extend(b.children)
|
||||
return out
|
||||
|
||||
trunk_set = set(trunk)
|
||||
core = trunk_set | set(limb.values())
|
||||
orphans = [b for b in eb
|
||||
if b.name != roles.hips and b.name not in trunk_set
|
||||
and is_root_like(b.parent)]
|
||||
|
||||
reparented = 0
|
||||
cosmetic_left = []
|
||||
for bone in orphans:
|
||||
if is_cosmetic(bone.name):
|
||||
cosmetic_left.append(bone)
|
||||
continue
|
||||
target = fixed.get(bone.name)
|
||||
if target is None:
|
||||
banned = descendants(bone)
|
||||
cands = [n for n in core if n in eb and n not in banned]
|
||||
if not cands:
|
||||
continue
|
||||
target = min(cands, key=lambda n: anchor_distance(bone.head, n))
|
||||
if target in eb and target != bone.name:
|
||||
bone.parent = eb[target]
|
||||
bone.use_connect = False
|
||||
reparented += 1
|
||||
|
||||
# Cloth and hair hang from the TRUNK — never from a limb, and never from
|
||||
# each other.
|
||||
#
|
||||
# Attaching cloth to whatever bone is nearest puts 16 of Taila's 21 skirt
|
||||
# bones on a thigh, where the panel rides one leg like a trouser leg. The
|
||||
# trunk restriction fixes that.
|
||||
#
|
||||
# Reconstructing multi-bone chains is deliberately NOT attempted. The
|
||||
# temptation is obvious — Taila's skirt is really 7 panels of 3 — but the
|
||||
# information is not in the file. glTF stores no bone tails, the panel
|
||||
# numbering is not sequential (`skirt` -> `skirt.011` -> `skirt.002`), and
|
||||
# neighbouring panel roots ring the waist 0.04 m apart, far closer than any
|
||||
# of them is to the trunk. Successive attempts at distance, chain-direction
|
||||
# and grow-outward rules each produced a topology that was still wrong
|
||||
# somewhere — stitching panels together sideways, or hanging hair off an
|
||||
# eye bone. A wrong chain is worse than no chain: the solver then swings
|
||||
# bones along axes the mesh was never weighted for, and tears it.
|
||||
#
|
||||
# So each orphan becomes its own pendulum from the body. Chains the ARTIST
|
||||
# authored survive untouched, because only chain ROOTS are orphans — which
|
||||
# is why Taila's hair keeps its real 4-7 bone strands while her
|
||||
# flat-exported skirt becomes per-panel pendulums. Both look like cloth;
|
||||
# only the authored one gets true multi-segment drape.
|
||||
for bone in cosmetic_left:
|
||||
cands = [n for n in trunk_set if n in eb]
|
||||
if not cands:
|
||||
break
|
||||
bone.parent = eb[min(cands, key=lambda n: anchor_distance(bone.head, n))]
|
||||
bone.use_connect = False
|
||||
reparented += 1
|
||||
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
print(f"Re-attached {reparented} orphaned bones")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- normalize
|
||||
|
||||
|
||||
def flatten_and_scale(arm, meshes, target_height):
|
||||
"""Bake the import hierarchy away and set the character's real-world size.
|
||||
|
||||
Sketchfab wraps everything in scaled/rotated empties. Left in place they
|
||||
turn up as a scale on the exported Skeleton3D, and every measurement the
|
||||
game makes off bone rests reads in the wrong units.
|
||||
"""
|
||||
if arm.animation_data:
|
||||
arm.animation_data_clear()
|
||||
for pb in arm.pose.bones:
|
||||
pb.matrix_basis = Matrix()
|
||||
|
||||
for obj in [arm] + meshes:
|
||||
world = obj.matrix_world.copy()
|
||||
obj.parent = None
|
||||
obj.matrix_world = world
|
||||
for o in [o for o in bpy.data.objects if o.type == "EMPTY"]:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
def apply_all():
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in [arm] + meshes:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
apply_all()
|
||||
|
||||
lo = Vector((1e9, 1e9, 1e9))
|
||||
hi = -lo.copy()
|
||||
for m in meshes:
|
||||
for corner in m.bound_box:
|
||||
p = m.matrix_world @ Vector(corner)
|
||||
lo = Vector((min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z)))
|
||||
hi = Vector((max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z)))
|
||||
height = hi.z - lo.z
|
||||
if height > 1e-4:
|
||||
s = target_height / height
|
||||
for obj in [arm] + meshes:
|
||||
obj.scale = (s, s, s)
|
||||
apply_all()
|
||||
lo *= s
|
||||
hi *= s
|
||||
print(f"Scaled by {s:.4f} to {target_height:.2f} m")
|
||||
|
||||
offset = Vector((-(lo.x + hi.x) * 0.5, -(lo.y + hi.y) * 0.5, -lo.z))
|
||||
for obj in [arm] + meshes:
|
||||
obj.location = offset
|
||||
apply_all()
|
||||
|
||||
# Re-parent meshes under the armature so the export writes one clean skin.
|
||||
for m in meshes:
|
||||
m.parent = arm
|
||||
m.matrix_parent_inverse = Matrix()
|
||||
if not any(mod.type == "ARMATURE" and mod.object is arm for mod in m.modifiers):
|
||||
mod = m.modifiers.new("Armature", "ARMATURE")
|
||||
mod.object = arm
|
||||
|
||||
|
||||
def fix_unlit_materials(meshes):
|
||||
"""Route each material's texture into Base Color.
|
||||
|
||||
Anime models are commonly authored UNLIT: black base colour with the albedo
|
||||
wired to emission. Our toon shader reads ALBEDO, so left alone the
|
||||
character renders pitch black.
|
||||
"""
|
||||
seen = set()
|
||||
for m in meshes:
|
||||
for mat in m.data.materials:
|
||||
if not mat or not mat.use_nodes or mat.name in seen:
|
||||
continue
|
||||
seen.add(mat.name)
|
||||
nt = mat.node_tree
|
||||
tex = next((n for n in nt.nodes if n.type == "TEX_IMAGE" and n.image), None)
|
||||
bsdf = next((n for n in nt.nodes if n.type == "BSDF_PRINCIPLED"), None)
|
||||
if not bsdf or not tex:
|
||||
continue
|
||||
base = bsdf.inputs["Base Color"]
|
||||
if not base.links:
|
||||
nt.links.new(tex.outputs["Color"], base)
|
||||
print(f"Material '{mat.name}': routed '{tex.image.name}' to base colour")
|
||||
if "Emission Strength" in bsdf.inputs:
|
||||
bsdf.inputs["Emission Strength"].default_value = 0.0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ retarget
|
||||
|
||||
|
||||
def world_rest(arm):
|
||||
mw = arm.matrix_world
|
||||
return {b.name: mw @ b.matrix_local for b in arm.data.bones}
|
||||
|
||||
|
||||
def rig_forward(arm, roles):
|
||||
"""Which way the rest pose faces, on the ground plane, from the feet."""
|
||||
mw = arm.matrix_world
|
||||
acc = Vector((0.0, 0.0, 0.0))
|
||||
for side in ("L", "R"):
|
||||
for role in ("toe", "foot"):
|
||||
name = roles.limb.get((role, side))
|
||||
if not name:
|
||||
continue
|
||||
b = arm.data.bones[name]
|
||||
v = (mw @ b.tail_local) - (mw @ b.matrix_local.translation)
|
||||
v.z = 0.0
|
||||
if v.length > 1e-5:
|
||||
acc += v.normalized()
|
||||
break
|
||||
return acc.normalized() if acc.length > 1e-5 else None
|
||||
|
||||
|
||||
def facing_correction(src_arm, src_roles, tgt_arm, tgt_roles):
|
||||
"""Yaw that carries the source rig's forward onto the target's.
|
||||
|
||||
Without it a library that rests facing -Y drives a character that rests
|
||||
facing +Y and every clip plays backwards.
|
||||
"""
|
||||
a = rig_forward(src_arm, src_roles)
|
||||
b = rig_forward(tgt_arm, tgt_roles)
|
||||
if a is None or b is None:
|
||||
return Quaternion()
|
||||
# angle_signed is 2D-only in mathutils, which is what we want anyway: the
|
||||
# correction is a yaw about world up, never a tilt.
|
||||
angle = Vector((a.x, a.y)).angle_signed(Vector((b.x, b.y)), 0.0)
|
||||
if abs(angle) < 1e-4:
|
||||
return Quaternion()
|
||||
print(f"Facing correction: {angle * 57.2958:.1f} deg")
|
||||
return Quaternion(UP, angle)
|
||||
|
||||
|
||||
def bone_order(arm):
|
||||
"""Every bone, parents before children."""
|
||||
out = []
|
||||
|
||||
def walk(b):
|
||||
out.append(b.name)
|
||||
for c in b.children:
|
||||
walk(c)
|
||||
|
||||
for b in arm.data.bones:
|
||||
if b.parent is None:
|
||||
walk(b)
|
||||
return out
|
||||
|
||||
|
||||
def solve_pose(arm, order, rest_w, desired_rot, hips, hips_head):
|
||||
"""Turn desired WORLD orientations into per-bone local basis transforms.
|
||||
|
||||
Done arithmetically rather than by setting `pose_bone.matrix` and letting
|
||||
Blender solve, because that needs a depsgraph update per bone — 150 bones
|
||||
across 18 clips is tens of thousands of scene evaluations.
|
||||
|
||||
Blender relates pose to rest as
|
||||
pose = parent_pose * parent_rest^-1 * rest * basis
|
||||
so with M standing for everything left of `basis`, a rotation-only basis of
|
||||
M.rot^-1 * desired lands the bone on `desired` exactly.
|
||||
"""
|
||||
pose_w = {}
|
||||
basis = {}
|
||||
bones = arm.data.bones
|
||||
for name in order:
|
||||
b = bones[name]
|
||||
rest = rest_w[name]
|
||||
if b.parent is not None:
|
||||
M = pose_w[b.parent.name] @ rest_w[b.parent.name].inverted() @ rest
|
||||
else:
|
||||
M = rest
|
||||
q = Quaternion()
|
||||
if name in desired_rot:
|
||||
q = M.to_quaternion().inverted() @ desired_rot[name]
|
||||
loc = Vector((0.0, 0.0, 0.0))
|
||||
if name == hips and hips_head is not None:
|
||||
loc = M.inverted() @ hips_head
|
||||
basis[name] = (loc, q)
|
||||
pose_w[name] = M @ Matrix.Translation(loc) @ q.to_matrix().to_4x4()
|
||||
return basis
|
||||
|
||||
|
||||
def retarget_clip(src_arm, src_roles, tgt_arm, tgt_roles, mapping, action,
|
||||
clip_name, yaw, scale):
|
||||
src_rest = world_rest(src_arm)
|
||||
tgt_rest = world_rest(tgt_arm)
|
||||
order = bone_order(tgt_arm)
|
||||
src_rest_rot = {n: m.to_quaternion() for n, m in src_rest.items()}
|
||||
tgt_rest_rot = {n: m.to_quaternion() for n, m in tgt_rest.items()}
|
||||
yaw_inv = yaw.inverted()
|
||||
|
||||
src_hips = src_roles.hips
|
||||
tgt_hips = tgt_roles.hips
|
||||
src_hips_rest = src_rest[src_hips].translation.copy()
|
||||
tgt_hips_rest = tgt_rest[tgt_hips].translation.copy()
|
||||
|
||||
assign_action(src_arm, action)
|
||||
f0, f1 = (int(round(v)) for v in action.frame_range)
|
||||
|
||||
baked = bpy.data.actions.new(clip_name)
|
||||
assign_action(tgt_arm, baked)
|
||||
for pb in tgt_arm.pose.bones:
|
||||
pb.rotation_mode = "QUATERNION"
|
||||
|
||||
scene = bpy.context.scene
|
||||
for frame in range(f0, f1 + 1):
|
||||
scene.frame_set(frame)
|
||||
dg = bpy.context.evaluated_depsgraph_get()
|
||||
src_eval = src_arm.evaluated_get(dg)
|
||||
smw = src_eval.matrix_world
|
||||
|
||||
desired = {}
|
||||
for tgt_name, src_name in mapping.items():
|
||||
if src_name not in src_eval.pose.bones or tgt_name not in tgt_rest_rot:
|
||||
continue
|
||||
pose_rot = (smw @ src_eval.pose.bones[src_name].matrix).to_quaternion()
|
||||
delta = pose_rot @ src_rest_rot[src_name].inverted()
|
||||
desired[tgt_name] = (yaw @ delta @ yaw_inv) @ tgt_rest_rot[tgt_name]
|
||||
|
||||
hips_head = None
|
||||
if src_hips in src_eval.pose.bones:
|
||||
moved = (smw @ src_eval.pose.bones[src_hips].matrix).translation
|
||||
d = yaw @ ((moved - src_hips_rest) * scale)
|
||||
if STRIP_ROOT_MOTION:
|
||||
d.x = 0.0
|
||||
d.y = 0.0 # gameplay code moves the body; keep the vertical bob
|
||||
hips_head = tgt_hips_rest + d
|
||||
|
||||
basis = solve_pose(tgt_arm, order, tgt_rest, desired, tgt_hips, hips_head)
|
||||
for name in mapping:
|
||||
if name not in basis:
|
||||
continue
|
||||
pb = tgt_arm.pose.bones[name]
|
||||
pb.rotation_quaternion = basis[name][1]
|
||||
pb.keyframe_insert("rotation_quaternion", frame=frame)
|
||||
if hips_head is not None:
|
||||
pb = tgt_arm.pose.bones[tgt_hips]
|
||||
pb.location = basis[tgt_hips][0]
|
||||
pb.keyframe_insert("location", frame=frame)
|
||||
|
||||
assign_action(tgt_arm, None)
|
||||
return baked
|
||||
|
||||
|
||||
def assign_action(obj, action):
|
||||
if not obj.animation_data:
|
||||
obj.animation_data_create()
|
||||
obj.animation_data.action = action
|
||||
if action is None:
|
||||
return
|
||||
try: # Blender 4.4+ slotted actions
|
||||
if not obj.animation_data.action_slot and len(action.slots):
|
||||
obj.animation_data.action_slot = action.slots[0]
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def add_nla_clip(arm, action, name):
|
||||
action.name = name
|
||||
track = arm.animation_data.nla_tracks.new()
|
||||
track.name = name
|
||||
strip = track.strips.new(name, 0, action)
|
||||
strip.name = name
|
||||
track.mute = True
|
||||
action.use_fake_user = True
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- sidecar
|
||||
|
||||
|
||||
def _dominant_vertices(meshes, arm):
|
||||
"""bone name -> world positions of the vertices it mostly owns.
|
||||
|
||||
"Mostly" as in holds the largest share — a vertex belongs to one bone for
|
||||
the purpose of measuring what that bone covers, even though it is skinned
|
||||
to several.
|
||||
"""
|
||||
out = defaultdict(list)
|
||||
for m in meshes:
|
||||
gname = {g.index: g.name for g in m.vertex_groups}
|
||||
mw = m.matrix_world
|
||||
for v in m.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[gname.get(best.group, "")].append(mw @ v.co)
|
||||
return out
|
||||
|
||||
|
||||
def _bone_tip(arm, bone, chain, index, owned, fallback):
|
||||
"""Where a bone effectively points, in its own rest space.
|
||||
|
||||
The next bone along when there is one. Otherwise the centroid of the
|
||||
geometry this bone actually drives — which is the only real answer for
|
||||
Taila's skirt, whose 21 panel bones export with no children and no usable
|
||||
tail, so there is nothing in the skeleton to say which way a panel hangs.
|
||||
"""
|
||||
rest_world = arm.matrix_world @ arm.data.bones[bone].matrix_local
|
||||
if index + 1 < len(chain):
|
||||
nxt = arm.matrix_world @ arm.data.bones[chain[index + 1]].matrix_local
|
||||
return rest_world.inverted() @ nxt.translation
|
||||
pts = owned.get(bone, [])
|
||||
if pts:
|
||||
centroid = sum(pts, Vector((0.0, 0.0, 0.0))) / len(pts)
|
||||
local = rest_world.inverted() @ centroid
|
||||
# The centroid sits mid-panel, so the far edge is roughly twice out.
|
||||
if local.length > 1e-4:
|
||||
return local * 2.0
|
||||
return fallback
|
||||
|
||||
|
||||
def _leg_colliders(arm, roles, owned):
|
||||
"""Capsules for the legs, sized from the body geometry itself.
|
||||
|
||||
The skirt has to be kept off the thighs, and a guessed radius either lets
|
||||
it clip through or holds it out in a bell. The 70th percentile of how far a
|
||||
leg bone's own vertices sit from its axis measures the actual limb.
|
||||
"""
|
||||
out = []
|
||||
for role, child_role in (("thigh", "shin"), ("shin", "foot")):
|
||||
for side in ("L", "R"):
|
||||
name = roles.limb.get((role, side))
|
||||
child = roles.limb.get((child_role, side))
|
||||
if not name or not child:
|
||||
continue
|
||||
a = (arm.matrix_world @ arm.data.bones[name].matrix_local).translation
|
||||
b = (arm.matrix_world @ arm.data.bones[child].matrix_local).translation
|
||||
pts = owned.get(name, [])
|
||||
if len(pts) < 8:
|
||||
continue
|
||||
radii = sorted(_seg_distance(p, a, b) for p in pts)
|
||||
out.append({
|
||||
"bone": name, "child": child,
|
||||
"radius": round(radii[int(len(radii) * 0.7)], 4),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def describe_rig(arm, roles, mapping, meshes):
|
||||
"""Record what we worked out, so the runtime never re-guesses anatomy."""
|
||||
names = set(b.name for b in arm.data.bones)
|
||||
owned = _dominant_vertices(meshes, arm)
|
||||
roles_out = {"hips": roles.hips, "head": roles.head, "neck": roles.neck,
|
||||
"spine": list(roles.spine)}
|
||||
for (role, side), name in roles.limb.items():
|
||||
roles_out[f"{role}.{side}"] = name
|
||||
|
||||
driven = set(mapping)
|
||||
chains = []
|
||||
springy = {b.name: b for b in arm.data.bones
|
||||
if b.name not in driven
|
||||
and any(t in SPRING_CLASSES for t in tokens(b.name))}
|
||||
for name, bone in springy.items():
|
||||
if bone.parent is not None and bone.parent.name in springy:
|
||||
continue # not the root of a chain
|
||||
# One chain per leaf path, so each strand solves independently.
|
||||
stack = [[name]]
|
||||
while stack:
|
||||
path = stack.pop()
|
||||
kids = [c.name for c in arm.data.bones[path[-1]].children
|
||||
if c.name in springy]
|
||||
if not kids:
|
||||
tips = []
|
||||
fallback = Vector((0.0, 0.0, -0.06))
|
||||
for i in range(len(path)):
|
||||
t = _bone_tip(arm, path[i], path, i, owned, fallback)
|
||||
tips.append([round(t.x, 5), round(t.y, 5), round(t.z, 5)])
|
||||
fallback = t
|
||||
chains.append({
|
||||
"class": next((t for t in tokens(path[0])
|
||||
if t in SPRING_CLASSES), "cloth"),
|
||||
"root_parent": bone.parent.name if bone.parent else None,
|
||||
"bones": path,
|
||||
"tips": tips,
|
||||
})
|
||||
continue
|
||||
for k in kids:
|
||||
stack.append(path + [k])
|
||||
|
||||
# Twist bones only — a hair link is also `X.001`, but it is cloth, and
|
||||
# listing it here would have the twist distributor and the spring solver
|
||||
# both writing the same bone.
|
||||
twist = []
|
||||
for b in arm.data.bones:
|
||||
if b.name in driven or is_cosmetic(b.name) or not is_segment_of(b.name, names):
|
||||
continue
|
||||
if b.parent is not None:
|
||||
twist.append({"bone": b.name, "parent": b.parent.name,
|
||||
"child": b.children[0].name if b.children else None})
|
||||
return {"roles": roles_out, "chains": chains, "twist": twist,
|
||||
"colliders": _leg_colliders(arm, roles, owned),
|
||||
"weights_authored": _weights_look_authored(meshes, roles),
|
||||
"driven_bones": sorted(driven)}
|
||||
|
||||
|
||||
def _weights_look_authored(meshes, roles):
|
||||
"""Were these weights painted, or solved by a nearest-bone fit?
|
||||
|
||||
The runtime decides from this whether to run its destructive load-time
|
||||
weight repair, so it is MEASURED rather than inferred from which pipeline
|
||||
branch ran — a model that arrives unrigged still goes through autorig and
|
||||
out through this same tool, and must not be handed a sidecar that says its
|
||||
weights are fine when they are not.
|
||||
|
||||
Two signatures, both taken from the shipped-vs-source comparison that
|
||||
started this rework: the nearest-four-bones fit left 16% of vertices pulled
|
||||
by BOTH legs and gave 86% of them the full four influences, where the
|
||||
artist's own weights had 0.1% and 26%.
|
||||
"""
|
||||
legs = {}
|
||||
for (role, side), name in roles.limb.items():
|
||||
if role in ("thigh", "shin", "foot", "toe"):
|
||||
legs[name] = -1 if side == "L" else 1
|
||||
|
||||
def side_of(group_name):
|
||||
for name, s in legs.items():
|
||||
if group_name == name or group_name.startswith(name + "."):
|
||||
return s
|
||||
return 0
|
||||
|
||||
total = 0
|
||||
bleeding = 0
|
||||
four = 0
|
||||
for m in meshes:
|
||||
gside = {g.index: side_of(g.name) for g in m.vertex_groups}
|
||||
for v in m.data.vertices:
|
||||
wl = wr = 0.0
|
||||
n = 0
|
||||
for g in v.groups:
|
||||
if g.weight <= 0.005:
|
||||
continue
|
||||
n += 1
|
||||
s = gside.get(g.group, 0)
|
||||
if s == -1:
|
||||
wl += g.weight
|
||||
elif s == 1:
|
||||
wr += g.weight
|
||||
total += 1
|
||||
if n >= 4:
|
||||
four += 1
|
||||
if wl > 0.005 and wr > 0.005:
|
||||
bleeding += 1
|
||||
if total == 0:
|
||||
return False
|
||||
authored = bleeding / total < 0.02 and four / total < 0.5
|
||||
print(f"Weights: {bleeding} cross-leg ({bleeding / total * 100:.1f}%), "
|
||||
f"{four / total * 100:.0f}% at four influences — "
|
||||
f"{'authored' if authored else 'solved, runtime repair stays on'}")
|
||||
return authored
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ main
|
||||
|
||||
|
||||
def main():
|
||||
clear_scene()
|
||||
print(f"Importing character {CHARACTER}")
|
||||
# Normalise emissive-albedo materials FIRST. Blender honours
|
||||
# KHR_materials_unlit and reads only base colour, so an unlit model with its
|
||||
# texture in the emissive slot imports with no images at all and exports a
|
||||
# black silhouette — there is no node graph left to repair afterwards.
|
||||
import_any(gltf_fix.prepare(CHARACTER, tempfile.gettempdir()))
|
||||
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
||||
if not arms:
|
||||
print("ERROR: character has no armature. Rig it first (see docs/3D_ASSET_PIPELINE.md).")
|
||||
sys.exit(1)
|
||||
arm = max(arms, key=lambda a: len(a.data.bones))
|
||||
meshes = skinned_meshes(arm)
|
||||
if not meshes:
|
||||
print("ERROR: no skinned meshes bound to the armature")
|
||||
sys.exit(1)
|
||||
for o in [o for o in bpy.data.objects if o.type == "MESH" and o not in meshes]:
|
||||
print(f"Dropping unskinned prop mesh: {o.name}")
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
print(f"Character: {len(arm.data.bones)} bones, {len(meshes)} meshes "
|
||||
f"({sum(len(m.data.vertices) for m in meshes)} verts) — weights kept as authored")
|
||||
|
||||
strip_import_suffixes(arm, meshes)
|
||||
for a in list(bpy.data.actions):
|
||||
bpy.data.actions.remove(a) # the character's own clip is not ours
|
||||
|
||||
roles = RigRoles(arm)
|
||||
missing = roles.missing_core()
|
||||
if missing:
|
||||
print(f"ERROR: could not identify these bones on the character rig: {missing}")
|
||||
print("Resolved so far:\n" + roles.describe())
|
||||
sys.exit(1)
|
||||
rebuild_hierarchy(arm, roles)
|
||||
flatten_and_scale(arm, meshes, TARGET_HEIGHT)
|
||||
fix_unlit_materials(meshes)
|
||||
roles = RigRoles(arm) # rest positions moved; re-read
|
||||
|
||||
library = os.path.join(ANIM_DIR, "_library.glb")
|
||||
if not os.path.exists(library):
|
||||
print(f"ERROR: animation library not found: {library}")
|
||||
sys.exit(1)
|
||||
before = set(bpy.data.objects)
|
||||
before_actions = set(bpy.data.actions)
|
||||
bpy.ops.import_scene.gltf(filepath=library)
|
||||
new_objects = [o for o in bpy.data.objects if o not in before]
|
||||
src_arm = next((o for o in new_objects if o.type == "ARMATURE"), None)
|
||||
if not src_arm:
|
||||
print("ERROR: no armature in the animation library")
|
||||
sys.exit(1)
|
||||
src_roles = RigRoles(src_arm)
|
||||
|
||||
mapping = build_map(src_roles, roles, OVERRIDES)
|
||||
print("\nLibrary rig:\n" + src_roles.describe())
|
||||
print("\nCharacter rig:\n" + roles.describe())
|
||||
print(f"\nDriving {len(mapping)} of {len(arm.data.bones)} bones from the library; "
|
||||
f"{len(arm.data.bones) - len(mapping)} left free for secondary motion.")
|
||||
|
||||
yaw = facing_correction(src_arm, src_roles, arm, roles)
|
||||
src_h = (src_arm.matrix_world @ src_arm.data.bones[src_roles.hips].matrix_local).translation.z
|
||||
tgt_h = (arm.matrix_world @ arm.data.bones[roles.hips].matrix_local).translation.z
|
||||
scale = tgt_h / src_h if src_h > 1e-5 else 1.0
|
||||
print(f"Hips height: library {src_h:.3f} m, character {tgt_h:.3f} m (scale {scale:.3f})")
|
||||
|
||||
if not arm.animation_data:
|
||||
arm.animation_data_create()
|
||||
for track in list(arm.animation_data.nla_tracks):
|
||||
arm.animation_data.nla_tracks.remove(track)
|
||||
|
||||
merged = 0
|
||||
for action in [a for a in bpy.data.actions if a not in before_actions]:
|
||||
clip = LIBRARY_CLIP_MAP.get(action.name.split(".")[0])
|
||||
if not clip:
|
||||
continue
|
||||
print(f" {action.name} -> {clip}")
|
||||
baked = retarget_clip(src_arm, src_roles, arm, roles, mapping, action,
|
||||
clip, yaw, scale)
|
||||
add_nla_clip(arm, baked, clip)
|
||||
merged += 1
|
||||
if merged == 0:
|
||||
print("ERROR: no clips retargeted")
|
||||
sys.exit(1)
|
||||
|
||||
for o in new_objects:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
||||
sidecar = os.path.splitext(OUTPUT)[0] + ".rig.json"
|
||||
info = describe_rig(arm, roles, mapping, meshes)
|
||||
with open(sidecar, "w", encoding="utf-8") as f:
|
||||
json.dump(info, f, indent=2)
|
||||
print(f"Wrote {os.path.basename(sidecar)}: {len(info['chains'])} cloth chains, "
|
||||
f"{len(info['twist'])} twist bones, {len(info['colliders'])} leg colliders")
|
||||
|
||||
print(f"Merged {merged} clips. Exporting {OUTPUT}")
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=OUTPUT,
|
||||
export_format="GLB",
|
||||
export_yup=True,
|
||||
export_apply=False,
|
||||
export_skins=True,
|
||||
export_animations=True,
|
||||
export_animation_mode="NLA_TRACKS",
|
||||
# Export ONLY the bones the clips actually key. Baking every bone
|
||||
# writes rest-pose tracks for the skirt and hair too, which triples the
|
||||
# clip data and — worse — has the AnimationPlayer overwrite exactly the
|
||||
# bones the spring solver is meant to own.
|
||||
export_bake_animation=False,
|
||||
export_optimize_animation_size=True,
|
||||
# "keep_anim_armature" is what forces a track onto every bone whether
|
||||
# or not the clip touches it. Off, so the skirt and hair export with no
|
||||
# tracks at all and belong entirely to the spring solver.
|
||||
export_optimize_animation_keep_anim_armature=False,
|
||||
)
|
||||
print("Done.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Work out which bone on one rig means the same thing as which bone on another.
|
||||
|
||||
This is the piece that lets us STOP throwing away a character's own skeleton.
|
||||
The old pipeline discarded any foreign rig (tools/strip_rig.py) because
|
||||
merge_animations.py retargeted by exact bone NAME, so a rig that named things
|
||||
differently produced a rest-pose statue. Rebinding the mesh to the library
|
||||
skeleton then destroyed everything an artist had done: per-part weights, skirt
|
||||
and hair bone chains, limb twist bones. Solving the naming problem here is what
|
||||
makes keeping the original rig possible.
|
||||
|
||||
Roles are resolved STRUCTURALLY wherever a name would lie:
|
||||
|
||||
* `hips` is the lowest common ancestor of both thighs and the head, not
|
||||
whatever is called "hips". Rigify calls it `DEF-spine`; Mixamo calls it
|
||||
`mixamorig:Hips`; both land on the same bone this way.
|
||||
* The spine chain is walked from the hips upward, refusing to turn down a
|
||||
limb or a cosmetic chain (hair/skirt/face). The bone it ends on is the
|
||||
head. That matters because a stock Rigify rig has NO bone with "head" in
|
||||
its name — the head is `DEF-spine.006`.
|
||||
* Chains of different length are matched by normalised position along the
|
||||
chain, so a 4-bone torso drives a 3-bone one and vice versa.
|
||||
|
||||
Only bones the CLIPS need are mapped. Everything else on the character rig —
|
||||
skirt chains, hair chains, twist bones, face bones — is deliberately left
|
||||
unmapped so it rests relative to its parent and is free to be driven by
|
||||
secondary motion at runtime. That is the whole point: the locomotion library
|
||||
animates the body, physics animates the cloth.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Names that are never part of the body proper. Walking the spine must not turn
|
||||
# down one of these, and they must never claim a limb role.
|
||||
#
|
||||
# Matched as whole NAME TOKENS, never as substrings. A plain `"ear" in name`
|
||||
# test marks every `DEF-forearm.L` cosmetic — which silently cost both rigs
|
||||
# their forearms and is exactly the class of bug this file exists to avoid.
|
||||
COSMETIC = ("hair", "skirt", "cloth", "ribbon", "tail", "cape", "coat",
|
||||
"scarf", "sleeve", "breast", "bust", "ear", "horn", "wing",
|
||||
"face", "cheek", "nose", "mouth", "eye", "brow", "jaw", "tongue",
|
||||
"teeth", "tooth", "lip", "chin", "accessory", "prop", "weapon",
|
||||
"bell", "strap", "belt", "buckle", "feather", "antenna")
|
||||
|
||||
# role -> ordered alternative stems. Ordered because "leg" must not win before
|
||||
# "upleg" has had its chance: Mixamo's LeftUpLeg is a thigh and its LeftLeg is
|
||||
# a shin, so the more specific spelling has to be tested first.
|
||||
LIMB_ROLES = {
|
||||
"thigh": ("upleg", "upperleg", "thigh", "leg_upper", "upper_leg", "hip"),
|
||||
"shin": ("lowerleg", "lowleg", "shin", "calf", "knee", "leg_lower", "leg"),
|
||||
"foot": ("foot", "ankle"),
|
||||
"toe": ("toebase", "toe", "ball"),
|
||||
"shoulder": ("shoulder", "clavicle", "collar"),
|
||||
"upper_arm": ("upperarm", "upper_arm", "arm_upper", "armupper", "arm"),
|
||||
"forearm": ("forearm", "lowerarm", "lowarm", "arm_lower", "elbow"),
|
||||
"hand": ("hand", "wrist"),
|
||||
}
|
||||
# Longest-first inside each role, so "upperarm" is tried before "arm".
|
||||
LIMB_ORDER = ["toe", "foot", "shin", "thigh", "shoulder", "hand", "forearm", "upper_arm"]
|
||||
|
||||
_PREFIXES = re.compile(
|
||||
r"^(def[-_]|org[-_]|mch[-_]|ctrl[-_]|mixamorig\d*[:_]|bip\d*[-_ ]|"
|
||||
r"j_bip_[clr]_|j_sec_[clr]_|valvebiped\.|bone_|b_)", re.I)
|
||||
# The glTF importer appends _<node index> to every bone name; strip it so
|
||||
# `DEF-spine.006_2` reads as `DEF-spine.006`.
|
||||
_GLTF_SUFFIX = re.compile(r"_\d+$")
|
||||
|
||||
|
||||
def strip_gltf_suffix(name: str) -> str:
|
||||
return _GLTF_SUFFIX.sub("", name)
|
||||
|
||||
|
||||
def canon(name: str) -> str:
|
||||
"""Bone name reduced to a comparable stem: no rig prefix, no separators."""
|
||||
n = strip_gltf_suffix(name).lower()
|
||||
while True:
|
||||
stripped = _PREFIXES.sub("", n)
|
||||
if stripped == n:
|
||||
break
|
||||
n = stripped
|
||||
return re.sub(r"[^a-z0-9]", "", n)
|
||||
|
||||
|
||||
def side_of(name: str):
|
||||
"""'L', 'R' or None. Checked on the ORIGINAL name so `.L` survives."""
|
||||
n = strip_gltf_suffix(name).lower()
|
||||
if re.search(r"(^|[._\- ])l($|[._\- 0-9])", n) or "left" in n:
|
||||
return "L"
|
||||
if re.search(r"(^|[._\- ])r($|[._\- 0-9])", n) or "right" in n:
|
||||
return "R"
|
||||
return None
|
||||
|
||||
|
||||
def tokens(name: str):
|
||||
"""Name split into alphabetic words: `DEF-hair.L.001` -> def, hair, l."""
|
||||
return [t for t in re.split(r"[^a-z]+", strip_gltf_suffix(name).lower()) if t]
|
||||
|
||||
|
||||
def is_cosmetic(name: str) -> bool:
|
||||
"""Whole-token match only — see the note on COSMETIC."""
|
||||
return any(t in COSMETIC for t in tokens(name))
|
||||
|
||||
|
||||
def is_segment_of(name: str, all_names: set) -> bool:
|
||||
"""True for twist/segment bones like `DEF-upper_arm.L.001`.
|
||||
|
||||
Rigify subdivides a limb into a main bone plus numbered twist bones. Those
|
||||
must never claim the limb's role — the clip would drive the twist bone and
|
||||
the real limb would stay put. Detected by construction rather than by a
|
||||
name list: strip a trailing `.001` and see whether the parent spelling is
|
||||
itself a bone on this rig.
|
||||
"""
|
||||
base = strip_gltf_suffix(name)
|
||||
m = re.match(r"^(.*)\.(\d{3})$", base)
|
||||
if not m or m.group(2) == "000":
|
||||
return False
|
||||
stem = m.group(1)
|
||||
return any(strip_gltf_suffix(n) == stem for n in all_names)
|
||||
|
||||
|
||||
class RigRoles:
|
||||
"""Resolved anatomy of one armature."""
|
||||
|
||||
def __init__(self, arm):
|
||||
self.arm = arm
|
||||
self.bones = {b.name: b for b in arm.data.bones}
|
||||
self.names = set(self.bones)
|
||||
self.limb = {} # ("thigh", "L") -> bone name
|
||||
self.spine = [] # hips-exclusive chain, ordered, ending on the head
|
||||
self.hips = None
|
||||
self.head = None
|
||||
self.neck = None
|
||||
self._resolve_limbs()
|
||||
self._resolve_spine()
|
||||
|
||||
# ---------------------------------------------------------------- limbs
|
||||
|
||||
def _resolve_limbs(self):
|
||||
for name in self.names:
|
||||
if is_cosmetic(name) or is_segment_of(name, self.names):
|
||||
continue
|
||||
side = side_of(name)
|
||||
if side is None:
|
||||
continue
|
||||
stem = canon(name)
|
||||
for role in LIMB_ORDER:
|
||||
if (role, side) in self.limb:
|
||||
continue
|
||||
if any(alt.replace("_", "") in stem for alt in LIMB_ROLES[role]):
|
||||
self.limb[(role, side)] = name
|
||||
break
|
||||
|
||||
# ---------------------------------------------------------------- spine
|
||||
|
||||
def _resolve_spine(self):
|
||||
"""Find the trunk as the LONGEST chain of non-limb, non-cosmetic bones.
|
||||
|
||||
Deliberately not "lowest common ancestor of the two thighs", which is
|
||||
the obvious thing and is wrong on the rigs we actually get. A Rigify
|
||||
DEF-rig exports its chain ROOTS parented straight to the armature root
|
||||
(Rigify drives them by constraint, not by hierarchy), so on Taila the
|
||||
thighs, shoulders, skirt and hair all hang off `rootJoint` and the LCA
|
||||
is that root — which makes the hips the root bone and the spine one
|
||||
bone long. Taking the longest trunk chain instead reads the same rig
|
||||
correctly whether or not its hierarchy survived export.
|
||||
"""
|
||||
blocked = set(self.limb.values())
|
||||
|
||||
def candidate(name):
|
||||
if name in blocked or is_cosmetic(name):
|
||||
return False
|
||||
return not any(t in ("root", "master", "armature", "scene", "rootjoint")
|
||||
for t in tokens(name))
|
||||
|
||||
def walk(start):
|
||||
chain = [start]
|
||||
cur = self.bones[start]
|
||||
while True:
|
||||
nxt = [c for c in cur.children if candidate(c.name)]
|
||||
if not nxt:
|
||||
break
|
||||
# If the walk forks, follow whichever branch reaches highest —
|
||||
# the torso continues upward, a stray nub does not.
|
||||
cur = max(nxt, key=self._branch_height)
|
||||
chain.append(cur.name)
|
||||
return chain
|
||||
|
||||
best = []
|
||||
for name in self.names:
|
||||
if not candidate(name):
|
||||
continue
|
||||
parent = self.bones[name].parent
|
||||
if parent is not None and candidate(parent.name):
|
||||
continue # not the base of a chain
|
||||
chain = walk(name)
|
||||
if len(chain) > len(best):
|
||||
best = chain
|
||||
if not best:
|
||||
return
|
||||
|
||||
self.hips = best[0]
|
||||
self.spine = best[1:]
|
||||
if self.spine:
|
||||
self.head = self.spine[-1]
|
||||
named = [n for n in self.spine if "neck" in tokens(n)]
|
||||
self.neck = named[0] if named else (
|
||||
self.spine[-2] if len(self.spine) > 1 else None)
|
||||
|
||||
def _branch_height(self, bone):
|
||||
"""How high this branch reaches, in WORLD space.
|
||||
|
||||
Measured on bone HEADS. glTF has no concept of a bone tail — joints are
|
||||
just nodes — so the tails Blender's importer shows are synthesised, and
|
||||
on Taila every skirt bone comes back with an identical 0.78 m tail.
|
||||
Heads are the only authored positions here.
|
||||
|
||||
World space, not armature-local: the importer leaves the Y-up-to-Z-up
|
||||
correction on the armature OBJECT, so a local-space test picked the
|
||||
pelvis over the spine and the walk stopped one bone in.
|
||||
"""
|
||||
mw = self.arm.matrix_world
|
||||
best = (mw @ bone.matrix_local.translation).z
|
||||
for c in bone.children:
|
||||
best = max(best, self._branch_height(c))
|
||||
return best
|
||||
|
||||
# --------------------------------------------------------------- report
|
||||
|
||||
def missing_core(self):
|
||||
need = [("thigh", "L"), ("thigh", "R"), ("shin", "L"), ("shin", "R"),
|
||||
("foot", "L"), ("foot", "R"), ("upper_arm", "L"), ("upper_arm", "R"),
|
||||
("forearm", "L"), ("forearm", "R"), ("hand", "L"), ("hand", "R")]
|
||||
miss = [f"{r}.{s}" for r, s in need if (r, s) not in self.limb]
|
||||
if not self.hips:
|
||||
miss.append("hips")
|
||||
if not self.head:
|
||||
miss.append("head")
|
||||
return miss
|
||||
|
||||
def describe(self):
|
||||
lines = [f" hips {self.hips}", f" spine {' -> '.join(self.spine)}"]
|
||||
for key in sorted(self.limb):
|
||||
lines.append(f" {key[0]}.{key[1]:<10s} {self.limb[key]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _match_chains(src_chain, tgt_chain):
|
||||
"""Pair up two ordered chains of possibly different length.
|
||||
|
||||
Each target bone takes the source bone nearest it in NORMALISED position,
|
||||
so a 4-bone torso can be driven by a 3-bone one. Because the retarget
|
||||
copies ABSOLUTE world orientation rather than composing local rotations,
|
||||
two target bones sharing one source bone simply end up parallel — the chain
|
||||
still finishes where the source says it does, it does not double the bend.
|
||||
"""
|
||||
pairs = []
|
||||
if not src_chain or not tgt_chain:
|
||||
return pairs
|
||||
for i, tgt in enumerate(tgt_chain):
|
||||
t = (i + 0.5) / len(tgt_chain)
|
||||
j = min(range(len(src_chain)),
|
||||
key=lambda k: abs((k + 0.5) / len(src_chain) - t))
|
||||
pairs.append((src_chain[j], tgt))
|
||||
return pairs
|
||||
|
||||
|
||||
def build_map(src: RigRoles, tgt: RigRoles, overrides: dict = None) -> dict:
|
||||
"""target bone name -> source bone name, for the core body only."""
|
||||
mapping = {}
|
||||
|
||||
if src.hips and tgt.hips:
|
||||
mapping[tgt.hips] = src.hips
|
||||
|
||||
# Split both spines at the neck so a head never drives a chest, then match
|
||||
# torso-to-torso and neck-to-neck by position.
|
||||
def split(roles):
|
||||
chain = roles.spine
|
||||
if not chain:
|
||||
return [], [], None
|
||||
head = chain[-1]
|
||||
rest = chain[:-1]
|
||||
if roles.neck and roles.neck in rest:
|
||||
i = rest.index(roles.neck)
|
||||
return rest[:i], rest[i:], head
|
||||
return rest, [], head
|
||||
|
||||
s_torso, s_neck, s_head = split(src)
|
||||
t_torso, t_neck, t_head = split(tgt)
|
||||
for a, b in _match_chains(s_torso, t_torso):
|
||||
mapping[b] = a
|
||||
for a, b in _match_chains(s_neck or s_torso[-1:], t_neck):
|
||||
mapping[b] = a
|
||||
if s_head and t_head:
|
||||
mapping[t_head] = s_head
|
||||
|
||||
for key, tgt_name in tgt.limb.items():
|
||||
src_name = src.limb.get(key)
|
||||
if src_name:
|
||||
mapping[tgt_name] = src_name
|
||||
|
||||
# Fingers and anything else that happens to share a spelling: map by
|
||||
# canonical stem + side. Cheap, and it makes a shared trigger-finger pose
|
||||
# come across when both rigs have fingers.
|
||||
src_by_stem = {}
|
||||
for n in src.names:
|
||||
src_by_stem.setdefault((canon(n), side_of(n)), n)
|
||||
for n in tgt.names:
|
||||
if n in mapping or is_cosmetic(n) or is_segment_of(n, tgt.names):
|
||||
continue
|
||||
hit = src_by_stem.get((canon(n), side_of(n)))
|
||||
if hit:
|
||||
mapping[n] = hit
|
||||
|
||||
if overrides:
|
||||
for tgt_name, src_name in overrides.items():
|
||||
if src_name is None:
|
||||
mapping.pop(tgt_name, None)
|
||||
else:
|
||||
mapping[tgt_name] = src_name
|
||||
return mapping
|
||||
@@ -1,5 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DEPRECATED — do not use. Kept only to explain what it did and why it was wrong.
|
||||
|
||||
This discarded a character's skeleton so autorig.py could refit the library one.
|
||||
It "solved" a bone-NAMING problem by destroying the asset: on Taila it cost 21
|
||||
skirt bones, ~50 hair bones, 8 limb twist bones, split 18 per-part meshes into
|
||||
one blob, and turned 17 both-legs-at-once vertices into 2817. Every runtime
|
||||
weight repair in characters/skin_leg_repair.gd exists to undo its output.
|
||||
|
||||
The naming problem is solved properly in tools/rig_map.py, which pairs two
|
||||
skeletons by resolved ROLE instead of by name, so tools/retarget.py can move the
|
||||
animation library onto a character's own rig and keep everything. Use that.
|
||||
|
||||
Original description follows.
|
||||
|
||||
Strip an existing rig from a character GLB so tools/autorig.py re-rigs it on
|
||||
the animation-library skeleton.
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert KHR_materials_unlit "emissive albedo" materials in a GLB to plain PBR.
|
||||
|
||||
Anime-style models often ship unlit: black baseColorFactor with the real
|
||||
albedo in emissiveTexture. Blender's importer turns those into textureless
|
||||
EMISSION node trees (the image is dropped), so everything downstream renders
|
||||
pitch black. Rewriting the material JSON up front — baseColorTexture :=
|
||||
emissiveTexture, white base factor, unlit/emissive stripped — gives every
|
||||
tool in the pipeline a normal textured PBR model.
|
||||
|
||||
Usage: python tools/unlit_to_pbr.py <in.glb> <out.glb>
|
||||
"""
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python tools/unlit_to_pbr.py <in.glb> <out.glb>")
|
||||
sys.exit(1)
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
|
||||
with open(src, "rb") as f:
|
||||
data = f.read()
|
||||
magic, version, _length = struct.unpack_from("<III", data, 0)
|
||||
if magic != 0x46546C67:
|
||||
print("ERROR: not a GLB file")
|
||||
sys.exit(1)
|
||||
|
||||
offset = 12
|
||||
json_chunk = None
|
||||
other_chunks = []
|
||||
while offset < len(data):
|
||||
clen, ctype = struct.unpack_from("<II", data, offset)
|
||||
chunk = data[offset + 8:offset + 8 + clen]
|
||||
if ctype == 0x4E4F534A: # 'JSON'
|
||||
json_chunk = chunk
|
||||
else:
|
||||
other_chunks.append((ctype, chunk))
|
||||
offset += 8 + clen
|
||||
|
||||
doc = json.loads(json_chunk)
|
||||
fixed = 0
|
||||
for mat in doc.get("materials", []):
|
||||
emis_tex = mat.get("emissiveTexture")
|
||||
if emis_tex is None:
|
||||
continue
|
||||
pbr = mat.setdefault("pbrMetallicRoughness", {})
|
||||
if "baseColorTexture" not in pbr:
|
||||
pbr["baseColorTexture"] = emis_tex
|
||||
pbr["baseColorFactor"] = [1.0, 1.0, 1.0, 1.0]
|
||||
pbr.setdefault("metallicFactor", 0.0)
|
||||
pbr["roughnessFactor"] = 1.0
|
||||
mat.pop("emissiveTexture", None)
|
||||
mat.pop("emissiveFactor", None)
|
||||
exts = mat.get("extensions", {})
|
||||
exts.pop("KHR_materials_unlit", None)
|
||||
if not exts:
|
||||
mat.pop("extensions", None)
|
||||
fixed += 1
|
||||
used = doc.get("extensionsUsed", [])
|
||||
if "KHR_materials_unlit" in used:
|
||||
used.remove("KHR_materials_unlit")
|
||||
if not used:
|
||||
doc.pop("extensionsUsed", None)
|
||||
|
||||
payload = json.dumps(doc, separators=(",", ":")).encode("utf-8")
|
||||
payload += b" " * (-len(payload) % 4)
|
||||
out = bytearray()
|
||||
out += struct.pack("<II", len(payload), 0x4E4F534A) + payload
|
||||
for ctype, chunk in other_chunks:
|
||||
chunk = chunk + b"\x00" * (-len(chunk) % 4)
|
||||
out += struct.pack("<II", len(chunk), ctype) + chunk
|
||||
header = struct.pack("<III", 0x46546C67, version, 12 + len(out))
|
||||
with open(dst, "wb") as f:
|
||||
f.write(header + out)
|
||||
print(f"Rewrote {fixed} unlit material(s) -> {dst}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check a built character GLB against the things that have actually gone wrong.
|
||||
|
||||
Every assertion here corresponds to a real defect this project shipped, so a
|
||||
green run means those specific failures are gone rather than that the file
|
||||
merely loads:
|
||||
|
||||
CROSS-LEG BLEED A vertex pulled by both legs sits between them and stays
|
||||
there while they separate, stretching every triangle around
|
||||
it. The old nearest-bone rebind left 2817 of these on
|
||||
Taila, worst at a dead 50/50 — the boots and thighs that
|
||||
characters/skin_leg_repair.gd was written to patch at
|
||||
runtime. Authored weights have none.
|
||||
INFLUENCE SPREAD 86% of vertices carrying the full four influences is the
|
||||
signature of K=4 Euclidean weighting, not of an artist.
|
||||
Real weights are mostly one or two bones.
|
||||
PART SPLIT One joined mesh means body, cloth and hair deform under one
|
||||
rule. Separate meshes per material is what lets a thigh
|
||||
stay solid while a skirt drapes.
|
||||
ORPHAN CHAINS A Rigify DEF-rig exports its chain roots on the armature
|
||||
root. If the rebuild missed one, that limb or strand floats
|
||||
in place while the body moves.
|
||||
CLIP MOTION A retarget that silently fails produces clips that exist
|
||||
but never move — the rest-pose statue this pipeline has
|
||||
produced before. Every clip must actually rotate the hips
|
||||
and the legs.
|
||||
CLOTH IS FREE Cloth bones must carry no keys, or the clips would fight
|
||||
the spring solver for them.
|
||||
|
||||
Usage:
|
||||
blender --background --python tools/verify_character.py -- <character.glb> [rig.json]
|
||||
|
||||
Exits non-zero if any check fails, so it can gate the pipeline.
|
||||
"""
|
||||
import bpy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if not argv:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
PATH = argv[0]
|
||||
SIDECAR = argv[1] if len(argv) > 1 else os.path.splitext(PATH)[0] + ".rig.json"
|
||||
|
||||
LEG_HINTS = ("thigh", "shin", "foot", "toe", "upleg", "calf")
|
||||
failures = []
|
||||
warnings = []
|
||||
# Set from the sidecar. A model whose source had no skeleton at all has to go
|
||||
# through autorig, and its weights are then a nearest-bone fit by construction —
|
||||
# there is no better result to demand. The deformation checks still RUN and
|
||||
# still print, so the cost is visible, but they cannot fail a build that had no
|
||||
# alternative; SkinLegRepair covers those models at load time instead.
|
||||
weights_authored = True
|
||||
|
||||
|
||||
def check(ok, label, detail="", needs_authored_weights=False):
|
||||
soft = needs_authored_weights and not weights_authored
|
||||
tag = "PASS" if ok else ("WARN" if soft else "FAIL")
|
||||
print(f" [{tag}] {label}" + (f" — {detail}" if detail else ""))
|
||||
if ok:
|
||||
return
|
||||
if soft:
|
||||
warnings.append(f"{label} — {detail}")
|
||||
else:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
def side_of(name):
|
||||
n = name.lower()
|
||||
if not any(h in n for h in LEG_HINTS):
|
||||
return 0
|
||||
if n.endswith(".l") or ".l." in n or "left" in n:
|
||||
return -1
|
||||
if n.endswith(".r") or ".r." in n or "right" in n:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if os.path.exists(SIDECAR):
|
||||
with open(SIDECAR, "r", encoding="utf-8") as f:
|
||||
weights_authored = bool(json.load(f).get("weights_authored", True))
|
||||
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
bpy.ops.import_scene.gltf(filepath=PATH)
|
||||
|
||||
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
||||
if not arms:
|
||||
print("FAIL: no armature")
|
||||
sys.exit(1)
|
||||
arm = max(arms, key=lambda a: len(a.data.bones))
|
||||
meshes = [o for o in bpy.data.objects if o.type == "MESH" and o.vertex_groups]
|
||||
|
||||
print(f"\n=== {os.path.basename(PATH)} — {len(arm.data.bones)} bones, "
|
||||
f"{len(meshes)} meshes, {sum(len(m.data.vertices) for m in meshes)} verts ===\n")
|
||||
|
||||
# ---------------------------------------------------------------- deformation
|
||||
total_bleed = 0
|
||||
worst_bleed = 0.0
|
||||
infl = defaultdict(int)
|
||||
for m in meshes:
|
||||
gside = {g.index: side_of(g.name) for g in m.vertex_groups}
|
||||
for v in m.data.vertices:
|
||||
wl = wr = 0.0
|
||||
n = 0
|
||||
for g in v.groups:
|
||||
if g.weight <= 0.005:
|
||||
continue
|
||||
n += 1
|
||||
s = gside.get(g.group, 0)
|
||||
if s == -1:
|
||||
wl += g.weight
|
||||
elif s == 1:
|
||||
wr += g.weight
|
||||
infl[n] += 1
|
||||
if wl > 0.005 and wr > 0.005:
|
||||
total_bleed += 1
|
||||
worst_bleed = max(worst_bleed, min(wl, wr) / (wl + wr))
|
||||
|
||||
nverts = sum(infl.values()) or 1
|
||||
four = infl.get(4, 0) / nverts
|
||||
# Not zero: a skirt hem genuinely spans both legs, and Taila's artist left 17
|
||||
# such vertices (0.1%) on purpose. The failure mode being caught is the SOLVER
|
||||
# signature — the nearest-bone rebind put 16% of the model in this state.
|
||||
bleed_frac = total_bleed / nverts
|
||||
check(bleed_frac < 0.005, "cross-leg blending is limited to draping cloth",
|
||||
f"{total_bleed} verts ({bleed_frac * 100:.1f}%), worst minority share {worst_bleed:.2f}",
|
||||
needs_authored_weights=True)
|
||||
check(four < 0.5, "influences look authored, not solved",
|
||||
f"{four * 100:.0f}% of verts carry 4 influences; spread {dict(sorted(infl.items()))}",
|
||||
needs_authored_weights=True)
|
||||
check(len(meshes) > 1, "model keeps its per-part meshes", f"{len(meshes)} meshes",
|
||||
needs_authored_weights=True)
|
||||
|
||||
# ------------------------------------------------------------------- skeleton
|
||||
def is_rootish(b):
|
||||
return b is None or any(t in b.name.lower()
|
||||
for t in ("root", "master", "armature"))
|
||||
|
||||
|
||||
orphans = [b.name for b in arm.data.bones
|
||||
if is_rootish(b.parent) and not is_rootish(b)]
|
||||
check(len(orphans) <= 1, "every chain is attached to the body",
|
||||
f"{len(orphans)} bones still on the armature root: {orphans[:6]}")
|
||||
|
||||
# ----------------------------------------------------------------------- clips
|
||||
actions = {a.name: a for a in bpy.data.actions}
|
||||
print(f"\n {len(actions)} clips: {', '.join(sorted(actions))}\n")
|
||||
check(len(actions) >= 10, "the canonical clip set shipped", f"{len(actions)} clips")
|
||||
|
||||
|
||||
def curves(action):
|
||||
legacy = getattr(action, "fcurves", None)
|
||||
if legacy is not None:
|
||||
return list(legacy)
|
||||
out = []
|
||||
for layer in getattr(action, "layers", []):
|
||||
for strip in layer.strips:
|
||||
for cbag in getattr(strip, "channelbags", []):
|
||||
out.extend(cbag.fcurves)
|
||||
return out
|
||||
|
||||
|
||||
def bone_of(path):
|
||||
if 'pose.bones["' not in path:
|
||||
return None
|
||||
s = path.index('"') + 1
|
||||
return path[s:path.index('"', s)]
|
||||
|
||||
|
||||
cloth = set()
|
||||
if os.path.exists(SIDECAR):
|
||||
with open(SIDECAR, "r", encoding="utf-8") as f:
|
||||
info = json.load(f)
|
||||
for c in info.get("chains", []):
|
||||
cloth.update(c["bones"])
|
||||
print(f" sidecar: {len(info.get('chains', []))} cloth chains "
|
||||
f"({len(cloth)} bones), {len(info.get('twist', []))} twist bones")
|
||||
else:
|
||||
warnings.append(f"no sidecar at {SIDECAR}")
|
||||
|
||||
# Clips whose legs MUST move. The library's Pistol_Idle_Loop and Pistol_Shoot
|
||||
# are upper-body clips with genuinely static legs, so demanding leg motion from
|
||||
# every clip fails on a correct build.
|
||||
LOCOMOTION = {"Idle", "Walk", "Run", "Sprint", "Jump", "Fall", "Land",
|
||||
"CrouchIdle", "CrouchWalk", "Dash"}
|
||||
|
||||
frozen = []
|
||||
legless = []
|
||||
keyed_cloth = set()
|
||||
for name, action in sorted(actions.items()):
|
||||
moved = defaultdict(float)
|
||||
for fc in curves(action):
|
||||
b = bone_of(fc.data_path)
|
||||
if not b or len(fc.keyframe_points) < 2:
|
||||
continue
|
||||
if b in cloth:
|
||||
keyed_cloth.add(b)
|
||||
vals = [kp.co.y for kp in fc.keyframe_points]
|
||||
moved[b] = max(moved[b], max(vals) - min(vals))
|
||||
if max(moved.values(), default=0.0) < 0.005:
|
||||
frozen.append(name)
|
||||
legs = max((v for b, v in moved.items()
|
||||
if any(h in b.lower() for h in ("thigh", "shin"))), default=0.0)
|
||||
if name in LOCOMOTION and legs < 0.01:
|
||||
legless.append(f"{name}({legs:.4f})")
|
||||
|
||||
check(not frozen, "no clip retargeted to a frozen rest pose",
|
||||
f"frozen: {', '.join(frozen)}" if frozen else "")
|
||||
check(not legless, "locomotion clips animate the legs",
|
||||
f"static legs: {', '.join(legless)}" if legless else "")
|
||||
check(not keyed_cloth, "cloth bones carry no animation keys",
|
||||
f"{len(keyed_cloth)} keyed: {sorted(keyed_cloth)[:5]}")
|
||||
|
||||
print()
|
||||
for w in warnings:
|
||||
print(f" [WARN] {w}")
|
||||
if failures:
|
||||
print(f"\n{len(failures)} CHECK(S) FAILED: {failures}\n")
|
||||
sys.exit(1)
|
||||
print("\nAll checks passed.\n")
|
||||
Reference in New Issue
Block a user