Files
AIC-Project/Client/Assets/Editor/ModelTranslatorMenu.cs
T

287 lines
11 KiB
C#
Raw 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.
#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<string> _pending = new List<string>();
// 仓库根 = <repo>/Client/Assets 的上两级
private static string RepoRoot =>
Path.GetFullPath(Path.Combine(Application.dataPath, "..", ".."));
public static void Open()
{
var w = GetWindow<ModelTranslatorWindow>("模型转换(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();
if (_proc != null)
UnityEngine.Debug.LogWarning("[模型转换] 窗口关闭或脚本重编译中止了正在进行的转换");
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<string> pick, Action<string> 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(
"-u \"{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<DefaultAsset>(_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
{
// 用 taskkill /T 连 blender 子进程一起杀(Mono BCL 无 Kill(tree) 重载)
if (!_proc.HasExited)
Process.Start(new ProcessStartInfo("taskkill", "/PID " + _proc.Id + " /T /F")
{ UseShellExecute = false, CreateNoWindow = true });
}
catch { /* 进程可能刚好退出 */ }
_proc.Dispose();
_proc = null;
lock (_lock) _pending.Clear(); // 丢弃残留输出,避免插在 [已取消] 之后
}
}
}
#endif