feat: implement automated 3D character pipeline with retargeting and rig management tools
This commit is contained in:
@@ -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")
|
||||
+67
-21
@@ -25,6 +25,7 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@@ -56,6 +57,32 @@ def find_blender() -> str:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def has_skeleton(path: str) -> bool:
|
||||
"""Does this glTF already carry a skin? Read straight out of the container
|
||||
so the check costs nothing — launching Blender just to ask takes seconds.
|
||||
Non-glTF formats can't be probed this way; pass --rigged for those.
|
||||
"""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
try:
|
||||
if ext == ".gltf":
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return bool(json.load(f).get("skins"))
|
||||
if ext != ".glb":
|
||||
return False
|
||||
with open(path, "rb") as f:
|
||||
magic, _ver, total = struct.unpack("<III", f.read(12))
|
||||
if magic != 0x46546C67:
|
||||
return False
|
||||
while f.tell() < total:
|
||||
length, ctype = struct.unpack("<II", f.read(8))
|
||||
chunk = f.read(length)
|
||||
if ctype == 0x4E4F534A: # JSON
|
||||
return bool(json.loads(chunk.decode("utf-8")).get("skins"))
|
||||
except (OSError, ValueError, struct.error) as e:
|
||||
print(f"WARNING: could not probe '{path}' for a skeleton ({e})")
|
||||
return False
|
||||
|
||||
|
||||
def run(cmd: list[str], step: str) -> None:
|
||||
print(f"\n=== {step} ===")
|
||||
print(" ".join(f'"{c}"' if " " in c else c for c in cmd))
|
||||
@@ -98,7 +125,11 @@ def main() -> None:
|
||||
p.add_argument("--name", required=True, help="skin id (snake_case)")
|
||||
p.add_argument("--display-name", help="name shown in menus (default: from --name)")
|
||||
p.add_argument("--description", default="", help="skin description")
|
||||
p.add_argument("--rigged", action="store_true", help="input is already rigged — skip autorig")
|
||||
p.add_argument("--rigged", action="store_true",
|
||||
help="force the keep-the-rig path (auto-detected for glTF)")
|
||||
p.add_argument("--rebind", action="store_true",
|
||||
help="discard the source rig and fit the library skeleton "
|
||||
"(last resort — destroys authored weights and cloth bones)")
|
||||
p.add_argument("--height", type=float, default=1.75, help="target character height in meters")
|
||||
p.add_argument("--keep-root-motion", action="store_true", help="don't strip hips motion from clips")
|
||||
p.add_argument("--anim-dir", default=ANIM_DIR, help="animation library directory")
|
||||
@@ -131,29 +162,44 @@ def main() -> None:
|
||||
blender = find_blender()
|
||||
print(f"Using Blender: {blender}")
|
||||
|
||||
# 2. Auto-rig (or pass through if already rigged).
|
||||
rigged_path = os.path.join(STAGING, f"{name}_rigged.glb")
|
||||
if args.rigged:
|
||||
rigged_path = input_path
|
||||
print("Skipping autorig (--rigged)")
|
||||
else:
|
||||
run([blender, "--background", "--python", os.path.join(TOOLS, "autorig.py"),
|
||||
"--", input_path, rigged_path, str(args.height)],
|
||||
"Auto-rig (Blender)")
|
||||
|
||||
# 3. Merge the shared animation library.
|
||||
if not os.path.isdir(args.anim_dir) or not any(
|
||||
f.lower().endswith((".fbx", ".glb", ".gltf")) for f in os.listdir(args.anim_dir)):
|
||||
print(f"ERROR: animation library is empty: {args.anim_dir}")
|
||||
print("Fill it with one clip per file (idle.fbx, run.fbx, ...) — see docs/ASSET_SOURCES.md")
|
||||
# 2. Decide whether the model already has a skeleton worth keeping.
|
||||
#
|
||||
# Keeping it is strongly preferred and is now the default. The old route
|
||||
# (strip_rig -> autorig) discarded the source skeleton, joined every mesh
|
||||
# into one, and rebound with nearest-bone weights — which is what put 2817
|
||||
# both-legs-at-once vertices into the shipped Taila and cost her the skirt
|
||||
# and hair bone chains outright. --rebind still exists for a model that
|
||||
# genuinely has no usable rig, but it is the lossy path.
|
||||
rigged = args.rigged or (not args.rebind and has_skeleton(input_path))
|
||||
if not os.path.isdir(args.anim_dir) or not os.path.exists(
|
||||
os.path.join(args.anim_dir, "_library.glb")):
|
||||
print(f"ERROR: animation library not found: {args.anim_dir}/_library.glb")
|
||||
print("See docs/ASSET_SOURCES.md")
|
||||
sys.exit(1)
|
||||
|
||||
final_path = os.path.join(SKINS_DIR, f"{name}.glb")
|
||||
merge_cmd = [blender, "--background", "--python", os.path.join(TOOLS, "merge_animations.py"),
|
||||
"--", rigged_path, args.anim_dir, final_path]
|
||||
if args.keep_root_motion:
|
||||
merge_cmd.append("--keep-root-motion")
|
||||
run(merge_cmd, "Merge animation library (Blender)")
|
||||
if rigged:
|
||||
print("Model is rigged — keeping its skeleton, weights and cloth chains")
|
||||
cmd = [blender, "--background", "--python", os.path.join(TOOLS, "retarget.py"),
|
||||
"--", input_path, args.anim_dir, final_path, "--height", str(args.height)]
|
||||
if args.keep_root_motion:
|
||||
cmd.append("--keep-root-motion")
|
||||
run(cmd, "Retarget animation library onto the model's own rig (Blender)")
|
||||
else:
|
||||
print("No skeleton found — fitting the library rig (authored weights unavailable)")
|
||||
rigged_path = os.path.join(STAGING, f"{name}_rigged.glb")
|
||||
run([blender, "--background", "--python", os.path.join(TOOLS, "autorig.py"),
|
||||
"--", input_path, rigged_path, str(args.height)],
|
||||
"Auto-rig (Blender)")
|
||||
cmd = [blender, "--background", "--python", os.path.join(TOOLS, "retarget.py"),
|
||||
"--", rigged_path, args.anim_dir, final_path, "--height", str(args.height)]
|
||||
if args.keep_root_motion:
|
||||
cmd.append("--keep-root-motion")
|
||||
run(cmd, "Retarget animation library (Blender)")
|
||||
|
||||
# 3. Gate on the checks that encode every way this has gone wrong before.
|
||||
run([blender, "--background", "--python", os.path.join(TOOLS, "verify_character.py"),
|
||||
"--", final_path], "Verify the built character (Blender)")
|
||||
|
||||
# 4. Carry the license file along if the model came from Sketchfab.
|
||||
lic_src = os.path.splitext(input_path)[0] + ".license.json"
|
||||
|
||||
@@ -0,0 +1,887 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Put the animation library onto a character WITHOUT touching how it deforms.
|
||||
|
||||
This replaces the old strip_rig -> autorig -> merge_animations route, which
|
||||
solved a naming problem by destroying the asset. That route threw away the
|
||||
character's skeleton, joined every mesh into one blob, and rebound the result
|
||||
with nearest-four-bones Euclidean weights. Measured on the shipped taila.glb it
|
||||
produced 2817 vertices pulled by BOTH legs (worst a dead 50/50 split) and 86%
|
||||
of all vertices carrying the full four influences — while the ORIGINAL file it
|
||||
was built from had zero cross-leg bleed, one mesh per material, and dedicated
|
||||
bone chains for the skirt and the hair. Every runtime "repair" in
|
||||
characters/skin_leg_repair.gd exists to undo damage done right here.
|
||||
|
||||
So: keep the character's own rig, weights, per-part meshes and cloth chains,
|
||||
and move the ANIMATION onto it instead.
|
||||
|
||||
1. Rebuild parenting. A Rigify DEF-rig exports its chain roots parented
|
||||
straight to the armature root, because Rigify drives them by constraint
|
||||
rather than hierarchy. Left that way, rotating the hips would leave the
|
||||
legs, skirt and hair floating in place. Orphans are re-attached by
|
||||
anatomy where it is known and by rest geometry otherwise — and cloth may
|
||||
only ever attach to the trunk, never to a limb, or a skirt would ride one
|
||||
thigh.
|
||||
2. Retarget by ROLE, not by name (see tools/rig_map.py).
|
||||
3. Bake each clip as a rest-relative delta:
|
||||
|
||||
R_world = src_pose_rot * src_rest_rot^-1 (what the clip does)
|
||||
tgt_rot = R_world * tgt_rest_rot (done to THIS rig)
|
||||
|
||||
Copying absolute world orientation instead — which is what the old
|
||||
constraint bake did — forces the library's bone roll onto a mesh that was
|
||||
bound with a different one, and twists every limb by a constant offset.
|
||||
4. Drive ONLY the body. Skirt, hair, twist and face bones are left with no
|
||||
keys at all, so they rest relative to their parents and are free for the
|
||||
spring solver at runtime. That split — clips animate the body, physics
|
||||
animates the cloth — is the whole point.
|
||||
|
||||
Usage:
|
||||
blender --background --python tools/retarget.py -- \
|
||||
<character.glb> <animations_dir> <output.glb> [--height 1.75] [--keep-root-motion]
|
||||
|
||||
Writes <output>.rig.json beside the GLB: resolved bone roles, cloth chains and
|
||||
twist pairs, so the runtime never has to re-guess the skeleton's anatomy.
|
||||
"""
|
||||
import bpy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from mathutils import Matrix, Quaternion, Vector
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import gltf_fix
|
||||
import rig_map
|
||||
from rig_map import RigRoles, build_map, is_cosmetic, is_segment_of, tokens
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if len(argv) < 3:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
CHARACTER, ANIM_DIR, OUTPUT = argv[0], argv[1], argv[2]
|
||||
STRIP_ROOT_MOTION = "--keep-root-motion" not in argv
|
||||
TARGET_HEIGHT = 1.75
|
||||
if "--height" in argv:
|
||||
TARGET_HEIGHT = float(argv[argv.index("--height") + 1])
|
||||
OVERRIDES = {}
|
||||
if "--bone-map" in argv:
|
||||
with open(argv[argv.index("--bone-map") + 1], "r", encoding="utf-8") as f:
|
||||
OVERRIDES = json.load(f)
|
||||
|
||||
# Library clip -> the game's canonical clip name. Unmapped clips are skipped so
|
||||
# character GLBs stay small.
|
||||
LIBRARY_CLIP_MAP = {
|
||||
"Idle_Loop": "Idle",
|
||||
"Walk_Loop": "Walk",
|
||||
"Jog_Fwd_Loop": "Run",
|
||||
"Sprint_Loop": "Sprint",
|
||||
"Jump_Start": "Jump",
|
||||
"Jump_Loop": "Fall",
|
||||
"Jump_Land": "Land",
|
||||
"Crouch_Idle_Loop": "CrouchIdle",
|
||||
"Crouch_Fwd_Loop": "CrouchWalk",
|
||||
"Roll": "Dash",
|
||||
"Death01": "Death",
|
||||
"Hit_Chest": "Hit",
|
||||
"Dance_Loop": "Dance",
|
||||
"Swim_Fwd_Loop": "Grapple",
|
||||
"Pistol_Idle_Loop": "PistolIdle",
|
||||
"Pistol_Shoot": "PistolShoot",
|
||||
"Pistol_Reload": "PistolReload",
|
||||
"Sword_Attack": "Throw",
|
||||
}
|
||||
|
||||
UP = Vector((0.0, 0.0, 1.0))
|
||||
|
||||
# Which cosmetic chains get secondary motion. A face-shape or eye chain is
|
||||
# cosmetic but must never swing, so this is deliberately narrower than
|
||||
# rig_map.COSMETIC.
|
||||
SPRING_CLASSES = {"hair", "skirt", "cloth", "ribbon", "tail", "cape", "coat",
|
||||
"scarf", "sleeve", "breast", "bust", "feather", "strap",
|
||||
"antenna", "wing"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- import
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.actions):
|
||||
for b in list(blocks):
|
||||
if b.users == 0:
|
||||
blocks.remove(b)
|
||||
|
||||
|
||||
def import_any(path):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in (".glb", ".gltf"):
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
elif ext == ".fbx":
|
||||
bpy.ops.import_scene.fbx(filepath=path)
|
||||
else:
|
||||
print(f"ERROR: unsupported character format {ext}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def skinned_meshes(arm):
|
||||
"""Meshes actually driven by this armature.
|
||||
|
||||
Anything else in the file is scene dressing — Sketchfab models routinely
|
||||
ship a display base or a diorama, and those must not become part of the
|
||||
player.
|
||||
"""
|
||||
out = []
|
||||
for o in [o for o in bpy.data.objects if o.type == "MESH"]:
|
||||
driven = any(m.type == "ARMATURE" and m.object is arm for m in o.modifiers)
|
||||
if driven or (o.parent is arm and o.vertex_groups):
|
||||
out.append(o)
|
||||
return out
|
||||
|
||||
|
||||
def strip_import_suffixes(arm, meshes):
|
||||
"""`DEF-thigh.L_16` -> `DEF-thigh.L`.
|
||||
|
||||
The glTF importer appends the node index to every bone name. Those names
|
||||
ship in the exported GLB and are what the game code matches on, so clean
|
||||
them up here rather than teaching every consumer about the suffix.
|
||||
"""
|
||||
renames = {}
|
||||
taken = set(b.name for b in arm.data.bones)
|
||||
for bone in arm.data.bones:
|
||||
clean = rig_map.strip_gltf_suffix(bone.name)
|
||||
if clean != bone.name and clean not in taken:
|
||||
renames[bone.name] = clean
|
||||
taken.discard(bone.name)
|
||||
taken.add(clean)
|
||||
for old, new in renames.items():
|
||||
arm.data.bones[old].name = new
|
||||
# Vertex groups are matched to bones by NAME and are not renamed for us.
|
||||
for m in meshes:
|
||||
for vg in m.vertex_groups:
|
||||
new = renames.get(vg.name)
|
||||
if new and new not in m.vertex_groups:
|
||||
vg.name = new
|
||||
print(f"Cleaned {len(renames)} bone names")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- hierarchy
|
||||
|
||||
|
||||
def _seg_distance(p, a, b):
|
||||
ab = b - a
|
||||
d2 = ab.dot(ab)
|
||||
t = 0.0 if d2 < 1e-12 else max(0.0, min(1.0, (p - a).dot(ab) / d2))
|
||||
return (p - (a + ab * t)).length
|
||||
|
||||
|
||||
def rebuild_hierarchy(arm, roles):
|
||||
"""Re-attach chain roots that exported parented to the armature root.
|
||||
|
||||
Anatomy first (a thigh belongs to the hips, a forearm to the upper arm),
|
||||
rest geometry second. Cloth and hair are only ever allowed to attach to the
|
||||
TRUNK: pick anchors by raw proximity and a skirt panel hanging beside a leg
|
||||
attaches to that thigh and rides it like a trouser leg.
|
||||
"""
|
||||
trunk = [roles.hips] + list(roles.spine)
|
||||
trunk = [n for n in trunk if n]
|
||||
limb = roles.limb
|
||||
|
||||
def anat(role, side):
|
||||
return limb.get((role, side))
|
||||
|
||||
fixed = {}
|
||||
for side in ("L", "R"):
|
||||
chest = trunk[-3] if len(trunk) >= 3 else (trunk[-1] if trunk else None)
|
||||
pairs = [
|
||||
(("thigh", side), roles.hips),
|
||||
(("shin", side), anat("thigh", side)),
|
||||
(("foot", side), anat("shin", side)),
|
||||
(("toe", side), anat("foot", side)),
|
||||
(("shoulder", side), chest),
|
||||
(("upper_arm", side), anat("shoulder", side) or chest),
|
||||
(("forearm", side), anat("upper_arm", side)),
|
||||
(("hand", side), anat("forearm", side)),
|
||||
]
|
||||
for key, parent in pairs:
|
||||
name = limb.get(key)
|
||||
if name and parent:
|
||||
fixed[name] = parent
|
||||
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
eb = arm.data.edit_bones
|
||||
|
||||
# Where each anchor bone actually EXTENDS TO, from authored data only.
|
||||
#
|
||||
# glTF stores joints as nodes with no tail, so the tails Blender reports are
|
||||
# invented — every one of Taila's skirt bones comes back 0.78 m long. Using
|
||||
# them, DEF-spine's phantom tail runs straight down through the whole skirt
|
||||
# and every panel measures ~0.05 m from "the hips", which beat each panel's
|
||||
# real 0.15 m link to its own chain root and flattened all 7 chains.
|
||||
# A bone's true extent is the head of the next bone along.
|
||||
span = {}
|
||||
for i, name in enumerate(trunk):
|
||||
nxt = trunk[i + 1] if i + 1 < len(trunk) else None
|
||||
span[name] = (eb[name].head,
|
||||
eb[nxt].head if nxt and nxt in eb else eb[name].tail)
|
||||
for name in limb.values():
|
||||
if name not in eb:
|
||||
continue
|
||||
kids = [c for c in eb[name].children if not is_cosmetic(c.name)]
|
||||
span[name] = (eb[name].head, kids[0].head if kids else eb[name].tail)
|
||||
|
||||
def anchor_distance(point, name):
|
||||
a, b = span.get(name, (eb[name].head, eb[name].tail))
|
||||
return _seg_distance(point, a, b)
|
||||
|
||||
def is_root_like(bone):
|
||||
return bone is None or any(
|
||||
t in ("root", "master", "armature", "scene", "rootjoint")
|
||||
for t in tokens(bone.name))
|
||||
|
||||
def descendants(bone):
|
||||
out = {bone.name}
|
||||
stack = list(bone.children)
|
||||
while stack:
|
||||
b = stack.pop()
|
||||
out.add(b.name)
|
||||
stack.extend(b.children)
|
||||
return out
|
||||
|
||||
trunk_set = set(trunk)
|
||||
core = trunk_set | set(limb.values())
|
||||
orphans = [b for b in eb
|
||||
if b.name != roles.hips and b.name not in trunk_set
|
||||
and is_root_like(b.parent)]
|
||||
|
||||
reparented = 0
|
||||
cosmetic_left = []
|
||||
for bone in orphans:
|
||||
if is_cosmetic(bone.name):
|
||||
cosmetic_left.append(bone)
|
||||
continue
|
||||
target = fixed.get(bone.name)
|
||||
if target is None:
|
||||
banned = descendants(bone)
|
||||
cands = [n for n in core if n in eb and n not in banned]
|
||||
if not cands:
|
||||
continue
|
||||
target = min(cands, key=lambda n: anchor_distance(bone.head, n))
|
||||
if target in eb and target != bone.name:
|
||||
bone.parent = eb[target]
|
||||
bone.use_connect = False
|
||||
reparented += 1
|
||||
|
||||
# Cloth and hair hang from the TRUNK — never from a limb, and never from
|
||||
# each other.
|
||||
#
|
||||
# Attaching cloth to whatever bone is nearest puts 16 of Taila's 21 skirt
|
||||
# bones on a thigh, where the panel rides one leg like a trouser leg. The
|
||||
# trunk restriction fixes that.
|
||||
#
|
||||
# Reconstructing multi-bone chains is deliberately NOT attempted. The
|
||||
# temptation is obvious — Taila's skirt is really 7 panels of 3 — but the
|
||||
# information is not in the file. glTF stores no bone tails, the panel
|
||||
# numbering is not sequential (`skirt` -> `skirt.011` -> `skirt.002`), and
|
||||
# neighbouring panel roots ring the waist 0.04 m apart, far closer than any
|
||||
# of them is to the trunk. Successive attempts at distance, chain-direction
|
||||
# and grow-outward rules each produced a topology that was still wrong
|
||||
# somewhere — stitching panels together sideways, or hanging hair off an
|
||||
# eye bone. A wrong chain is worse than no chain: the solver then swings
|
||||
# bones along axes the mesh was never weighted for, and tears it.
|
||||
#
|
||||
# So each orphan becomes its own pendulum from the body. Chains the ARTIST
|
||||
# authored survive untouched, because only chain ROOTS are orphans — which
|
||||
# is why Taila's hair keeps its real 4-7 bone strands while her
|
||||
# flat-exported skirt becomes per-panel pendulums. Both look like cloth;
|
||||
# only the authored one gets true multi-segment drape.
|
||||
for bone in cosmetic_left:
|
||||
cands = [n for n in trunk_set if n in eb]
|
||||
if not cands:
|
||||
break
|
||||
bone.parent = eb[min(cands, key=lambda n: anchor_distance(bone.head, n))]
|
||||
bone.use_connect = False
|
||||
reparented += 1
|
||||
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
print(f"Re-attached {reparented} orphaned bones")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- normalize
|
||||
|
||||
|
||||
def flatten_and_scale(arm, meshes, target_height):
|
||||
"""Bake the import hierarchy away and set the character's real-world size.
|
||||
|
||||
Sketchfab wraps everything in scaled/rotated empties. Left in place they
|
||||
turn up as a scale on the exported Skeleton3D, and every measurement the
|
||||
game makes off bone rests reads in the wrong units.
|
||||
"""
|
||||
if arm.animation_data:
|
||||
arm.animation_data_clear()
|
||||
for pb in arm.pose.bones:
|
||||
pb.matrix_basis = Matrix()
|
||||
|
||||
for obj in [arm] + meshes:
|
||||
world = obj.matrix_world.copy()
|
||||
obj.parent = None
|
||||
obj.matrix_world = world
|
||||
for o in [o for o in bpy.data.objects if o.type == "EMPTY"]:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
def apply_all():
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in [arm] + meshes:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
apply_all()
|
||||
|
||||
lo = Vector((1e9, 1e9, 1e9))
|
||||
hi = -lo.copy()
|
||||
for m in meshes:
|
||||
for corner in m.bound_box:
|
||||
p = m.matrix_world @ Vector(corner)
|
||||
lo = Vector((min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z)))
|
||||
hi = Vector((max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z)))
|
||||
height = hi.z - lo.z
|
||||
if height > 1e-4:
|
||||
s = target_height / height
|
||||
for obj in [arm] + meshes:
|
||||
obj.scale = (s, s, s)
|
||||
apply_all()
|
||||
lo *= s
|
||||
hi *= s
|
||||
print(f"Scaled by {s:.4f} to {target_height:.2f} m")
|
||||
|
||||
offset = Vector((-(lo.x + hi.x) * 0.5, -(lo.y + hi.y) * 0.5, -lo.z))
|
||||
for obj in [arm] + meshes:
|
||||
obj.location = offset
|
||||
apply_all()
|
||||
|
||||
# Re-parent meshes under the armature so the export writes one clean skin.
|
||||
for m in meshes:
|
||||
m.parent = arm
|
||||
m.matrix_parent_inverse = Matrix()
|
||||
if not any(mod.type == "ARMATURE" and mod.object is arm for mod in m.modifiers):
|
||||
mod = m.modifiers.new("Armature", "ARMATURE")
|
||||
mod.object = arm
|
||||
|
||||
|
||||
def fix_unlit_materials(meshes):
|
||||
"""Route each material's texture into Base Color.
|
||||
|
||||
Anime models are commonly authored UNLIT: black base colour with the albedo
|
||||
wired to emission. Our toon shader reads ALBEDO, so left alone the
|
||||
character renders pitch black.
|
||||
"""
|
||||
seen = set()
|
||||
for m in meshes:
|
||||
for mat in m.data.materials:
|
||||
if not mat or not mat.use_nodes or mat.name in seen:
|
||||
continue
|
||||
seen.add(mat.name)
|
||||
nt = mat.node_tree
|
||||
tex = next((n for n in nt.nodes if n.type == "TEX_IMAGE" and n.image), None)
|
||||
bsdf = next((n for n in nt.nodes if n.type == "BSDF_PRINCIPLED"), None)
|
||||
if not bsdf or not tex:
|
||||
continue
|
||||
base = bsdf.inputs["Base Color"]
|
||||
if not base.links:
|
||||
nt.links.new(tex.outputs["Color"], base)
|
||||
print(f"Material '{mat.name}': routed '{tex.image.name}' to base colour")
|
||||
if "Emission Strength" in bsdf.inputs:
|
||||
bsdf.inputs["Emission Strength"].default_value = 0.0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ retarget
|
||||
|
||||
|
||||
def world_rest(arm):
|
||||
mw = arm.matrix_world
|
||||
return {b.name: mw @ b.matrix_local for b in arm.data.bones}
|
||||
|
||||
|
||||
def rig_forward(arm, roles):
|
||||
"""Which way the rest pose faces, on the ground plane, from the feet."""
|
||||
mw = arm.matrix_world
|
||||
acc = Vector((0.0, 0.0, 0.0))
|
||||
for side in ("L", "R"):
|
||||
for role in ("toe", "foot"):
|
||||
name = roles.limb.get((role, side))
|
||||
if not name:
|
||||
continue
|
||||
b = arm.data.bones[name]
|
||||
v = (mw @ b.tail_local) - (mw @ b.matrix_local.translation)
|
||||
v.z = 0.0
|
||||
if v.length > 1e-5:
|
||||
acc += v.normalized()
|
||||
break
|
||||
return acc.normalized() if acc.length > 1e-5 else None
|
||||
|
||||
|
||||
def facing_correction(src_arm, src_roles, tgt_arm, tgt_roles):
|
||||
"""Yaw that carries the source rig's forward onto the target's.
|
||||
|
||||
Without it a library that rests facing -Y drives a character that rests
|
||||
facing +Y and every clip plays backwards.
|
||||
"""
|
||||
a = rig_forward(src_arm, src_roles)
|
||||
b = rig_forward(tgt_arm, tgt_roles)
|
||||
if a is None or b is None:
|
||||
return Quaternion()
|
||||
# angle_signed is 2D-only in mathutils, which is what we want anyway: the
|
||||
# correction is a yaw about world up, never a tilt.
|
||||
angle = Vector((a.x, a.y)).angle_signed(Vector((b.x, b.y)), 0.0)
|
||||
if abs(angle) < 1e-4:
|
||||
return Quaternion()
|
||||
print(f"Facing correction: {angle * 57.2958:.1f} deg")
|
||||
return Quaternion(UP, angle)
|
||||
|
||||
|
||||
def bone_order(arm):
|
||||
"""Every bone, parents before children."""
|
||||
out = []
|
||||
|
||||
def walk(b):
|
||||
out.append(b.name)
|
||||
for c in b.children:
|
||||
walk(c)
|
||||
|
||||
for b in arm.data.bones:
|
||||
if b.parent is None:
|
||||
walk(b)
|
||||
return out
|
||||
|
||||
|
||||
def solve_pose(arm, order, rest_w, desired_rot, hips, hips_head):
|
||||
"""Turn desired WORLD orientations into per-bone local basis transforms.
|
||||
|
||||
Done arithmetically rather than by setting `pose_bone.matrix` and letting
|
||||
Blender solve, because that needs a depsgraph update per bone — 150 bones
|
||||
across 18 clips is tens of thousands of scene evaluations.
|
||||
|
||||
Blender relates pose to rest as
|
||||
pose = parent_pose * parent_rest^-1 * rest * basis
|
||||
so with M standing for everything left of `basis`, a rotation-only basis of
|
||||
M.rot^-1 * desired lands the bone on `desired` exactly.
|
||||
"""
|
||||
pose_w = {}
|
||||
basis = {}
|
||||
bones = arm.data.bones
|
||||
for name in order:
|
||||
b = bones[name]
|
||||
rest = rest_w[name]
|
||||
if b.parent is not None:
|
||||
M = pose_w[b.parent.name] @ rest_w[b.parent.name].inverted() @ rest
|
||||
else:
|
||||
M = rest
|
||||
q = Quaternion()
|
||||
if name in desired_rot:
|
||||
q = M.to_quaternion().inverted() @ desired_rot[name]
|
||||
loc = Vector((0.0, 0.0, 0.0))
|
||||
if name == hips and hips_head is not None:
|
||||
loc = M.inverted() @ hips_head
|
||||
basis[name] = (loc, q)
|
||||
pose_w[name] = M @ Matrix.Translation(loc) @ q.to_matrix().to_4x4()
|
||||
return basis
|
||||
|
||||
|
||||
def retarget_clip(src_arm, src_roles, tgt_arm, tgt_roles, mapping, action,
|
||||
clip_name, yaw, scale):
|
||||
src_rest = world_rest(src_arm)
|
||||
tgt_rest = world_rest(tgt_arm)
|
||||
order = bone_order(tgt_arm)
|
||||
src_rest_rot = {n: m.to_quaternion() for n, m in src_rest.items()}
|
||||
tgt_rest_rot = {n: m.to_quaternion() for n, m in tgt_rest.items()}
|
||||
yaw_inv = yaw.inverted()
|
||||
|
||||
src_hips = src_roles.hips
|
||||
tgt_hips = tgt_roles.hips
|
||||
src_hips_rest = src_rest[src_hips].translation.copy()
|
||||
tgt_hips_rest = tgt_rest[tgt_hips].translation.copy()
|
||||
|
||||
assign_action(src_arm, action)
|
||||
f0, f1 = (int(round(v)) for v in action.frame_range)
|
||||
|
||||
baked = bpy.data.actions.new(clip_name)
|
||||
assign_action(tgt_arm, baked)
|
||||
for pb in tgt_arm.pose.bones:
|
||||
pb.rotation_mode = "QUATERNION"
|
||||
|
||||
scene = bpy.context.scene
|
||||
for frame in range(f0, f1 + 1):
|
||||
scene.frame_set(frame)
|
||||
dg = bpy.context.evaluated_depsgraph_get()
|
||||
src_eval = src_arm.evaluated_get(dg)
|
||||
smw = src_eval.matrix_world
|
||||
|
||||
desired = {}
|
||||
for tgt_name, src_name in mapping.items():
|
||||
if src_name not in src_eval.pose.bones or tgt_name not in tgt_rest_rot:
|
||||
continue
|
||||
pose_rot = (smw @ src_eval.pose.bones[src_name].matrix).to_quaternion()
|
||||
delta = pose_rot @ src_rest_rot[src_name].inverted()
|
||||
desired[tgt_name] = (yaw @ delta @ yaw_inv) @ tgt_rest_rot[tgt_name]
|
||||
|
||||
hips_head = None
|
||||
if src_hips in src_eval.pose.bones:
|
||||
moved = (smw @ src_eval.pose.bones[src_hips].matrix).translation
|
||||
d = yaw @ ((moved - src_hips_rest) * scale)
|
||||
if STRIP_ROOT_MOTION:
|
||||
d.x = 0.0
|
||||
d.y = 0.0 # gameplay code moves the body; keep the vertical bob
|
||||
hips_head = tgt_hips_rest + d
|
||||
|
||||
basis = solve_pose(tgt_arm, order, tgt_rest, desired, tgt_hips, hips_head)
|
||||
for name in mapping:
|
||||
if name not in basis:
|
||||
continue
|
||||
pb = tgt_arm.pose.bones[name]
|
||||
pb.rotation_quaternion = basis[name][1]
|
||||
pb.keyframe_insert("rotation_quaternion", frame=frame)
|
||||
if hips_head is not None:
|
||||
pb = tgt_arm.pose.bones[tgt_hips]
|
||||
pb.location = basis[tgt_hips][0]
|
||||
pb.keyframe_insert("location", frame=frame)
|
||||
|
||||
assign_action(tgt_arm, None)
|
||||
return baked
|
||||
|
||||
|
||||
def assign_action(obj, action):
|
||||
if not obj.animation_data:
|
||||
obj.animation_data_create()
|
||||
obj.animation_data.action = action
|
||||
if action is None:
|
||||
return
|
||||
try: # Blender 4.4+ slotted actions
|
||||
if not obj.animation_data.action_slot and len(action.slots):
|
||||
obj.animation_data.action_slot = action.slots[0]
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def add_nla_clip(arm, action, name):
|
||||
action.name = name
|
||||
track = arm.animation_data.nla_tracks.new()
|
||||
track.name = name
|
||||
strip = track.strips.new(name, 0, action)
|
||||
strip.name = name
|
||||
track.mute = True
|
||||
action.use_fake_user = True
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- sidecar
|
||||
|
||||
|
||||
def _dominant_vertices(meshes, arm):
|
||||
"""bone name -> world positions of the vertices it mostly owns.
|
||||
|
||||
"Mostly" as in holds the largest share — a vertex belongs to one bone for
|
||||
the purpose of measuring what that bone covers, even though it is skinned
|
||||
to several.
|
||||
"""
|
||||
out = defaultdict(list)
|
||||
for m in meshes:
|
||||
gname = {g.index: g.name for g in m.vertex_groups}
|
||||
mw = m.matrix_world
|
||||
for v in m.data.vertices:
|
||||
best = None
|
||||
for g in v.groups:
|
||||
if best is None or g.weight > best.weight:
|
||||
best = g
|
||||
if best is not None and best.weight > 0.25:
|
||||
out[gname.get(best.group, "")].append(mw @ v.co)
|
||||
return out
|
||||
|
||||
|
||||
def _bone_tip(arm, bone, chain, index, owned, fallback):
|
||||
"""Where a bone effectively points, in its own rest space.
|
||||
|
||||
The next bone along when there is one. Otherwise the centroid of the
|
||||
geometry this bone actually drives — which is the only real answer for
|
||||
Taila's skirt, whose 21 panel bones export with no children and no usable
|
||||
tail, so there is nothing in the skeleton to say which way a panel hangs.
|
||||
"""
|
||||
rest_world = arm.matrix_world @ arm.data.bones[bone].matrix_local
|
||||
if index + 1 < len(chain):
|
||||
nxt = arm.matrix_world @ arm.data.bones[chain[index + 1]].matrix_local
|
||||
return rest_world.inverted() @ nxt.translation
|
||||
pts = owned.get(bone, [])
|
||||
if pts:
|
||||
centroid = sum(pts, Vector((0.0, 0.0, 0.0))) / len(pts)
|
||||
local = rest_world.inverted() @ centroid
|
||||
# The centroid sits mid-panel, so the far edge is roughly twice out.
|
||||
if local.length > 1e-4:
|
||||
return local * 2.0
|
||||
return fallback
|
||||
|
||||
|
||||
def _leg_colliders(arm, roles, owned):
|
||||
"""Capsules for the legs, sized from the body geometry itself.
|
||||
|
||||
The skirt has to be kept off the thighs, and a guessed radius either lets
|
||||
it clip through or holds it out in a bell. The 70th percentile of how far a
|
||||
leg bone's own vertices sit from its axis measures the actual limb.
|
||||
"""
|
||||
out = []
|
||||
for role, child_role in (("thigh", "shin"), ("shin", "foot")):
|
||||
for side in ("L", "R"):
|
||||
name = roles.limb.get((role, side))
|
||||
child = roles.limb.get((child_role, side))
|
||||
if not name or not child:
|
||||
continue
|
||||
a = (arm.matrix_world @ arm.data.bones[name].matrix_local).translation
|
||||
b = (arm.matrix_world @ arm.data.bones[child].matrix_local).translation
|
||||
pts = owned.get(name, [])
|
||||
if len(pts) < 8:
|
||||
continue
|
||||
radii = sorted(_seg_distance(p, a, b) for p in pts)
|
||||
out.append({
|
||||
"bone": name, "child": child,
|
||||
"radius": round(radii[int(len(radii) * 0.7)], 4),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def describe_rig(arm, roles, mapping, meshes):
|
||||
"""Record what we worked out, so the runtime never re-guesses anatomy."""
|
||||
names = set(b.name for b in arm.data.bones)
|
||||
owned = _dominant_vertices(meshes, arm)
|
||||
roles_out = {"hips": roles.hips, "head": roles.head, "neck": roles.neck,
|
||||
"spine": list(roles.spine)}
|
||||
for (role, side), name in roles.limb.items():
|
||||
roles_out[f"{role}.{side}"] = name
|
||||
|
||||
driven = set(mapping)
|
||||
chains = []
|
||||
springy = {b.name: b for b in arm.data.bones
|
||||
if b.name not in driven
|
||||
and any(t in SPRING_CLASSES for t in tokens(b.name))}
|
||||
for name, bone in springy.items():
|
||||
if bone.parent is not None and bone.parent.name in springy:
|
||||
continue # not the root of a chain
|
||||
# One chain per leaf path, so each strand solves independently.
|
||||
stack = [[name]]
|
||||
while stack:
|
||||
path = stack.pop()
|
||||
kids = [c.name for c in arm.data.bones[path[-1]].children
|
||||
if c.name in springy]
|
||||
if not kids:
|
||||
tips = []
|
||||
fallback = Vector((0.0, 0.0, -0.06))
|
||||
for i in range(len(path)):
|
||||
t = _bone_tip(arm, path[i], path, i, owned, fallback)
|
||||
tips.append([round(t.x, 5), round(t.y, 5), round(t.z, 5)])
|
||||
fallback = t
|
||||
chains.append({
|
||||
"class": next((t for t in tokens(path[0])
|
||||
if t in SPRING_CLASSES), "cloth"),
|
||||
"root_parent": bone.parent.name if bone.parent else None,
|
||||
"bones": path,
|
||||
"tips": tips,
|
||||
})
|
||||
continue
|
||||
for k in kids:
|
||||
stack.append(path + [k])
|
||||
|
||||
# Twist bones only — a hair link is also `X.001`, but it is cloth, and
|
||||
# listing it here would have the twist distributor and the spring solver
|
||||
# both writing the same bone.
|
||||
twist = []
|
||||
for b in arm.data.bones:
|
||||
if b.name in driven or is_cosmetic(b.name) or not is_segment_of(b.name, names):
|
||||
continue
|
||||
if b.parent is not None:
|
||||
twist.append({"bone": b.name, "parent": b.parent.name,
|
||||
"child": b.children[0].name if b.children else None})
|
||||
return {"roles": roles_out, "chains": chains, "twist": twist,
|
||||
"colliders": _leg_colliders(arm, roles, owned),
|
||||
"weights_authored": _weights_look_authored(meshes, roles),
|
||||
"driven_bones": sorted(driven)}
|
||||
|
||||
|
||||
def _weights_look_authored(meshes, roles):
|
||||
"""Were these weights painted, or solved by a nearest-bone fit?
|
||||
|
||||
The runtime decides from this whether to run its destructive load-time
|
||||
weight repair, so it is MEASURED rather than inferred from which pipeline
|
||||
branch ran — a model that arrives unrigged still goes through autorig and
|
||||
out through this same tool, and must not be handed a sidecar that says its
|
||||
weights are fine when they are not.
|
||||
|
||||
Two signatures, both taken from the shipped-vs-source comparison that
|
||||
started this rework: the nearest-four-bones fit left 16% of vertices pulled
|
||||
by BOTH legs and gave 86% of them the full four influences, where the
|
||||
artist's own weights had 0.1% and 26%.
|
||||
"""
|
||||
legs = {}
|
||||
for (role, side), name in roles.limb.items():
|
||||
if role in ("thigh", "shin", "foot", "toe"):
|
||||
legs[name] = -1 if side == "L" else 1
|
||||
|
||||
def side_of(group_name):
|
||||
for name, s in legs.items():
|
||||
if group_name == name or group_name.startswith(name + "."):
|
||||
return s
|
||||
return 0
|
||||
|
||||
total = 0
|
||||
bleeding = 0
|
||||
four = 0
|
||||
for m in meshes:
|
||||
gside = {g.index: side_of(g.name) for g in m.vertex_groups}
|
||||
for v in m.data.vertices:
|
||||
wl = wr = 0.0
|
||||
n = 0
|
||||
for g in v.groups:
|
||||
if g.weight <= 0.005:
|
||||
continue
|
||||
n += 1
|
||||
s = gside.get(g.group, 0)
|
||||
if s == -1:
|
||||
wl += g.weight
|
||||
elif s == 1:
|
||||
wr += g.weight
|
||||
total += 1
|
||||
if n >= 4:
|
||||
four += 1
|
||||
if wl > 0.005 and wr > 0.005:
|
||||
bleeding += 1
|
||||
if total == 0:
|
||||
return False
|
||||
authored = bleeding / total < 0.02 and four / total < 0.5
|
||||
print(f"Weights: {bleeding} cross-leg ({bleeding / total * 100:.1f}%), "
|
||||
f"{four / total * 100:.0f}% at four influences — "
|
||||
f"{'authored' if authored else 'solved, runtime repair stays on'}")
|
||||
return authored
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ main
|
||||
|
||||
|
||||
def main():
|
||||
clear_scene()
|
||||
print(f"Importing character {CHARACTER}")
|
||||
# Normalise emissive-albedo materials FIRST. Blender honours
|
||||
# KHR_materials_unlit and reads only base colour, so an unlit model with its
|
||||
# texture in the emissive slot imports with no images at all and exports a
|
||||
# black silhouette — there is no node graph left to repair afterwards.
|
||||
import_any(gltf_fix.prepare(CHARACTER, tempfile.gettempdir()))
|
||||
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
||||
if not arms:
|
||||
print("ERROR: character has no armature. Rig it first (see docs/3D_ASSET_PIPELINE.md).")
|
||||
sys.exit(1)
|
||||
arm = max(arms, key=lambda a: len(a.data.bones))
|
||||
meshes = skinned_meshes(arm)
|
||||
if not meshes:
|
||||
print("ERROR: no skinned meshes bound to the armature")
|
||||
sys.exit(1)
|
||||
for o in [o for o in bpy.data.objects if o.type == "MESH" and o not in meshes]:
|
||||
print(f"Dropping unskinned prop mesh: {o.name}")
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
print(f"Character: {len(arm.data.bones)} bones, {len(meshes)} meshes "
|
||||
f"({sum(len(m.data.vertices) for m in meshes)} verts) — weights kept as authored")
|
||||
|
||||
strip_import_suffixes(arm, meshes)
|
||||
for a in list(bpy.data.actions):
|
||||
bpy.data.actions.remove(a) # the character's own clip is not ours
|
||||
|
||||
roles = RigRoles(arm)
|
||||
missing = roles.missing_core()
|
||||
if missing:
|
||||
print(f"ERROR: could not identify these bones on the character rig: {missing}")
|
||||
print("Resolved so far:\n" + roles.describe())
|
||||
sys.exit(1)
|
||||
rebuild_hierarchy(arm, roles)
|
||||
flatten_and_scale(arm, meshes, TARGET_HEIGHT)
|
||||
fix_unlit_materials(meshes)
|
||||
roles = RigRoles(arm) # rest positions moved; re-read
|
||||
|
||||
library = os.path.join(ANIM_DIR, "_library.glb")
|
||||
if not os.path.exists(library):
|
||||
print(f"ERROR: animation library not found: {library}")
|
||||
sys.exit(1)
|
||||
before = set(bpy.data.objects)
|
||||
before_actions = set(bpy.data.actions)
|
||||
bpy.ops.import_scene.gltf(filepath=library)
|
||||
new_objects = [o for o in bpy.data.objects if o not in before]
|
||||
src_arm = next((o for o in new_objects if o.type == "ARMATURE"), None)
|
||||
if not src_arm:
|
||||
print("ERROR: no armature in the animation library")
|
||||
sys.exit(1)
|
||||
src_roles = RigRoles(src_arm)
|
||||
|
||||
mapping = build_map(src_roles, roles, OVERRIDES)
|
||||
print("\nLibrary rig:\n" + src_roles.describe())
|
||||
print("\nCharacter rig:\n" + roles.describe())
|
||||
print(f"\nDriving {len(mapping)} of {len(arm.data.bones)} bones from the library; "
|
||||
f"{len(arm.data.bones) - len(mapping)} left free for secondary motion.")
|
||||
|
||||
yaw = facing_correction(src_arm, src_roles, arm, roles)
|
||||
src_h = (src_arm.matrix_world @ src_arm.data.bones[src_roles.hips].matrix_local).translation.z
|
||||
tgt_h = (arm.matrix_world @ arm.data.bones[roles.hips].matrix_local).translation.z
|
||||
scale = tgt_h / src_h if src_h > 1e-5 else 1.0
|
||||
print(f"Hips height: library {src_h:.3f} m, character {tgt_h:.3f} m (scale {scale:.3f})")
|
||||
|
||||
if not arm.animation_data:
|
||||
arm.animation_data_create()
|
||||
for track in list(arm.animation_data.nla_tracks):
|
||||
arm.animation_data.nla_tracks.remove(track)
|
||||
|
||||
merged = 0
|
||||
for action in [a for a in bpy.data.actions if a not in before_actions]:
|
||||
clip = LIBRARY_CLIP_MAP.get(action.name.split(".")[0])
|
||||
if not clip:
|
||||
continue
|
||||
print(f" {action.name} -> {clip}")
|
||||
baked = retarget_clip(src_arm, src_roles, arm, roles, mapping, action,
|
||||
clip, yaw, scale)
|
||||
add_nla_clip(arm, baked, clip)
|
||||
merged += 1
|
||||
if merged == 0:
|
||||
print("ERROR: no clips retargeted")
|
||||
sys.exit(1)
|
||||
|
||||
for o in new_objects:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
||||
sidecar = os.path.splitext(OUTPUT)[0] + ".rig.json"
|
||||
info = describe_rig(arm, roles, mapping, meshes)
|
||||
with open(sidecar, "w", encoding="utf-8") as f:
|
||||
json.dump(info, f, indent=2)
|
||||
print(f"Wrote {os.path.basename(sidecar)}: {len(info['chains'])} cloth chains, "
|
||||
f"{len(info['twist'])} twist bones, {len(info['colliders'])} leg colliders")
|
||||
|
||||
print(f"Merged {merged} clips. Exporting {OUTPUT}")
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=OUTPUT,
|
||||
export_format="GLB",
|
||||
export_yup=True,
|
||||
export_apply=False,
|
||||
export_skins=True,
|
||||
export_animations=True,
|
||||
export_animation_mode="NLA_TRACKS",
|
||||
# Export ONLY the bones the clips actually key. Baking every bone
|
||||
# writes rest-pose tracks for the skirt and hair too, which triples the
|
||||
# clip data and — worse — has the AnimationPlayer overwrite exactly the
|
||||
# bones the spring solver is meant to own.
|
||||
export_bake_animation=False,
|
||||
export_optimize_animation_size=True,
|
||||
# "keep_anim_armature" is what forces a track onto every bone whether
|
||||
# or not the clip touches it. Off, so the skirt and hair export with no
|
||||
# tracks at all and belong entirely to the spring solver.
|
||||
export_optimize_animation_keep_anim_armature=False,
|
||||
)
|
||||
print("Done.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Work out which bone on one rig means the same thing as which bone on another.
|
||||
|
||||
This is the piece that lets us STOP throwing away a character's own skeleton.
|
||||
The old pipeline discarded any foreign rig (tools/strip_rig.py) because
|
||||
merge_animations.py retargeted by exact bone NAME, so a rig that named things
|
||||
differently produced a rest-pose statue. Rebinding the mesh to the library
|
||||
skeleton then destroyed everything an artist had done: per-part weights, skirt
|
||||
and hair bone chains, limb twist bones. Solving the naming problem here is what
|
||||
makes keeping the original rig possible.
|
||||
|
||||
Roles are resolved STRUCTURALLY wherever a name would lie:
|
||||
|
||||
* `hips` is the lowest common ancestor of both thighs and the head, not
|
||||
whatever is called "hips". Rigify calls it `DEF-spine`; Mixamo calls it
|
||||
`mixamorig:Hips`; both land on the same bone this way.
|
||||
* The spine chain is walked from the hips upward, refusing to turn down a
|
||||
limb or a cosmetic chain (hair/skirt/face). The bone it ends on is the
|
||||
head. That matters because a stock Rigify rig has NO bone with "head" in
|
||||
its name — the head is `DEF-spine.006`.
|
||||
* Chains of different length are matched by normalised position along the
|
||||
chain, so a 4-bone torso drives a 3-bone one and vice versa.
|
||||
|
||||
Only bones the CLIPS need are mapped. Everything else on the character rig —
|
||||
skirt chains, hair chains, twist bones, face bones — is deliberately left
|
||||
unmapped so it rests relative to its parent and is free to be driven by
|
||||
secondary motion at runtime. That is the whole point: the locomotion library
|
||||
animates the body, physics animates the cloth.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Names that are never part of the body proper. Walking the spine must not turn
|
||||
# down one of these, and they must never claim a limb role.
|
||||
#
|
||||
# Matched as whole NAME TOKENS, never as substrings. A plain `"ear" in name`
|
||||
# test marks every `DEF-forearm.L` cosmetic — which silently cost both rigs
|
||||
# their forearms and is exactly the class of bug this file exists to avoid.
|
||||
COSMETIC = ("hair", "skirt", "cloth", "ribbon", "tail", "cape", "coat",
|
||||
"scarf", "sleeve", "breast", "bust", "ear", "horn", "wing",
|
||||
"face", "cheek", "nose", "mouth", "eye", "brow", "jaw", "tongue",
|
||||
"teeth", "tooth", "lip", "chin", "accessory", "prop", "weapon",
|
||||
"bell", "strap", "belt", "buckle", "feather", "antenna")
|
||||
|
||||
# role -> ordered alternative stems. Ordered because "leg" must not win before
|
||||
# "upleg" has had its chance: Mixamo's LeftUpLeg is a thigh and its LeftLeg is
|
||||
# a shin, so the more specific spelling has to be tested first.
|
||||
LIMB_ROLES = {
|
||||
"thigh": ("upleg", "upperleg", "thigh", "leg_upper", "upper_leg", "hip"),
|
||||
"shin": ("lowerleg", "lowleg", "shin", "calf", "knee", "leg_lower", "leg"),
|
||||
"foot": ("foot", "ankle"),
|
||||
"toe": ("toebase", "toe", "ball"),
|
||||
"shoulder": ("shoulder", "clavicle", "collar"),
|
||||
"upper_arm": ("upperarm", "upper_arm", "arm_upper", "armupper", "arm"),
|
||||
"forearm": ("forearm", "lowerarm", "lowarm", "arm_lower", "elbow"),
|
||||
"hand": ("hand", "wrist"),
|
||||
}
|
||||
# Longest-first inside each role, so "upperarm" is tried before "arm".
|
||||
LIMB_ORDER = ["toe", "foot", "shin", "thigh", "shoulder", "hand", "forearm", "upper_arm"]
|
||||
|
||||
_PREFIXES = re.compile(
|
||||
r"^(def[-_]|org[-_]|mch[-_]|ctrl[-_]|mixamorig\d*[:_]|bip\d*[-_ ]|"
|
||||
r"j_bip_[clr]_|j_sec_[clr]_|valvebiped\.|bone_|b_)", re.I)
|
||||
# The glTF importer appends _<node index> to every bone name; strip it so
|
||||
# `DEF-spine.006_2` reads as `DEF-spine.006`.
|
||||
_GLTF_SUFFIX = re.compile(r"_\d+$")
|
||||
|
||||
|
||||
def strip_gltf_suffix(name: str) -> str:
|
||||
return _GLTF_SUFFIX.sub("", name)
|
||||
|
||||
|
||||
def canon(name: str) -> str:
|
||||
"""Bone name reduced to a comparable stem: no rig prefix, no separators."""
|
||||
n = strip_gltf_suffix(name).lower()
|
||||
while True:
|
||||
stripped = _PREFIXES.sub("", n)
|
||||
if stripped == n:
|
||||
break
|
||||
n = stripped
|
||||
return re.sub(r"[^a-z0-9]", "", n)
|
||||
|
||||
|
||||
def side_of(name: str):
|
||||
"""'L', 'R' or None. Checked on the ORIGINAL name so `.L` survives."""
|
||||
n = strip_gltf_suffix(name).lower()
|
||||
if re.search(r"(^|[._\- ])l($|[._\- 0-9])", n) or "left" in n:
|
||||
return "L"
|
||||
if re.search(r"(^|[._\- ])r($|[._\- 0-9])", n) or "right" in n:
|
||||
return "R"
|
||||
return None
|
||||
|
||||
|
||||
def tokens(name: str):
|
||||
"""Name split into alphabetic words: `DEF-hair.L.001` -> def, hair, l."""
|
||||
return [t for t in re.split(r"[^a-z]+", strip_gltf_suffix(name).lower()) if t]
|
||||
|
||||
|
||||
def is_cosmetic(name: str) -> bool:
|
||||
"""Whole-token match only — see the note on COSMETIC."""
|
||||
return any(t in COSMETIC for t in tokens(name))
|
||||
|
||||
|
||||
def is_segment_of(name: str, all_names: set) -> bool:
|
||||
"""True for twist/segment bones like `DEF-upper_arm.L.001`.
|
||||
|
||||
Rigify subdivides a limb into a main bone plus numbered twist bones. Those
|
||||
must never claim the limb's role — the clip would drive the twist bone and
|
||||
the real limb would stay put. Detected by construction rather than by a
|
||||
name list: strip a trailing `.001` and see whether the parent spelling is
|
||||
itself a bone on this rig.
|
||||
"""
|
||||
base = strip_gltf_suffix(name)
|
||||
m = re.match(r"^(.*)\.(\d{3})$", base)
|
||||
if not m or m.group(2) == "000":
|
||||
return False
|
||||
stem = m.group(1)
|
||||
return any(strip_gltf_suffix(n) == stem for n in all_names)
|
||||
|
||||
|
||||
class RigRoles:
|
||||
"""Resolved anatomy of one armature."""
|
||||
|
||||
def __init__(self, arm):
|
||||
self.arm = arm
|
||||
self.bones = {b.name: b for b in arm.data.bones}
|
||||
self.names = set(self.bones)
|
||||
self.limb = {} # ("thigh", "L") -> bone name
|
||||
self.spine = [] # hips-exclusive chain, ordered, ending on the head
|
||||
self.hips = None
|
||||
self.head = None
|
||||
self.neck = None
|
||||
self._resolve_limbs()
|
||||
self._resolve_spine()
|
||||
|
||||
# ---------------------------------------------------------------- limbs
|
||||
|
||||
def _resolve_limbs(self):
|
||||
for name in self.names:
|
||||
if is_cosmetic(name) or is_segment_of(name, self.names):
|
||||
continue
|
||||
side = side_of(name)
|
||||
if side is None:
|
||||
continue
|
||||
stem = canon(name)
|
||||
for role in LIMB_ORDER:
|
||||
if (role, side) in self.limb:
|
||||
continue
|
||||
if any(alt.replace("_", "") in stem for alt in LIMB_ROLES[role]):
|
||||
self.limb[(role, side)] = name
|
||||
break
|
||||
|
||||
# ---------------------------------------------------------------- spine
|
||||
|
||||
def _resolve_spine(self):
|
||||
"""Find the trunk as the LONGEST chain of non-limb, non-cosmetic bones.
|
||||
|
||||
Deliberately not "lowest common ancestor of the two thighs", which is
|
||||
the obvious thing and is wrong on the rigs we actually get. A Rigify
|
||||
DEF-rig exports its chain ROOTS parented straight to the armature root
|
||||
(Rigify drives them by constraint, not by hierarchy), so on Taila the
|
||||
thighs, shoulders, skirt and hair all hang off `rootJoint` and the LCA
|
||||
is that root — which makes the hips the root bone and the spine one
|
||||
bone long. Taking the longest trunk chain instead reads the same rig
|
||||
correctly whether or not its hierarchy survived export.
|
||||
"""
|
||||
blocked = set(self.limb.values())
|
||||
|
||||
def candidate(name):
|
||||
if name in blocked or is_cosmetic(name):
|
||||
return False
|
||||
return not any(t in ("root", "master", "armature", "scene", "rootjoint")
|
||||
for t in tokens(name))
|
||||
|
||||
def walk(start):
|
||||
chain = [start]
|
||||
cur = self.bones[start]
|
||||
while True:
|
||||
nxt = [c for c in cur.children if candidate(c.name)]
|
||||
if not nxt:
|
||||
break
|
||||
# If the walk forks, follow whichever branch reaches highest —
|
||||
# the torso continues upward, a stray nub does not.
|
||||
cur = max(nxt, key=self._branch_height)
|
||||
chain.append(cur.name)
|
||||
return chain
|
||||
|
||||
best = []
|
||||
for name in self.names:
|
||||
if not candidate(name):
|
||||
continue
|
||||
parent = self.bones[name].parent
|
||||
if parent is not None and candidate(parent.name):
|
||||
continue # not the base of a chain
|
||||
chain = walk(name)
|
||||
if len(chain) > len(best):
|
||||
best = chain
|
||||
if not best:
|
||||
return
|
||||
|
||||
self.hips = best[0]
|
||||
self.spine = best[1:]
|
||||
if self.spine:
|
||||
self.head = self.spine[-1]
|
||||
named = [n for n in self.spine if "neck" in tokens(n)]
|
||||
self.neck = named[0] if named else (
|
||||
self.spine[-2] if len(self.spine) > 1 else None)
|
||||
|
||||
def _branch_height(self, bone):
|
||||
"""How high this branch reaches, in WORLD space.
|
||||
|
||||
Measured on bone HEADS. glTF has no concept of a bone tail — joints are
|
||||
just nodes — so the tails Blender's importer shows are synthesised, and
|
||||
on Taila every skirt bone comes back with an identical 0.78 m tail.
|
||||
Heads are the only authored positions here.
|
||||
|
||||
World space, not armature-local: the importer leaves the Y-up-to-Z-up
|
||||
correction on the armature OBJECT, so a local-space test picked the
|
||||
pelvis over the spine and the walk stopped one bone in.
|
||||
"""
|
||||
mw = self.arm.matrix_world
|
||||
best = (mw @ bone.matrix_local.translation).z
|
||||
for c in bone.children:
|
||||
best = max(best, self._branch_height(c))
|
||||
return best
|
||||
|
||||
# --------------------------------------------------------------- report
|
||||
|
||||
def missing_core(self):
|
||||
need = [("thigh", "L"), ("thigh", "R"), ("shin", "L"), ("shin", "R"),
|
||||
("foot", "L"), ("foot", "R"), ("upper_arm", "L"), ("upper_arm", "R"),
|
||||
("forearm", "L"), ("forearm", "R"), ("hand", "L"), ("hand", "R")]
|
||||
miss = [f"{r}.{s}" for r, s in need if (r, s) not in self.limb]
|
||||
if not self.hips:
|
||||
miss.append("hips")
|
||||
if not self.head:
|
||||
miss.append("head")
|
||||
return miss
|
||||
|
||||
def describe(self):
|
||||
lines = [f" hips {self.hips}", f" spine {' -> '.join(self.spine)}"]
|
||||
for key in sorted(self.limb):
|
||||
lines.append(f" {key[0]}.{key[1]:<10s} {self.limb[key]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _match_chains(src_chain, tgt_chain):
|
||||
"""Pair up two ordered chains of possibly different length.
|
||||
|
||||
Each target bone takes the source bone nearest it in NORMALISED position,
|
||||
so a 4-bone torso can be driven by a 3-bone one. Because the retarget
|
||||
copies ABSOLUTE world orientation rather than composing local rotations,
|
||||
two target bones sharing one source bone simply end up parallel — the chain
|
||||
still finishes where the source says it does, it does not double the bend.
|
||||
"""
|
||||
pairs = []
|
||||
if not src_chain or not tgt_chain:
|
||||
return pairs
|
||||
for i, tgt in enumerate(tgt_chain):
|
||||
t = (i + 0.5) / len(tgt_chain)
|
||||
j = min(range(len(src_chain)),
|
||||
key=lambda k: abs((k + 0.5) / len(src_chain) - t))
|
||||
pairs.append((src_chain[j], tgt))
|
||||
return pairs
|
||||
|
||||
|
||||
def build_map(src: RigRoles, tgt: RigRoles, overrides: dict = None) -> dict:
|
||||
"""target bone name -> source bone name, for the core body only."""
|
||||
mapping = {}
|
||||
|
||||
if src.hips and tgt.hips:
|
||||
mapping[tgt.hips] = src.hips
|
||||
|
||||
# Split both spines at the neck so a head never drives a chest, then match
|
||||
# torso-to-torso and neck-to-neck by position.
|
||||
def split(roles):
|
||||
chain = roles.spine
|
||||
if not chain:
|
||||
return [], [], None
|
||||
head = chain[-1]
|
||||
rest = chain[:-1]
|
||||
if roles.neck and roles.neck in rest:
|
||||
i = rest.index(roles.neck)
|
||||
return rest[:i], rest[i:], head
|
||||
return rest, [], head
|
||||
|
||||
s_torso, s_neck, s_head = split(src)
|
||||
t_torso, t_neck, t_head = split(tgt)
|
||||
for a, b in _match_chains(s_torso, t_torso):
|
||||
mapping[b] = a
|
||||
for a, b in _match_chains(s_neck or s_torso[-1:], t_neck):
|
||||
mapping[b] = a
|
||||
if s_head and t_head:
|
||||
mapping[t_head] = s_head
|
||||
|
||||
for key, tgt_name in tgt.limb.items():
|
||||
src_name = src.limb.get(key)
|
||||
if src_name:
|
||||
mapping[tgt_name] = src_name
|
||||
|
||||
# Fingers and anything else that happens to share a spelling: map by
|
||||
# canonical stem + side. Cheap, and it makes a shared trigger-finger pose
|
||||
# come across when both rigs have fingers.
|
||||
src_by_stem = {}
|
||||
for n in src.names:
|
||||
src_by_stem.setdefault((canon(n), side_of(n)), n)
|
||||
for n in tgt.names:
|
||||
if n in mapping or is_cosmetic(n) or is_segment_of(n, tgt.names):
|
||||
continue
|
||||
hit = src_by_stem.get((canon(n), side_of(n)))
|
||||
if hit:
|
||||
mapping[n] = hit
|
||||
|
||||
if overrides:
|
||||
for tgt_name, src_name in overrides.items():
|
||||
if src_name is None:
|
||||
mapping.pop(tgt_name, None)
|
||||
else:
|
||||
mapping[tgt_name] = src_name
|
||||
return mapping
|
||||
@@ -1,5 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DEPRECATED — do not use. Kept only to explain what it did and why it was wrong.
|
||||
|
||||
This discarded a character's skeleton so autorig.py could refit the library one.
|
||||
It "solved" a bone-NAMING problem by destroying the asset: on Taila it cost 21
|
||||
skirt bones, ~50 hair bones, 8 limb twist bones, split 18 per-part meshes into
|
||||
one blob, and turned 17 both-legs-at-once vertices into 2817. Every runtime
|
||||
weight repair in characters/skin_leg_repair.gd exists to undo its output.
|
||||
|
||||
The naming problem is solved properly in tools/rig_map.py, which pairs two
|
||||
skeletons by resolved ROLE instead of by name, so tools/retarget.py can move the
|
||||
animation library onto a character's own rig and keep everything. Use that.
|
||||
|
||||
Original description follows.
|
||||
|
||||
Strip an existing rig from a character GLB so tools/autorig.py re-rigs it on
|
||||
the animation-library skeleton.
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert KHR_materials_unlit "emissive albedo" materials in a GLB to plain PBR.
|
||||
|
||||
Anime-style models often ship unlit: black baseColorFactor with the real
|
||||
albedo in emissiveTexture. Blender's importer turns those into textureless
|
||||
EMISSION node trees (the image is dropped), so everything downstream renders
|
||||
pitch black. Rewriting the material JSON up front — baseColorTexture :=
|
||||
emissiveTexture, white base factor, unlit/emissive stripped — gives every
|
||||
tool in the pipeline a normal textured PBR model.
|
||||
|
||||
Usage: python tools/unlit_to_pbr.py <in.glb> <out.glb>
|
||||
"""
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python tools/unlit_to_pbr.py <in.glb> <out.glb>")
|
||||
sys.exit(1)
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
|
||||
with open(src, "rb") as f:
|
||||
data = f.read()
|
||||
magic, version, _length = struct.unpack_from("<III", data, 0)
|
||||
if magic != 0x46546C67:
|
||||
print("ERROR: not a GLB file")
|
||||
sys.exit(1)
|
||||
|
||||
offset = 12
|
||||
json_chunk = None
|
||||
other_chunks = []
|
||||
while offset < len(data):
|
||||
clen, ctype = struct.unpack_from("<II", data, offset)
|
||||
chunk = data[offset + 8:offset + 8 + clen]
|
||||
if ctype == 0x4E4F534A: # 'JSON'
|
||||
json_chunk = chunk
|
||||
else:
|
||||
other_chunks.append((ctype, chunk))
|
||||
offset += 8 + clen
|
||||
|
||||
doc = json.loads(json_chunk)
|
||||
fixed = 0
|
||||
for mat in doc.get("materials", []):
|
||||
emis_tex = mat.get("emissiveTexture")
|
||||
if emis_tex is None:
|
||||
continue
|
||||
pbr = mat.setdefault("pbrMetallicRoughness", {})
|
||||
if "baseColorTexture" not in pbr:
|
||||
pbr["baseColorTexture"] = emis_tex
|
||||
pbr["baseColorFactor"] = [1.0, 1.0, 1.0, 1.0]
|
||||
pbr.setdefault("metallicFactor", 0.0)
|
||||
pbr["roughnessFactor"] = 1.0
|
||||
mat.pop("emissiveTexture", None)
|
||||
mat.pop("emissiveFactor", None)
|
||||
exts = mat.get("extensions", {})
|
||||
exts.pop("KHR_materials_unlit", None)
|
||||
if not exts:
|
||||
mat.pop("extensions", None)
|
||||
fixed += 1
|
||||
used = doc.get("extensionsUsed", [])
|
||||
if "KHR_materials_unlit" in used:
|
||||
used.remove("KHR_materials_unlit")
|
||||
if not used:
|
||||
doc.pop("extensionsUsed", None)
|
||||
|
||||
payload = json.dumps(doc, separators=(",", ":")).encode("utf-8")
|
||||
payload += b" " * (-len(payload) % 4)
|
||||
out = bytearray()
|
||||
out += struct.pack("<II", len(payload), 0x4E4F534A) + payload
|
||||
for ctype, chunk in other_chunks:
|
||||
chunk = chunk + b"\x00" * (-len(chunk) % 4)
|
||||
out += struct.pack("<II", len(chunk), ctype) + chunk
|
||||
header = struct.pack("<III", 0x46546C67, version, 12 + len(out))
|
||||
with open(dst, "wb") as f:
|
||||
f.write(header + out)
|
||||
print(f"Rewrote {fixed} unlit material(s) -> {dst}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check a built character GLB against the things that have actually gone wrong.
|
||||
|
||||
Every assertion here corresponds to a real defect this project shipped, so a
|
||||
green run means those specific failures are gone rather than that the file
|
||||
merely loads:
|
||||
|
||||
CROSS-LEG BLEED A vertex pulled by both legs sits between them and stays
|
||||
there while they separate, stretching every triangle around
|
||||
it. The old nearest-bone rebind left 2817 of these on
|
||||
Taila, worst at a dead 50/50 — the boots and thighs that
|
||||
characters/skin_leg_repair.gd was written to patch at
|
||||
runtime. Authored weights have none.
|
||||
INFLUENCE SPREAD 86% of vertices carrying the full four influences is the
|
||||
signature of K=4 Euclidean weighting, not of an artist.
|
||||
Real weights are mostly one or two bones.
|
||||
PART SPLIT One joined mesh means body, cloth and hair deform under one
|
||||
rule. Separate meshes per material is what lets a thigh
|
||||
stay solid while a skirt drapes.
|
||||
ORPHAN CHAINS A Rigify DEF-rig exports its chain roots on the armature
|
||||
root. If the rebuild missed one, that limb or strand floats
|
||||
in place while the body moves.
|
||||
CLIP MOTION A retarget that silently fails produces clips that exist
|
||||
but never move — the rest-pose statue this pipeline has
|
||||
produced before. Every clip must actually rotate the hips
|
||||
and the legs.
|
||||
CLOTH IS FREE Cloth bones must carry no keys, or the clips would fight
|
||||
the spring solver for them.
|
||||
|
||||
Usage:
|
||||
blender --background --python tools/verify_character.py -- <character.glb> [rig.json]
|
||||
|
||||
Exits non-zero if any check fails, so it can gate the pipeline.
|
||||
"""
|
||||
import bpy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if not argv:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
PATH = argv[0]
|
||||
SIDECAR = argv[1] if len(argv) > 1 else os.path.splitext(PATH)[0] + ".rig.json"
|
||||
|
||||
LEG_HINTS = ("thigh", "shin", "foot", "toe", "upleg", "calf")
|
||||
failures = []
|
||||
warnings = []
|
||||
# Set from the sidecar. A model whose source had no skeleton at all has to go
|
||||
# through autorig, and its weights are then a nearest-bone fit by construction —
|
||||
# there is no better result to demand. The deformation checks still RUN and
|
||||
# still print, so the cost is visible, but they cannot fail a build that had no
|
||||
# alternative; SkinLegRepair covers those models at load time instead.
|
||||
weights_authored = True
|
||||
|
||||
|
||||
def check(ok, label, detail="", needs_authored_weights=False):
|
||||
soft = needs_authored_weights and not weights_authored
|
||||
tag = "PASS" if ok else ("WARN" if soft else "FAIL")
|
||||
print(f" [{tag}] {label}" + (f" — {detail}" if detail else ""))
|
||||
if ok:
|
||||
return
|
||||
if soft:
|
||||
warnings.append(f"{label} — {detail}")
|
||||
else:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
def side_of(name):
|
||||
n = name.lower()
|
||||
if not any(h in n for h in LEG_HINTS):
|
||||
return 0
|
||||
if n.endswith(".l") or ".l." in n or "left" in n:
|
||||
return -1
|
||||
if n.endswith(".r") or ".r." in n or "right" in n:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if os.path.exists(SIDECAR):
|
||||
with open(SIDECAR, "r", encoding="utf-8") as f:
|
||||
weights_authored = bool(json.load(f).get("weights_authored", True))
|
||||
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
bpy.ops.import_scene.gltf(filepath=PATH)
|
||||
|
||||
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
||||
if not arms:
|
||||
print("FAIL: no armature")
|
||||
sys.exit(1)
|
||||
arm = max(arms, key=lambda a: len(a.data.bones))
|
||||
meshes = [o for o in bpy.data.objects if o.type == "MESH" and o.vertex_groups]
|
||||
|
||||
print(f"\n=== {os.path.basename(PATH)} — {len(arm.data.bones)} bones, "
|
||||
f"{len(meshes)} meshes, {sum(len(m.data.vertices) for m in meshes)} verts ===\n")
|
||||
|
||||
# ---------------------------------------------------------------- deformation
|
||||
total_bleed = 0
|
||||
worst_bleed = 0.0
|
||||
infl = defaultdict(int)
|
||||
for m in meshes:
|
||||
gside = {g.index: side_of(g.name) for g in m.vertex_groups}
|
||||
for v in m.data.vertices:
|
||||
wl = wr = 0.0
|
||||
n = 0
|
||||
for g in v.groups:
|
||||
if g.weight <= 0.005:
|
||||
continue
|
||||
n += 1
|
||||
s = gside.get(g.group, 0)
|
||||
if s == -1:
|
||||
wl += g.weight
|
||||
elif s == 1:
|
||||
wr += g.weight
|
||||
infl[n] += 1
|
||||
if wl > 0.005 and wr > 0.005:
|
||||
total_bleed += 1
|
||||
worst_bleed = max(worst_bleed, min(wl, wr) / (wl + wr))
|
||||
|
||||
nverts = sum(infl.values()) or 1
|
||||
four = infl.get(4, 0) / nverts
|
||||
# Not zero: a skirt hem genuinely spans both legs, and Taila's artist left 17
|
||||
# such vertices (0.1%) on purpose. The failure mode being caught is the SOLVER
|
||||
# signature — the nearest-bone rebind put 16% of the model in this state.
|
||||
bleed_frac = total_bleed / nverts
|
||||
check(bleed_frac < 0.005, "cross-leg blending is limited to draping cloth",
|
||||
f"{total_bleed} verts ({bleed_frac * 100:.1f}%), worst minority share {worst_bleed:.2f}",
|
||||
needs_authored_weights=True)
|
||||
check(four < 0.5, "influences look authored, not solved",
|
||||
f"{four * 100:.0f}% of verts carry 4 influences; spread {dict(sorted(infl.items()))}",
|
||||
needs_authored_weights=True)
|
||||
check(len(meshes) > 1, "model keeps its per-part meshes", f"{len(meshes)} meshes",
|
||||
needs_authored_weights=True)
|
||||
|
||||
# ------------------------------------------------------------------- skeleton
|
||||
def is_rootish(b):
|
||||
return b is None or any(t in b.name.lower()
|
||||
for t in ("root", "master", "armature"))
|
||||
|
||||
|
||||
orphans = [b.name for b in arm.data.bones
|
||||
if is_rootish(b.parent) and not is_rootish(b)]
|
||||
check(len(orphans) <= 1, "every chain is attached to the body",
|
||||
f"{len(orphans)} bones still on the armature root: {orphans[:6]}")
|
||||
|
||||
# ----------------------------------------------------------------------- clips
|
||||
actions = {a.name: a for a in bpy.data.actions}
|
||||
print(f"\n {len(actions)} clips: {', '.join(sorted(actions))}\n")
|
||||
check(len(actions) >= 10, "the canonical clip set shipped", f"{len(actions)} clips")
|
||||
|
||||
|
||||
def curves(action):
|
||||
legacy = getattr(action, "fcurves", None)
|
||||
if legacy is not None:
|
||||
return list(legacy)
|
||||
out = []
|
||||
for layer in getattr(action, "layers", []):
|
||||
for strip in layer.strips:
|
||||
for cbag in getattr(strip, "channelbags", []):
|
||||
out.extend(cbag.fcurves)
|
||||
return out
|
||||
|
||||
|
||||
def bone_of(path):
|
||||
if 'pose.bones["' not in path:
|
||||
return None
|
||||
s = path.index('"') + 1
|
||||
return path[s:path.index('"', s)]
|
||||
|
||||
|
||||
cloth = set()
|
||||
if os.path.exists(SIDECAR):
|
||||
with open(SIDECAR, "r", encoding="utf-8") as f:
|
||||
info = json.load(f)
|
||||
for c in info.get("chains", []):
|
||||
cloth.update(c["bones"])
|
||||
print(f" sidecar: {len(info.get('chains', []))} cloth chains "
|
||||
f"({len(cloth)} bones), {len(info.get('twist', []))} twist bones")
|
||||
else:
|
||||
warnings.append(f"no sidecar at {SIDECAR}")
|
||||
|
||||
# Clips whose legs MUST move. The library's Pistol_Idle_Loop and Pistol_Shoot
|
||||
# are upper-body clips with genuinely static legs, so demanding leg motion from
|
||||
# every clip fails on a correct build.
|
||||
LOCOMOTION = {"Idle", "Walk", "Run", "Sprint", "Jump", "Fall", "Land",
|
||||
"CrouchIdle", "CrouchWalk", "Dash"}
|
||||
|
||||
frozen = []
|
||||
legless = []
|
||||
keyed_cloth = set()
|
||||
for name, action in sorted(actions.items()):
|
||||
moved = defaultdict(float)
|
||||
for fc in curves(action):
|
||||
b = bone_of(fc.data_path)
|
||||
if not b or len(fc.keyframe_points) < 2:
|
||||
continue
|
||||
if b in cloth:
|
||||
keyed_cloth.add(b)
|
||||
vals = [kp.co.y for kp in fc.keyframe_points]
|
||||
moved[b] = max(moved[b], max(vals) - min(vals))
|
||||
if max(moved.values(), default=0.0) < 0.005:
|
||||
frozen.append(name)
|
||||
legs = max((v for b, v in moved.items()
|
||||
if any(h in b.lower() for h in ("thigh", "shin"))), default=0.0)
|
||||
if name in LOCOMOTION and legs < 0.01:
|
||||
legless.append(f"{name}({legs:.4f})")
|
||||
|
||||
check(not frozen, "no clip retargeted to a frozen rest pose",
|
||||
f"frozen: {', '.join(frozen)}" if frozen else "")
|
||||
check(not legless, "locomotion clips animate the legs",
|
||||
f"static legs: {', '.join(legless)}" if legless else "")
|
||||
check(not keyed_cloth, "cloth bones carry no animation keys",
|
||||
f"{len(keyed_cloth)} keyed: {sorted(keyed_cloth)[:5]}")
|
||||
|
||||
print()
|
||||
for w in warnings:
|
||||
print(f" [WARN] {w}")
|
||||
if failures:
|
||||
print(f"\n{len(failures)} CHECK(S) FAILED: {failures}\n")
|
||||
sys.exit(1)
|
||||
print("\nAll checks passed.\n")
|
||||
Reference in New Issue
Block a user