Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""高模 → 低模减面 + 贴图烘焙(一键流水线)。
|
||||
|
||||
流程对齐 ClientTrunk ModelTools:
|
||||
高模(FBX/GLB/glTF)
|
||||
-> model_decimate:合并 Mesh、清理、三角化、减面、重展 UV -> <名>_low.fbx
|
||||
-> model_bake:Cycles selected-to-active
|
||||
-> 低模 FBX + color / normal / ao / metallic / roughness PNG
|
||||
|
||||
用法:
|
||||
python Tools/model_tools/model_lowpoly_bake.py high.glb -o Tools/output/prop --tris 5000 --size 2048
|
||||
python Tools/model_tools/model_lowpoly_bake.py --json "{\"high\":\"a.glb\",\"out\":\"out/a\",\"tris\":5000}" --result-only
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence, Union
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
if str(_HERE) not in sys.path:
|
||||
sys.path.insert(0, str(_HERE))
|
||||
|
||||
from bl_bake import output_stem # noqa: E402
|
||||
from bl_decimate import model_format # noqa: E402
|
||||
from mt_run import find_blender, run_blender_script # noqa: E402
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
class CallbackHub:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_file: Optional[Union[str, Path]] = None,
|
||||
quiet: bool = False,
|
||||
) -> None:
|
||||
self.callback_url = callback_url
|
||||
self.callback_file = Path(callback_file) if callback_file else None
|
||||
self.quiet = quiet
|
||||
self.events: List[Dict[str, Any]] = []
|
||||
|
||||
def emit(self, event: str, **payload: Any) -> Dict[str, Any]:
|
||||
record = {"event": event, "ts": _now_iso(), **payload}
|
||||
self.events.append(record)
|
||||
if not self.quiet:
|
||||
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||||
if self.callback_file:
|
||||
self.callback_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.callback_file.write_text(
|
||||
json.dumps(
|
||||
{"latest": record, "history": self.events},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if self.callback_url:
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
data = json.dumps(record, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
self.callback_url,
|
||||
data=data,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30.0) as resp:
|
||||
resp.read()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if not self.quiet:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "callback_error",
|
||||
"ts": _now_iso(),
|
||||
"error": str(exc),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def _load_json_arg(raw: Optional[str]) -> Dict[str, Any]:
|
||||
if not raw:
|
||||
return {}
|
||||
if raw == "-":
|
||||
return json.load(sys.stdin)
|
||||
path = Path(raw)
|
||||
if path.is_file():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def run_pipeline(
|
||||
*,
|
||||
high: str,
|
||||
out: str,
|
||||
tris: int = 5000,
|
||||
size: int = 2048,
|
||||
samples: int = 64,
|
||||
unwrap: str = "seam",
|
||||
max_overlap: Optional[float] = None,
|
||||
ray_distance: Optional[float] = None,
|
||||
blender: Optional[str] = None,
|
||||
keep_low: bool = True,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_file: Optional[str] = None,
|
||||
quiet: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
callbacks = CallbackHub(
|
||||
callback_url=callback_url, callback_file=callback_file, quiet=quiet
|
||||
)
|
||||
high_path = Path(high).expanduser().resolve()
|
||||
if not high_path.is_file():
|
||||
raise FileNotFoundError(f"高模不存在: {high_path}")
|
||||
try:
|
||||
model_format(str(high_path))
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
if tris <= 0:
|
||||
raise ValueError("--tris 必须为正整数")
|
||||
|
||||
out_dir = Path(out).expanduser().resolve()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = high_path.stem
|
||||
low_dir = out_dir / "_low_work" if not keep_low else out_dir
|
||||
low_dir.mkdir(parents=True, exist_ok=True)
|
||||
low_fbx = low_dir / f"{name}_low.fbx"
|
||||
stem = output_stem(f"{name}_low")
|
||||
bake_dir = out_dir / stem
|
||||
|
||||
blender_exe = find_blender(blender)
|
||||
callbacks.emit(
|
||||
"started",
|
||||
stage="开始高模转低模烘焙",
|
||||
progress=1,
|
||||
high=str(high_path),
|
||||
out=str(out_dir),
|
||||
tris=tris,
|
||||
size=size,
|
||||
blender=blender_exe,
|
||||
)
|
||||
|
||||
callbacks.emit("stage", stage="减面并重展 UV", progress=20)
|
||||
overlap_arg = "" if max_overlap is None else str(max_overlap)
|
||||
dec = run_blender_script(
|
||||
blender_exe,
|
||||
"bl_decimate.py",
|
||||
[str(high_path), str(low_fbx), str(tris), unwrap, overlap_arg],
|
||||
)
|
||||
callbacks.emit(
|
||||
"decimated",
|
||||
stage="减面完成",
|
||||
progress=50,
|
||||
tris_before=dec["tris_before"],
|
||||
tris_after=dec["tris_after"],
|
||||
low_fbx=str(low_fbx),
|
||||
uv=dec.get("uv"),
|
||||
warnings=dec.get("warnings") or [],
|
||||
)
|
||||
|
||||
# Optional UV preview (same as model_decimate.py)
|
||||
if dec.get("uv_polys"):
|
||||
try:
|
||||
from uv_preview import render_uv_png
|
||||
|
||||
with open(dec["uv_polys"], encoding="utf-8") as fp:
|
||||
polys = json.load(fp)
|
||||
render_uv_png(polys, dec["uv_preview"])
|
||||
callbacks.emit(
|
||||
"uv_preview",
|
||||
stage="UV 观察图已生成",
|
||||
progress=55,
|
||||
path=dec["uv_preview"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
callbacks.emit(
|
||||
"warning", stage=f"UV 观察图失败: {exc}", progress=55
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(dec["uv_polys"]):
|
||||
os.remove(dec["uv_polys"])
|
||||
|
||||
callbacks.emit("stage", stage="高低模贴图烘焙", progress=60)
|
||||
ray = "auto" if ray_distance is None else str(ray_distance)
|
||||
bake = run_blender_script(
|
||||
blender_exe,
|
||||
"bl_bake.py",
|
||||
[
|
||||
str(high_path),
|
||||
str(low_fbx),
|
||||
str(bake_dir),
|
||||
str(size),
|
||||
ray,
|
||||
str(samples),
|
||||
],
|
||||
blender_args=("-t", "1"),
|
||||
)
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"status": "succeeded",
|
||||
"progress": 100,
|
||||
"high": str(high_path),
|
||||
"low_fbx": str(low_fbx),
|
||||
"bake_dir": str(bake_dir),
|
||||
"fbx": str(bake_dir / bake["fbx"]),
|
||||
"maps": {k: str(bake_dir / v) for k, v in bake["outputs"].items()},
|
||||
"tris_before": dec["tris_before"],
|
||||
"tris_after": bake["low_tris"],
|
||||
"high_tris": bake["high_tris"],
|
||||
"size": size,
|
||||
"ray_distance": bake["ray_distance"],
|
||||
"warnings": list(dec.get("warnings") or []) + list(bake.get("warnings") or []),
|
||||
"hint": "生成完成后请在 Doc/ResDesc_Model.md 登记该资源",
|
||||
}
|
||||
callbacks.emit("succeeded", **result)
|
||||
return result
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="model_lowpoly_bake",
|
||||
description="高模减面 + Cycles 贴图烘焙(一键)",
|
||||
)
|
||||
p.add_argument("high", nargs="?", help="高模 FBX/GLB/glTF")
|
||||
p.add_argument("-o", "--out", help="输出目录")
|
||||
p.add_argument("--tris", type=int, default=5000, help="目标三角面数,默认 5000")
|
||||
p.add_argument("--size", type=int, default=2048, help="烘焙贴图边长,默认 2048")
|
||||
p.add_argument("--samples", type=int, default=64, help="AO 采样数,默认 64")
|
||||
p.add_argument(
|
||||
"--unwrap",
|
||||
choices=("seam", "smart"),
|
||||
default="seam",
|
||||
help="UV 展开模式,默认 seam",
|
||||
)
|
||||
p.add_argument("--max-overlap", type=float, default=None, help="UV 重叠容忍 (0,1]")
|
||||
p.add_argument("--ray-distance", type=float, default=None, help="烘焙射线距离")
|
||||
p.add_argument("--blender", default=None, help="blender.exe 路径")
|
||||
p.add_argument(
|
||||
"--callback-url", default=None, help="流程回调 URL:每个事件 POST JSON"
|
||||
)
|
||||
p.add_argument(
|
||||
"--callback-file",
|
||||
default=None,
|
||||
help="流程回调状态文件(latest/history)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--json",
|
||||
dest="json_input",
|
||||
default=None,
|
||||
help='JSON 参数(对象字符串 / 文件路径 / "-" 读 stdin)',
|
||||
)
|
||||
p.add_argument("--result-only", action="store_true")
|
||||
p.add_argument("--quiet", action="store_true")
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
payload = _load_json_arg(args.json_input)
|
||||
quiet = bool(args.quiet or args.result_only or payload.get("quiet"))
|
||||
|
||||
high = payload.get("high") or args.high
|
||||
out = payload.get("out") or payload.get("output") or args.out
|
||||
if not high or not out:
|
||||
parser.error("必须提供高模路径与 -o/--out(或 JSON.high / JSON.out)")
|
||||
|
||||
try:
|
||||
result = run_pipeline(
|
||||
high=high,
|
||||
out=out,
|
||||
tris=int(payload.get("tris") or args.tris),
|
||||
size=int(payload.get("size") or args.size),
|
||||
samples=int(payload.get("samples") or args.samples),
|
||||
unwrap=payload.get("unwrap") or args.unwrap,
|
||||
max_overlap=(
|
||||
float(payload["max_overlap"])
|
||||
if payload.get("max_overlap") is not None
|
||||
else args.max_overlap
|
||||
),
|
||||
ray_distance=(
|
||||
float(payload["ray_distance"])
|
||||
if payload.get("ray_distance") is not None
|
||||
else args.ray_distance
|
||||
),
|
||||
blender=payload.get("blender") or args.blender,
|
||||
callback_url=payload.get("callback_url") or args.callback_url,
|
||||
callback_file=payload.get("callback_file") or args.callback_file,
|
||||
quiet=quiet,
|
||||
)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
err = {"ok": False, "error": str(exc)}
|
||||
print(json.dumps(err, ensure_ascii=False), flush=True)
|
||||
return 1
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user