#!/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())