diff --git a/Tools/ModelTranslator/model_translator.py b/Tools/ModelTranslator/model_translator.py new file mode 100644 index 00000000..4bf50045 --- /dev/null +++ b/Tools/ModelTranslator/model_translator.py @@ -0,0 +1,106 @@ +"""ModelTranslator CLI:FBX -> 纯模型 FBX + XPbr 贴图 + Unity .mat/.meta。 +用法:python model_translator.py src/tong.fbx [-o out] [--max-size 2048] [--blender exe]""" +import argparse +import json +import os +import shutil +import subprocess +import sys + +import unity_assets as ua + +DEFAULT_BLENDER = r"D:\tools\blender-5.0.0-windows-x64\blender.exe" +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def find_blender(cli_arg): + for cand in (cli_arg, os.environ.get("BLENDER_EXE"), DEFAULT_BLENDER, + shutil.which("blender")): + if cand and os.path.isfile(cand): + return cand + sys.exit("找不到 blender.exe,请用 --blender 指定或设置环境变量 BLENDER_EXE") + + +def run_blender(blender, src, outdir, max_size): + cmd = [blender, "-b", "--factory-startup", + "--python", os.path.join(HERE, "bl_convert.py"), + "--", src, outdir, str(max_size)] + proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", + errors="replace") + summary = None + for line in (proc.stdout or "").splitlines(): + if line.startswith("MT_SUMMARY "): + summary = json.loads(line[len("MT_SUMMARY "):]) + if proc.returncode != 0 or summary is None: + sys.stderr.write(proc.stdout[-2000:] + "\n" + proc.stderr[-2000:] + "\n") + sys.exit("Blender 转换失败:%s" % src) + return summary + + +def write_unity_assets(outdir, summary, max_size): + remaps = [] + for m in summary["materials"]: + base_png = os.path.join(outdir, m["base_png"]) + mix_png = os.path.join(outdir, m["mix_png"]) + base_guid = ua.guid_for(base_png) + mix_guid = ua.guid_for(mix_png) + with open(base_png + ".meta", "w", encoding="utf-8", newline="\n") as f: + f.write(ua.texture_meta(base_guid, srgb=True, max_size=max_size)) + with open(mix_png + ".meta", "w", encoding="utf-8", newline="\n") as f: + f.write(ua.texture_meta(mix_guid, srgb=False, max_size=max_size)) + + mat_name = "%s_%s" % (summary["model"], m["safe_name"]) + mat_path = os.path.join(outdir, mat_name + ".mat") + mat_guid = ua.guid_for(mat_path) + alpha_test = m["alpha_mode"] == "transparency" + with open(mat_path, "w", encoding="utf-8", newline="\n") as f: + f.write(ua.material_yaml(mat_name, ua.XPBR_SHADER_GUID, + base_guid, mix_guid, alpha_test)) + with open(mat_path + ".meta", "w", encoding="utf-8", newline="\n") as f: + f.write(ua.material_meta(mat_guid)) + remaps.append((m["name"], mat_guid)) + + fbx_path = os.path.join(outdir, summary["fbx"]) + fbx_guid = ua.guid_for(fbx_path) # 必须在 open("w") 截断文件之前读取,否则复用失效 + with open(fbx_path + ".meta", "w", encoding="utf-8", newline="\n") as f: + f.write(ua.model_meta(fbx_guid, remaps)) + + +def report(summary, outdir): + print("== %s -> %s" % (summary["model"], outdir)) + for m in summary["materials"]: + print(" 材质 %-24s A通道=%s 尺寸=%dx%d" % + (m["name"], m["alpha_mode"], m["size"][0], m["size"][1])) + for k, v in m["sources"].items(): + print(" %-10s %s" % (k, v or "(常量)")) + for w in m["warnings"]: + print(" [警告] %s" % w) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("input", help="FBX 文件或目录") + ap.add_argument("-o", "--out", default=os.path.join(HERE, "out")) + ap.add_argument("--max-size", type=int, default=2048) + ap.add_argument("--blender", default=None) + args = ap.parse_args() + + blender = find_blender(args.blender) + if os.path.isdir(args.input): + srcs = [os.path.join(args.input, f) for f in sorted(os.listdir(args.input)) + if f.lower().endswith(".fbx")] + if not srcs: + sys.exit("目录中没有 FBX:%s" % args.input) + else: + srcs = [args.input] + + for src in srcs: + name = os.path.splitext(os.path.basename(src))[0] + outdir = os.path.join(args.out, name) + summary = run_blender(blender, src, outdir, args.max_size) + write_unity_assets(outdir, summary, args.max_size) + report(summary, outdir) + + +if __name__ == "__main__": + main()