Files
AIGame/Tools/dgx3d_animal_pipeline.py

375 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""非人角色:已验收游戏模 → TokenRig 绑骨 → UniMate 三动作(Idle/Walk/Attack)。
正确流程(与 GameResProductProc 二.3.B 一致):
1. 原混元网页 + model_lowpoly_bake + SetupCharacter 做出静态模(XPbr)
2. 本脚本只吃已验收网格(FBX/GLB),不再走 DGX Hunyuan3D 重做模
3. TokenRig + UniMate(经本机 LLM3DGraphics backend,SSH 到 DGXA)
示例:
python Tools/dgx3d_animal_pipeline.py \\
--mesh AIClient/Assets/Bundles/Characters/Animal/RockBall/RockBall.fbx \\
-o Tools/output/rockball_dgx \\
--name RockBall
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
import uuid
import zipfile
from pathlib import Path
from typing import Dict, List, Optional, Tuple
REPO = Path(__file__).resolve().parents[1]
DEFAULT_LLM3D = Path(
os.environ.get("LLM3D_ROOT", r"D:\UD\AI\XModel\LLM3DGraphics")
)
DEFAULT_MOTIONS = [
("Idle", "a small bipedal character idles with a gentle breathing motion"),
("Walk", "a small bipedal character walks forward upright with alternating short steps and gentle arm swings"),
("Attack", "a small bipedal character makes a short forward body strike"),
]
_BLENDER_CONVERT = r'''
import sys
from pathlib import Path
import bpy
from mathutils import Matrix
from math import pi
argv = sys.argv[sys.argv.index("--") + 1:]
src, dst = Path(argv[0]), Path(argv[1])
bpy.ops.wm.read_factory_settings(use_empty=True)
ext = src.suffix.lower()
if ext == ".fbx":
bpy.ops.import_scene.fbx(filepath=str(src))
# Project static FBX stores Y-up vertices. TokenRig/glTF requires Blender
# Z-up before the glTF exporter performs its Y-up conversion.
if any(o.type == 'ARMATURE' for o in bpy.context.scene.objects):
raise SystemExit('Expected approved STATIC mesh, not an already skinned FBX')
for obj in bpy.context.scene.objects:
if obj.parent is None:
obj.matrix_world = Matrix.Rotation(pi / 2, 4, 'X') @ obj.matrix_world
elif ext in (".glb", ".gltf"):
bpy.ops.import_scene.gltf(filepath=str(src))
else:
raise SystemExit(f"unsupported: {ext}")
# Prefer sibling albedo if FBX came without embeds
parent = src.parent
for name in (src.stem + "_base.png", src.stem + "_color.png", "diffuse.png"):
tex = parent / name
if not tex.is_file():
continue
img = bpy.data.images.load(str(tex), check_existing=True)
for mat in bpy.data.materials:
mat.use_nodes = True
nt = mat.node_tree
nodes, links = nt.nodes, nt.links
principled = next((n for n in nodes if n.type == "BSDF_PRINCIPLED"), None)
if principled is None:
continue
tex_node = nodes.new("ShaderNodeTexImage")
tex_node.image = img
links.new(tex_node.outputs["Color"], principled.inputs["Base Color"])
break
dst.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(dst), export_format="GLB", export_yup=True)
print("OK", dst)
'''
def _ensure_llm3d() -> Path:
root = DEFAULT_LLM3D.resolve()
if not (root / "backend" / "tokenrig_runner.py").is_file():
raise SystemExit(f"LLM3DGraphics 未找到: {root}(可用 LLM3D_ROOT 覆盖)")
if str(root) not in sys.path:
sys.path.insert(0, str(root))
return root
def _blender() -> Path:
_ensure_llm3d()
from backend.settings import blender_path
path = blender_path()
if not path:
raise SystemExit("未配置 Blender(LLM3DGraphics blender_path)")
return Path(path)
def require_dgx_backends() -> None:
_ensure_llm3d()
from backend.tokenrig_runner import tokenrig_ready
from backend.unimate_runner import unimate_ready
tr, uni = bool(tokenrig_ready()), bool(unimate_ready())
if not tr or not uni:
raise SystemExit(
f"DGX 能力红灯:tokenrig_ready={tr} unimate_ready={uni}\n"
"请先 nvsync connect DGXA --detach,并确认 SkinTokens/UniMate 就绪。"
)
def mesh_to_glb(mesh: Path, out_glb: Path) -> Path:
mesh = mesh.resolve()
out_glb = out_glb.resolve()
out_glb.parent.mkdir(parents=True, exist_ok=True)
if mesh.suffix.lower() == ".glb":
if mesh.resolve() != out_glb:
shutil.copy2(mesh, out_glb)
return out_glb
script = out_glb.parent / "_fbx_to_glb.py"
script.write_text(_BLENDER_CONVERT, encoding="utf-8")
blender = _blender()
proc = subprocess.run(
[
str(blender),
"--background",
"--factory-startup",
"--python",
str(script),
"--",
str(mesh),
str(out_glb),
],
capture_output=True,
text=True,
timeout=300,
check=False,
)
if proc.returncode != 0 or not out_glb.is_file():
raise SystemExit(
f"FBX→GLB 失败 rc={proc.returncode}\n"
f"{(proc.stdout or '')[-1500:]}\n{(proc.stderr or '')[-1500:]}"
)
print(f" mesh glb={out_glb} ({out_glb.stat().st_size} bytes)", flush=True)
return out_glb
def extract_rigged_fbx(zip_or_fbx: Path, out_dir: Path) -> Path:
out_dir.mkdir(parents=True, exist_ok=True)
if zip_or_fbx.suffix.lower() == ".fbx":
dest = out_dir / zip_or_fbx.name
if zip_or_fbx.resolve() != dest.resolve():
shutil.copy2(zip_or_fbx, dest)
return dest
with zipfile.ZipFile(zip_or_fbx, "r") as zf:
zf.extractall(out_dir)
candidates = list(out_dir.rglob("model.fbx")) + list(out_dir.rglob("*.fbx"))
if not candidates:
raise FileNotFoundError(f"no fbx in {zip_or_fbx}")
for c in candidates:
if c.name.lower() == "model.fbx":
return c
return candidates[0]
def parse_motions(raw: Optional[str], name: str) -> List[Tuple[str, str]]:
if not raw:
if name.lower() != "rockball":
return [
("Idle", f"{name}宠物原地待机,轻微呼吸,可循环"),
("Walk", f"{name}宠物原地走路,可循环"),
("Attack", f"{name}宠物原地短促攻击"),
]
return list(DEFAULT_MOTIONS)
items = json.loads(raw)
return [(str(it["name"]), str(it["prompt"])) for it in items]
def run_pipeline(
mesh: Path,
out_dir: Path,
*,
name: str,
motions: List[Tuple[str, str]],
skip_gate: bool = False,
) -> dict:
mesh = mesh.resolve()
if not mesh.is_file():
raise SystemExit(f"mesh not found: {mesh}")
out_dir = out_dir.resolve()
out_dir.mkdir(parents=True, exist_ok=True)
if not skip_gate:
require_dgx_backends()
else:
print("WARNING: --skip-gate", flush=True)
_ensure_llm3d()
from backend.motion_branches import export_rigged_fbx
from backend.tokenrig_runner import run_tokenrig
import backend.unimate_runner as unimate_runner
from backend.unimate_runner import submit_unimate_job
# Preserve explicit English captions: upstream rewrites every English walk
# to "a dog" and gives Chinese 跳 priority over 走. Log submitted prompts.
original_normalize = unimate_runner.normalize_unimate_prompt
def normalize_prompt(prompt):
if not any('\u4e00' <= char <= '\u9fff' for char in prompt):
return prompt.strip()
return original_normalize(prompt)
unimate_runner.normalize_unimate_prompt = normalize_prompt
# DGXA 上 HF 偶发超时:强制离线用本地 flan-t5 缓存
_orig_ssh = unimate_runner._ssh
def _ssh_offline(host, cmd, timeout=120):
if "run_job.sh" in str(cmd):
cmd = (
"export HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 "
"HF_DATASETS_OFFLINE=1; " + cmd
)
return _orig_ssh(host, cmd, timeout=timeout)
unimate_runner._ssh = _ssh_offline
job_id = uuid.uuid4().hex
job_dir = out_dir / job_id
job_dir.mkdir(parents=True, exist_ok=True)
log_path = job_dir / "pipeline.log"
print(f"job {job_id} mesh={mesh}", flush=True)
mesh_glb = mesh_to_glb(mesh, job_dir / "model_ex.glb")
print("TokenRig …", flush=True)
rig_path = run_tokenrig(
mesh_glb, job_dir, log_path, skeleton_template="original"
)
print(f" rigged={rig_path}", flush=True)
zip_cand = job_dir / "rigged_fbx.zip"
print("export rigged FBX zip …", flush=True)
exported = export_rigged_fbx(rig_path, zip_cand, log_path)
if exported is None or not zip_cand.is_file():
raise SystemExit("rigged FBX 导出失败,见 pipeline.log")
rig_extract = job_dir / "rigged"
model_fbx = extract_rigged_fbx(zip_cand, rig_extract)
named = job_dir / f"{name}_Rigged.fbx"
shutil.copy2(model_fbx, named)
anim_dir = job_dir / "anims"
anim_dir.mkdir(exist_ok=True)
anim_paths: Dict[str, str] = {}
for clip_name, prompt in motions:
print(f"animate {clip_name}: {prompt}", flush=True)
stamp = f"{job_id}_{clip_name.lower()}"
last_err: Optional[BaseException] = None
for attempt in range(1, 4):
try:
submit_unimate_job(
rig_path=rig_path,
prompt=prompt,
directory=job_dir,
log_path=log_path,
job_id=f"{stamp}_a{attempt}",
)
last_err = None
break
except Exception as exc: # noqa: BLE001
last_err = exc
print(f" UniMate {clip_name} attempt {attempt} failed: {exc}", flush=True)
time.sleep(5)
if last_err is not None:
raise SystemExit(f"UniMate {clip_name} failed: {last_err}") from last_err
src_fbx = job_dir / "animated.fbx"
if not src_fbx.is_file():
raise SystemExit(f"UniMate 未产出 animated.fbx ({clip_name})")
dest = anim_dir / f"{name}_{clip_name}.fbx"
shutil.copy2(src_fbx, dest)
anim_paths[clip_name] = str(dest.relative_to(out_dir))
glb = job_dir / "animated.glb"
if glb.is_file():
shutil.copy2(glb, dest.with_suffix(".glb"))
print(f" saved {dest}", flush=True)
manifest = {
"ok": True,
"name": name,
"job_id": job_id,
"source_mesh": str(mesh),
"pipeline": "approved_mesh→TokenRig→UniMate",
"rigged_fbx": str(named),
"rigged_zip": str(zip_cand) if zip_cand.is_file() else str(exported),
"anims": anim_paths,
"motions": [{"name": n, "prompt": p} for n, p in motions],
"hint": "Unity: SetupAnimalAnimator 挂回静态模 XPbr;动画用 Anims/*.fbx",
}
(out_dir / f"{name}_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
(job_dir / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(manifest, ensure_ascii=False, indent=2), flush=True)
return manifest
def main(argv: Optional[List[str]] = None) -> int:
ap = argparse.ArgumentParser(
description="已验收网格 → TokenRig + UniMate(不重做 DGX 模型)"
)
ap.add_argument(
"--mesh",
"-m",
type=Path,
required=False,
help="已验收游戏模 FBX/GLB(推荐 Bundles/.../{Name}.fbx)",
)
ap.add_argument("-o", "--out", type=Path, required=False)
ap.add_argument("--name", default=None, help="English asset name, e.g. RockBall")
ap.add_argument("--motions-json", default=None)
ap.add_argument("--skip-gate", action="store_true")
ap.add_argument(
"--caps-only",
action="store_true",
help="只检查 TokenRig/UniMate 就绪",
)
ap.add_argument(
"-i",
"--image",
type=Path,
default=None,
help=argparse.SUPPRESS, # 旧图生入口已废弃
)
args = ap.parse_args(argv)
if args.caps_only:
require_dgx_backends()
print(json.dumps({"tokenrig_ready": True, "unimate_ready": True}, indent=2))
return 0
if args.image is not None:
raise SystemExit(
"已废弃:勿再用 -i 设计图走 DGX Hunyuan3D 重做模。\n"
"请先按原流程烘焙验收静态模,再:\n"
" python Tools/dgx3d_animal_pipeline.py --mesh "
"AIClient/Assets/Bundles/Characters/Animal/{Name}/{Name}.fbx "
"-o Tools/output/{name}_dgx --name {Name}"
)
if not args.mesh or not args.out or not args.name:
raise SystemExit("需要 --mesh / -o / --name(或 --caps-only)")
motions = parse_motions(args.motions_json, args.name)
t0 = time.time()
run_pipeline(
args.mesh,
args.out,
name=args.name,
motions=motions,
skip_gate=args.skip_gate,
)
print(f"done in {time.time() - t0:.0f}s", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())