Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
19 changed files with 3220 additions and 221 deletions
Showing only changes of commit 374d9f9822 - Show all commits
Binary file not shown.
+110
View File
@@ -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"
]
}
+1 -1
View File
@@ -10,7 +10,7 @@
{ {
"id": "taila", "id": "taila",
"name": "Taila", "name": "Taila",
"description": "Original anime character by Partaevil (CC-BY)", "description": "",
"model": "res://assets/characters/skins/taila.glb", "model": "res://assets/characters/skins/taila.glb",
"unlocked": true "unlocked": true
} }
Binary file not shown.
+853
View File
@@ -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"
]
}
+17 -10
View File
@@ -34,10 +34,15 @@ class_name SkinJointHelper
## SkinnedPlayerModel.ShooterPoseModifier). A helper that is not updated in step ## SkinnedPlayerModel.ShooterPoseModifier). A helper that is not updated in step
## with its child deforms the limb instead of saving it. ## with its child deforms the limb instead of saving it.
## [parent, child] per joint, both sides. ## The CHILD bone of each joint to subdivide. The parent is whatever the
const JOINTS := [ ## skeleton says it is, not a second hardcoded name.
["DEF-thigh.L", "DEF-shin.L"], ["DEF-thigh.R", "DEF-shin.R"], ##
] ## 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 ## 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. ## any one vertex to blend across, which is a ~3% collapse at a hard tuck.
const SEGMENTS := 4 const SEGMENTS := 4
@@ -55,16 +60,18 @@ static func install(root: Node, skeleton: Skeleton3D) -> Array:
var driven: Array = [] var driven: Array = []
# child bone -> [parent bone, [helper bones, inner first]] # child bone -> [parent bone, [helper bones, inner first]]
var joint_helpers := {} var joint_helpers := {}
for joint in JOINTS: for child_name in JOINT_CHILDREN:
var parent := skeleton.find_bone(joint[0]) var child := skeleton.find_bone(child_name)
var child := skeleton.find_bone(joint[1]) if child < 0:
if parent < 0 or child < 0:
continue 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 continue # already installed
var helpers: Array = [] var helpers: Array = []
for step in range(1, SEGMENTS): 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) skeleton.add_bone(hname)
var h := skeleton.find_bone(hname) var h := skeleton.find_bone(hname)
skeleton.set_bone_parent(h, parent) skeleton.set_bone_parent(h, parent)
+77 -5
View File
@@ -88,6 +88,11 @@ var _current_clip: String = ""
var _weapon_attachment: BoneAttachment3D var _weapon_attachment: BoneAttachment3D
## [child_bone, helper_bone] pairs driven every frame by the pose modifier. ## [child_bone, helper_bone] pairs driven every frame by the pose modifier.
var _joint_helpers: Array = [] 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 var is_holding_weapon: bool = false
# Animation blending: locomotion plays full-body through a Transition node; # 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) push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
else: else:
_ensure_meshes_bound(scene) _ensure_meshes_bound(scene)
# Boots weighted to BOTH legs get dragged into the gap and stretch as _rig_info = _load_rig_info(path)
# the legs separate. See SkinLegRepair. # `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) var fixed := SkinLegRepair.repair(scene, skeleton)
if fixed[0] > 0 or fixed[1] > 0: if fixed[0] > 0 or fixed[1] > 0:
print("SkinnedPlayerModel: '%s' — snapped %d cross-leg vertices, dropped %d bridging triangles" print("SkinnedPlayerModel: '%s' — snapped %d cross-leg vertices, dropped %d bridging triangles"
% [path.get_file(), fixed[0], fixed[1]]) % [path.get_file(), fixed[0], fixed[1]])
# Half-angle helper bones at every leg joint. Without them the limb # Joint subdivision runs for EVERY model, however it was rigged. It is
# pinches at a hard bend — see SkinJointHelper. # 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) _joint_helpers = SkinJointHelper.install(scene, skeleton)
_pose_mod = ShooterPoseModifier.new() _pose_mod = ShooterPoseModifier.new()
_pose_mod.joint_helpers = _joint_helpers _pose_mod.joint_helpers = _joint_helpers
_pose_mod.roles = _rig_info.get("roles", {})
_pose_mod.name = "ShooterPose" _pose_mod.name = "ShooterPose"
skeleton.add_child(_pose_mod) 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. # Cel-shaded look: toon shading over the imported textures + ink outline.
LevelMaterials.apply_toon_recursive(scene) LevelMaterials.apply_toon_recursive(scene)
@@ -201,6 +231,23 @@ func load_model(path: String) -> void:
_play_clip("Idle") _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. ## Make sure every skinned MeshInstance3D is actually driven by the skeleton.
## A correctly-exported GLB binds automatically, but if one imports with a skin ## A correctly-exported GLB binds automatically, but if one imports with a skin
## resource whose `skeleton` NodePath doesn't resolve, the mesh renders its bind ## resource whose `skeleton` NodePath doesn't resolve, the mesh renders its bind
@@ -782,6 +829,10 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var _idx: Dictionary = {} var _idx: Dictionary = {}
var _resolved := false 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: func _resolve() -> void:
var skel := get_skeleton() 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.R", "DEF-forearm.R", "DEF-hand.R",
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L", "DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
"DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.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: 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 _resolved = true
func _process_modification() -> void: func _process_modification() -> void:
+259
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://deskxxpcexwyt
+38 -1
View File
@@ -108,7 +108,12 @@ func _measure(store_rest: bool) -> void:
bone_of[b] = bi bone_of[b] = bi
for s in range(mi.mesh.get_surface_count()): for s in range(mi.mesh.get_surface_count()):
var mat = mi.mesh.surface_get_material(s) 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 arrays: Array = mi.mesh.surface_get_arrays(s)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES] var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
@@ -140,6 +145,31 @@ func _skinned(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, p: Vector3,
return q 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, func _span(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, mname: String,
verts: PackedVector3Array, bones: PackedInt32Array, verts: PackedVector3Array, bones: PackedInt32Array,
weights: PackedFloat32Array, per: int, store_rest: bool) -> void: 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) var t: float = clampf((p - ha).dot(hf) / hf.length_squared(), 0.0, 1.0)
if p.distance_to(ha + hf * t) > 0.10: if p.distance_to(ha + hf * t) > 0.10:
continue 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) 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)) 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)) hi = Vector3(maxf(hi.x, q.x), maxf(hi.y, q.y), maxf(hi.z, q.z))
+195 -95
View File
@@ -1,127 +1,227 @@
# Character Pipeline: SketchfabRigged → Animated → In-Game # Character Pipeline: source modelrigged → 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 ```bash
python tools/pipeline.py --uid <sketchfab-uid> --name space_marine python tools/pipeline.py --uid <sketchfab-uid> --name space_marine
``` ```
That downloads the model, auto-rigs it in Blender, merges the shared That downloads the model, moves the shared animation library onto it, writes
animation library onto it, writes `assets/characters/skins/space_marine.glb`, `assets/characters/skins/space_marine.glb` plus a `.rig.json` sidecar, verifies
and registers it in `skins.json`. Restart the game — the skin is in the main the result, and registers it in `skins.json`. Restart the game — the skin is in
menu dropdown, fully animated in first AND third person, synced in multiplayer. 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>. **If a model arrives with a skeleton, that skeleton is what ships.** Its bones,
Put it in the env var `SKETCHFAB_API_TOKEN`, or in a file named its artist-painted weights, its separate per-part meshes, and its dedicated
`.sketchfab_token` in the project root (gitignored). skirt/hair bone chains all survive; only the ANIMATION is moved onto it.
2. **Blender 3.6+** — on PATH, or set `BLENDER_PATH` to `blender.exe`.
3. **Animation library** — already bundled. `assets/characters/animations/_library.glb` This is the whole point, and the pipeline used to do the opposite. The old route
is the CC0 Quaternius Universal Animation Library (13 game-relevant clips (`strip_rig.py``autorig.py``merge_animations.py`) discarded any foreign
mapped in `LIBRARY_CLIP_MAP`). `tools/autorig.py` rigs every character to skeleton, joined every mesh into one, and rebound the result by weighting each
this library's skeleton (fitting the arm bones to the model's actual pose), vertex to its nearest four bone segments. It did that to work around a *naming*
and `merge_animations.py` retargets each clip onto that fitted rest. To use a problem — the retarget matched bones by exact name — and the cost was the entire
different/larger set instead, see "Swapping the animation library" below. 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 ## The pipeline, step by step
Each step is also runnable on its own:
| Step | Tool | What it does | | Step | Tool | What it does |
|---|---|---| |---|---|---|
| 1. Find | `python tools/sketchfab_import.py search "anime robot" --rigged` | Search downloadable models (license shown per result) | | 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/` | | 2. Download | `python tools/sketchfab_import.py download <uid>` | GLB + license 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 | | 3. Retarget | `blender --background --python tools/retarget.py -- in.glb assets/characters/animations out.glb` | Keeps the rig; moves the clip library onto it |
| 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 | | 3b. *(unrigged only)* | `blender --background --python tools/autorig.py -- in.glb rigged.glb` | Fits the library skeleton and solves weights — lossy, see below |
| 5. Register | (automatic in pipeline.py) | Copies to `skins/`, adds entry to `skins.json` | | 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). - `--input file.glb` instead of `--uid` for local files.
- `--rigged`skip auto-rig for models that already have a skeleton - `--rigged`force the keep-the-rig path (needed for FBX, which cannot be probed).
(Mixamo/AccuRig/Tripo output). Bones get renamed to Mixamo convention - `--rebind` — force the lossy path. Last resort.
automatically when recognizable. - `--height 1.6` — target character height in metres.
- `--height 1.6` — target character height in meters.
## 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 `tools/rig_map.py` resolves both skeletons to ROLES and pairs them up, so bone
automation needs an alternative. In order of preference: names never have to match. It works structurally wherever a name would lie:
1. **Built-in autorig (`tools/autorig.py`)** — heuristic skeleton fit + - **Hips** is found as the base of the longest non-limb, non-cosmetic chain —
Blender automatic weights, fully automated, zero cost. Works well for not by looking for "hips". Rigify calls it `DEF-spine`.
normal-proportioned upright humanoids. Weakest on flowing - **The head** is wherever that chain ends. A stock Rigify rig has no bone with
dresses/capes/extreme proportions. "head" in its name at all; the head is `DEF-spine.006`.
2. **Mixamo web (manual, ~2 min/model)** — upload FBX/OBJ at - **Chains of different lengths** are matched by normalised position, so a
<https://www.mixamo.com>, place 7 markers, download rigged FBX "without 6-bone spine is driven by a 5-bone one.
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.
All roads lead to the same place: a GLB with Mixamo bone names, which is what `tools/retarget.py` then bakes each clip as a rest-relative delta —
`merge_animations.py` and the game expect. `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 ## Swapping the animation library
`merge_animations.py` supports two library layouts in `assets/characters/animations/_library.glb` is the CC0 Quaternius Universal
`assets/characters/animations/`: 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, Characters no longer have to be rigged to the library's skeleton, so **replacing
its clips are merged and mapped through `LIBRARY_CLIP_MAP` in `_library.glb` does not require re-rigging anything** — just rebuild the skins.
`tools/merge_animations.py`. The bundled file is the CC0 Quaternius Universal Missing clips are fine: the game falls back along sensible chains
Animation Library (13 clips: Idle, Walk, Run, Sprint, Jump, Fall, Land, (`Slide → CrouchIdle → Idle`, `WallRun → Run` — see `CLIP_FALLBACKS` in
CrouchIdle, CrouchWalk, Dash, Death, Hit, Dance). **Important:** `autorig.py` `characters/skinned_player_model.gd`).
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`.
**B. Loose one-clip-per-file (Mixamo).** Delete `_library.glb` and instead add ## Measuring, not eyeballing
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).
Missing clips are fine either way: the game falls back along sensible chains - `blender --background --python tools/verify_character.py -- <glb>` — the build
(`Slide → CrouchIdle → Idle`, `WallRun → Run`, etc. — see `CLIP_FALLBACKS` in gate. Every check corresponds to a defect this project actually shipped.
`characters/skinned_player_model.gd`). A model with just Idle/Walk/Run still - `godot --headless --path . -s res://debug/limb_deform_check.gd -- <glb>`
animates in every movement state. 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.850.86, everything else 0.891.00, worst stretch 1.16.
## How it works in-game ## How it works in-game
- **`SkinManager` (autoload)** reads `assets/characters/skins/skins.json` at - **`SkinManager`** (autoload) reads `skins.json` at boot; the selection persists
boot. Selected skin persists per-user and is synced to other players via per user and syncs via `synced_skin_id`.
`synced_skin_id`. - **`SkinnedPlayerModel`** loads the GLB, reads the sidecar, installs
- **`SkinnedPlayerModel`** loads the GLB at runtime, maps canonical clip `ShooterPoseModifier` (lean / ADS / slide / wall-run / weapon hold) then
names, sets loop modes, blends between clips (0.15 s), and scales `SpringBones` — in that order, so the springs react to the FINAL body pose.
locomotion playback speed to actual movement speed. - **`SkinJointHelper`** runs for every model regardless of rig. It is not a
- **First person (owner):** the model renders shadows-only for the owner (the weight repair: linear-blend skinning collapses any joint by cos(θ/2) however
camera sits inside the head, so drawing the mesh would show its inside). It's good the weights are. It subdivides the knee through helper bones. It resolves
still fully animated and casts a real shadow; other players see the full body. the joint's parent from the SKELETON — hardcoding `DEF-thigh.L` meant it
- **Third person:** press **V** (`toggle_camera_view`) to swap to an silently did nothing on a rig with twist bones, and the knee measured 0.76
over-the-shoulder `SpringArm3D` camera and reveal your own animated model — instead of 0.85.
the easiest way to eyeball that a new skin's animations look right. Firing - **First person (owner):** the model renders shadows-only (the camera sits
still uses the first-person camera, so aim is unchanged. Press V again to inside the head). Press **V** to swap to the over-the-shoulder camera.
return to first person. - **Licensing:** every Sketchfab download writes `<name>.license.json`. CC-BY
- **Other players** always see the full third-person model, driven by the models require crediting the author — surface these in a credits screen.
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.
## Troubleshooting ## Troubleshooting
- *Model T-poses in game* — the GLB has no animations; re-run - *Character is a black silhouette* — unlit materials; see **Materials**. Check
`merge_animations.py` and check it printed `Merged N clips`. the built GLB actually has images.
- *Mesh deforms badly at shoulders/hips* — heuristic rig didn't fit; rig via - *Model T-poses* — the GLB has no animations, or the retarget produced a frozen
Mixamo web or AccuRig and re-run with `--rigged`. rest pose. `verify_character.py` catches both.
- *Character slides while walking* — clips exported with root motion; re-run - *Skirt rides one leg* — a cloth bone got parented to a limb. Cloth must anchor
without `--keep-root-motion` (stripping is the default). to the trunk only.
- *Skin missing in exported build* — raw `.glb` files must be included in the - *Cloth is rigid* — the sidecar is missing (check the export filter) or the
export: Project → Export → Resources → include filter `*.glb, *.json`. 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
View File
@@ -6,7 +6,12 @@ runnable=true
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" 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="" exclude_filter=""
export_path="./Papaya-Shooter.exe" export_path="./Papaya-Shooter.exe"
patches=PackedStringArray() patches=PackedStringArray()
+145
View File
@@ -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
View File
@@ -25,6 +25,7 @@ import argparse
import json import json
import os import os
import shutil import shutil
import struct
import subprocess import subprocess
import sys import sys
@@ -56,6 +57,32 @@ def find_blender() -> str:
sys.exit(1) 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: def run(cmd: list[str], step: str) -> None:
print(f"\n=== {step} ===") print(f"\n=== {step} ===")
print(" ".join(f'"{c}"' if " " in c else c for c in cmd)) 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("--name", required=True, help="skin id (snake_case)")
p.add_argument("--display-name", help="name shown in menus (default: from --name)") p.add_argument("--display-name", help="name shown in menus (default: from --name)")
p.add_argument("--description", default="", help="skin description") 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("--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("--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") p.add_argument("--anim-dir", default=ANIM_DIR, help="animation library directory")
@@ -131,29 +162,44 @@ def main() -> None:
blender = find_blender() blender = find_blender()
print(f"Using Blender: {blender}") print(f"Using Blender: {blender}")
# 2. Auto-rig (or pass through if already rigged). # 2. Decide whether the model already has a skeleton worth keeping.
rigged_path = os.path.join(STAGING, f"{name}_rigged.glb") #
if args.rigged: # Keeping it is strongly preferred and is now the default. The old route
rigged_path = input_path # (strip_rig -> autorig) discarded the source skeleton, joined every mesh
print("Skipping autorig (--rigged)") # into one, and rebound with nearest-bone weights — which is what put 2817
else: # both-legs-at-once vertices into the shipped Taila and cost her the skirt
run([blender, "--background", "--python", os.path.join(TOOLS, "autorig.py"), # and hair bone chains outright. --rebind still exists for a model that
"--", input_path, rigged_path, str(args.height)], # genuinely has no usable rig, but it is the lossy path.
"Auto-rig (Blender)") 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(
# 3. Merge the shared animation library. os.path.join(args.anim_dir, "_library.glb")):
if not os.path.isdir(args.anim_dir) or not any( print(f"ERROR: animation library not found: {args.anim_dir}/_library.glb")
f.lower().endswith((".fbx", ".glb", ".gltf")) for f in os.listdir(args.anim_dir)): print("See docs/ASSET_SOURCES.md")
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")
sys.exit(1) sys.exit(1)
final_path = os.path.join(SKINS_DIR, f"{name}.glb") final_path = os.path.join(SKINS_DIR, f"{name}.glb")
merge_cmd = [blender, "--background", "--python", os.path.join(TOOLS, "merge_animations.py"), if rigged:
"--", rigged_path, args.anim_dir, final_path] 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: if args.keep_root_motion:
merge_cmd.append("--keep-root-motion") cmd.append("--keep-root-motion")
run(merge_cmd, "Merge animation library (Blender)") 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. # 4. Carry the license file along if the model came from Sketchfab.
lic_src = os.path.splitext(input_path)[0] + ".license.json" lic_src = os.path.splitext(input_path)[0] + ".license.json"
+887
View File
@@ -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()
+320
View File
@@ -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
+14
View File
@@ -1,5 +1,19 @@
#!/usr/bin/env python3 #!/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 Strip an existing rig from a character GLB so tools/autorig.py re-rigs it on
the animation-library skeleton. the animation-library skeleton.
-83
View File
@@ -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()
+226
View File
@@ -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")