Miku shipped with 0 cloth chains against Taila's 35, so her twin tails hung off her skull like a helmet. Nothing downstream could fix it: the spring solver simulates cloth BONES, and a garment with none is welded to whatever body bone it was weighted to. Every auto-rigged model is in that state, and her source was an unrigged mesh. tools/cloth_bones.py builds them, which is the job a technical artist does by hand on a model like this. It finds the geometry by MATERIAL SLOT — the artist already answered which surface is hair, and on a joined mesh (what the auto-rig leaves behind) the slot is the only separation left. Hair is split into connected islands, because a strand is a connected piece of surface and clustering by position would merge two ponytails passing near each other. A skirt is split into radial wedges instead, because a skirt is ONE connected surface and islands would return the whole thing as a single piece — the bell-shaped failure. Each clump gets a polyline fitted down its middle by binning vertices by distance and taking centroids, so the chain follows the piece's own curve rather than cutting the corner on a bend, and vertices are re-weighted onto it while the first 22% keeps its original body weight so the scalp stays on the skull. On Miku: 19 chains, 57 bones from one `hair` slot. Sidecar 0 -> 19 chains. Idle stability 0.007-0.018 deg/frame. Mesh intact, verified by render. Opt-in, via `pipeline.py --grow-cloth`, and run before the retarget so describe_rig() finds the chains by name exactly as it would an artist's. Known limits, recorded in the skill: it cannot find a garment sharing a material with the body (Miku's skirt is on her `body` slot, so she got hair and no skirt), and grown chains are a fallback — an artist's chains carry intent that no geometric fit recovers. Co-Authored-By: Claude Opus 5 <[email protected]>
241 lines
10 KiB
Python
241 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
One-command character pipeline for Papaya-Shooter:
|
|
|
|
Sketchfab model -> auto-rig -> merge animation library -> in game.
|
|
|
|
Examples:
|
|
# From a Sketchfab UID (needs SKETCHFAB_API_TOKEN):
|
|
python tools/pipeline.py --uid a1b2c3... --name space_marine
|
|
|
|
# From a local unrigged mesh:
|
|
python tools/pipeline.py --input downloads/robot.glb --name robot
|
|
|
|
# From an ALREADY-RIGGED model (Mixamo/AccuRig/Tripo output) — skips autorig:
|
|
python tools/pipeline.py --input rigged/knight.fbx --name knight --rigged
|
|
|
|
# Rigged, but with no skirt/hair bones — grow them so the costume can move:
|
|
python tools/pipeline.py --input rigged/knight.fbx --name knight --rigged --grow-cloth
|
|
|
|
The result is assets/characters/skins/<name>.glb with the full canonical
|
|
animation set, plus a registry entry in assets/characters/skins/skins.json
|
|
that SkinManager picks up automatically — no code changes needed.
|
|
|
|
Requires Blender 3.6+ on PATH or BLENDER_PATH set.
|
|
Animation library: assets/characters/animations/ (see docs/ASSET_SOURCES.md).
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
|
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
TOOLS = os.path.join(PROJECT_ROOT, "tools")
|
|
SKINS_DIR = os.path.join(PROJECT_ROOT, "assets", "characters", "skins")
|
|
ANIM_DIR = os.path.join(PROJECT_ROOT, "assets", "characters", "animations")
|
|
STAGING = os.path.join(PROJECT_ROOT, "assets", "characters", "incoming")
|
|
SKINS_JSON = os.path.join(SKINS_DIR, "skins.json")
|
|
|
|
|
|
def find_blender() -> str:
|
|
env = os.environ.get("BLENDER_PATH")
|
|
if env and os.path.exists(env):
|
|
return env
|
|
on_path = shutil.which("blender")
|
|
if on_path:
|
|
return on_path
|
|
candidates = []
|
|
for pf in (r"C:\Program Files\Blender Foundation", r"C:\Program Files (x86)\Blender Foundation"):
|
|
if os.path.isdir(pf):
|
|
for d in sorted(os.listdir(pf), reverse=True):
|
|
exe = os.path.join(pf, d, "blender.exe")
|
|
if os.path.exists(exe):
|
|
candidates.append(exe)
|
|
if candidates:
|
|
return candidates[0]
|
|
print("ERROR: Blender not found. Install Blender or set BLENDER_PATH to blender.exe")
|
|
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))
|
|
result = subprocess.run(cmd)
|
|
if result.returncode != 0:
|
|
print(f"ERROR: step '{step}' failed (exit {result.returncode})")
|
|
sys.exit(result.returncode)
|
|
|
|
|
|
def register_skin(name: str, display_name: str, model_res_path: str, description: str) -> None:
|
|
registry = {"skins": []}
|
|
if os.path.exists(SKINS_JSON):
|
|
with open(SKINS_JSON, "r", encoding="utf-8") as f:
|
|
registry = json.load(f)
|
|
skins = registry.setdefault("skins", [])
|
|
entry = {
|
|
"id": name,
|
|
"name": display_name,
|
|
"description": description,
|
|
"model": model_res_path,
|
|
"unlocked": True,
|
|
}
|
|
for i, s in enumerate(skins):
|
|
if s.get("id") == name:
|
|
skins[i] = entry
|
|
break
|
|
else:
|
|
skins.append(entry)
|
|
os.makedirs(SKINS_DIR, exist_ok=True)
|
|
with open(SKINS_JSON, "w", encoding="utf-8") as f:
|
|
json.dump(registry, f, indent=2)
|
|
print(f"Registered skin '{name}' in {os.path.relpath(SKINS_JSON, PROJECT_ROOT)}")
|
|
|
|
|
|
def main() -> None:
|
|
p = argparse.ArgumentParser(description="Sketchfab -> rigged, animated, in-game character")
|
|
src = p.add_mutually_exclusive_group(required=True)
|
|
src.add_argument("--uid", help="Sketchfab model UID to download")
|
|
src.add_argument("--input", help="local model file (glb/gltf/fbx/obj)")
|
|
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="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")
|
|
p.add_argument("--grow-cloth", action="store_true",
|
|
help="grow skirt/hair bone chains for a model that has none "
|
|
"(see tools/cloth_bones.py) — needed for anything that "
|
|
"went through the auto-rig, whose costume is welded solid")
|
|
args = p.parse_args()
|
|
|
|
name = args.name
|
|
display_name = args.display_name or name.replace("_", " ").title()
|
|
os.makedirs(STAGING, exist_ok=True)
|
|
|
|
# 1. Acquire the model.
|
|
if args.uid:
|
|
run([sys.executable, os.path.join(TOOLS, "sketchfab_import.py"),
|
|
"download", args.uid, "--name", name, "--out", STAGING],
|
|
"Download from Sketchfab")
|
|
input_path = os.path.join(STAGING, f"{name}.glb")
|
|
if not os.path.exists(input_path):
|
|
# glTF-zip fallback path used by the importer
|
|
alt = os.path.join(STAGING, name)
|
|
gltfs = [f for f in os.listdir(alt) if f.endswith((".gltf", ".glb"))] if os.path.isdir(alt) else []
|
|
if not gltfs:
|
|
print("ERROR: downloaded model not found in staging dir")
|
|
sys.exit(1)
|
|
input_path = os.path.join(alt, gltfs[0])
|
|
else:
|
|
input_path = os.path.abspath(args.input)
|
|
if not os.path.exists(input_path):
|
|
print(f"ERROR: input not found: {input_path}")
|
|
sys.exit(1)
|
|
|
|
blender = find_blender()
|
|
print(f"Using Blender: {blender}")
|
|
|
|
# 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")
|
|
|
|
# Grow cloth chains BEFORE the retarget, so describe_rig() finds them by name
|
|
# exactly as it would an artist's and writes them to the sidecar.
|
|
#
|
|
# A model with no cosmetic bones has a costume that cannot move: the spring
|
|
# solver simulates cloth BONES, so a skirt with none is welded to the hips
|
|
# whatever the runtime does. That is every auto-rigged model, and it is why
|
|
# Miku shipped with 0 cloth chains against Taila's 35.
|
|
if args.grow_cloth:
|
|
grown = os.path.join(STAGING, f"{name}_cloth.glb")
|
|
run([blender, "--background", "--python", os.path.join(TOOLS, "cloth_bones.py"),
|
|
"--", input_path, grown],
|
|
"Grow skirt/hair bone chains (Blender)")
|
|
input_path = grown
|
|
rigged = True
|
|
|
|
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"
|
|
if os.path.exists(lic_src):
|
|
shutil.copyfile(lic_src, os.path.join(SKINS_DIR, f"{name}.license.json"))
|
|
|
|
# 5. Register so the game sees it.
|
|
register_skin(name, display_name, f"res://assets/characters/skins/{name}.glb", args.description)
|
|
|
|
print(f"\nDONE: {os.path.relpath(final_path, PROJECT_ROOT)}")
|
|
print(f"'{display_name}' is now selectable in-game (SkinManager reads skins.json).")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|