#if UNITY_EDITOR using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Text; using UnityEditor; using UnityEngine; namespace XGame.Editor { public static class ModelTranslatorMenu { [MenuItem("XWorld/模型转换(减面·烘焙·XPbr)", false, 803)] public static void Open() => ModelTranslatorWindow.Open(); } // 减面 → 烘焙 → 转换XPbr 三级流水线,同一窗口三个折叠区,共用 Blender/Python、日志与进程管理; // 每级成功后把产物自动填入下一级输入(减面low→烘焙low、烘焙fbx→转换fbx)。一次只跑一个进程。 public sealed class ModelTranslatorWindow : EditorWindow { private const string PrefPrefix = "XWorld.ModelTranslator."; private const string DefaultBlender = @"D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe"; private static readonly int[] SizeOptions = { 512, 1024, 2048, 4096 }; private static readonly string[] SizeLabels = { "512", "1024", "2048", "4096" }; private static readonly string[] ReducerLabels = { "Quad Remesher(四边)", "Collapse(塌陷)" }; private static readonly string[] UnwrapLabels = { "seam(锐边接缝)", "smart(投影)" }; // ① 减面 private string _decHigh = ""; private int _decReducer; // 0=quad 1=collapse private int _decTris = 5000; private bool _decAdvanced; private int _decUnwrap; // 0=seam 1=smart private float _decMaxOverlap = 0.08f; private int _decQrCap = 50000; // ② 烘焙 private string _bakeHigh = ""; private string _bakeLow = ""; private int _bakeSize = 2048; private int _bakeSamples = 64; private string _bakeRayDist = ""; // 空=auto 自适应 // ③ 转换 XPbr private string _fbxPath = ""; private int _maxSize = 2048; private string _outSubDir = ""; // 共用 private string _blenderPath = DefaultBlender; private string _pythonPath = "python"; private bool _foldDec = true, _foldBake = true, _foldConv = true; private Process _proc; private Action _onSuccess; // 退出码 0 时回调(ping / 链式填充) private string _stageName = ""; // 当前阶段名(减面/烘焙/转换),用于进度提示 private double _procStartTime; // 进程启动时刻(timeSinceStartup) private double _lastAnimAt; // 上次动画重绘时刻(节流用) private readonly StringBuilder _log = new StringBuilder(); private Vector2 _scroll; // stdout/stderr 回调在后台线程触发,先入队、OnUpdate 时再合入 _log private readonly object _lock = new object(); private readonly List _pending = new List(); // 仓库根 = /Client/Assets 的上两级 private static string RepoRoot => Path.GetFullPath(Path.Combine(Application.dataPath, "..", "..")); public static void Open() { var w = GetWindow("模型转换"); w.minSize = new Vector2(500, 560); w.Show(); } private void OnEnable() { _decReducer = EditorPrefs.GetInt(PrefPrefix + "decReducer", 0); _decTris = EditorPrefs.GetInt(PrefPrefix + "decTris", 5000); _decUnwrap = EditorPrefs.GetInt(PrefPrefix + "decUnwrap", 0); _decMaxOverlap = EditorPrefs.GetFloat(PrefPrefix + "decMaxOverlap", 0.08f); _decQrCap = EditorPrefs.GetInt(PrefPrefix + "decQrCap", 50000); _bakeSize = EditorPrefs.GetInt(PrefPrefix + "bakeSize", 2048); _bakeSamples = EditorPrefs.GetInt(PrefPrefix + "bakeSamples", 64); _bakeRayDist = EditorPrefs.GetString(PrefPrefix + "bakeRayDist", ""); _maxSize = EditorPrefs.GetInt(PrefPrefix + "maxSize", 2048); _outSubDir = EditorPrefs.GetString(PrefPrefix + "outSubDir", ""); _blenderPath = EditorPrefs.GetString(PrefPrefix + "blenderPath", DefaultBlender); _pythonPath = EditorPrefs.GetString(PrefPrefix + "pythonPath", "python"); EditorApplication.update += OnUpdate; } private void OnDisable() { EditorApplication.update -= OnUpdate; SavePrefs(); if (_proc != null) UnityEngine.Debug.LogWarning("[模型转换] 窗口关闭或脚本重编译中止了正在进行的处理"); KillProc(); } private void SavePrefs() { EditorPrefs.SetInt(PrefPrefix + "decReducer", _decReducer); EditorPrefs.SetInt(PrefPrefix + "decTris", _decTris); EditorPrefs.SetInt(PrefPrefix + "decUnwrap", _decUnwrap); EditorPrefs.SetFloat(PrefPrefix + "decMaxOverlap", _decMaxOverlap); EditorPrefs.SetInt(PrefPrefix + "decQrCap", _decQrCap); EditorPrefs.SetInt(PrefPrefix + "bakeSize", _bakeSize); EditorPrefs.SetInt(PrefPrefix + "bakeSamples", _bakeSamples); EditorPrefs.SetString(PrefPrefix + "bakeRayDist", _bakeRayDist); EditorPrefs.SetInt(PrefPrefix + "maxSize", _maxSize); EditorPrefs.SetString(PrefPrefix + "outSubDir", _outSubDir); EditorPrefs.SetString(PrefPrefix + "blenderPath", _blenderPath); EditorPrefs.SetString(PrefPrefix + "pythonPath", _pythonPath); } private void OnGUI() { bool running = _proc != null; using (new EditorGUI.DisabledScope(running)) { PathField("Blender", ref _blenderPath, () => EditorUtility.OpenFilePanel("选择 blender.exe", "", "exe"), null); PathField("Python", ref _pythonPath, () => EditorUtility.OpenFilePanel("选择 python.exe", "", "exe"), null); } GUILayout.Space(4); _foldDec = EditorGUILayout.Foldout(_foldDec, "① 减面 (model_decimate)", true); if (_foldDec) DrawDecimate(running); GUILayout.Space(2); _foldBake = EditorGUILayout.Foldout(_foldBake, "② 烘焙 (model_bake)", true); if (_foldBake) DrawBake(running); GUILayout.Space(2); _foldConv = EditorGUILayout.Foldout(_foldConv, "③ 转换 XPbr (model_translator)", true); if (_foldConv) DrawConvert(running); GUILayout.Space(4); if (running) { if (GUILayout.Button("取消", GUILayout.Height(24))) { KillProc(); Append("[已取消]"); } double el = EditorApplication.timeSinceStartup - _procStartTime; char spin = "|/-\\"[(int)(el * 6) % 4]; EditorGUILayout.HelpBox(string.Format( "{0} {1}中… 已用时 {2:00}:{3:00}(Blender 处理大模型可能要几分钟,进度见下方日志)", spin, _stageName, (int)(el / 60), (int)(el % 60)), MessageType.Info); } _scroll = EditorGUILayout.BeginScrollView(_scroll); EditorGUILayout.TextArea(_log.ToString(), GUILayout.ExpandHeight(true)); EditorGUILayout.EndScrollView(); } private void DrawDecimate(bool running) { EditorGUI.indentLevel++; using (new EditorGUI.DisabledScope(running)) { FbxField("高模 FBX", ref _decHigh); _decReducer = EditorGUILayout.Popup("减面后端", _decReducer, ReducerLabels); _decTris = EditorGUILayout.IntField("目标三角面数", _decTris); _decAdvanced = EditorGUILayout.Foldout(_decAdvanced, "高级"); if (_decAdvanced) { EditorGUI.indentLevel++; _decUnwrap = EditorGUILayout.Popup("UV 展开", _decUnwrap, UnwrapLabels); _decMaxOverlap = EditorGUILayout.FloatField( new GUIContent("UV 重叠门限", "0-1,默认 0.08;调高可让更高 seam 角度过门、减少碎岛"), _decMaxOverlap); _decQrCap = EditorGUILayout.IntField( new GUIContent("QR 预塌陷面数", "quad 后端 QR 前中等密度目标面数,默认 50000;高密度直接 QR 会破洞"), _decQrCap); EditorGUI.indentLevel--; } } RunButton("减面 →② 低模", ValidateDecimate(), running, StartDecimate); EditorGUI.indentLevel--; } private void DrawBake(bool running) { EditorGUI.indentLevel++; using (new EditorGUI.DisabledScope(running)) { FbxField("高模 FBX", ref _bakeHigh); FbxField("低模 FBX", ref _bakeLow); int si = Array.IndexOf(SizeOptions, _bakeSize); if (si < 0) si = 2; si = EditorGUILayout.Popup("贴图尺寸", si, SizeLabels); _bakeSize = SizeOptions[si]; _bakeSamples = EditorGUILayout.IntField("AO 采样", _bakeSamples); _bakeRayDist = EditorGUILayout.TextField( new GUIContent("射线距离", "留空 = auto 自适应(推荐,文档最优);填数字则覆盖"), _bakeRayDist); } RunButton("烘焙 →③ FBX", ValidateBake(), running, StartBake); EditorGUI.indentLevel--; } private void DrawConvert(bool running) { EditorGUI.indentLevel++; using (new EditorGUI.DisabledScope(running)) { FbxField("FBX 文件", ref _fbxPath); int idx = Array.IndexOf(SizeOptions, _maxSize); if (idx < 0) idx = 2; idx = EditorGUILayout.Popup("贴图最大尺寸", idx, SizeLabels); _maxSize = SizeOptions[idx]; _outSubDir = EditorGUILayout.TextField(new GUIContent("输出子目录", "Assets/Res/ 下的相对目录,留空=直接放 Res/<模型名>"), _outSubDir); } RunButton("转换 XPbr", ValidateConvert(), running, StartConvert); EditorGUI.indentLevel--; } // 带"选择…"按钮、picker 固定为 FBX 的路径行(记住上次目录) private void FbxField(string label, ref string value) { PathField(label, ref value, () => EditorUtility.OpenFilePanel("选择 FBX", EditorPrefs.GetString(PrefPrefix + "lastFbxDir", ""), "fbx"), picked => EditorPrefs.SetString(PrefPrefix + "lastFbxDir", Path.GetDirectoryName(picked))); } // 带"选择…"按钮的路径行;onPicked 可为 null private static void PathField(string label, ref string value, Func pick, Action onPicked) { EditorGUILayout.BeginHorizontal(); value = EditorGUILayout.TextField(label, value); if (GUILayout.Button("选择…", GUILayout.Width(56))) { string p = pick(); if (!string.IsNullOrEmpty(p)) { value = p.Replace('/', '\\'); onPicked?.Invoke(p); GUI.FocusControl(null); } } EditorGUILayout.EndHorizontal(); } // 运行按钮 + 错误提示;err==null 才可点击 private void RunButton(string label, string err, bool running, Action start) { using (new EditorGUI.DisabledScope(running || err != null)) if (GUILayout.Button(label, GUILayout.Height(24))) start(); if (err != null && !running) EditorGUILayout.HelpBox(err, MessageType.Info); } // ---- 校验 ---- private string ValidateTools() { if (!File.Exists(_blenderPath)) return "未找到 blender.exe,请在上方指定路径"; // python 允许裸命令名(走 PATH),带目录时才检查存在 if ((_pythonPath.Contains("\\") || _pythonPath.Contains("/")) && !File.Exists(_pythonPath)) return "未找到 python,请安装 Python 3 或在上方指定路径"; return null; } private static string ValidateFbx(string p) { if (string.IsNullOrEmpty(p)) return "请选择 FBX 文件"; if (!File.Exists(p)) return "FBX 文件不存在:" + p; if (!p.ToLowerInvariant().EndsWith(".fbx")) return "请选择 .fbx 文件"; return null; } private string ValidateDecimate() { return ValidateTools() ?? ValidateFbx(_decHigh) ?? (_decTris <= 0 ? "目标三角面数必须 > 0" : null); } private string ValidateBake() { return ValidateTools() ?? ValidateFbx(_bakeHigh) ?? ValidateFbx(_bakeLow); } private string ValidateConvert() { string e = ValidateTools() ?? ValidateFbx(_fbxPath); if (e != null) return e; string sub = _outSubDir.Replace('\\', '/').Trim('/', ' '); if (sub.Contains("..") || Path.IsPathRooted(sub)) return "输出子目录必须是 Assets/Res 下的相对路径"; return null; } // ---- 三级启动 ---- private void StartDecimate() { string high = _decHigh; string highName = Path.GetFileNameWithoutExtension(high); // CLI -o 默认=源文件同目录,产物 <名>_low.fbx(见 model_decimate.py) string lowOut = Path.Combine(Path.GetDirectoryName(high) ?? "", highName + "_low.fbx"); string reducer = _decReducer == 0 ? "quad" : "collapse"; string unwrap = _decUnwrap == 0 ? "seam" : "smart"; string args = string.Format(CultureInfo.InvariantCulture, "\"{0}\" --tris {1} --reducer {2} --unwrap {3} --max-overlap {4} --qr-input-cap {5} --blender \"{6}\"", high, _decTris, reducer, unwrap, _decMaxOverlap, _decQrCap, _blenderPath); StartProcess("model_decimate.py", args, "== 减面 " + Path.GetFileName(high) + " -> " + Path.GetFileName(lowOut), "减面", () => { Append("[完成] " + lowOut); if (File.Exists(lowOut)) { _bakeHigh = high; _bakeLow = lowOut; _foldBake = true; Append("[链式] 已填入②烘焙:高模 + 低模"); } else { Append("[警告] 未找到减面产物 " + lowOut + ",请手动指定②低模"); } }); } private void StartBake() { string high = _bakeHigh, low = _bakeLow; string lowName = Path.GetFileNameWithoutExtension(low); // 镜像 bl_bake.output_stem:低模名去掉结尾 _low 作为输出前缀(对齐工具3命名约定)。 // 例:cityboy_low -> out_bake/cityboy/cityboy.fbx;supergirl_uv -> 原样保留 string stem = lowName.EndsWith("_low", StringComparison.Ordinal) ? lowName.Substring(0, lowName.Length - 4) : lowName; // CLI -o 默认=out_bake,产物 out_bake//.fbx(见 model_bake.py / output_stem) string bakeOutFbx = Path.Combine(RepoRoot, "Tools", "ModelTranslator", "out_bake", stem, stem + ".fbx"); var sb = new StringBuilder(); sb.AppendFormat("\"{0}\" \"{1}\" --size {2} --samples {3}", high, low, _bakeSize, _bakeSamples); string ray = (_bakeRayDist ?? "").Trim(); if (ray.Length > 0) sb.AppendFormat(CultureInfo.InvariantCulture, " --ray-distance {0}", ray); sb.AppendFormat(" --blender \"{0}\"", _blenderPath); StartProcess("model_bake.py", sb.ToString(), "== 烘焙 " + Path.GetFileName(low) + "(高模 " + Path.GetFileName(high) + ")", "烘焙", () => { Append("[完成] " + bakeOutFbx); if (File.Exists(bakeOutFbx)) { _fbxPath = bakeOutFbx; _foldConv = true; Append("[链式] 已填入③转换 XPbr:FBX"); } else { Append("[警告] 未找到烘焙产物 " + bakeOutFbx + ",请手动指定③FBX"); } }); } private void StartConvert() { string sub = _outSubDir.Replace('\\', '/').Trim('/', ' '); string outRel = string.IsNullOrEmpty(sub) ? "Client/Assets/Res" : "Client/Assets/Res/" + sub; string modelName = Path.GetFileNameWithoutExtension(_fbxPath); // CLI 会在 -o 下自动建 <模型名>/ 子目录(见 model_translator.py main) string resultAssetDir = (string.IsNullOrEmpty(sub) ? "Assets/Res/" : "Assets/Res/" + sub + "/") + modelName; string args = string.Format( "\"{0}\" -o \"{1}\" --max-size {2} --blender \"{3}\"", _fbxPath, Path.Combine(RepoRoot, outRel), _maxSize, _blenderPath); StartProcess("model_translator.py", args, "== " + Path.GetFileName(_fbxPath) + " -> " + resultAssetDir, "转换", () => { Append("[完成] " + resultAssetDir); AssetDatabase.Refresh(); var folder = AssetDatabase.LoadAssetAtPath(resultAssetDir); if (folder != null) { Selection.activeObject = folder; EditorGUIUtility.PingObject(folder); } }); } // 启动