diff --git a/Client/Assets/Editor/ModelTranslatorMenu.cs b/Client/Assets/Editor/ModelTranslatorMenu.cs new file mode 100644 index 00000000..b47de9e5 --- /dev/null +++ b/Client/Assets/Editor/ModelTranslatorMenu.cs @@ -0,0 +1,277 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Diagnostics; +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(); + } + + public sealed class ModelTranslatorWindow : EditorWindow + { + private const string PrefPrefix = "XWorld.ModelTranslator."; + private const string DefaultBlender = @"D:\tools\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 string _fbxPath = ""; + private int _maxSize = 2048; + private string _outSubDir = ""; + private string _blenderPath = DefaultBlender; + private string _pythonPath = "python"; + + private Process _proc; + private string _resultAssetDir; // 成功后要 ping 的 Assets 相对目录 + 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("模型转换(XPbr)"); + w.minSize = new Vector2(480, 380); + w.Show(); + } + + private void OnEnable() + { + _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(); + KillProc(); + } + + private void SavePrefs() + { + 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("FBX 文件", ref _fbxPath, () => + EditorUtility.OpenFilePanel("选择 FBX", + EditorPrefs.GetString(PrefPrefix + "lastFbxDir", ""), "fbx"), + picked => EditorPrefs.SetString(PrefPrefix + "lastFbxDir", + Path.GetDirectoryName(picked))); + + 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); + + PathField("Blender", ref _blenderPath, () => + EditorUtility.OpenFilePanel("选择 blender.exe", "", "exe"), null); + PathField("Python", ref _pythonPath, () => + EditorUtility.OpenFilePanel("选择 python.exe", "", "exe"), null); + } + + GUILayout.Space(4); + if (!running) + { + string err = Validate(); + using (new EditorGUI.DisabledScope(err != null)) + if (GUILayout.Button("转换", GUILayout.Height(28))) + StartConvert(); + if (err != null) + EditorGUILayout.HelpBox(err, MessageType.Info); + } + else + { + if (GUILayout.Button("取消", GUILayout.Height(28))) + { + KillProc(); + Append("[已取消]"); + } + EditorGUILayout.HelpBox("转换中…(Blender 处理大模型可能要几十秒)", MessageType.Info); + } + + _scroll = EditorGUILayout.BeginScrollView(_scroll); + EditorGUILayout.TextArea(_log.ToString(), GUILayout.ExpandHeight(true)); + EditorGUILayout.EndScrollView(); + } + + // 带"选择…"按钮的路径行;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(); + } + + // 返回错误提示;null = 可以转换 + private string Validate() + { + if (string.IsNullOrEmpty(_fbxPath)) + return "请选择要转换的 FBX 文件"; + if (!File.Exists(_fbxPath)) + return "FBX 文件不存在:" + _fbxPath; + if (!_fbxPath.ToLowerInvariant().EndsWith(".fbx")) + return "请选择 .fbx 文件"; + if (!File.Exists(_blenderPath)) + return "未找到 blender.exe,请在上方指定路径"; + // python 允许裸命令名(走 PATH),带目录时才检查存在 + if ((_pythonPath.Contains("\\") || _pythonPath.Contains("/")) + && !File.Exists(_pythonPath)) + return "未找到 python,请安装 Python 3 或在上方指定路径"; + string sub = _outSubDir.Replace('\\', '/').Trim('/', ' '); + if (sub.Contains("..") || Path.IsPathRooted(sub)) + return "输出子目录必须是 Assets/Res 下的相对路径"; + return null; + } + + private void StartConvert() + { + SavePrefs(); + 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) + _resultAssetDir = (string.IsNullOrEmpty(sub) ? "Assets/Res/" : "Assets/Res/" + sub + "/") + + modelName; + string script = Path.Combine(RepoRoot, "Tools", "ModelTranslator", "model_translator.py"); + + var psi = new ProcessStartInfo + { + FileName = _pythonPath, + Arguments = string.Format( + "\"{0}\" \"{1}\" -o \"{2}\" --max-size {3} --blender \"{4}\"", + script, _fbxPath, Path.Combine(RepoRoot, outRel), _maxSize, _blenderPath), + WorkingDirectory = RepoRoot, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + psi.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"; // CLI 输出中文 + + _log.Length = 0; + Append("== " + Path.GetFileName(_fbxPath) + " -> " + _resultAssetDir); + try + { + _proc = Process.Start(psi); + } + catch (Exception e) + { + _proc = null; + EditorUtility.DisplayDialog("模型转换", + "启动 python 失败:" + e.Message + + "\n请安装 Python 3 或在面板中指定 python.exe 路径", "确定"); + return; + } + _proc.OutputDataReceived += (_, ev) => Enqueue(ev.Data); + _proc.ErrorDataReceived += (_, ev) => Enqueue(ev.Data); + _proc.BeginOutputReadLine(); + _proc.BeginErrorReadLine(); + } + + private void Enqueue(string line) + { + if (line == null) return; + lock (_lock) _pending.Add(line); + } + + private void Append(string line) + { + _log.AppendLine(line); + _scroll.y = float.MaxValue; // 滚到最底 + } + + private void OnUpdate() + { + bool dirty = false; + lock (_lock) + { + foreach (string l in _pending) { Append(l); dirty = true; } + _pending.Clear(); + } + + if (_proc != null && _proc.HasExited) + { + _proc.WaitForExit(); // 确保异步输出全部送达 + lock (_lock) // 先冲掉剩余输出,保证 [完成]/[失败] 在日志最后 + { + foreach (string l in _pending) Append(l); + _pending.Clear(); + } + int code = _proc.ExitCode; + _proc.Dispose(); + _proc = null; + dirty = true; + if (code == 0) + { + Append("[完成] " + _resultAssetDir); + AssetDatabase.Refresh(); + var folder = AssetDatabase.LoadAssetAtPath(_resultAssetDir); + if (folder != null) + { + Selection.activeObject = folder; + EditorGUIUtility.PingObject(folder); + } + } + else + { + Append("[失败] 退出码 " + code); + EditorUtility.DisplayDialog("模型转换", + "转换失败(退出码 " + code + "),详见窗口日志", "确定"); + } + } + + if (dirty) Repaint(); + } + + private void KillProc() + { + if (_proc == null) return; + try { if (!_proc.HasExited) _proc.Kill(); } + catch { /* 进程可能刚好退出 */ } + _proc.Dispose(); + _proc = null; + } + } +} +#endif