Files
AIGame/Tools/model_tools/model_lowpoly_bake.py
udxandCursor 79369e4473 Compress baked PNGs and tighten XPbr shadows with URP quality tweaks.
Add png_compress to the model pipeline and update TownSquare lighting plus MaleExplorer/plaza textures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 16:25:38 +08:00

378 lines
12 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
"""高模 → 低模减面 + 贴图烘焙(一键流水线)。
流程对齐 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())