Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 14:21:07 +08:00
co-authored by Cursor
commit f3f66ab063
931 changed files with 112366 additions and 0 deletions
+319
View File
@@ -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())