feat: implement automated 3D character pipeline with retargeting and rig management tools

This commit is contained in:
Nicholas Butzke
2026-07-24 20:52:35 -04:00
parent afc954e129
commit 374d9f9822
19 changed files with 3220 additions and 221 deletions
+67 -21
View File
@@ -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"