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

531 lines
23 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.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<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>("模型转换");
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<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();
}
// 运行按钮 + 错误提示;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.fbxsupergirl_uv -> 原样保留
string stem = lowName.EndsWith("_low", StringComparison.Ordinal)
? lowName.Substring(0, lowName.Length - 4) : lowName;
// CLI -o 默认=out_bake,产物 out_bake/<stem>/<stem>.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("[链式] 已填入③转换 XPbrFBX");
}
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<DefaultAsset>(resultAssetDir);
if (folder != null)
{
Selection.activeObject = folder;
EditorGUIUtility.PingObject(folder);
}
});
}
// 启动 <script> 子进程(python -u),成功(退出码0)时回调 onSuccess
private void StartProcess(string scriptRel, string args, string startLabel,
string stageName, Action onSuccess)
{
SavePrefs();
_stageName = stageName;
_procStartTime = EditorApplication.timeSinceStartup;
_lastAnimAt = _procStartTime;
string script = Path.Combine(RepoRoot, "Tools", "ModelTranslator", scriptRel);
var psi = new ProcessStartInfo
{
FileName = _pythonPath,
Arguments = string.Format("-u \"{0}\" {1}", script, args),
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(startLabel);
_onSuccess = onSuccess;
try
{
_proc = Process.Start(psi);
}
catch (Exception e)
{
_proc = null;
_onSuccess = 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;
var cb = _onSuccess;
_onSuccess = null;
dirty = true;
if (code == 0)
{
cb?.Invoke();
}
else
{
Append("[失败] 退出码 " + code);
EditorUtility.DisplayDialog("模型转换",
"处理失败(退出码 " + code + "),详见窗口日志", "确定");
}
double dt = EditorApplication.timeSinceStartup - _procStartTime;
Append(string.Format("[耗时 {0:00}:{1:00}]", (int)(dt / 60), (int)(dt % 60)));
}
// 运行中按 ~10fps 节流重绘,让计时器/spinner 动起来(即使暂无新日志)
if (_proc != null && EditorApplication.timeSinceStartup - _lastAnimAt > 0.1)
{
_lastAnimAt = EditorApplication.timeSinceStartup;
dirty = true;
}
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;
_onSuccess = null;
lock (_lock) _pending.Clear(); // 丢弃残留输出,避免插在 [已取消] 之后
}
}
}
#endif