feat: implement automated 3D character pipeline with retargeting and rig management tools
This commit is contained in:
+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"
|
||||
|
||||
Reference in New Issue
Block a user