Add png_compress to the model pipeline and update TownSquare lighting plus MaleExplorer/plaza textures. Co-authored-by: Cursor <cursoragent@cursor.com>
378 lines
12 KiB
Python
378 lines
12 KiB
Python
#!/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
|
||
from png_compress import compress_pngs # 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,
|
||
xpbr: 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"),
|
||
)
|
||
compress_pngs(
|
||
str(bake_dir / fname) for fname in (bake.get("outputs") or {}).values()
|
||
)
|
||
|
||
xpbr_dir = None
|
||
xpbr_summary = None
|
||
if xpbr:
|
||
callbacks.emit("stage", stage="打包 XPbr base/mix 贴图与材质", progress=88)
|
||
from model_translator import translate_fbx
|
||
|
||
bake_fbx = bake_dir / bake["fbx"]
|
||
xpbr_dir = out_dir / f"{stem}_xpbr"
|
||
xpbr_summary = translate_fbx(
|
||
str(bake_fbx), str(xpbr_dir), max_size=size, blender=blender_exe
|
||
)
|
||
callbacks.emit(
|
||
"xpbr",
|
||
stage="XPbr 资源已生成",
|
||
progress=95,
|
||
outdir=str(xpbr_dir),
|
||
fbx=xpbr_summary.get("fbx"),
|
||
materials=[m.get("name") for m in xpbr_summary.get("materials") or []],
|
||
)
|
||
|
||
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()},
|
||
"xpbr_dir": str(xpbr_dir) if xpbr_dir else None,
|
||
"xpbr": (
|
||
{
|
||
"fbx": str(xpbr_dir / xpbr_summary["fbx"]),
|
||
"materials": xpbr_summary.get("materials"),
|
||
}
|
||
if xpbr_summary and xpbr_dir
|
||
else None
|
||
),
|
||
"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": (
|
||
"XPbr 目录可拷进 AIClient/Assets;"
|
||
"预设用 Unity 菜单 AIGame/M3/Setup Character Prefab + Art Collector;"
|
||
"并登记 Doc/ResDesc_Model.md"
|
||
if xpbr
|
||
else "生成完成后请在 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(
|
||
"--xpbr",
|
||
dest="xpbr",
|
||
action="store_true",
|
||
default=True,
|
||
help="烘焙后打包 XPbr base/mix + .mat(默认开启)",
|
||
)
|
||
p.add_argument(
|
||
"--no-xpbr",
|
||
dest="xpbr",
|
||
action="store_false",
|
||
help="跳过 XPbr 打包,只保留五通道烘焙图",
|
||
)
|
||
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,
|
||
xpbr=bool(
|
||
payload["xpbr"]
|
||
if "xpbr" in payload
|
||
else args.xpbr
|
||
),
|
||
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())
|