422 lines
18 KiB
C#
422 lines
18 KiB
C#
/*
|
|
* MonsterVATBatchBaker
|
|
*
|
|
* 一键怪物 VAT 烘焙工具:
|
|
* 输入 — Assets/_External/Monsters/<name>/<name>.FBX 主 FBX(必须 Legacy animationType=1)
|
|
* 流程 —
|
|
* 1) 临时场景中实例化主 FBX
|
|
* 2) 给 root 加 Animation 组件
|
|
* 3) 从同目录的 <name>@Run.FBX 拿 Legacy Run AnimationClip,AddClip 到 Animation
|
|
* 4) 反射调用 SelectiveVATBakerWindow.BakeAll(自动 X=-90 if Z-up)
|
|
* 5) 销毁场景实例
|
|
* 输出 — Assets/_VAT_Baking/Output/<name>/<name>_VAT.prefab + Atlas + Database
|
|
*
|
|
* 用法:
|
|
* - Project 窗口里选中一个或多个主 FBX(必须在 _External/Monsters 或 _External/Bosses 下),
|
|
* 菜单 "Tools/OctoberStudio/VAT/Bake Selected Monsters (Run-only)" 即可。
|
|
* - 或者直接选中 _External/Monsters 文件夹批量处理。
|
|
*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
|
|
namespace OctoberStudio.EditorTools
|
|
{
|
|
public static class MonsterVATBatchBaker
|
|
{
|
|
private const string OutputRoot = "Assets/_VAT_Baking/Output";
|
|
private static readonly string[] AnimNameCandidates = { "Run", "run", "RUN" };
|
|
|
|
// 测试入口: 直接烤 mon1001 (用 Resources/Role/Monster 下已配置的 .prefab)
|
|
[MenuItem("Tools/OctoberStudio/VAT/Bake mon1001 (Test Sample)", priority = 59)]
|
|
public static void BakeMon1001Sample()
|
|
{
|
|
BakePrefab("Assets/Resources/Role/Monster/mon1001.prefab");
|
|
}
|
|
|
|
private const string MonsterPrefabsDir = "Assets/Resources/Role/Monster";
|
|
|
|
// 批量烤 Resources/Role/Monster/*.prefab 全部小怪 (使用配好 Animation + SMR 的 prefab,
|
|
// 不需要外挂 @Run.FBX 文件查找)
|
|
[MenuItem("Tools/OctoberStudio/VAT/Bake ALL Monsters in Resources", priority = 58)]
|
|
public static void BakeAllResourcesMonsters()
|
|
{
|
|
if (!AssetDatabase.IsValidFolder(MonsterPrefabsDir))
|
|
{
|
|
Debug.LogError($"[BatchBake] 目录不存在: {MonsterPrefabsDir}");
|
|
return;
|
|
}
|
|
|
|
// 找该目录下所有 .prefab(不递归子目录,直接 1 层)
|
|
var guids = AssetDatabase.FindAssets("t:Prefab", new[] { MonsterPrefabsDir });
|
|
var paths = new List<string>();
|
|
foreach (var g in guids)
|
|
{
|
|
var p = AssetDatabase.GUIDToAssetPath(g);
|
|
// 限定在该目录下 (FindAssets 默认是递归,这里再过滤一道)
|
|
if (p.StartsWith(MonsterPrefabsDir + "/") &&
|
|
Path.GetDirectoryName(p)?.Replace("\\", "/") == MonsterPrefabsDir)
|
|
{
|
|
paths.Add(p);
|
|
}
|
|
}
|
|
paths.Sort();
|
|
|
|
if (paths.Count == 0)
|
|
{
|
|
Debug.LogWarning($"[BatchBake] {MonsterPrefabsDir} 下没找到 prefab");
|
|
return;
|
|
}
|
|
|
|
Debug.Log($"[BatchBake] 共 {paths.Count} 只小怪开始烤: " + string.Join(", ", paths.Select(Path.GetFileNameWithoutExtension)));
|
|
|
|
int ok = 0, skip = 0, fail = 0;
|
|
var failList = new List<string>();
|
|
for (int i = 0; i < paths.Count; i++)
|
|
{
|
|
var name = Path.GetFileNameWithoutExtension(paths[i]);
|
|
if (EditorUtility.DisplayCancelableProgressBar(
|
|
"Bake ALL Monsters", $"[{i + 1}/{paths.Count}] {name}",
|
|
(float)i / paths.Count))
|
|
{
|
|
Debug.LogWarning("[BatchBake] 用户取消");
|
|
break;
|
|
}
|
|
|
|
// 已烤过(目录存在)就跳过,避免覆盖手工调整的 prefab。强制重烤请删 OutputDir/<name>
|
|
var outPath = $"{OutputRoot}/{name}";
|
|
if (AssetDatabase.IsValidFolder(outPath))
|
|
{
|
|
Debug.Log($"[BatchBake] 跳过(已存在): {outPath}");
|
|
skip++;
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
BakePrefab(paths[i]);
|
|
ok++;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogError($"[BatchBake] 失败 {name}: {e.Message}");
|
|
fail++;
|
|
failList.Add($"{name}: {e.Message}");
|
|
}
|
|
}
|
|
EditorUtility.ClearProgressBar();
|
|
AssetDatabase.SaveAssets();
|
|
AssetDatabase.Refresh();
|
|
|
|
Debug.Log($"[BatchBake] Done — OK={ok}, Skip={skip}, Fail={fail}");
|
|
if (failList.Count > 0)
|
|
Debug.LogWarning("[BatchBake] 失败列表:\n" + string.Join("\n", failList));
|
|
}
|
|
|
|
// 给后续批量烤怪用 — 直接用已配置 prefab,不需要外挂 Animation 组件
|
|
public static void BakePrefab(string prefabPath)
|
|
{
|
|
try
|
|
{
|
|
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
|
if (prefab == null) { Debug.LogError($"[BakePrefab] 找不到: {prefabPath}"); return; }
|
|
|
|
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
|
|
if (instance == null) { Debug.LogError("[BakePrefab] Instantiate failed"); return; }
|
|
try
|
|
{
|
|
// 关 Animator(如果存在),避免和 Animation 双源冲突
|
|
var animator = instance.GetComponentInChildren<Animator>();
|
|
if (animator != null) animator.enabled = false;
|
|
|
|
var anim = instance.GetComponent<Animation>();
|
|
if (anim == null)
|
|
{
|
|
// root 没 Animation 时,从子节点提
|
|
anim = instance.GetComponentInChildren<Animation>();
|
|
if (anim == null) { Debug.LogError("[BakePrefab] 没找到 Animation 组件"); return; }
|
|
}
|
|
|
|
var smr = instance.GetComponentInChildren<SkinnedMeshRenderer>();
|
|
if (smr == null) { Debug.LogError("[BakePrefab] 没找到 SkinnedMeshRenderer"); return; }
|
|
|
|
Debug.Log($"[BakePrefab] {Path.GetFileNameWithoutExtension(prefabPath)}: mesh={smr.sharedMesh?.name} verts={smr.sharedMesh?.vertexCount}");
|
|
|
|
// 如果 Animation 在子节点上,我们需要把它搬到 root 才能让 baker 用
|
|
// 但 baker 用 _target.GetComponent<Animation>(),所以 root 必须有 Animation
|
|
if (instance.GetComponent<Animation>() == null && anim != null)
|
|
{
|
|
// 把子节点 Animation 引用的 clip 抽出来,在 root 加 Animation 组件
|
|
var newAnim = instance.AddComponent<Animation>();
|
|
foreach (AnimationState st in anim) newAnim.AddClip(st.clip, st.clip.name);
|
|
var firstClip = anim.clip;
|
|
if (firstClip != null) newAnim.clip = firstClip;
|
|
}
|
|
|
|
BakeViaSelectiveBaker(instance);
|
|
}
|
|
finally
|
|
{
|
|
UnityEngine.Object.DestroyImmediate(instance);
|
|
}
|
|
AssetDatabase.SaveAssets();
|
|
AssetDatabase.Refresh();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogError($"[BakePrefab] Exception: {e}");
|
|
}
|
|
}
|
|
|
|
[MenuItem("Tools/OctoberStudio/VAT/Bake Selected Monsters (Run-only)", priority = 60)]
|
|
public static void BakeSelected()
|
|
{
|
|
BakeFromPaths(CollectFbxFromSelection().ToArray());
|
|
}
|
|
|
|
private static void BakeFromPaths(string[] paths)
|
|
{
|
|
var fbxList = paths != null ? paths.ToList() : new List<string>();
|
|
if (fbxList.Count == 0)
|
|
{
|
|
Debug.LogWarning("[BakeMonsters] 没有 FBX 路径输入。");
|
|
return;
|
|
}
|
|
|
|
int ok = 0, fail = 0;
|
|
var failList = new List<string>();
|
|
for (int i = 0; i < fbxList.Count; i++)
|
|
{
|
|
var path = fbxList[i];
|
|
if (EditorUtility.DisplayCancelableProgressBar(
|
|
"Bake Monsters", $"[{i + 1}/{fbxList.Count}] {Path.GetFileName(path)}",
|
|
(float)i / fbxList.Count))
|
|
{
|
|
Debug.LogWarning("[BakeMonsters] User cancelled.");
|
|
break;
|
|
}
|
|
|
|
try
|
|
{
|
|
BakeOne(path);
|
|
ok++;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogError($"[BakeMonsters] Failed: {path}\n{e}");
|
|
fail++;
|
|
failList.Add($"{Path.GetFileNameWithoutExtension(path)}: {e.Message}");
|
|
}
|
|
}
|
|
EditorUtility.ClearProgressBar();
|
|
AssetDatabase.SaveAssets();
|
|
AssetDatabase.Refresh();
|
|
|
|
Debug.Log($"[BakeMonsters] Done. OK={ok}, Fail={fail}");
|
|
if (failList.Count > 0)
|
|
Debug.LogWarning("[BakeMonsters] 失败列表:\n" + string.Join("\n", failList));
|
|
}
|
|
|
|
private static List<string> CollectFbxFromSelection()
|
|
{
|
|
var paths = new List<string>();
|
|
foreach (var obj in Selection.objects)
|
|
{
|
|
var p = AssetDatabase.GetAssetPath(obj);
|
|
if (string.IsNullOrEmpty(p)) continue;
|
|
|
|
if (Directory.Exists(p))
|
|
{
|
|
// 文件夹: 递归找主 FBX (不含 @)
|
|
var allFbx = AssetDatabase.FindAssets("t:Model", new[] { p });
|
|
foreach (var g in allFbx)
|
|
{
|
|
var path = AssetDatabase.GUIDToAssetPath(g);
|
|
var name = Path.GetFileName(path);
|
|
if (!name.Contains("@") && !paths.Contains(path)) paths.Add(path);
|
|
}
|
|
}
|
|
else if (p.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase) ||
|
|
p.EndsWith(".FBX", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var name = Path.GetFileName(p);
|
|
if (!name.Contains("@") && !paths.Contains(p)) paths.Add(p);
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
private static void BakeOne(string fbxAssetPath)
|
|
{
|
|
// 1) 加载主 FBX
|
|
var fbx = AssetDatabase.LoadAssetAtPath<GameObject>(fbxAssetPath);
|
|
if (fbx == null) throw new Exception("FBX not loaded: " + fbxAssetPath);
|
|
|
|
// 2) 找同目录的 @Run.FBX 中的 Run clip
|
|
var runClip = FindRunClip(fbxAssetPath);
|
|
if (runClip == null)
|
|
throw new Exception("找不到 Run AnimationClip。请确保同目录有 <name>@Run.FBX 且其中含 Legacy Run clip");
|
|
|
|
// 3) 实例化到临时隐藏 GameObject (不入场景层级,但 baker 需要 SMR + Animation)
|
|
// PrefabUtility.InstantiatePrefab 不能直接 hideFlags,但我们烤完立刻销毁,无所谓
|
|
var instance = (GameObject)PrefabUtility.InstantiatePrefab(fbx);
|
|
if (instance == null) throw new Exception("Instantiate failed: " + fbxAssetPath);
|
|
|
|
try
|
|
{
|
|
// 4) 确保有 SkinnedMeshRenderer
|
|
var smr = instance.GetComponentInChildren<SkinnedMeshRenderer>();
|
|
if (smr == null) throw new Exception("FBX 内找不到 SkinnedMeshRenderer: " + fbxAssetPath);
|
|
|
|
// 5) 加 Animation 组件 (主 FBX importAnimation=0 时根上没有 Animation)
|
|
var anim = instance.GetComponent<Animation>();
|
|
if (anim == null) anim = instance.AddComponent<Animation>();
|
|
|
|
// 把 Run clip 加入,并设 default clip
|
|
if (!runClip.legacy)
|
|
throw new Exception("Run clip 不是 Legacy。请在 @Run.FBX Inspector 的 Animation Type 设为 Legacy");
|
|
|
|
anim.AddClip(runClip, "Run");
|
|
anim.clip = runClip;
|
|
anim.playAutomatically = false;
|
|
|
|
Debug.Log($"[BakeOne] {Path.GetFileNameWithoutExtension(fbxAssetPath)}: mesh={smr.sharedMesh?.name} verts={smr.sharedMesh?.vertexCount}, clip={runClip.name} ({runClip.length:F2}s)");
|
|
|
|
// 6) 反射触发 SelectiveVATBakerWindow.BakeAll
|
|
BakeViaSelectiveBaker(instance);
|
|
}
|
|
finally
|
|
{
|
|
UnityEngine.Object.DestroyImmediate(instance);
|
|
}
|
|
}
|
|
|
|
private static AnimationClip FindRunClip(string mainFbxPath)
|
|
{
|
|
var dir = Path.GetDirectoryName(mainFbxPath)?.Replace("\\", "/");
|
|
var baseName = Path.GetFileNameWithoutExtension(mainFbxPath);
|
|
if (string.IsNullOrEmpty(dir) || string.IsNullOrEmpty(baseName)) return null;
|
|
|
|
// 候选: <name>@Run.FBX, <name>@run.FBX
|
|
foreach (var suffix in AnimNameCandidates)
|
|
{
|
|
var p = $"{dir}/{baseName}@{suffix}.FBX";
|
|
var clip = LoadFirstClipFromFbx(p);
|
|
if (clip != null) return clip;
|
|
}
|
|
|
|
// 或者主 FBX 自带 Run clip
|
|
return LoadFirstClipFromFbx(mainFbxPath, "Run");
|
|
}
|
|
|
|
private static AnimationClip LoadFirstClipFromFbx(string fbxPath, string preferName = null)
|
|
{
|
|
if (!File.Exists(Path.GetFullPath(fbxPath))) return null;
|
|
var all = AssetDatabase.LoadAllAssetsAtPath(fbxPath);
|
|
AnimationClip first = null;
|
|
foreach (var a in all)
|
|
{
|
|
if (a is AnimationClip ac && !ac.name.StartsWith("__preview__"))
|
|
{
|
|
if (!string.IsNullOrEmpty(preferName) && ac.name.IndexOf(preferName, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
return ac;
|
|
if (first == null) first = ac;
|
|
}
|
|
}
|
|
return first;
|
|
}
|
|
|
|
// 反射 — SelectiveVATBakerWindow 不在我们命名空间,但脚本可访问 ScriptableObject + 反射
|
|
private static void BakeViaSelectiveBaker(GameObject target)
|
|
{
|
|
// 类型
|
|
var bakerType = GetTypeByName("SelectiveVATBakerWindow");
|
|
if (bakerType == null) throw new Exception("找不到 SelectiveVATBakerWindow 类型");
|
|
|
|
var win = ScriptableObject.CreateInstance(bakerType);
|
|
try
|
|
{
|
|
// _target = target
|
|
SetField(win, "_target", target);
|
|
// 自动 Z-up 检测
|
|
var smr = target.GetComponentInChildren<SkinnedMeshRenderer>();
|
|
if (smr != null && smr.sharedMesh != null)
|
|
{
|
|
var size = smr.sharedMesh.bounds.size;
|
|
float x = Mathf.Abs(size.x), y = Mathf.Abs(size.y), z = Mathf.Abs(size.z);
|
|
bool zUp = z > y && z > x;
|
|
SetField(win, "_bakedMeshFacingX", zUp ? -90f : 0f);
|
|
}
|
|
|
|
// _outputRoot
|
|
SetField(win, "_outputRoot", OutputRoot);
|
|
|
|
// Refresh clips + Match defaults (会勾 Idle/Ready/Run/Die)
|
|
Invoke(win, "RefreshClipsFromTarget");
|
|
Invoke(win, "MatchDefaults");
|
|
|
|
// 重点: 我们只勾选 Run (强制覆盖默认)
|
|
var selectedField = bakerType.GetField("_selected", BindingFlags.NonPublic | BindingFlags.Instance);
|
|
if (selectedField != null)
|
|
{
|
|
var dict = selectedField.GetValue(win) as System.Collections.IDictionary;
|
|
if (dict != null)
|
|
{
|
|
// 先全置 false
|
|
var keys = new List<object>();
|
|
foreach (var k in dict.Keys) keys.Add(k);
|
|
foreach (var k in keys) dict[k] = false;
|
|
// 把 name == Run 的设 true
|
|
foreach (var k in keys)
|
|
{
|
|
var ac = k as AnimationClip;
|
|
if (ac == null) continue;
|
|
if (string.Equals(ac.name, "Run", StringComparison.OrdinalIgnoreCase))
|
|
dict[k] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// BakeAll()
|
|
Invoke(win, "BakeAll");
|
|
}
|
|
finally
|
|
{
|
|
UnityEngine.Object.DestroyImmediate(win);
|
|
}
|
|
}
|
|
|
|
// -------- Reflection helpers --------
|
|
private static Type GetTypeByName(string typeName)
|
|
{
|
|
foreach (var a in AppDomain.CurrentDomain.GetAssemblies())
|
|
{
|
|
foreach (var t in a.GetTypes())
|
|
{
|
|
if (t.Name == typeName) return t;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static void SetField(object obj, string field, object value)
|
|
{
|
|
var f = obj.GetType().GetField(field, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public);
|
|
if (f == null) throw new Exception($"Field not found: {field}");
|
|
f.SetValue(obj, value);
|
|
}
|
|
|
|
private static void Invoke(object obj, string method)
|
|
{
|
|
var m = obj.GetType().GetMethod(method, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public);
|
|
if (m == null) throw new Exception($"Method not found: {method}");
|
|
m.Invoke(obj, null);
|
|
}
|
|
}
|
|
}
|