commit aeb24d26940e4be48d1fd70b993228c3aacadcef Author: cursor Date: Thu May 28 19:35:09 2026 +0800 feat: initial VAT pipeline (decimation + vertex animation texture baking) toolkit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9713d2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Unity generated +[Ll]ibrary/ +[Tt]emp/ +[Oo]bj/ +[Bb]uild/ +[Bb]uilds/ +[Ll]ogs/ +[Mm]emoryCaptures/ +[Uu]ser[Ss]ettings/ + +# Asset meta data should be ignored only for excluded items +*.pidb +*.booproj +*.svd +*.pdb +*.mdb +*.opendb +*.VC.db + +# Visual Studio / Rider / VSCode +.vs/ +.idea/ +.vscode/ +*.csproj +*.unityproj +*.sln +*.suo +*.tmp +*.user +*.userprefs + +# Unity3D generated meta files (none here since this repo only contains source + meta) +sysinfo.txt + +# Build outputs (we do not commit VAT baked outputs) +**/_VAT_Baking/Output/ + +# Mac / Win artifacts +.DS_Store +Thumbs.db diff --git a/Assets/Common/Scripts/Editor/CharacterVATConverter.cs b/Assets/Common/Scripts/Editor/CharacterVATConverter.cs new file mode 100644 index 0000000..3695ed9 --- /dev/null +++ b/Assets/Common/Scripts/Editor/CharacterVATConverter.cs @@ -0,0 +1,218 @@ +/* + * CharacterVATConverter + * + * 一键把现有 SpriteRenderer 主角 (Wizard) 转成 VAT 3D 主角: + * 1) 实例化 Wizard.prefab 作为模板 + * 2) 在根节点挂上 R02_VAT.prefab 作为 "View" 子物体 + * 3) 添加 RendererAdapter (Mode=Mesh, fetchRoot=View) + * 4) 把 RendererAdapter 拖到 CharacterBehavior.rendererAdapter 槽 + * 5) 保存为 Wizard_R02.prefab + * 6) (可选) 把 CharactersDatabase 的 WIZARD 的 prefab 指向新 Wizard_R02.prefab + * + * 用途:验证 RendererAdapter 在 OctoberStudio 主角生命周期里能正常 wire 起来。 + */ + +using OctoberStudio.Rendering; +using UnityEditor; +using UnityEngine; + +namespace OctoberStudio.EditorTools +{ + public static class CharacterVATConverter + { + private const string TemplatePath = "Assets/Common/Prefabs/Characters/Wizard.prefab"; + private const string VATSourcePath = "Assets/_VAT_Baking/Output/R02_S_body01_world/R02_S_body01_world_VAT.prefab"; + private const string OutputPath = "Assets/Common/Prefabs/Characters/Wizard_R02.prefab"; + private const string DatabasePath = "Assets/Common/Scriptables/Characters Database.asset"; + + [MenuItem("Tools/OctoberStudio/VAT/1. Make Wizard_R02 Variant", priority = 1)] + public static void MakeVariant() + { + var ok = MakeWizardR02(); + // 不弹 modal dialog (会阻塞 MCP 主线程通信),改用 console log。 + if (ok) Debug.Log($"[VAT Convert] Created: {OutputPath}\n下一步: Tools/OctoberStudio/VAT/2. Apply Wizard_R02 to Database"); + } + + [MenuItem("Tools/OctoberStudio/VAT/2. Apply Wizard_R02 to Database", priority = 2)] + public static void ApplyToDatabase() + { + var ok = ApplyR02ToDatabase(); + if (ok) Debug.Log("[VAT Convert] CharactersDatabase: WIZARD prefab → Wizard_R02.prefab. 跑场景查看效果。"); + } + + // Legacy: 用于校正旧版"单层"R02_VAT.prefab(无 Root+Mesh 结构)。 + // 新版 baker 已把 Y=180 烤入 Mesh 子节点,无需调用此菜单。 + [MenuItem("Tools/OctoberStudio/VAT/[Legacy] Calibrate Wizard_R02 (Y=180 + auto pivot)", priority = 100)] + public static void QuickCalibrateWizardR02() + { + CalibratePrefab(OutputPath, new Vector3(0f, 180f, 0f), 0.5f, autoCenterPivot: true); + } + + /// + /// 直接在 prefab asset 上做朝向 + 缩放 + pivot 校正,无需中转场景。 + /// + public static void CalibratePrefab(string prefabPath, Vector3 euler, float uniformScale, bool autoCenterPivot) + { + var prefabRoot = PrefabUtility.LoadPrefabContents(prefabPath); + if (prefabRoot == null) + { + Debug.LogError($"[VAT] Cannot load prefab contents: {prefabPath}"); + return; + } + try + { + var view = prefabRoot.transform.Find("View"); + if (view == null) + { + Debug.LogError("[VAT] View transform not found"); + return; + } + + view.localRotation = Quaternion.Euler(euler); + view.localScale = Vector3.one * uniformScale; + + if (autoCenterPivot) + { + var mf = view.GetComponentInChildren(); + if (mf != null && mf.sharedMesh != null) + { + var meshCenter = mf.sharedMesh.bounds.center; // model-space + var scaled = Vector3.Scale(meshCenter, view.localScale); + var offset = view.localRotation * scaled; // view-parent space + view.localPosition = -offset; + } + } + + PrefabUtility.SaveAsPrefabAsset(prefabRoot, prefabPath); + Debug.Log($"[VAT] Calibrated {prefabPath}: euler={euler}, scale={uniformScale}, pos={view.localPosition}"); + } + finally + { + PrefabUtility.UnloadPrefabContents(prefabRoot); + } + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + } + + [MenuItem("Tools/OctoberStudio/VAT/3. Restore Wizard (Original Sprite)", priority = 3)] + public static void RestoreOriginal() + { + var db = AssetDatabase.LoadAssetAtPath(DatabasePath); + if (db == null) { Debug.LogError("Database not found"); return; } + var template = AssetDatabase.LoadAssetAtPath(TemplatePath); + if (template == null) { Debug.LogError("Wizard.prefab not found"); return; } + + var so = new SerializedObject(db); + var characters = so.FindProperty("characters"); + if (characters.arraySize > 0) + { + characters.GetArrayElementAtIndex(0).FindPropertyRelative("prefab").objectReferenceValue = template; + so.ApplyModifiedProperties(); + AssetDatabase.SaveAssets(); + Debug.Log("[VAT] Restored original Wizard.prefab"); + } + } + + // ========================= IMPL ========================= + + public static bool MakeWizardR02() + { + var template = AssetDatabase.LoadAssetAtPath(TemplatePath); + var vatSrc = AssetDatabase.LoadAssetAtPath(VATSourcePath); + + if (template == null) + { + Debug.LogError($"[VAT] Template prefab not found: {TemplatePath}"); + return false; + } + if (vatSrc == null) + { + Debug.LogError($"[VAT] VAT prefab not found: {VATSourcePath}"); + return false; + } + + // 1) Instantiate Wizard as base + var instance = (GameObject)PrefabUtility.InstantiatePrefab(template); + instance.name = "Wizard_R02_tmp"; + + try + { + // 2) Instantiate R02 VAT as child "View" + // R02_VAT 自身是 Root + Mesh 两层结构, Mesh 节点上已经烤入 Y=180 (面相机), + // 所以这里 View 的 localRotation 保持 identity 即可,无需再做朝向校正 + var view = (GameObject)PrefabUtility.InstantiatePrefab(vatSrc, instance.transform); + view.name = "View"; + view.transform.localPosition = Vector3.zero; + view.transform.localRotation = Quaternion.identity; + // VAT 模型默认尺寸是真实角色级 (~1.7m);OctoberStudio 单位较小,先给个 0.5 缩放避免太大 + // 用户可以在 prefab 里再调 + view.transform.localScale = Vector3.one * 0.5f; + + // 3) Add RendererAdapter on root, mode=Mesh, fetchRoot=View + var adapter = instance.AddComponent(); + { + var soAdapter = new SerializedObject(adapter); + var modeProp = soAdapter.FindProperty("mode"); + if (modeProp != null) modeProp.enumValueIndex = (int)RendererMode.Mesh; + var rootProp = soAdapter.FindProperty("fetchRoot"); + if (rootProp != null) rootProp.objectReferenceValue = view.transform; + soAdapter.ApplyModifiedProperties(); + adapter.FetchComponents(); + adapter.ApplyMode(); + } + + // 4) Wire adapter into CharacterBehavior.rendererAdapter + var character = instance.GetComponent(); + if (character != null) + { + var soChar = new SerializedObject(character); + var fieldProp = soChar.FindProperty("rendererAdapter"); + if (fieldProp != null) fieldProp.objectReferenceValue = adapter; + soChar.ApplyModifiedProperties(); + } + else + { + Debug.LogWarning("[VAT] CharacterBehavior not found on Wizard root"); + } + + // 5) Save as new prefab (规则的 prefab,不是 variant,独立可改) + var savedAsset = PrefabUtility.SaveAsPrefabAsset(instance, OutputPath, out var success); + if (!success || savedAsset == null) + { + Debug.LogError("[VAT] SaveAsPrefabAsset failed"); + return false; + } + Debug.Log($"[VAT] Saved: {OutputPath}"); + } + finally + { + Object.DestroyImmediate(instance); + } + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + return true; + } + + public static bool ApplyR02ToDatabase() + { + var db = AssetDatabase.LoadAssetAtPath(DatabasePath); + if (db == null) { Debug.LogError("Database not found"); return false; } + var newPrefab = AssetDatabase.LoadAssetAtPath(OutputPath); + if (newPrefab == null) { Debug.LogError($"Output prefab not found: {OutputPath}"); return false; } + + var so = new SerializedObject(db); + var characters = so.FindProperty("characters"); + if (characters == null || characters.arraySize == 0) + { + Debug.LogError("Database has no characters"); + return false; + } + characters.GetArrayElementAtIndex(0).FindPropertyRelative("prefab").objectReferenceValue = newPrefab; + so.ApplyModifiedProperties(); + AssetDatabase.SaveAssets(); + Debug.Log("[VAT] CharactersDatabase: WIZARD prefab → Wizard_R02.prefab"); + return true; + } + } +} diff --git a/Assets/Common/Scripts/Editor/CharacterVATConverter.cs.meta b/Assets/Common/Scripts/Editor/CharacterVATConverter.cs.meta new file mode 100644 index 0000000..2cd315b --- /dev/null +++ b/Assets/Common/Scripts/Editor/CharacterVATConverter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f91ea5c3ce2e6a54190cf8a9ab2fb004 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Common/Scripts/Editor/EnemyVATConverter.cs b/Assets/Common/Scripts/Editor/EnemyVATConverter.cs new file mode 100644 index 0000000..49bd2f3 --- /dev/null +++ b/Assets/Common/Scripts/Editor/EnemyVATConverter.cs @@ -0,0 +1,368 @@ +/* + * EnemyVATConverter + * + * 一键给 Stage 1 的 6 种小怪挂上 VAT 视觉层: + * 1) 创建 Enemy_VAT 子目录(Variants/),复制原 prefab 为 _VAT.prefab + * 2) 在 _VAT.prefab 上加 EnemyVATAttachment 组件,引用 mon1001_VAT.prefab + * 3) 复制 EnemiesDatabase.asset 为 EnemiesDatabaseVAT.asset, 把 type 0/1/2/3/4/8 的 prefab 指向 _VAT 版本 + * + * 注意: 不修改源 prefab/database。新文件落在: + * Assets/Common/Prefabs/Enemies/Variants_VAT/Enemy _VAT.prefab + * Assets/Common/Scriptables/Enemies Database VAT.asset + * + * 用法: + * 菜单 "Tools/OctoberStudio/VAT/Generate Stage1 VAT Enemies" + * 生成后:再用 Tools/OctoberStudio/VAT/Set Stage1 EnemiesDatabase To VAT(替换 GameController 中的引用) + */ + +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace OctoberStudio.EditorTools +{ + public static class EnemyVATConverter + { + private const string SourceEnemiesDir = "Assets/Common/Prefabs/Enemies"; + private const string OutputDir = "Assets/Common/Prefabs/Enemies/Variants_VAT"; + private const string VATPrefabPath = "Assets/_VAT_Baking/Output/mon1001/mon1001_VAT.prefab"; + private const string SourceDBPath = "Assets/Common/Scriptables/Enemies Database.asset"; + private const string OutputDBPath = "Assets/Common/Scriptables/Enemies Database VAT.asset"; + + // mon1001 mesh bounds.y ≈ 261(单位 cm * 0.01),实际渲染高度 2.61m; + // Survivors 原 sprite 怪大约 0.5~0.8m 高;0.4 让 VAT 怪约 ~1m 高,比 sprite 怪稍大一点更醒目 + private static readonly Vector3 DefaultVATLocalScale = new Vector3(0.4f, 0.4f, 0.4f); + private static readonly Vector3 DefaultVATLocalOffset = new Vector3(0f, 0f, 0f); + + // Stage 1 用到的 EnemyType: Pumpkin=0, Bat=1, Slime=2, Vampire=3, Plant=4, Bug=8 + // 对应 prefab 文件名 + private static readonly Dictionary Stage1EnemyPrefabs = new Dictionary + { + { "Pumpkin", "Enemy Pumpkin.prefab" }, + { "Bat", "Enemy Bat.prefab" }, + { "Slime", "Enemy Slime.prefab" }, + { "Vampire", "Enemy Vampire.prefab" }, + { "Plant", "Enemy Plant.prefab" }, + { "Bug", "Enemy Bug.prefab" }, + }; + + // 全 17 个 EnemyType → 源 sprite prefab 名 + 映射到的 mon* VAT(按枚举定义顺序一一对应) + // mon* 数量正好 17,与 EnemyType 一一对齐 + private static readonly (string enumName, string srcPrefab, string monName)[] AllEnemyMapping = + { + ("Pumpkin", "Enemy Pumpkin.prefab", "mon1001"), + ("Bat", "Enemy Bat.prefab", "mon1002"), + ("Slime", "Enemy Slime.prefab", "mon1003_02"), + ("Vampire", "Enemy Vampire.prefab", "mon1004_01"), + ("Plant", "Enemy Plant.prefab", "mon1008"), + ("Jellyfish", "Enemy Jellyfish.prefab", "mon1009"), + ("Bug", "Enemy Bug.prefab", "mon1010"), + ("Wasp", "Enemy Wasp.prefab", "mon1012"), + ("Hand", "Enemy Hand.prefab", "mon1014"), + ("Eye", "Enemy Eye.prefab", "mon1015"), + ("FireSlime", "Enemy Fire Slime.prefab", "mon1016"), + ("PurpleJellyfish", "Enemy Jellyfish Purple.prefab", "mon1021"), + ("StagBeetle", "Enemy Stag Beetle.prefab", "mon1025"), + ("Shade", "Enemy Shade.prefab", "mon1030"), + ("ShadeJellyfish", "Enemy Jellyfish Shade.prefab", "mon1031"), + ("ShadeBat", "Enemy Bat Shade.prefab", "mon1032"), + ("ShadeVampire", "Enemy Vampire Shade.prefab", "mon1033"), + }; + + private const string VATBakedDir = "Assets/_VAT_Baking/Output"; + + [MenuItem("Tools/OctoberStudio/VAT/Generate ALL VAT Enemies (17 → mon1001~mon1033)", priority = 71)] + public static void GenerateAllVATEnemies() + { + if (!AssetDatabase.IsValidFolder(OutputDir)) + { + Directory.CreateDirectory(Path.GetFullPath(OutputDir)); + AssetDatabase.Refresh(); + } + + int generated = 0, missing = 0; + foreach (var map in AllEnemyMapping) + { + var srcPath = $"{SourceEnemiesDir}/{map.srcPrefab}"; + var src = AssetDatabase.LoadAssetAtPath(srcPath); + if (src == null) + { + Debug.LogWarning($"[EnemyVATConverter] 源 prefab 不存在: {srcPath}"); + missing++; + continue; + } + + var vatMonPath = $"{VATBakedDir}/{map.monName}/{map.monName}_VAT.prefab"; + var vatMon = AssetDatabase.LoadAssetAtPath(vatMonPath); + if (vatMon == null) + { + Debug.LogWarning($"[EnemyVATConverter] VAT mon 不存在: {vatMonPath}"); + missing++; + continue; + } + + var dstPath = $"{OutputDir}/{Path.GetFileNameWithoutExtension(map.srcPrefab)}_VAT.prefab"; + try + { + BuildOne(src, dstPath, vatMon); + generated++; + } + catch (Exception e) + { + Debug.LogError($"[EnemyVATConverter] 失败 {map.enumName}: {e.Message}"); + } + } + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + // 重生成 EnemiesDatabase VAT 副本(用所有 17 个映射,覆盖之前只 Stage1 的逻辑) + GenerateVATDatabaseForAll(); + + // GenerateVATDatabaseForAll 中 DeleteAsset + CopyAsset 会让 VAT DB 的 GUID 变, + // 场景里 EnemiesSpawner.database 旧 GUID 引用会失效 → Init 时崩 NPE。 + // 这里自动把 Game 场景重新指向新 GUID 的 VAT DB,避免 user 每次 Generate 后手动切。 + SwitchSceneDatabase(OutputDBPath, "VAT"); + + Debug.Log($"[EnemyVATConverter] Generate ALL Done — 生成 {generated} 个 _VAT prefab, 缺失 {missing}"); + } + + private static void GenerateVATDatabaseForAll() + { + var src = AssetDatabase.LoadAssetAtPath(SourceDBPath); + if (src == null) + { + Debug.LogWarning($"[EnemyVATConverter] 源 DB 不存在: {SourceDBPath}"); + return; + } + + if (AssetDatabase.LoadAssetAtPath(OutputDBPath) != null) + AssetDatabase.DeleteAsset(OutputDBPath); + AssetDatabase.CopyAsset(SourceDBPath, OutputDBPath); + AssetDatabase.Refresh(); + + var dbVAT = AssetDatabase.LoadAssetAtPath(OutputDBPath); + if (dbVAT == null) throw new Exception("无法加载复制后的 DB"); + + // 构建 enumName → _VAT prefab 路径 的快查表 + var nameToPath = new Dictionary(); + foreach (var m in AllEnemyMapping) + { + var vp = $"{OutputDir}/{Path.GetFileNameWithoutExtension(m.srcPrefab)}_VAT.prefab"; + nameToPath[m.enumName] = vp; + } + + var so = new SerializedObject(dbVAT); + var enemiesProp = so.FindProperty("enemies"); + int replaced = 0; + for (int i = 0; i < enemiesProp.arraySize; i++) + { + var entry = enemiesProp.GetArrayElementAtIndex(i); + var typeProp = entry.FindPropertyRelative("type"); + var prefabProp = entry.FindPropertyRelative("prefab"); + string enumName = typeProp.enumNames[typeProp.enumValueIndex]; + + if (!nameToPath.TryGetValue(enumName, out var vatPath)) continue; + var vatPrefab = AssetDatabase.LoadAssetAtPath(vatPath); + if (vatPrefab == null) continue; + prefabProp.objectReferenceValue = vatPrefab; + replaced++; + } + so.ApplyModifiedProperties(); + AssetDatabase.SaveAssets(); + Debug.Log($"[EnemyVATConverter] DB VAT 全量替换: {OutputDBPath} (替换 {replaced}/{enemiesProp.arraySize} 个条目)"); + } + + [MenuItem("Tools/OctoberStudio/VAT/Generate Stage1 VAT Enemies", priority = 70)] + public static void GenerateStage1Enemies() + { + var vatPrefab = AssetDatabase.LoadAssetAtPath(VATPrefabPath); + if (vatPrefab == null) + { + Debug.LogError($"[EnemyVATConverter] VAT prefab 未找到: {VATPrefabPath}\n请先跑 Tools/OctoberStudio/VAT/Bake mon9020 (Test Sample)"); + return; + } + + if (!AssetDatabase.IsValidFolder(OutputDir)) + { + Directory.CreateDirectory(Path.GetFullPath(OutputDir)); + AssetDatabase.Refresh(); + } + + int generated = 0; + foreach (var kv in Stage1EnemyPrefabs) + { + var srcPath = $"{SourceEnemiesDir}/{kv.Value}"; + var src = AssetDatabase.LoadAssetAtPath(srcPath); + if (src == null) + { + Debug.LogWarning($"[EnemyVATConverter] 源 prefab 不存在: {srcPath}"); + continue; + } + + var dstPath = $"{OutputDir}/{Path.GetFileNameWithoutExtension(kv.Value)}_VAT.prefab"; + try + { + BuildOne(src, dstPath, vatPrefab); + generated++; + } + catch (Exception e) + { + Debug.LogError($"[EnemyVATConverter] 失败 {kv.Key}: {e}"); + } + } + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + // 生成 EnemiesDatabase VAT 副本 + GenerateVATDatabase(); + // 自动 rewire 场景到新 GUID,避免 user 每次手动切 + SwitchSceneDatabase(OutputDBPath, "VAT"); + + Debug.Log($"[EnemyVATConverter] Done. Generated {generated} VAT prefabs in {OutputDir}"); + } + + private static void BuildOne(GameObject srcPrefab, string dstPath, GameObject vatPrefab) + { + // 加载 prefab 实例(unpacked = false 保留嵌套 prefab 链接) + var instance = (GameObject)PrefabUtility.InstantiatePrefab(srcPrefab); + if (instance == null) throw new Exception("Instantiate failed"); + + try + { + // 找到 EnemyBehavior 所在的 root + var behavior = instance.GetComponent(); + if (behavior == null) throw new Exception("EnemyBehavior 组件不存在"); + + // 加 EnemyVATAttachment 组件 + var attach = instance.GetComponent(); + if (attach == null) attach = instance.AddComponent(); + + attach.VATPrefab = vatPrefab; + attach.LocalScale = DefaultVATLocalScale; + attach.LocalOffset = DefaultVATLocalOffset; + + // 保存为新 prefab (覆盖式) + PrefabUtility.SaveAsPrefabAsset(instance, dstPath); + Debug.Log($"[EnemyVATConverter] Saved: {dstPath}"); + } + finally + { + UnityEngine.Object.DestroyImmediate(instance); + } + } + + private static void GenerateVATDatabase() + { + var src = AssetDatabase.LoadAssetAtPath(SourceDBPath); + if (src == null) + { + Debug.LogWarning($"[EnemyVATConverter] 源 DB 不存在: {SourceDBPath},跳过 DB 生成"); + return; + } + + // 复制原 asset 文件作为 base + if (AssetDatabase.LoadAssetAtPath(OutputDBPath) != null) + AssetDatabase.DeleteAsset(OutputDBPath); + AssetDatabase.CopyAsset(SourceDBPath, OutputDBPath); + AssetDatabase.Refresh(); + + var dbVAT = AssetDatabase.LoadAssetAtPath(OutputDBPath); + if (dbVAT == null) throw new Exception("无法加载复制后的 DB"); + + // 通过 SerializedObject 把每个 entry 的 prefab 指向 _VAT + var so = new SerializedObject(dbVAT); + var enemiesProp = so.FindProperty("enemies"); + + int replaced = 0; + for (int i = 0; i < enemiesProp.arraySize; i++) + { + var entry = enemiesProp.GetArrayElementAtIndex(i); + var typeProp = entry.FindPropertyRelative("type"); + var prefabProp = entry.FindPropertyRelative("prefab"); + + EnemyType t = (EnemyType)typeProp.enumValueIndex; + // 注意: enum value 是 enumValueIndex 不是 intValue;但 EnemyType 枚举值不连续(Jellyfish=5 后是 Bug=8) + // 用 enumDisplayNames + enumNames 检查 + string enumName = typeProp.enumNames[typeProp.enumValueIndex]; + + if (!Stage1EnemyPrefabs.TryGetValue(enumName, out string srcFileName)) continue; + + var vatPrefabPath = $"{OutputDir}/{Path.GetFileNameWithoutExtension(srcFileName)}_VAT.prefab"; + var vatPrefab = AssetDatabase.LoadAssetAtPath(vatPrefabPath); + if (vatPrefab == null) + { + Debug.LogWarning($"[EnemyVATConverter] VAT prefab 未找到: {vatPrefabPath}"); + continue; + } + + prefabProp.objectReferenceValue = vatPrefab; + replaced++; + } + + so.ApplyModifiedProperties(); + AssetDatabase.SaveAssets(); + Debug.Log($"[EnemyVATConverter] DB VAT 副本: {OutputDBPath} (替换 {replaced} 个条目)"); + } + + // ============== 场景切换:把 EnemiesSpawner.database 切换为 VAT 副本 ============== + + private const string GameScenePath = "Assets/Common/Scenes/Game.unity"; + + [MenuItem("Tools/OctoberStudio/VAT/Switch Stage1 to VAT (Game scene)", priority = 80)] + public static void SwitchSceneToVAT() + { + SwitchSceneDatabase(OutputDBPath, "VAT"); + } + + [MenuItem("Tools/OctoberStudio/VAT/Switch Stage1 to Original (Game scene)", priority = 81)] + public static void SwitchSceneToOriginal() + { + SwitchSceneDatabase(SourceDBPath, "Original"); + } + + private static void SwitchSceneDatabase(string dbAssetPath, string label) + { + var dbAsset = AssetDatabase.LoadAssetAtPath(dbAssetPath); + if (dbAsset == null) + { + Debug.LogError($"[EnemyVATConverter] DB 不存在: {dbAssetPath}"); + return; + } + + // Load Game scene additively if not already open + var scene = SceneManager.GetSceneByPath(GameScenePath); + bool wasOpen = scene.IsValid() && scene.isLoaded; + if (!wasOpen) + { + scene = EditorSceneManager.OpenScene(GameScenePath, OpenSceneMode.Single); + } + + int patched = 0; + foreach (var root in scene.GetRootGameObjects()) + { + foreach (var sp in root.GetComponentsInChildren(true)) + { + var so = new SerializedObject(sp); + var dbProp = so.FindProperty("database"); + if (dbProp != null) + { + dbProp.objectReferenceValue = dbAsset; + so.ApplyModifiedProperties(); + EditorUtility.SetDirty(sp); + patched++; + } + } + } + + EditorSceneManager.MarkSceneDirty(scene); + EditorSceneManager.SaveScene(scene); + Debug.Log($"[EnemyVATConverter] 场景切换到 {label}: 替换 {patched} 个 EnemiesSpawner.database → {dbAssetPath}"); + } + } +} diff --git a/Assets/Common/Scripts/Editor/EnemyVATConverter.cs.meta b/Assets/Common/Scripts/Editor/EnemyVATConverter.cs.meta new file mode 100644 index 0000000..8e916fd --- /dev/null +++ b/Assets/Common/Scripts/Editor/EnemyVATConverter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 907bea91edeac0e41897dfdc7a7ce743 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Common/Scripts/Editor/ImportMonsterPackageAndClean.cs b/Assets/Common/Scripts/Editor/ImportMonsterPackageAndClean.cs new file mode 100644 index 0000000..de085ee --- /dev/null +++ b/Assets/Common/Scripts/Editor/ImportMonsterPackageAndClean.cs @@ -0,0 +1,125 @@ +/* + * ImportMonsterPackageAndClean + * + * 1) 导入工程根下的 monster.unitypackage / role.unitypackage (非交互式) + * 2) 导入完成回调里自动清理夹带的 GameClient 代码/插件冗余 + * 只保留: Assets/Res (怪/特效/玩家) + * 清理: Code/Scripts/Editor/PSD2UGUI/Tools/ThirdParty + Plugins 多余子目录 + */ + +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace OctoberStudio.EditorTools +{ + public static class ImportMonsterPackageAndClean + { + [MenuItem("Tools/OctoberStudio/VAT/Import Role Package Auto Clean", priority = 2)] + public static void ImportRoleAndClean() => ImportAndCleanInternal("role.unitypackage"); + + [MenuItem("Tools/OctoberStudio/VAT/Import Monster Package Auto Clean", priority = 1)] + public static void ImportAndClean() => ImportAndCleanInternal("monster.unitypackage"); + + // 整目录删除清单 (导入后立刻删,只删 unitypackage 夹带,不动 Survivors 自有) + private static readonly string[] DeleteAfterImport = new[] + { + "Assets/Code", + "Assets/Scripts", + "Assets/Editor", + "Assets/PSD2UGUI", + "Assets/Tools", + "Assets/ThirdParty", + // unitypackage 新加的 Plugins 子目录 + "Assets/Plugins/Android", + "Assets/Plugins/BuglyPlugins", + "Assets/Plugins/Com", + "Assets/Plugins/DOTween", + "Assets/Plugins/IceMark", + "Assets/Plugins/SharpZipLib", + "Assets/Plugins/TexturePacker", + "Assets/Plugins/YangStudio", + "Assets/Plugins/Sirenix", // unitypackage 如果带了 Odin 也删 + "Assets/Plugins/Demigiant", // 同样防御 + "Assets/Plugins/YIUIFramework", // 防御 + // role.unitypackage 夹带的额外目录 + "Assets/Bundles", + "Assets/GameRes", + "Assets/Hotfix", + "Assets/HybridCLRData", + }; + + private static void ImportAndCleanInternal(string packageFileName) + { + var projRoot = Path.GetDirectoryName(Application.dataPath); + var pkg = Path.Combine(projRoot, packageFileName); + if (!File.Exists(pkg)) + { + Debug.LogError($"[ImportPackage] 找不到 {packageFileName}: {pkg}"); + return; + } + + AssetDatabase.importPackageCompleted -= OnImportDone; + AssetDatabase.importPackageCompleted += OnImportDone; + AssetDatabase.importPackageFailed -= OnImportFailed; + AssetDatabase.importPackageFailed += OnImportFailed; + AssetDatabase.importPackageCancelled -= OnImportCancelled; + AssetDatabase.importPackageCancelled += OnImportCancelled; + + Debug.Log($"[ImportPackage] 开始导入: {pkg}"); + AssetDatabase.ImportPackage(pkg, false); + } + + private static void OnImportDone(string packageName) + { + Debug.Log($"[ImportPackage] 导入完成: {packageName}. 开始自动清理冗余..."); + Detach(); + EditorApplication.delayCall += DoClean; + } + + private static void OnImportFailed(string packageName, string errorMessage) + { + Debug.LogError($"[ImportPackage] 导入失败: {packageName}\n{errorMessage}"); + Detach(); + } + + private static void OnImportCancelled(string packageName) + { + Debug.LogWarning($"[ImportPackage] 导入取消: {packageName}"); + Detach(); + } + + private static void Detach() + { + AssetDatabase.importPackageCompleted -= OnImportDone; + AssetDatabase.importPackageFailed -= OnImportFailed; + AssetDatabase.importPackageCancelled -= OnImportCancelled; + } + + private static void DoClean() + { + int ok = 0; + long savedBytes = 0; + foreach (var path in DeleteAfterImport) + { + if (!AssetDatabase.IsValidFolder(path)) continue; + long size = 0; + try + { + foreach (var f in Directory.GetFiles(Path.GetFullPath(path), "*", SearchOption.AllDirectories)) + size += new FileInfo(f).Length; + } + catch { } + + if (AssetDatabase.DeleteAsset(path)) + { + ok++; + savedBytes += size; + Debug.Log($"[Clean] 删除 {path} (-{size / 1024 / 1024} MB)"); + } + } + AssetDatabase.Refresh(); + Debug.Log($"[Clean] 完成 — 删除 {ok} 目录,释放 {savedBytes / 1024f / 1024f:F1} MB"); + } + } +} diff --git a/Assets/Common/Scripts/Editor/ImportMonsterPackageAndClean.cs.meta b/Assets/Common/Scripts/Editor/ImportMonsterPackageAndClean.cs.meta new file mode 100644 index 0000000..3ac2536 --- /dev/null +++ b/Assets/Common/Scripts/Editor/ImportMonsterPackageAndClean.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 410e208d9c7e2fe49bdacb510b91c66d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Common/Scripts/Editor/MonsterOrientationCheck.cs b/Assets/Common/Scripts/Editor/MonsterOrientationCheck.cs new file mode 100644 index 0000000..cc25d33 --- /dev/null +++ b/Assets/Common/Scripts/Editor/MonsterOrientationCheck.cs @@ -0,0 +1,148 @@ +/* + * MonsterOrientationCheck + * + * 一键检测 Assets/_External/Monsters 和 Assets/_External/Bosses 下所有怪物 FBX 的朝向。 + * 判定逻辑:实例化 FBX 到隐藏临时场景,读 SkinnedMeshRenderer.bounds: + * - height = bounds.size.y (Unity 站立高度) + * - depth = bounds.size.z + * - width = bounds.size.x + * 如果 height > max(depth, width) * 1.2 → 站立 OK + * 否则需要 X=-90 (从 Z-up 转 Y-up) 或 X=90 + * + * 输出表格到 Console + 写 Assets/_External/_OrientationReport.txt。 + */ + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using UnityEditor; +using UnityEngine; + +namespace OctoberStudio.EditorTools +{ + public static class MonsterOrientationCheck + { + private const string MonstersDir = "Assets/Res/Role/Monster"; + private const string BossesDir = "Assets/Res/Role/Boss"; // 可选,有就扫 + private const string ReportPath = "Assets/_OrientationReport.txt"; + + [MenuItem("Tools/OctoberStudio/VAT/Check Monster Orientation", priority = 50)] + public static void Run() + { + var sb = new StringBuilder(); + sb.AppendLine("# Monster/Boss FBX 朝向检测报告"); + sb.AppendLine($"# 生成时间: {System.DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine("# 判定:height 是 size.y; 若 height 不是最大维度,需要 X 校正"); + sb.AppendLine(); + sb.AppendLine($"{"角色",-30} {"X",8} {"Y",8} {"Z",8} {"主轴",4} {"需要 X 校正?",-15}"); + sb.AppendLine(new string('-', 80)); + + var results = new List(); + CollectFromDir(MonstersDir, results); + CollectFromDir(BossesDir, results); + + int needXCorrect = 0; + foreach (var r in results.OrderBy(e => e.Category).ThenBy(e => e.Name)) + { + string mainAxis = GetMainAxis(r.Size); + bool needFix = mainAxis != "Y"; + string fixHint = needFix + ? (mainAxis == "Z" ? "X=-90 (Z-up)" : (mainAxis == "X" ? "Y=90 (X-up?)" : "?")) + : "OK (Y-up)"; + + if (needFix) needXCorrect++; + + sb.AppendLine($"{r.Name,-30} {r.Size.x,8:F2} {r.Size.y,8:F2} {r.Size.z,8:F2} {mainAxis,4} {fixHint,-15}"); + } + + sb.AppendLine(); + sb.AppendLine($"# 总计: {results.Count}, 需要 X 校正: {needXCorrect}, 直接可用: {results.Count - needXCorrect}"); + + var text = sb.ToString(); + File.WriteAllText(Path.GetFullPath(ReportPath), text); + AssetDatabase.ImportAsset(ReportPath); + + Debug.Log(text); + Debug.Log($"[Orientation Check] 报告已写入: {ReportPath}"); + } + + private static void CollectFromDir(string dir, List results) + { + if (!AssetDatabase.IsValidFolder(dir)) return; + + // 找每个子目录的主 FBX(不带 @ 的那个) + var subDirs = AssetDatabase.GetSubFolders(dir); + foreach (var sub in subDirs) + { + var fbxGuids = AssetDatabase.FindAssets("t:Model", new[] { sub }); + foreach (var g in fbxGuids) + { + var path = AssetDatabase.GUIDToAssetPath(g); + var fileName = Path.GetFileName(path); + // 跳过 @animation / @Run 这种动画 FBX,只测主 FBX + if (fileName.Contains("@")) continue; + + var fbx = AssetDatabase.LoadAssetAtPath(path); + if (fbx == null) continue; + + var bounds = GetCombinedBounds(fbx); + if (bounds.HasValue) + { + results.Add(new Entry + { + Category = dir, + Name = Path.GetFileNameWithoutExtension(path) + " (" + Path.GetFileName(sub) + ")", + Size = bounds.Value.size, + Path = path, + }); + } + } + } + } + + private static Bounds? GetCombinedBounds(GameObject fbxAsset) + { + // 临时实例化(不入场景),取 SMR.sharedMesh.bounds(这是 local space,反映 FBX 内部朝向) + // 注意: SkinnedMeshRenderer.bounds 是 world space, 但 sharedMesh.bounds 才是本地(模型空间) + var smrs = fbxAsset.GetComponentsInChildren(true); + var mfs = fbxAsset.GetComponentsInChildren(true); + + bool inited = false; + Bounds b = new Bounds(); + + foreach (var smr in smrs) + { + if (smr.sharedMesh == null) continue; + if (!inited) { b = smr.sharedMesh.bounds; inited = true; } + else b.Encapsulate(smr.sharedMesh.bounds); + } + foreach (var mf in mfs) + { + if (mf.sharedMesh == null) continue; + if (!inited) { b = mf.sharedMesh.bounds; inited = true; } + else b.Encapsulate(mf.sharedMesh.bounds); + } + + return inited ? b : (Bounds?)null; + } + + private static string GetMainAxis(Vector3 size) + { + float x = Mathf.Abs(size.x); + float y = Mathf.Abs(size.y); + float z = Mathf.Abs(size.z); + if (y >= x && y >= z) return "Y"; + if (z >= x && z >= y) return "Z"; + return "X"; + } + + private class Entry + { + public string Category; + public string Name; + public Vector3 Size; + public string Path; + } + } +} diff --git a/Assets/Common/Scripts/Editor/MonsterOrientationCheck.cs.meta b/Assets/Common/Scripts/Editor/MonsterOrientationCheck.cs.meta new file mode 100644 index 0000000..e934f35 --- /dev/null +++ b/Assets/Common/Scripts/Editor/MonsterOrientationCheck.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5874804b9c848b5408ec1045d693f6fd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Common/Scripts/Editor/MonsterVATBatchBaker.cs b/Assets/Common/Scripts/Editor/MonsterVATBatchBaker.cs new file mode 100644 index 0000000..a2e017c --- /dev/null +++ b/Assets/Common/Scripts/Editor/MonsterVATBatchBaker.cs @@ -0,0 +1,421 @@ +/* + * MonsterVATBatchBaker + * + * 一键怪物 VAT 烘焙工具: + * 输入 — Assets/_External/Monsters//.FBX 主 FBX(必须 Legacy animationType=1) + * 流程 — + * 1) 临时场景中实例化主 FBX + * 2) 给 root 加 Animation 组件 + * 3) 从同目录的 @Run.FBX 拿 Legacy Run AnimationClip,AddClip 到 Animation + * 4) 反射调用 SelectiveVATBakerWindow.BakeAll(自动 X=-90 if Z-up) + * 5) 销毁场景实例 + * 输出 — Assets/_VAT_Baking/Output//_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(); + 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(); + 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/ + 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(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(); + if (animator != null) animator.enabled = false; + + var anim = instance.GetComponent(); + if (anim == null) + { + // root 没 Animation 时,从子节点提 + anim = instance.GetComponentInChildren(); + if (anim == null) { Debug.LogError("[BakePrefab] 没找到 Animation 组件"); return; } + } + + var smr = instance.GetComponentInChildren(); + 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(),所以 root 必须有 Animation + if (instance.GetComponent() == null && anim != null) + { + // 把子节点 Animation 引用的 clip 抽出来,在 root 加 Animation 组件 + var newAnim = instance.AddComponent(); + 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(); + if (fbxList.Count == 0) + { + Debug.LogWarning("[BakeMonsters] 没有 FBX 路径输入。"); + return; + } + + int ok = 0, fail = 0; + var failList = new List(); + 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 CollectFbxFromSelection() + { + var paths = new List(); + 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(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。请确保同目录有 @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(); + if (smr == null) throw new Exception("FBX 内找不到 SkinnedMeshRenderer: " + fbxAssetPath); + + // 5) 加 Animation 组件 (主 FBX importAnimation=0 时根上没有 Animation) + var anim = instance.GetComponent(); + if (anim == null) anim = instance.AddComponent(); + + // 把 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; + + // 候选: @Run.FBX, @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(); + 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(); + 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); + } + } +} diff --git a/Assets/Common/Scripts/Editor/MonsterVATBatchBaker.cs.meta b/Assets/Common/Scripts/Editor/MonsterVATBatchBaker.cs.meta new file mode 100644 index 0000000..2efd770 --- /dev/null +++ b/Assets/Common/Scripts/Editor/MonsterVATBatchBaker.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 042cd68b7950219468cdbd4875f1f571 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Common/Scripts/Editor/OrientationCalibrator.cs b/Assets/Common/Scripts/Editor/OrientationCalibrator.cs new file mode 100644 index 0000000..026c86a --- /dev/null +++ b/Assets/Common/Scripts/Editor/OrientationCalibrator.cs @@ -0,0 +1,200 @@ +/* + * OrientationCalibrator + * + * 用途:调试 R02 (或任意 3D View 子物体) 的朝向 + 缩放 + 偏移, + * 一键 apply 到 prefab 上持久化保存。 + * + * 默认目标:选中场景里的 GameObject (含 "View" 子物体),然后菜单 Tools/OctoberStudio/VAT/Orientation Calibrator + */ + +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace OctoberStudio.EditorTools +{ + public class OrientationCalibrator : EditorWindow + { + private GameObject _target; + private Transform _view; + + private Vector3 _euler = new Vector3(0f, 180f, 0f); + private float _uniformScale = 0.5f; + private Vector3 _localPos = Vector3.zero; + + [MenuItem("Tools/OctoberStudio/VAT/Orientation Calibrator", priority = 10)] + public static void ShowWindow() + { + var win = GetWindow("Orientation Calibrator"); + win.AutoPickTarget(); + } + + private void OnEnable() => AutoPickTarget(); + private void OnSelectionChange() { AutoPickTarget(); Repaint(); } + + private void AutoPickTarget() + { + if (Selection.activeGameObject != null) + { + _target = Selection.activeGameObject; + _view = _target.transform.Find("View"); + if (_view != null) + { + _euler = _view.localEulerAngles; + var s = _view.localScale; + _uniformScale = (s.x + s.y + s.z) / 3f; + _localPos = _view.localPosition; + } + } + } + + private void OnGUI() + { + EditorGUILayout.LabelField("Orientation Calibrator", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "1) 在场景里选中 Wizard_R02 (或任意含 'View' 子物体的角色)\n" + + "2) 点 Quick Face 按钮预览朝向\n" + + "3) 调好后点 [Apply to Prefab] 持久化", MessageType.Info); + + _target = (GameObject)EditorGUILayout.ObjectField("Target", _target, typeof(GameObject), true); + if (_target == null) return; + + _view = _target.transform.Find("View"); + if (_view == null) + { + EditorGUILayout.HelpBox("Target 没有名为 'View' 的子物体", MessageType.Warning); + return; + } + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Quick Face (相机看 +Z 方向)", EditorStyles.boldLabel); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Face Camera\n(Y=180)")) Apply(new Vector3(0, 180, 0)); + if (GUILayout.Button("Face Right\n(Y=270)")) Apply(new Vector3(0, 270, 0)); + if (GUILayout.Button("Face Left\n(Y=90)")) Apply(new Vector3(0, 90, 0)); + if (GUILayout.Button("Face Away\n(Y=0)")) Apply(new Vector3(0, 0, 0)); + } + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Lay Down +\n(X=-90)")) Apply(new Vector3(-90, 0, 0)); + if (GUILayout.Button("Lay Down -\n(X=90)")) Apply(new Vector3(90, 0, 0)); + if (GUILayout.Button("Tilt Up 15°")) Apply(_euler + new Vector3(-15, 0, 0)); + if (GUILayout.Button("Tilt Down 15°")) Apply(_euler + new Vector3(15, 0, 0)); + } + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Rotate Y -45")) Apply(_euler + new Vector3(0, -45, 0)); + if (GUILayout.Button("Rotate Y -15")) Apply(_euler + new Vector3(0, -15, 0)); + if (GUILayout.Button("Rotate Y +15")) Apply(_euler + new Vector3(0, 15, 0)); + if (GUILayout.Button("Rotate Y +45")) Apply(_euler + new Vector3(0, 45, 0)); + } + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Manual", EditorStyles.boldLabel); + EditorGUI.BeginChangeCheck(); + _euler = EditorGUILayout.Vector3Field("Local Euler", _euler); + _uniformScale = EditorGUILayout.Slider("Uniform Scale", _uniformScale, 0.05f, 5f); + _localPos = EditorGUILayout.Vector3Field("Local Position", _localPos); + if (EditorGUI.EndChangeCheck()) ApplyToScene(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Pivot Helper (基于 MeshRenderer.bounds 自动校正)", EditorStyles.boldLabel); + if (GUILayout.Button("Auto Center Pivot (基于 bounds 把模型对齐到原点)")) + { + AutoCenterPivot(); + } + + EditorGUILayout.Space(); + using (new EditorGUI.DisabledScope(_view == null)) + { + if (GUILayout.Button("Apply to Prefab (持久化)", GUILayout.Height(36))) + { + ApplyToPrefab(); + } + } + } + + private void Apply(Vector3 newEuler) + { + _euler = NormalizeEuler(newEuler); + ApplyToScene(); + } + + private static Vector3 NormalizeEuler(Vector3 e) + { + return new Vector3( + Mathf.Repeat(e.x + 180f, 360f) - 180f, + Mathf.Repeat(e.y + 180f, 360f) - 180f, + Mathf.Repeat(e.z + 180f, 360f) - 180f + ); + } + + private void ApplyToScene() + { + if (_view == null) return; + Undo.RecordObject(_view, "Calibrate View"); + _view.localEulerAngles = _euler; + _view.localScale = Vector3.one * _uniformScale; + _view.localPosition = _localPos; + EditorUtility.SetDirty(_view); + SceneView.RepaintAll(); + } + + private void AutoCenterPivot() + { + if (_view == null) return; + var mr = _view.GetComponentInChildren(); + if (mr == null) { Debug.LogWarning("No MeshRenderer under View"); return; } + // bounds.center in world → 转 view local → 反向偏移 + var centerWorld = mr.bounds.center; + var centerLocalInView = _view.InverseTransformPoint(centerWorld); + // 想让模型中心对齐到 View 的原点:localPos -= local-space offset (scaled) + // 简化:直接把 mesh.localBounds.center 当偏移 (避免世界缩放影响) + var mf = _view.GetComponentInChildren(); + if (mf != null && mf.sharedMesh != null) + { + var meshCenter = mf.sharedMesh.bounds.center; // model-space + _localPos = -Vector3.Scale(meshCenter, Vector3.one * _uniformScale); + ApplyToScene(); + Debug.Log($"[Calibrator] Auto-center: model bounds.center={meshCenter}, set View.localPos={_localPos}"); + } + } + + private void ApplyToPrefab() + { + if (_target == null) return; + ApplyToScene(); + + // Try outermost prefab instance and apply overrides + var inst = PrefabUtility.GetNearestPrefabInstanceRoot(_target); + if (inst == null) + { + EditorUtility.DisplayDialog("Calibrator", "Target 不是 prefab 实例", "OK"); + return; + } + + // 1) Apply property modifications (rotation/scale/position) of the View transform back to its corresponding prefab asset + var path = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(inst); + if (string.IsNullOrEmpty(path)) + { + EditorUtility.DisplayDialog("Calibrator", "找不到 prefab 资产路径", "OK"); + return; + } + + // Apply overrides on the entire instance + try + { + PrefabUtility.ApplyPrefabInstance(inst, InteractionMode.AutomatedAction); + AssetDatabase.SaveAssets(); + Debug.Log($"[Calibrator] Applied overrides to prefab: {path}"); + EditorUtility.DisplayDialog("Calibrator", $"已 Apply 到 prefab:\n{path}", "OK"); + } + catch (System.Exception e) + { + Debug.LogException(e); + EditorUtility.DisplayDialog("Calibrator", $"Apply 失败: {e.Message}", "OK"); + } + } + } +} diff --git a/Assets/Common/Scripts/Editor/OrientationCalibrator.cs.meta b/Assets/Common/Scripts/Editor/OrientationCalibrator.cs.meta new file mode 100644 index 0000000..a50e128 --- /dev/null +++ b/Assets/Common/Scripts/Editor/OrientationCalibrator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 22c57c07cb6329049a0aff762e60d2e3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/AnimMapBaker/Documentation.pdf b/Assets/Plugins/AnimMapBaker/Documentation.pdf new file mode 100644 index 0000000..a23b10d Binary files /dev/null and b/Assets/Plugins/AnimMapBaker/Documentation.pdf differ diff --git a/Assets/Plugins/AnimMapBaker/Documentation.pdf.meta b/Assets/Plugins/AnimMapBaker/Documentation.pdf.meta new file mode 100644 index 0000000..5350232 --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Documentation.pdf.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a808e9721af7ae64697f4bb71d3b4cb6 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/AnimMapBaker/Scripts.meta b/Assets/Plugins/AnimMapBaker/Scripts.meta new file mode 100644 index 0000000..52722a9 --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5fea0bab86abec74aac8d8298be78ae0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/AnimMapBaker/Scripts/AnimMapBaker.cs b/Assets/Plugins/AnimMapBaker/Scripts/AnimMapBaker.cs new file mode 100644 index 0000000..5233dbe --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/AnimMapBaker.cs @@ -0,0 +1,211 @@ +/* + * Created by jiadong chen + * https://jiadong-chen.medium.com/ + * 用来烘焙动作贴图。烘焙对象使用Animation组件,并且在导入时设置Rig为Legacy + */ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; +using System.Linq; +using System.IO; + +/// +/// 保存需要烘焙的动画的相关数据 +/// +public struct AnimData +{ + #region FIELDS + + private int _vertexCount; + private int _mapWidth; + private readonly List _animClips; + private string _name; + + private Animation _animation; + private SkinnedMeshRenderer _skin; + + public List AnimationClips => _animClips; + public int MapWidth => _mapWidth; + public string Name => _name; + + #endregion + + public AnimData(Animation anim, SkinnedMeshRenderer smr, string goName) + { + _vertexCount = smr.sharedMesh.vertexCount; + _mapWidth = Mathf.NextPowerOfTwo(_vertexCount); + _animClips = new List(anim.Cast()); + _animation = anim; + _skin = smr; + _name = goName; + } + + #region METHODS + + public void AnimationPlay(string animName) + { + _animation.Play(animName); + } + + public void SampleAnimAndBakeMesh(ref Mesh m) + { + SampleAnim(); + BakeMesh(ref m); + } + + private void SampleAnim() + { + if (_animation == null) + { + Debug.LogError("animation is null!!"); + return; + } + + _animation.Sample(); + } + + private void BakeMesh(ref Mesh m) + { + if (_skin == null) + { + Debug.LogError("skin is null!!"); + return; + } + + _skin.BakeMesh(m); + } + + + #endregion + +} + +/// +/// 烘焙后的数据 +/// +public struct BakedData +{ + #region FIELDS + + private readonly string _name; + private readonly float _animLen; + private readonly byte[] _rawAnimMap; + private readonly int _animMapWidth; + private readonly int _animMapHeight; + + #endregion + + public BakedData(string name, float animLen, Texture2D animMap) + { + _name = name; + _animLen = animLen; + _animMapHeight = animMap.height; + _animMapWidth = animMap.width; + _rawAnimMap = animMap.GetRawTextureData(); + } + + public int AnimMapWidth => _animMapWidth; + + public string Name => _name; + + public float AnimLen => _animLen; + + public byte[] RawAnimMap => _rawAnimMap; + + public int AnimMapHeight => _animMapHeight; +} + +/// +/// 烘焙器 +/// +public class AnimMapBaker{ + + #region FIELDS + + private AnimData? _animData = null; + private Mesh _bakedMesh; + private readonly List _vertices = new List(); + private readonly List _bakedDataList = new List(); + + #endregion + + #region METHODS + + public void SetAnimData(GameObject go) + { + if(go == null) + { + Debug.LogError("go is null!!"); + return; + } + + var anim = go.GetComponent(); + var smr = go.GetComponentInChildren(); + + if(anim == null || smr == null) + { + Debug.LogError("anim or smr is null!!"); + return; + } + _bakedMesh = new Mesh(); + _animData = new AnimData(anim, smr, go.name); + } + + public List Bake() + { + if(_animData == null) + { + Debug.LogError("bake data is null!!"); + return _bakedDataList; + } + + //每一个动作都生成一个动作图 + foreach (var t in _animData.Value.AnimationClips) + { + if(!t.clip.legacy) + { + Debug.LogError(string.Format($"{t.clip.name} is not legacy!!")); + continue; + } + BakePerAnimClip(t); + } + + return _bakedDataList; + } + + private void BakePerAnimClip(AnimationState curAnim) + { + var curClipFrame = 0; + float sampleTime = 0; + float perFrameTime = 0; + + curClipFrame = Mathf.ClosestPowerOfTwo((int)(curAnim.clip.frameRate * curAnim.length)); + perFrameTime = curAnim.length / curClipFrame; ; + + var animMap = new Texture2D(_animData.Value.MapWidth, curClipFrame, TextureFormat.RGBAHalf, true); + animMap.name = string.Format($"{_animData.Value.Name}_{curAnim.name}.animMap"); + _animData.Value.AnimationPlay(curAnim.name); + + for (var i = 0; i < curClipFrame; i++) + { + curAnim.time = sampleTime; + + _animData.Value.SampleAnimAndBakeMesh(ref _bakedMesh); + + for(var j = 0; j < _bakedMesh.vertexCount; j++) + { + var vertex = _bakedMesh.vertices[j]; + animMap.SetPixel(j, i, new Color(vertex.x, vertex.y, vertex.z)); + } + + sampleTime += perFrameTime; + } + animMap.Apply(); + + _bakedDataList.Add(new BakedData(animMap.name, curAnim.clip.length, animMap)); + } + + #endregion + +} diff --git a/Assets/Plugins/AnimMapBaker/Scripts/AnimMapBaker.cs.meta b/Assets/Plugins/AnimMapBaker/Scripts/AnimMapBaker.cs.meta new file mode 100644 index 0000000..6f933b7 --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/AnimMapBaker.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: d58f874be9d391e43866e5e334ddf986 +timeCreated: 1501134718 +licenseType: Free +MonoImporter: + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/AnimMapBaker/Scripts/Editor.meta b/Assets/Plugins/AnimMapBaker/Scripts/Editor.meta new file mode 100644 index 0000000..81caab4 --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e754dc049ab8a644abf6ea8b312ef7a1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/AnimMapBaker/Scripts/Editor/AnimMapBakerWindow.cs b/Assets/Plugins/AnimMapBaker/Scripts/Editor/AnimMapBakerWindow.cs new file mode 100644 index 0000000..5d418f4 --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/Editor/AnimMapBakerWindow.cs @@ -0,0 +1,219 @@ +/* + * Created by jiadong chen + * https://jiadong-chen.medium.com/ + */ + +using System; +using UnityEngine; +using UnityEditor; +using System.IO; +using UnityEngine.Rendering; + +public class AnimMapBakerWindow : EditorWindow +{ + private enum SaveStrategy + { + // Only anim map + AnimMap, + // With shader + Mat, + // Prefab with mat + Prefab + } + + #region FIELDS + + private const string BuiltInShader = "chenjd/BuiltIn/AnimMapShader"; + private const string URPShader = "chenjd/URP/AnimMapShader"; + private const string ShadowShader = "chenjd/BuiltIn/AnimMapWithShadowShader"; + + private static readonly int MainTex = Shader.PropertyToID("_MainTex"); + private static readonly int AnimMap = Shader.PropertyToID("_AnimMap"); + private static readonly int AnimLen = Shader.PropertyToID("_AnimLen"); + + private GameObject _targetGo; + private AnimMapBaker _baker; + private string _path = "AnimMapBaker"; + private string _subPath = "SubPath"; + + private SaveStrategy _strategy = SaveStrategy.Prefab; + private Shader _animMapShader; + private Shader _prevAnimMapShader; + private bool _isShadowEnabled = false; + + #endregion + + + #region METHODS + + [MenuItem("Window/AnimMapBaker")] + public static void ShowWindow() + { + var window = GetWindow(); + + window._baker = new AnimMapBaker(); + var shaderName = GraphicsSettings.renderPipelineAsset != null ? URPShader : BuiltInShader; + window._animMapShader = Shader.Find(shaderName); + } + + private void OnGUI() + { + _targetGo = (GameObject)EditorGUILayout.ObjectField(_targetGo, typeof(GameObject), true); + _subPath = _targetGo == null ? _subPath : _targetGo.name; + + var resultPath = "Wrong path entered!"; + var canProceed = true; + + try + { + resultPath = Path.Combine(_path, _subPath); + } + catch (Exception e) + { + canProceed = false; + } + + EditorGUILayout.LabelField(string.Format($"Output Path: {resultPath}")); + _path = EditorGUILayout.TextField(_path); + _subPath = EditorGUILayout.TextField(_subPath); + + _strategy = (SaveStrategy)EditorGUILayout.EnumPopup("Output Type:", _strategy); + + _isShadowEnabled = EditorGUILayout.Toggle("Enable Shadow", _isShadowEnabled); + + if(_isShadowEnabled) + { + var style = new GUIStyle(EditorStyles.label); + style.normal.textColor = Color.yellow; + + EditorGUILayout.LabelField("Warning: Enabling shadows will cause additional draw calls to draw shadows.", style); + + _prevAnimMapShader = _animMapShader; + _animMapShader = Shader.Find(ShadowShader); + } + else if(_prevAnimMapShader != null) + { + _animMapShader = _prevAnimMapShader; + } + + var prevGuiEnabled = GUI.enabled; + GUI.enabled = canProceed; + var requestedBake = GUILayout.Button("Bake"); + GUI.enabled = prevGuiEnabled; + + if (!requestedBake) return; + + if(_targetGo == null) + { + EditorUtility.DisplayDialog("err", "targetGo is null!", "OK"); + return; + } + + if(_baker == null) + { + _baker = new AnimMapBaker(); + } + + _baker.SetAnimData(_targetGo); + + var list = _baker.Bake(); + + if (list == null || list.Count == 0) + { + EditorUtility.DisplayDialog("err", "No baked data was generated. Possible, selected wrong asset.\nYou need to select prefab with Animation component and Animations list filled with animations.", "OK"); + return; + } + + foreach (var t in list) + { + var data = t; + Save(ref data); + } + } + + private void Save(ref BakedData data) + { + switch(_strategy) + { + case SaveStrategy.AnimMap: + SaveAsAsset(ref data); + break; + case SaveStrategy.Mat: + SaveAsMat(ref data); + break; + case SaveStrategy.Prefab: + SaveAsPrefab(ref data); + break; + } + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + } + + private Texture2D SaveAsAsset(ref BakedData data) + { + var folderPath = CreateFolder(); + var animMap = new Texture2D(data.AnimMapWidth, data.AnimMapHeight, TextureFormat.RGBAHalf, false); + animMap.LoadRawTextureData(data.RawAnimMap); + AssetDatabase.CreateAsset(animMap, Path.Combine(folderPath, data.Name + ".asset")); + return animMap; + } + + private Material SaveAsMat(ref BakedData data) + { + if(_animMapShader == null) + { + EditorUtility.DisplayDialog("err", "shader is null!!", "OK"); + return null; + } + + if(_targetGo == null || !_targetGo.GetComponentInChildren()) + { + EditorUtility.DisplayDialog("err", "SkinnedMeshRender is null!!", "OK"); + return null; + } + + var smr = _targetGo.GetComponentInChildren(); + var mat = new Material(_animMapShader); + var animMap = SaveAsAsset(ref data); + mat.SetTexture(MainTex, smr.sharedMaterial.mainTexture); + mat.SetTexture(AnimMap, animMap); + mat.SetFloat(AnimLen, data.AnimLen); + + var folderPath = CreateFolder(); + AssetDatabase.CreateAsset(mat, Path.Combine(folderPath, $"{data.Name}.mat")); + + return mat; + } + + private void SaveAsPrefab(ref BakedData data) + { + var mat = SaveAsMat(ref data); + + if(mat == null) + { + EditorUtility.DisplayDialog("err", "mat is null!!", "OK"); + return; + } + + var go = new GameObject(); + go.AddComponent().sharedMaterial = mat; + go.AddComponent().sharedMesh = _targetGo.GetComponentInChildren().sharedMesh; + + var folderPath = CreateFolder(); + PrefabUtility.SaveAsPrefabAsset(go, Path.Combine(folderPath, $"{data.Name}.prefab") + .Replace("\\", "/")); + } + + private string CreateFolder() + { + var folderPath = Path.Combine("Assets/" + _path, _subPath); + if (!AssetDatabase.IsValidFolder(folderPath)) + { + AssetDatabase.CreateFolder("Assets/" + _path, _subPath); + } + return folderPath; + } + + #endregion + +} diff --git a/Assets/Plugins/AnimMapBaker/Scripts/Editor/AnimMapBakerWindow.cs.meta b/Assets/Plugins/AnimMapBaker/Scripts/Editor/AnimMapBakerWindow.cs.meta new file mode 100644 index 0000000..e7075c9 --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/Editor/AnimMapBakerWindow.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: b0821312d64995540ae7372c5af049c0 +timeCreated: 1501143281 +licenseType: Free +MonoImporter: + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/AnimMapBaker/Scripts/Editor/MeshDecimatorWindow.cs b/Assets/Plugins/AnimMapBaker/Scripts/Editor/MeshDecimatorWindow.cs new file mode 100644 index 0000000..4e20c08 --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/Editor/MeshDecimatorWindow.cs @@ -0,0 +1,180 @@ +using System.IO; +using UnityEditor; +using UnityEngine; +using UnityMeshSimplifier; + +namespace VATPipeline.EditorTools +{ + /// + /// Step 1 of VAT pipeline: decimate (reduce polygons on) a SkinnedMesh while + /// preserving bone weights / bind poses so the animation still aligns with skeleton. + /// + public class MeshDecimatorWindow : EditorWindow + { + private GameObject _targetGo; + private float _quality = 0.3f; + private bool _preserveBorderEdges = true; + private bool _preserveSurfaceCurvature = false; + private bool _enableSmartLinking = true; + private string _outputFolder = "Assets/Generated/VAT/Mesh"; + private bool _replaceSharedMesh = true; + + [MenuItem("Tools/VAT Pipeline/1. Decimate SkinnedMesh", priority = 1)] + public static void Open() + { + var w = GetWindow("Mesh Decimator"); + w.minSize = new Vector2(420, 360); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("Step 1: Mesh Decimation (UnityMeshSimplifier)", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + _targetGo = (GameObject)EditorGUILayout.ObjectField( + new GUIContent("Target", "GameObject (or its child) carrying a SkinnedMeshRenderer."), + _targetGo, typeof(GameObject), true); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Decimation Settings", EditorStyles.boldLabel); + _quality = EditorGUILayout.Slider( + new GUIContent("Quality", "0.1 = keep ~10% of triangles | 1.0 = no reduction."), + _quality, 0.05f, 1.0f); + _preserveBorderEdges = EditorGUILayout.Toggle( + new GUIContent("Preserve Border Edges", "Keep open-edge silhouettes intact (recommended)."), + _preserveBorderEdges); + _preserveSurfaceCurvature = EditorGUILayout.Toggle( + new GUIContent("Preserve Surface Curvature", "Bias collapse cost on highly-curved areas (slower, higher quality)."), + _preserveSurfaceCurvature); + _enableSmartLinking = EditorGUILayout.Toggle( + new GUIContent("Enable Smart Linking", "Merge duplicate vertices across submeshes before reduction."), + _enableSmartLinking); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Output", EditorStyles.boldLabel); + _outputFolder = EditorGUILayout.TextField("Output Folder", _outputFolder); + _replaceSharedMesh = EditorGUILayout.Toggle( + new GUIContent("Replace SMR.sharedMesh", "After saving, also swap the SkinnedMeshRenderer to use the decimated mesh in this prefab/scene."), + _replaceSharedMesh); + + EditorGUILayout.Space(); + DrawTargetInfo(); + + EditorGUILayout.Space(); + using (new EditorGUI.DisabledScope(_targetGo == null)) + { + if (GUILayout.Button("Decimate", GUILayout.Height(32))) + { + Decimate(); + } + } + + EditorGUILayout.Space(); + EditorGUILayout.HelpBox( + "Pipeline:\n" + + " 1. (here) Decimate SkinnedMesh\n" + + " 2. Bake VAT -> Window/AnimMapBaker\n" + + "After step 2 you get a Mesh + Material + Prefab driven by texture-fetched vertices, fully GPU-instanced.", + MessageType.Info); + } + + private void DrawTargetInfo() + { + if (_targetGo == null) return; + var smr = _targetGo.GetComponentInChildren(); + if (smr == null || smr.sharedMesh == null) + { + EditorGUILayout.HelpBox("No SkinnedMeshRenderer / Mesh found on target.", MessageType.Warning); + return; + } + var src = smr.sharedMesh; + int tris = src.triangles.Length / 3; + int verts = src.vertexCount; + int afterTris = Mathf.Max(1, (int)(tris * _quality)); + EditorGUILayout.HelpBox( + $"Mesh: {src.name}\n" + + $"Verts: {verts} Tris: {tris}\n" + + $"Bones: {src.bindposes.Length}\n" + + $"After decimation (~{_quality:P0}): ~{afterTris} tris", + MessageType.None); + } + + private void Decimate() + { + var smr = _targetGo.GetComponentInChildren(); + if (smr == null || smr.sharedMesh == null) + { + EditorUtility.DisplayDialog("Decimator", "Target has no SkinnedMeshRenderer / sharedMesh.", "OK"); + return; + } + + var src = smr.sharedMesh; + int srcTris = src.triangles.Length / 3; + + try + { + EditorUtility.DisplayProgressBar("Decimating mesh...", $"Reducing {src.name} to {_quality:P0}", 0.2f); + + var simplifier = new MeshSimplifier(); + simplifier.PreserveBorderEdges = _preserveBorderEdges; + simplifier.PreserveSurfaceCurvature = _preserveSurfaceCurvature; + simplifier.EnableSmartLink = _enableSmartLinking; + simplifier.Initialize(src); + + EditorUtility.DisplayProgressBar("Decimating mesh...", "Running simplification...", 0.5f); + simplifier.SimplifyMesh(_quality); + + EditorUtility.DisplayProgressBar("Decimating mesh...", "Building result mesh...", 0.85f); + var dst = simplifier.ToMesh(); + dst.name = $"{src.name}_q{Mathf.RoundToInt(_quality * 100)}"; + dst.bindposes = src.bindposes; + dst.RecalculateBounds(); + + EnsureFolder(_outputFolder); + var path = $"{_outputFolder}/{dst.name}.asset"; + AssetDatabase.CreateAsset(dst, path); + AssetDatabase.SaveAssets(); + + if (_replaceSharedMesh) + { + Undo.RecordObject(smr, "Decimate Mesh"); + smr.sharedMesh = dst; + EditorUtility.SetDirty(smr); + if (PrefabUtility.IsPartOfPrefabInstance(smr)) + { + PrefabUtility.RecordPrefabInstancePropertyModifications(smr); + } + } + + int dstTris = dst.triangles.Length / 3; + Debug.Log($"[MeshDecimator] {src.name}: {srcTris} -> {dstTris} tris ({(float)dstTris/srcTris:P0}) saved at {path}"); + EditorUtility.DisplayDialog("Decimator", + $"OK\n\nSource: {srcTris} tris\nResult: {dstTris} tris\n\nSaved: {path}", + "Continue to Step 2"); + } + catch (System.Exception ex) + { + Debug.LogException(ex); + EditorUtility.DisplayDialog("Decimator failed", ex.Message, "OK"); + } + finally + { + EditorUtility.ClearProgressBar(); + } + } + + private static void EnsureFolder(string path) + { + if (AssetDatabase.IsValidFolder(path)) return; + var parts = path.Split('/'); + var cur = parts[0]; + for (int i = 1; i < parts.Length; i++) + { + var next = $"{cur}/{parts[i]}"; + if (!AssetDatabase.IsValidFolder(next)) + AssetDatabase.CreateFolder(cur, parts[i]); + cur = next; + } + } + } +} diff --git a/Assets/Plugins/AnimMapBaker/Scripts/Editor/MeshDecimatorWindow.cs.meta b/Assets/Plugins/AnimMapBaker/Scripts/Editor/MeshDecimatorWindow.cs.meta new file mode 100644 index 0000000..8dfe61f --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/Editor/MeshDecimatorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 75d526be0cd3b80439918e8073322fb2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/AnimMapBaker/Scripts/Editor/SelectiveVATBakerWindow.cs b/Assets/Plugins/AnimMapBaker/Scripts/Editor/SelectiveVATBakerWindow.cs new file mode 100644 index 0000000..5ca5c4d --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/Editor/SelectiveVATBakerWindow.cs @@ -0,0 +1,694 @@ +/* + * SelectiveVATBakerWindow + * 选择性 VAT 烘焙: + * 1. 让用户从 Animation 组件的全部 clip 里挑几个(默认按名字匹配 Idle/Run/Die) + * 2. 可选:对 SkinnedMeshRenderer.sharedMesh 做减面(UnityMeshSimplifier) + * 3. 调用 AnimMapBaker 烘焙 VAT 贴图 + * 4. 输出:减面 mesh + AnimMap 贴图 + Material + 静态 Prefab + * + * 全程不修改原始 prefab,只在场景实例上做临时改动,try/finally 还原。 + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; +using UnityMeshSimplifier; +using OctoberStudio.VAT; + +namespace OctoberStudio.VATPipeline +{ + public class SelectiveVATBakerWindow : EditorWindow + { + private const string BuiltInShader = "chenjd/BuiltIn/AnimMapShader"; + private const string URPShader = "chenjd/URP/AnimMapShader"; + + private static readonly int MainTexPropID = Shader.PropertyToID("_MainTex"); + private static readonly int AnimMapPropID = Shader.PropertyToID("_AnimMap"); + private static readonly int AnimLenPropID = Shader.PropertyToID("_AnimLen"); + private static readonly int AnimYStartPropID = Shader.PropertyToID("_AnimYStart"); + private static readonly int AnimYSpanPropID = Shader.PropertyToID("_AnimYSpan"); + + // Exact-match clip names (case-insensitive). Add more if needed. + // 同时匹配 Idle / Ready: R02 这类模型无独立 Idle 时,Ready 当作 Idle 使用 + private static readonly string[] DefaultClipNames = { "Idle", "Ready", "Run", "Die" }; + + // Baker 直接把"面相机"的朝向烤入 prefab 内部 Mesh 节点(原来要在运行时再 calibrate) + // - Y=180: 让 mocap 面相机(典型 Maya Y-up FBX,人物原本背对相机) + // - X=-90: Z-up FBX 校正(典型 3ds Max Biped 模型,如 mon9020),让躺着的模型立起来 + private const float BakedMeshFacingY = 180f; + private const float DefaultBakedMeshFacingX = 0f; + + private GameObject _target; + private Vector2 _scroll; + private string _outputRoot = "Assets/_VAT_Baking/Output"; + + private bool _enableDecimation = true; + [Range(0.05f, 1f)] private float _quality = 0.25f; + private bool _preserveBorderEdges = true; + private bool _preserveSurfaceCurvature = false; + // 输出 EXR (16bit half, ZIP 压缩) 替代 .asset。Git 友好、磁盘体积小,VRAM 不变(仍 RGBAHalf) + private bool _useEXR = true; + // Atlas 模式: 所有 clip 拼到 1 张图,生成 1 个 material + 1 个 VATClipDatabase + private bool _atlasMode = true; + + // Z-up FBX 校正: 3ds Max Biped 系怪物(主轴 = Z)烤入 Mesh.localEulerAngles.x = -90 让模型站立 + // GUI 上提供 "Auto Z-up Detect" 按钮,自动判定并设值 + private float _bakedMeshFacingX = DefaultBakedMeshFacingX; + + /// + /// 让 MeshRenderer 走最干净的 GPU Instancing 路径: + /// LightProbe / ReflectionProbe / Shadow / MotionVectors 都关掉,避免每实例不同的 probe 系数 + /// 把 instance 拆出 batch。Survivors 是 2.5D 无场景光照,影子用 sprite,这些都不需要。 + /// + private static void ConfigureBatchFriendlyRenderer(MeshRenderer mr) + { + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + mr.receiveShadows = false; + mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; + mr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; + mr.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion; + mr.allowOcclusionWhenDynamic = false; + } + + private readonly List _allClips = new List(); + private readonly Dictionary _selected = new Dictionary(); + + [MenuItem("Tools/VAT Pipeline/2. Selective VAT Baker", priority = 2)] + public static void ShowWindow() + { + var win = GetWindow("Selective VAT Baker"); + if (win._target == null && Selection.activeGameObject != null) + { + win._target = Selection.activeGameObject; + win.RefreshClipsFromTarget(); + } + } + + [MenuItem("Tools/VAT Pipeline/3. Quick Bake Selection (Idle+Run+Die)", priority = 3)] + public static void QuickBakeSelection() + { + if (Selection.activeGameObject == null) + { + EditorUtility.DisplayDialog("Quick Bake", "No GameObject selected in scene.", "OK"); + return; + } + var win = CreateInstance(); + win._target = Selection.activeGameObject; + win.RefreshClipsFromTarget(); + win.MatchDefaults(); + // 自动检测 Z-up FBX,无需手动设置 X 旋转 + win._bakedMeshFacingX = AutoDetectZUp(win._target) ? -90f : 0f; + try + { + win.BakeAll(); + } + catch (Exception e) + { + Debug.LogException(e); + EditorUtility.DisplayDialog("Quick Bake Failed", e.Message, "OK"); + } + finally + { + EditorUtility.ClearProgressBar(); + DestroyImmediate(win); + } + } + + private void OnSelectionChange() + { + if (_target == null && Selection.activeGameObject != null) + { + _target = Selection.activeGameObject; + RefreshClipsFromTarget(); + Repaint(); + } + } + + private void OnGUI() + { + EditorGUILayout.LabelField("Selective VAT Baker", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "1) 拖入场景中的角色 GameObject(必须含 Animation + SkinnedMeshRenderer)\n" + + "2) 勾选要烘焙的动画(默认按名字匹配 Idle/Run/Die)\n" + + "3) 可选:开启 Mesh 减面(推荐 0.3~0.6)\n" + + "4) 点击 Bake,会输出:减面 mesh + AnimMap 贴图 + Mat + 静态 Prefab", + MessageType.Info); + + EditorGUI.BeginChangeCheck(); + _target = (GameObject)EditorGUILayout.ObjectField("Target GameObject", _target, typeof(GameObject), true); + if (EditorGUI.EndChangeCheck()) + { + RefreshClipsFromTarget(); + } + + if (_target == null) + { + EditorGUILayout.HelpBox("请拖入场景中的目标 GameObject", MessageType.Warning); + return; + } + + var anim = _target.GetComponent(); + var smr = _target.GetComponentInChildren(); + if (anim == null || smr == null) + { + EditorGUILayout.HelpBox("目标必须含 Animation 组件和子物体上的 SkinnedMeshRenderer", MessageType.Error); + return; + } + + EditorGUILayout.Space(); + EditorGUILayout.LabelField($"Mesh: {smr.sharedMesh?.name} Verts={smr.sharedMesh?.vertexCount}", EditorStyles.miniLabel); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Output", EditorStyles.boldLabel); + _outputRoot = EditorGUILayout.TextField("Output Folder", _outputRoot); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Mesh Decimation", EditorStyles.boldLabel); + _enableDecimation = EditorGUILayout.Toggle("Enable Decimation", _enableDecimation); + using (new EditorGUI.DisabledScope(!_enableDecimation)) + { + _quality = EditorGUILayout.Slider("Quality (0.05=粗暴, 1=不减)", _quality, 0.05f, 1f); + _preserveBorderEdges = EditorGUILayout.Toggle("Preserve Border Edges", _preserveBorderEdges); + _preserveSurfaceCurvature = EditorGUILayout.Toggle("Preserve Surface Curvature", _preserveSurfaceCurvature); + } + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Output Format", EditorStyles.boldLabel); + _useEXR = EditorGUILayout.Toggle(new GUIContent("Use EXR (recommended)", "16bit half EXR + ZIP, 体积小 Git 友好, VRAM 不变"), _useEXR); + _atlasMode = EditorGUILayout.Toggle(new GUIContent("Atlas Mode (recommended)", "所有 clip 拼到 1 张图; 输出 1 个 material + 1 个 VATClipDatabase, 运行时切 clip 不切 material"), _atlasMode); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Mesh Facing (烤入 prefab 的 Mesh 节点)", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "Y=180 让模型面相机。X 校正用于解决躺着的模型:\n" + + " - Y-up FBX (Maya/Unity 标准): X = 0\n" + + " - Z-up FBX (3ds Max Biped, 如 R02_S_body01_world / mon9020): X = -90\n" + + "不确定就点 Auto Z-up? 由 bounds 自动判断。", + MessageType.Info); + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField($"Y (face camera) = {BakedMeshFacingY}°", GUILayout.Width(200)); + _bakedMeshFacingX = EditorGUILayout.FloatField(new GUIContent("X 校正", "Z-up FBX (3ds Max Biped) 设 -90; Y-up FBX 设 0"), _bakedMeshFacingX); + } + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.Space(204); + if (GUILayout.Button("Y-up (X=0)", GUILayout.Width(100))) _bakedMeshFacingX = 0f; + if (GUILayout.Button("Z-up (X=-90)", GUILayout.Width(100))) _bakedMeshFacingX = -90f; + if (GUILayout.Button("Auto Detect", GUILayout.Width(100))) _bakedMeshFacingX = AutoDetectZUp(_target) ? -90f : 0f; + } + + EditorGUILayout.Space(); + EditorGUILayout.LabelField($"Animation Clips ({_allClips.Count})", EditorStyles.boldLabel); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Select All")) SetAll(true); + if (GUILayout.Button("Select None")) SetAll(false); + if (GUILayout.Button("Match Idle/Run/Die")) MatchDefaults(); + if (GUILayout.Button("Refresh")) RefreshClipsFromTarget(); + } + + _scroll = EditorGUILayout.BeginScrollView(_scroll, GUILayout.Height(220)); + foreach (var c in _allClips) + { + if (c == null) continue; + if (!_selected.ContainsKey(c)) _selected[c] = false; + _selected[c] = EditorGUILayout.ToggleLeft($"{c.name} ({c.length:F2}s, fps={c.frameRate})", _selected[c]); + } + EditorGUILayout.EndScrollView(); + + var selectedCount = _selected.Count(kv => kv.Value); + EditorGUILayout.LabelField($"Selected: {selectedCount} clips"); + + EditorGUILayout.Space(); + using (new EditorGUI.DisabledScope(selectedCount == 0)) + { + if (GUILayout.Button("Bake VAT (Decimate + Bake + Save Prefab)", GUILayout.Height(40))) + { + try + { + BakeAll(); + } + catch (Exception e) + { + EditorUtility.DisplayDialog("Bake Failed", e.ToString(), "OK"); + Debug.LogException(e); + } + finally + { + EditorUtility.ClearProgressBar(); + } + } + } + } + + private void RefreshClipsFromTarget() + { + _allClips.Clear(); + _selected.Clear(); + if (_target == null) return; + var anim = _target.GetComponent(); + if (anim == null) return; + foreach (AnimationState st in anim) + { + if (st.clip != null && !_allClips.Contains(st.clip)) _allClips.Add(st.clip); + } + MatchDefaults(); + } + + private void SetAll(bool v) + { + foreach (var c in _allClips) _selected[c] = v; + } + + private void MatchDefaults() + { + foreach (var c in _allClips) + { + _selected[c] = DefaultClipNames.Any(k => string.Equals(c.name, k, StringComparison.OrdinalIgnoreCase)); + } + } + + // 通过 SMR.sharedMesh.bounds 判定 FBX 主轴: Z 最大 → Z-up → 需 X=-90 校正 + private static bool AutoDetectZUp(GameObject target) + { + if (target == null) return false; + var smr = target.GetComponentInChildren(); + if (smr == null || smr.sharedMesh == null) return false; + var size = smr.sharedMesh.bounds.size; + float x = Mathf.Abs(size.x), y = Mathf.Abs(size.y), z = Mathf.Abs(size.z); + return z > y && z > x; + } + + // ============= BAKE PIPELINE ============= + + private void BakeAll() + { + var smr = _target.GetComponentInChildren(); + var anim = _target.GetComponent(); + if (smr == null || anim == null) throw new Exception("Missing SMR or Animation"); + + var selectedClips = _selected.Where(kv => kv.Value && kv.Key != null).Select(kv => kv.Key).ToList(); + if (selectedClips.Count == 0) throw new Exception("No clip selected"); + + EditorUtility.DisplayProgressBar("VAT", "Preparing folders...", 0f); + var outDir = Path.Combine(_outputRoot, _target.name).Replace("\\", "/"); + EnsureFolder(outDir); + + // 1) Decimation + Mesh meshForBake = smr.sharedMesh; + Mesh decimatedAsset = null; + if (_enableDecimation) + { + EditorUtility.DisplayProgressBar("VAT", "Decimating mesh...", 0.1f); + decimatedAsset = DecimateMesh(smr.sharedMesh, outDir); + meshForBake = decimatedAsset; + } + + // 2) Backup state then mutate scene instance + var originalSharedMesh = smr.sharedMesh; + var originalClips = new List(); + foreach (AnimationState st in anim) originalClips.Add(st.clip); + + try + { + // Replace SMR mesh in scene instance + if (_enableDecimation) smr.sharedMesh = decimatedAsset; + + // Strip clips down to only selected + foreach (var c in originalClips) + { + if (!selectedClips.Contains(c)) anim.RemoveClip(c); + } + // ensure default clip is one of the selected (avoids null default) + // 优先级: Idle > Ready > 第一个 + if (selectedClips.Count > 0) + { + var defaultClip = selectedClips.FirstOrDefault(c => string.Equals(c.name, "Idle", StringComparison.OrdinalIgnoreCase)) + ?? selectedClips.FirstOrDefault(c => string.Equals(c.name, "Ready", StringComparison.OrdinalIgnoreCase)) + ?? selectedClips[0]; + anim.clip = defaultClip; + } + + // 3) Bake + EditorUtility.DisplayProgressBar("VAT", "Baking AnimMaps...", 0.3f); + var baker = new AnimMapBaker(); + baker.SetAnimData(_target); + var bakedList = baker.Bake(); + if (bakedList == null || bakedList.Count == 0) + throw new Exception("AnimMapBaker returned empty list"); + + // 4) Save assets + var shader = ResolveShader(); + if (shader == null) throw new Exception("Cannot find VAT shader (chenjd/BuiltIn/AnimMapShader or chenjd/URP/AnimMapShader)"); + + Texture mainTex = smr.sharedMaterial != null ? smr.sharedMaterial.mainTexture : null; + + if (_atlasMode) + { + BakeAndSaveAtlas(bakedList, shader, mainTex, meshForBake, outDir); + } + else + { + BakeAndSavePerClip(bakedList, shader, mainTex, meshForBake, outDir); + } + } + finally + { + // Restore scene instance state + smr.sharedMesh = originalSharedMesh; + // Re-add removed clips (AddClip with name overwrites, so safe to add all) + foreach (var c in originalClips) + { + if (anim.GetClip(c.name) == null) anim.AddClip(c, c.name); + } + } + } + + // ============= PER-CLIP MODE (一个 clip 一张图,旧路径) ============= + private void BakeAndSavePerClip(List bakedList, Shader shader, Texture mainTex, Mesh meshForBake, string outDir) + { + var mapClipPairs = new List<(BakedData baked, Texture2D map, Material mat)>(); + float progressStep = 0.6f / bakedList.Count; + float progress = 0.35f; + foreach (var data in bakedList) + { + EditorUtility.DisplayProgressBar("VAT", $"Saving {data.Name}...", progress); + progress += progressStep; + + var fileName = SanitizeFileName(data.Name); + Texture2D animMap = SaveAnimMap(data, outDir, fileName); + + var mat = new Material(shader); + mat.enableInstancing = true; // 默认开 GPU Instancing,VAT 主要目标就是合批 + if (mainTex != null) mat.SetTexture(MainTexPropID, mainTex); + mat.SetTexture(AnimMapPropID, animMap); + mat.SetFloat(AnimLenPropID, data.AnimLen); + mat.SetFloat(AnimYStartPropID, 0f); + mat.SetFloat(AnimYSpanPropID, 1f); + var matPath = Path.Combine(outDir, fileName + ".mat").Replace("\\", "/"); + AssetDatabase.CreateAsset(mat, matPath); + + mapClipPairs.Add((data, animMap, mat)); + } + + EditorUtility.DisplayProgressBar("VAT", "Building prefab...", 0.95f); + // Root + Mesh 两层结构: Root 是中立 transform (供运行时 SetFacing 旋转),Mesh 上烤入 (X=校正, Y=180 面相机) + var prefabGO = new GameObject(_target.name + "_VAT"); + var meshGO = new GameObject("Mesh"); + meshGO.transform.SetParent(prefabGO.transform, false); + meshGO.transform.localEulerAngles = new Vector3(_bakedMeshFacingX, BakedMeshFacingY, 0f); + + var mf = meshGO.AddComponent(); + mf.sharedMesh = meshForBake; + var mr = meshGO.AddComponent(); + ConfigureBatchFriendlyRenderer(mr); + // 默认材质: 优先 Idle,其次 Ready,再次第一个 + var defaultPair = mapClipPairs.FirstOrDefault(p => string.Equals(ExtractClipName(p.baked.Name), "Idle", StringComparison.OrdinalIgnoreCase)); + if (defaultPair.mat == null) defaultPair = mapClipPairs.FirstOrDefault(p => string.Equals(ExtractClipName(p.baked.Name), "Ready", StringComparison.OrdinalIgnoreCase)); + if (defaultPair.mat == null) defaultPair = mapClipPairs[0]; + mr.sharedMaterial = defaultPair.mat; + + var prefabPath = Path.Combine(outDir, _target.name + "_VAT.prefab").Replace("\\", "/"); + PrefabUtility.SaveAsPrefabAsset(prefabGO, prefabPath); + DestroyImmediate(prefabGO); + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + // 不再弹 modal dialog (会阻塞 MCP 主线程通信),改用 console log。 + Debug.Log($"[VAT] Bake Done (Per-Clip): Baked {bakedList.Count} clip(s) for '{_target.name}'\nOutput: {outDir}\nPrefab: {prefabPath}"); + } + + // ============= ATLAS MODE (所有 clip 拼一张图,推荐) ============= + // 把 N 个 BakedData(每个 RGBAHalf 的 w×h_i)沿 Y 轴堆叠到大图 (width = max(w_i), height = Σh_i), + // 生成 1 个 material(_AnimMap 指向 atlas)+ 1 个 VATClipDatabase + 1 个含 VATAnimationController 的 prefab。 + private void BakeAndSaveAtlas(List bakedList, Shader shader, Texture mainTex, Mesh meshForBake, string outDir) + { + // 1) Atlas 尺寸 + int atlasW = bakedList.Max(b => b.AnimMapWidth); + int atlasH = bakedList.Sum(b => b.AnimMapHeight); + const int bytesPerPixel = 8; // RGBAHalf + + EditorUtility.DisplayProgressBar("VAT", $"Composing atlas {atlasW}x{atlasH}...", 0.4f); + + // 2) 拼大图: 直接操作 raw bytes,避免逐像素 SetPixel 的开销 + var atlasBytes = new byte[atlasW * atlasH * bytesPerPixel]; + + var clips = new List(bakedList.Count); + int currentY = 0; + foreach (var data in bakedList) + { + int w = data.AnimMapWidth; + int h = data.AnimMapHeight; + int strideAtlas = atlasW * bytesPerPixel; + int strideClip = w * bytesPerPixel; + + // 按行复制 (大图的 atlasW 可能 ≥ clipW;clipW 不一致时右侧留 0,但实际同一 mesh 下都相等) + for (int row = 0; row < h; row++) + { + Buffer.BlockCopy(data.RawAnimMap, row * strideClip, + atlasBytes, (currentY + row) * strideAtlas, + strideClip); + } + + var entry = new VATClipEntry + { + // bakedData.Name 形如 "R02_S_body01_world_Idle.animMap"; 取最后一个下划线后的 token 当 clip 名 + name = ExtractClipName(data.Name), + yStart = (float)currentY / atlasH, + ySpan = (float)h / atlasH, + length = data.AnimLen, + frameCount = h, + loop = true, + }; + clips.Add(entry); + currentY += h; + } + + // 3) 写 atlas 贴图 + EditorUtility.DisplayProgressBar("VAT", "Writing atlas EXR...", 0.7f); + var atlasFileName = _target.name + "_VAT_Atlas"; + var atlas = SaveAtlasTexture(atlasBytes, atlasW, atlasH, outDir, atlasFileName); + + // 4) 生成 material (引用 atlas) + EditorUtility.DisplayProgressBar("VAT", "Creating material...", 0.82f); + var mat = new Material(shader); + mat.enableInstancing = true; // 默认开 GPU Instancing,VAT 主要目标就是合批 + if (mainTex != null) mat.SetTexture(MainTexPropID, mainTex); + mat.SetTexture(AnimMapPropID, atlas); + // 默认 clip: 优先 Idle,其次 Ready,再次第一个 + var defaultEntry = clips.FirstOrDefault(c => string.Equals(c.name, "Idle", StringComparison.OrdinalIgnoreCase)); + if (string.IsNullOrEmpty(defaultEntry.name)) defaultEntry = clips.FirstOrDefault(c => string.Equals(c.name, "Ready", StringComparison.OrdinalIgnoreCase)); + if (string.IsNullOrEmpty(defaultEntry.name)) defaultEntry = clips[0]; + mat.SetFloat(AnimYStartPropID, defaultEntry.yStart); + mat.SetFloat(AnimYSpanPropID, defaultEntry.ySpan); + mat.SetFloat(AnimLenPropID, defaultEntry.length); + var matPath = Path.Combine(outDir, atlasFileName + ".mat").Replace("\\", "/"); + AssetDatabase.CreateAsset(mat, matPath); + + // 5) 生成 VATClipDatabase + EditorUtility.DisplayProgressBar("VAT", "Creating clip database...", 0.88f); + var db = ScriptableObject.CreateInstance(); + db.atlas = atlas; + db.clips.AddRange(clips); + var dbPath = Path.Combine(outDir, _target.name + "_VATClips.asset").Replace("\\", "/"); + AssetDatabase.CreateAsset(db, dbPath); + + // 6) 生成 prefab (Root + Mesh 两层) + // Root 中立 transform,供运行时 VATAnimationController.SetFacing 旋转 + // Mesh 子节点上烤入 (X=校正, Y=180 面相机),并挂 MeshFilter + MeshRenderer + // VATAnimationController 挂在 Root 上,facingTransform=Root 自己 + EditorUtility.DisplayProgressBar("VAT", "Building prefab...", 0.95f); + var prefabGO = new GameObject(_target.name + "_VAT"); + var meshGO = new GameObject("Mesh"); + meshGO.transform.SetParent(prefabGO.transform, false); + meshGO.transform.localEulerAngles = new Vector3(_bakedMeshFacingX, BakedMeshFacingY, 0f); + + var mf = meshGO.AddComponent(); + mf.sharedMesh = meshForBake; + var mr = meshGO.AddComponent(); + ConfigureBatchFriendlyRenderer(mr); + mr.sharedMaterial = mat; + + var ctrl = prefabGO.AddComponent(); + ctrl.meshRenderer = mr; + ctrl.clipDatabase = db; + // 优先 Idle,无则 Ready,再无则第一个 + ctrl.defaultClip = defaultEntry.name; + ctrl.facingTransform = prefabGO.transform; + // 用户偏好: 站立时播 Ready (R02 类 mocap 模型,Ready 通常比 Idle 更自然)。 + // 仅当 Ready 被烤了才启用,否则 idleClipOverride 留空,Play("Idle") 走默认逻辑。 + var hasReady = clips.Any(c => string.Equals(c.name, "Ready", StringComparison.OrdinalIgnoreCase)); + ctrl.idleClipOverride = hasReady ? "Ready" : string.Empty; + + var prefabPath = Path.Combine(outDir, _target.name + "_VAT.prefab").Replace("\\", "/"); + PrefabUtility.SaveAsPrefabAsset(prefabGO, prefabPath); + DestroyImmediate(prefabGO); + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + // 不再弹 modal dialog (会阻塞 MCP 主线程通信),改用 console log。 + Debug.Log($"[VAT] Atlas Bake Done: {atlasW}x{atlasH} RGBAHalf (~{(atlasBytes.Length / 1024f / 1024f):F2} MB)" + + $"\nClips: {string.Join(", ", clips.Select(c => c.name))}" + + $"\nidleClipOverride={(hasReady ? "Ready" : "")}" + + $"\nOutput: {outDir}\nPrefab: {prefabPath}"); + } + + private static string ExtractClipName(string bakedDataName) + { + // "R02_S_body01_world_Idle.animMap" -> "Idle" + var s = bakedDataName; + int dot = s.LastIndexOf('.'); + if (dot > 0) s = s.Substring(0, dot); + int us = s.LastIndexOf('_'); + return us >= 0 ? s.Substring(us + 1) : s; + } + + private Texture2D SaveAtlasTexture(byte[] raw, int w, int h, string outDir, string fileName) + { + if (_useEXR) + { + var path = Path.Combine(outDir, fileName + ".exr").Replace("\\", "/"); + var tmp = new Texture2D(w, h, TextureFormat.RGBAHalf, false); + tmp.LoadRawTextureData(raw); + tmp.Apply(false, false); + var bytes = tmp.EncodeToEXR(Texture2D.EXRFlags.CompressZIP); + DestroyImmediate(tmp); + File.WriteAllBytes(Path.GetFullPath(path), bytes); + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); + ConfigureVATTextureImporter(path); + return AssetDatabase.LoadAssetAtPath(path); + } + else + { + var path = Path.Combine(outDir, fileName + ".asset").Replace("\\", "/"); + var tex = new Texture2D(w, h, TextureFormat.RGBAHalf, false); + tex.LoadRawTextureData(raw); + tex.Apply(false, false); + tex.filterMode = FilterMode.Point; + tex.wrapMode = TextureWrapMode.Clamp; + AssetDatabase.CreateAsset(tex, path); + return tex; + } + } + + private Mesh DecimateMesh(Mesh src, string outDir) + { + var simplifier = new MeshSimplifier(); + simplifier.PreserveBorderEdges = _preserveBorderEdges; + simplifier.PreserveSurfaceCurvature = _preserveSurfaceCurvature; + simplifier.Initialize(src); + simplifier.SimplifyMesh(_quality); + var dst = simplifier.ToMesh(); + dst.name = src.name + "_dec" + Mathf.RoundToInt(_quality * 100); + dst.RecalculateBounds(); + var assetPath = Path.Combine(outDir, SanitizeFileName(dst.name) + ".asset").Replace("\\", "/"); + AssetDatabase.CreateAsset(dst, assetPath); + return dst; + } + + private static Shader ResolveShader() + { + var useURP = GraphicsSettings.renderPipelineAsset != null; + return Shader.Find(useURP ? URPShader : BuiltInShader); + } + + private static void EnsureFolder(string assetPath) + { + assetPath = assetPath.Replace("\\", "/"); + if (AssetDatabase.IsValidFolder(assetPath)) return; + var parts = assetPath.Split('/'); + string current = parts[0]; + for (int i = 1; i < parts.Length; i++) + { + var next = current + "/" + parts[i]; + if (!AssetDatabase.IsValidFolder(next)) + { + AssetDatabase.CreateFolder(current, parts[i]); + } + current = next; + } + } + + private static string SanitizeFileName(string name) + { + foreach (var c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_'); + return name; + } + + // 把烘焙数据写到磁盘并返回 import 后的 Texture2D 引用。 + // EXR 路径: 写入 .exr,触发 TextureImporter (RGBAHalf / Point / Uncompressed / Non-readable / no mipmaps) + // Asset 路径: 直接 CreateAsset Texture2D (原方案,兼容老工程) + private Texture2D SaveAnimMap(BakedData data, string outDir, string fileName) + { + if (_useEXR) + { + var path = Path.Combine(outDir, fileName + ".exr").Replace("\\", "/"); + var tmp = new Texture2D(data.AnimMapWidth, data.AnimMapHeight, TextureFormat.RGBAHalf, false); + tmp.LoadRawTextureData(data.RawAnimMap); + tmp.Apply(false, false); + + // 16bit half + ZIP 压缩 (~50% 磁盘体积)。OutputAsFloat 不加 = 16bit。 + var bytes = tmp.EncodeToEXR(Texture2D.EXRFlags.CompressZIP); + DestroyImmediate(tmp); + + var sysPath = Path.GetFullPath(path); + File.WriteAllBytes(sysPath, bytes); + + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); + ConfigureVATTextureImporter(path); + + return AssetDatabase.LoadAssetAtPath(path); + } + else + { + var path = Path.Combine(outDir, fileName + ".asset").Replace("\\", "/"); + var animMap = new Texture2D(data.AnimMapWidth, data.AnimMapHeight, TextureFormat.RGBAHalf, false); + animMap.LoadRawTextureData(data.RawAnimMap); + animMap.Apply(false, false); + AssetDatabase.CreateAsset(animMap, path); + return animMap; + } + } + + // VAT 贴图的 importer 配置: 不能 sRGB / mipmap / NPOT 缩放 / 压缩 / 滤波,运行时不需要 readable + private static void ConfigureVATTextureImporter(string assetPath) + { + var ti = AssetImporter.GetAtPath(assetPath) as TextureImporter; + if (ti == null) return; + ti.textureType = TextureImporterType.Default; + ti.sRGBTexture = false; + ti.alphaSource = TextureImporterAlphaSource.FromInput; + ti.alphaIsTransparency = false; + ti.mipmapEnabled = false; + ti.isReadable = false; + ti.filterMode = FilterMode.Point; + ti.wrapMode = TextureWrapMode.Clamp; + ti.npotScale = TextureImporterNPOTScale.None; + + // 默认平台 + var def = ti.GetDefaultPlatformTextureSettings(); + def.textureCompression = TextureImporterCompression.Uncompressed; + def.format = TextureImporterFormat.RGBAHalf; + ti.SetPlatformTextureSettings(def); + + // 显式覆盖常见平台,避免移动端 import 时被默认压缩 (ASTC/ETC 不支持 RGBAHalf) + foreach (var platName in new[] { "Standalone", "Android", "iPhone", "WebGL" }) + { + var ps = ti.GetPlatformTextureSettings(platName); + ps.overridden = true; + ps.textureCompression = TextureImporterCompression.Uncompressed; + ps.format = TextureImporterFormat.RGBAHalf; + ti.SetPlatformTextureSettings(ps); + } + + ti.SaveAndReimport(); + } + } +} diff --git a/Assets/Plugins/AnimMapBaker/Scripts/Editor/SelectiveVATBakerWindow.cs.meta b/Assets/Plugins/AnimMapBaker/Scripts/Editor/SelectiveVATBakerWindow.cs.meta new file mode 100644 index 0000000..7ba7bfe --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/Editor/SelectiveVATBakerWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 371a94149b1651c4ca497ac788def754 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/AnimMapBaker/Scripts/VATAnimationController.cs b/Assets/Plugins/AnimMapBaker/Scripts/VATAnimationController.cs new file mode 100644 index 0000000..e665c54 --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/VATAnimationController.cs @@ -0,0 +1,154 @@ +using System; +using UnityEngine; + +namespace OctoberStudio.VAT +{ + /// + /// 用 MaterialPropertyBlock 控制 VAT Atlas 上的当前 clip 与播放时间偏移。 + /// 不切换 material → 不破坏 SRP Batcher / GPU Instancing,适合大量同种角色同屏。 + /// + /// 使用方式: + /// 1. 挂在含 MeshRenderer 的 GameObject 上 (或子物体 View 上,把 meshRenderer 指过来) + /// 2. 拖入 clipDatabase + /// 3. 调 Play("Idle" / "Run" / "Die") + /// + [DisallowMultipleComponent] + public class VATAnimationController : MonoBehaviour + { + private static readonly int AnimYStartID = Shader.PropertyToID("_AnimYStart"); + private static readonly int AnimYSpanID = Shader.PropertyToID("_AnimYSpan"); + private static readonly int AnimLenID = Shader.PropertyToID("_AnimLen"); + private static readonly int AnimTimeOffsetID = Shader.PropertyToID("_AnimTimeOffset"); + private static readonly int AnimLoopID = Shader.PropertyToID("_AnimLoop"); + + [Tooltip("要控制的 MeshRenderer。留空则自动在自身/子物体获取。")] + public MeshRenderer meshRenderer; + + [Tooltip("烘焙时生成的 VAT 元数据资产。")] + public VATClipDatabase clipDatabase; + + [Tooltip("启动时自动播放的 clip 名(找不到则不播)。Play 时如果找不到 'Idle' 会自动 fallback 到 'Ready'。")] + public string defaultClip = "Idle"; + + [Tooltip("把 Play(\"Idle\") 重定向到此 clip 名。空=不重定向。例:模型 Idle 不好看,设为 \"Ready\" 让站立时播 Ready。")] + public string idleClipOverride = ""; + + [Header("Facing (左右朝向)")] + [Tooltip("用来旋转的 transform (通常就是 VAT root 自身)。SetFacing(±1) 会改写它的 localEulerAngles.y。")] + public Transform facingTransform; + [Tooltip("朝右 (sign>=0) 时 facingTransform.localEulerAngles.y 的目标值。对称 -15 让走右时人物也微微侧身。")] + public float facingRightY = -15f; + [Tooltip("朝左 (sign<0) 时 facingTransform.localEulerAngles.y 的目标值。15 = 微旋。180 = 完全镜像(突兀)。")] + public float facingLeftY = 15f; + + // Idle 找不到时尝试播这些 clip(按顺序) + private static readonly string[] IdleFallbacks = { "Ready", "Stand", "Wait" }; + + private MaterialPropertyBlock _mpb; + private VATClipEntry _current; + private bool _hasCurrent; + // 0 = 未初始化(强制下一次 SetFacing 一定执行,即使 sign 跟默认 +1 重合) + private int _currentFacingSign = 0; + + /// 当前播放的 clip 名(可能为空字符串) + public string CurrentClip => _hasCurrent ? _current.name : string.Empty; + /// 当前左右朝向(+1 / -1) + public int CurrentFacingSign => _currentFacingSign; + + protected virtual void Awake() + { + if (meshRenderer == null) meshRenderer = GetComponentInChildren(true); + if (facingTransform == null) facingTransform = transform; + _mpb = new MaterialPropertyBlock(); + } + + protected virtual void OnEnable() + { + // 启动时强制把朝向应用到 facingTransform (避免初始 Y=0,走右后仍 Y=0 看起来"右边没旋转"的 bug) + SetFacing(1); + if (!string.IsNullOrEmpty(defaultClip)) Play(defaultClip); + } + + /// + /// 切到指定 clip。如果 name 不存在,会按 fallback 链尝试(Idle→Ready→Stand→Wait)。 + /// + public bool Play(string clipName, bool loop = true) + { + if (meshRenderer == null || clipDatabase == null) return false; + + // Idle 重定向: 允许把 Play("Idle") 实际播放 idleClipOverride 指向的 clip + if (!string.IsNullOrEmpty(idleClipOverride) && string.Equals(clipName, "Idle", StringComparison.OrdinalIgnoreCase)) + { + clipName = idleClipOverride; + } + + // 同一个 clip 且循环标记一致 → 不重置时间,避免 SetSpeed 每帧重置导致动画卡在第 0 帧 + if (_hasCurrent && string.Equals(_current.name, clipName, StringComparison.OrdinalIgnoreCase) && _current.loop == loop) + return true; + + if (!ResolveClip(clipName, out var entry)) + { + Debug.LogWarning($"[VATAnimationController] Clip '{clipName}' not found in {clipDatabase.name}"); + return false; + } + + entry.loop = loop; + _current = entry; + _hasCurrent = true; + + // 用 propertyBlock,不修改 sharedMaterial。 + meshRenderer.GetPropertyBlock(_mpb); + _mpb.SetFloat(AnimYStartID, entry.yStart); + _mpb.SetFloat(AnimYSpanID, entry.ySpan); + _mpb.SetFloat(AnimLenID, entry.length); + _mpb.SetFloat(AnimTimeOffsetID, Time.timeSinceLevelLoad); // 从"现在"开始播 + _mpb.SetFloat(AnimLoopID, loop ? 1f : 0f); + meshRenderer.SetPropertyBlock(_mpb); + return true; + } + + /// + /// 查找 clip,Idle 找不到时按 fallback 链 (Ready/Stand/Wait) 继续找。 + /// + private bool ResolveClip(string clipName, out VATClipEntry entry) + { + if (clipDatabase.TryFind(clipName, out entry)) return true; + // 只有请求 Idle 时才走 fallback,其他 clip 找不到就老实报错 + if (string.Equals(clipName, "Idle", StringComparison.OrdinalIgnoreCase)) + { + foreach (var alt in IdleFallbacks) + { + if (clipDatabase.TryFind(alt, out entry)) return true; + } + } + return false; + } + + /// + /// 设置左右朝向。sign >= 0 → facingRightY,sign < 0 → facingLeftY。 + /// 同一朝向重复调用是 no-op。 + /// + public void SetFacing(int sign) + { + int s = sign >= 0 ? 1 : -1; + // _currentFacingSign == 0 表示尚未初始化,必须执行一次(让初始 facingTransform.Y 从 0 切到 facingRightY) + if (s == _currentFacingSign && _currentFacingSign != 0) return; + _currentFacingSign = s; + if (facingTransform == null) return; + var e = facingTransform.localEulerAngles; + e.y = s > 0 ? facingRightY : facingLeftY; + facingTransform.localEulerAngles = e; + } + + /// 停止 (实际是 freeze 在最后一帧)。 + public void Stop() + { + if (meshRenderer == null) return; + meshRenderer.GetPropertyBlock(_mpb); + _mpb.SetFloat(AnimLoopID, 0f); + // 让 t 趋于 >=1: timeOffset 设很久以前即可 + _mpb.SetFloat(AnimTimeOffsetID, -1e6f); + meshRenderer.SetPropertyBlock(_mpb); + } + } +} diff --git a/Assets/Plugins/AnimMapBaker/Scripts/VATAnimationController.cs.meta b/Assets/Plugins/AnimMapBaker/Scripts/VATAnimationController.cs.meta new file mode 100644 index 0000000..e33f46a --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/VATAnimationController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7514a420a764f5f4aa89a81684157108 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/AnimMapBaker/Scripts/VATClipDatabase.cs b/Assets/Plugins/AnimMapBaker/Scripts/VATClipDatabase.cs new file mode 100644 index 0000000..7e0189d --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/VATClipDatabase.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace OctoberStudio.VAT +{ + /// + /// 一个 VAT Atlas 上每段 clip 的元数据。 + /// yStart/ySpan 是归一化到 [0,1] 的纵向区段 (= clipFrames / atlasHeight), + /// shader 通过它们计算 atlas 采样的 V 坐标。 + /// + [Serializable] + public struct VATClipEntry + { + public string name; + public float yStart; // [0,1] + public float ySpan; // [0,1] + public float length; // seconds + public int frameCount; + public bool loop; + } + + /// + /// 一个角色一份。Bake atlas 时由工具自动生成,运行时由 VATAnimationController 引用。 + /// + [CreateAssetMenu(menuName = "OctoberStudio/VAT/VAT Clip Database", fileName = "VATClipDatabase")] + public class VATClipDatabase : ScriptableObject + { + [Tooltip("整张 atlas 贴图。所有 clip 段沿 Y 轴堆叠。")] + public Texture2D atlas; + + [Tooltip("所有动画段的元数据。")] + public List clips = new List(); + + public bool TryFind(string name, out VATClipEntry entry) + { + for (int i = 0; i < clips.Count; i++) + { + if (string.Equals(clips[i].name, name, StringComparison.OrdinalIgnoreCase)) + { + entry = clips[i]; + return true; + } + } + entry = default; + return false; + } + + public int IndexOf(string name) + { + for (int i = 0; i < clips.Count; i++) + { + if (string.Equals(clips[i].name, name, StringComparison.OrdinalIgnoreCase)) + return i; + } + return -1; + } + } +} diff --git a/Assets/Plugins/AnimMapBaker/Scripts/VATClipDatabase.cs.meta b/Assets/Plugins/AnimMapBaker/Scripts/VATClipDatabase.cs.meta new file mode 100644 index 0000000..854a154 --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Scripts/VATClipDatabase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 46705c51f321a5d47883154cf9246185 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/AnimMapBaker/Shaders.meta b/Assets/Plugins/AnimMapBaker/Shaders.meta new file mode 100644 index 0000000..32a038e --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Shaders.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 11e8177631838ab488a6f6ce56410b02 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/AnimMapBaker/Shaders/AnimMapShader.shader b/Assets/Plugins/AnimMapBaker/Shaders/AnimMapShader.shader new file mode 100644 index 0000000..2b33079 --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Shaders/AnimMapShader.shader @@ -0,0 +1,104 @@ +/* +Created by jiadong chen +https://jiadong-chen.medium.com/ +*/ + +// Atlas VAT shader. +// 支持 1 张大图存多个 clip,clip 段沿 Y 轴堆叠。 +// 通过 MaterialPropertyBlock 设置 _AnimYStart / _AnimYSpan / _AnimLen / _AnimTimeOffset 可切换 clip, +// 无需切换 material → 不破坏 SRP Batcher / GPU Instancing。 +// 单 clip 模式向后兼容: _AnimYStart=0, _AnimYSpan=1, 行为与原 shader 一致。 +Shader "chenjd/BuiltIn/AnimMapShader" +{ + Properties + { + _MainTex ("Texture", 2D) = "white" {} + _AnimMap ("AnimMap", 2D) ="white" {} + _AnimLen("Anim Length", Float) = 1 + _AnimYStart("Anim Y Start [0,1]", Float) = 0 + _AnimYSpan ("Anim Y Span [0,1]", Float) = 1 + _AnimTimeOffset("Anim Time Offset", Float) = 0 + [Toggle] _AnimLoop("Loop Anim", Float) = 1 + } + + SubShader + { + Tags { "RenderType" = "Opaque" } + Cull off + LOD 100 + + Pass + { + CGPROGRAM + #pragma vertex vert + #pragma fragment frag + //开启gpu instancing + #pragma multi_compile_instancing + + #include "UnityCG.cginc" + + struct appdata + { + float2 uv : TEXCOORD0; + float4 pos : POSITION; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + struct v2f + { + float2 uv : TEXCOORD0; + float4 vertex : SV_POSITION; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + sampler2D _MainTex; + float4 _MainTex_ST; + + sampler2D _AnimMap; + float4 _AnimMap_TexelSize; // x == 1/width, y == 1/height + + // Per-instance friendly props (set via MaterialPropertyBlock to keep SRP Batcher / instancing) + UNITY_INSTANCING_BUFFER_START(Props) + UNITY_DEFINE_INSTANCED_PROP(float, _AnimLen) + UNITY_DEFINE_INSTANCED_PROP(float, _AnimYStart) + UNITY_DEFINE_INSTANCED_PROP(float, _AnimYSpan) + UNITY_DEFINE_INSTANCED_PROP(float, _AnimTimeOffset) + UNITY_DEFINE_INSTANCED_PROP(float, _AnimLoop) + UNITY_INSTANCING_BUFFER_END(Props) + + v2f vert (appdata v, uint vid : SV_VertexID) + { + UNITY_SETUP_INSTANCE_ID(v); + + float animLen = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimLen); + float yStart = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimYStart); + float ySpan = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimYSpan); + float timeOff = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimTimeOffset); + float loopFlag = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimLoop); + + // 时间 → 段内归一化 t [0,1] + float playTime = _Time.y - timeOff; + float t = playTime / max(animLen, 0.0001); + // 循环 or 停在最后一帧 + t = lerp(saturate(t), frac(t), step(0.5, loopFlag)); + + float animMap_x = (vid + 0.5) * _AnimMap_TexelSize.x; + float animMap_y = yStart + t * ySpan; + + float4 pos = tex2Dlod(_AnimMap, float4(animMap_x, animMap_y, 0, 0)); + + v2f o; + o.vertex = UnityObjectToClipPos(pos); + o.uv = TRANSFORM_TEX(v.uv, _MainTex); + return o; + } + + fixed4 frag (v2f i) : SV_Target + { + fixed4 col = tex2D(_MainTex, i.uv); + return col; + } + ENDCG + } + } +} diff --git a/Assets/Plugins/AnimMapBaker/Shaders/AnimMapShader.shader.meta b/Assets/Plugins/AnimMapBaker/Shaders/AnimMapShader.shader.meta new file mode 100644 index 0000000..c58d2d1 --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Shaders/AnimMapShader.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: a29e7330fcdae8849bd07e990b12f5de +timeCreated: 1501152148 +licenseType: Free +ShaderImporter: + defaultTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/AnimMapBaker/Shaders/AnimMapWithShadowShader.shader b/Assets/Plugins/AnimMapBaker/Shaders/AnimMapWithShadowShader.shader new file mode 100644 index 0000000..6f64f5c --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Shaders/AnimMapWithShadowShader.shader @@ -0,0 +1,115 @@ +/* +Created by jiadong chen +https://jiadong-chen.medium.com/ +*/ + +Shader "chenjd/BuiltIn/AnimMapWithShadowShader" +{ + Properties + { + _MainTex("Texture", 2D) = "white" {} + + [Header(AnimMap)] + _AnimMap("AnimMap", 2D) = "white" {} + _AnimLen("Anim Length", Float) = 0 + } + + CGINCLUDE + + #include "UnityCG.cginc" + + sampler2D _MainTex; + float4 _MainTex_ST; + + struct appdata + { + float4 vertex : POSITION; + float2 uv : TEXCOORD0; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + struct v2f + { + float2 uv : TEXCOORD0; + float4 vertex : SV_POSITION; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + fixed4 frag(v2f i) : SV_Target + { + fixed4 col = tex2D(_MainTex, i.uv); + return col; + } + + struct v2fShadow { + V2F_SHADOW_CASTER; + UNITY_VERTEX_OUTPUT_STEREO + }; + + float4 fragShadow(v2fShadow i) : SV_Target + { + SHADOW_CASTER_FRAGMENT(i) + } + + sampler2D _AnimMap; + float4 _AnimMap_TexelSize;//x == 1/width + + float _AnimLen; + + + v2f vert(appdata v, uint vid : SV_VertexID) + { + UNITY_SETUP_INSTANCE_ID(v); + + float f = _Time.y / _AnimLen; + + fmod(f, 1.0); + + float animMap_x = (vid + 0.5) * _AnimMap_TexelSize.x; + float animMap_y = f; + + float4 pos = tex2Dlod(_AnimMap, float4(animMap_x, animMap_y, 0, 0)); + + v2f o; + o.uv = TRANSFORM_TEX(v.uv, _MainTex); + o.vertex = UnityObjectToClipPos(pos); + return o; + } + + ENDCG + + + + SubShader + { + Pass + { + Tags { "RenderType" = "Opaque" "Queue" = "Geometry" } + Cull off + CGPROGRAM + #pragma vertex vert + #pragma fragment frag + //开启gpu instancing + #pragma multi_compile_instancing + + #include "UnityCG.cginc" + + + ENDCG + } + + Pass + { + Name "ShadowCaster" + Tags { "LightMode" = "ShadowCaster" } + + CGPROGRAM + #pragma vertex vert + #pragma fragment fragShadow + #pragma target 2.0 + #pragma multi_compile_shadowcaster + ENDCG + } + } +} + diff --git a/Assets/Plugins/AnimMapBaker/Shaders/AnimMapWithShadowShader.shader.meta b/Assets/Plugins/AnimMapBaker/Shaders/AnimMapWithShadowShader.shader.meta new file mode 100644 index 0000000..bdbc515 --- /dev/null +++ b/Assets/Plugins/AnimMapBaker/Shaders/AnimMapWithShadowShader.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 4dfd79683771c3f49a2a3fcf41ca09df +timeCreated: 1501152148 +licenseType: Free +ShaderImporter: + defaultTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7424c4 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# UnityVATTool + +一套 Unity 编辑器工具集,用于把 **SkinnedMesh + 动画** 烘焙成 **VAT(顶点动画贴图)** 静态网格,并附带 **减面(Mesh Decimation)** 流程,最终输出一个仅靠 Shader 采样贴图来播放动画的低开销 Prefab,可在大规模同屏(小怪海、群体单位等)场景下显著降低 SkinnedMeshRenderer/Animator 的 CPU 消耗。 + +--- + +## 一、目录结构 + +``` +Assets/ +├── Plugins/ +│ └── AnimMapBaker/ # 工具核心 +│ ├── Scripts/ +│ │ ├── AnimMapBaker.cs # 烘焙核心(采样动画 → 顶点位置写入 EXR) +│ │ ├── VATAnimationController.cs # 运行时 VAT 动画控制器(驱动 Shader) +│ │ ├── VATClipDatabase.cs # 烘焙生成的 Clip 元数据(起止帧 / 时长) +│ │ └── Editor/ +│ │ ├── AnimMapBakerWindow.cs # 原始单文件烘焙窗口 +│ │ ├── MeshDecimatorWindow.cs # 步骤 1:减面工具(UnityMeshSimplifier) +│ │ └── SelectiveVATBakerWindow.cs # 步骤 2:选择性烘焙窗口(减面 + 多 clip + VAT) +│ ├── Shaders/ +│ │ ├── AnimMapShader.shader # 内置管线(BuiltIn)VAT Shader +│ │ └── AnimMapWithShadowShader.shader # 带阴影版本 +│ └── Documentation.pdf # 原始作者文档 +└── Common/ + └── Scripts/Editor/ # 上层批量入口(Tools/OctoberStudio/VAT/...) + ├── MonsterVATBatchBaker.cs # 一键批量烘焙(按 Resources/Role/Monster 全量) + ├── EnemyVATConverter.cs # Enemy 类型 prefab → VAT Variant + ├── CharacterVATConverter.cs # Character 类型 prefab → VAT Variant + ├── MonsterOrientationCheck.cs # FBX 朝向(站立/Z-up)一键体检 + ├── OrientationCalibrator.cs # 朝向 + 缩放 + 偏移交互式校准 + └── ImportMonsterPackageAndClean.cs # 自动导入 unitypackage 并清理夹带文件 +``` + +--- + +## 二、依赖 + +| 依赖 | 版本 / 来源 | 必需性 | +|---|---|---| +| Unity | 2022 LTS 及以上(建议 2022.3+) | 必需 | +| [UnityMeshSimplifier (Whinarn)](https://github.com/Whinarn/UnityMeshSimplifier) | 任意稳定版 | **必需**(减面用) | +| 渲染管线 | BuiltIn / URP 均可,Shader 分别提供 | 必需 | + +**安装 UnityMeshSimplifier**(任选其一): + +- 通过 UPM 直接拉 git:在 `Packages/manifest.json` 加: + ```json + "com.whinarn.unitymeshsimplifier": "https://github.com/Whinarn/UnityMeshSimplifier.git" + ``` +- 或本地克隆后 `file:` 引用: + ```json + "com.whinarn.unitymeshsimplifier": "file:../_thirdparty/UnityMeshSimplifier" + ``` + +--- + +## 三、典型使用流程 + +### 方案 A:单只手动 —— 一步到位(推荐) + +1. 菜单:`Tools/VAT Pipeline/1. Decimate SkinnedMesh` + - 选择带 `SkinnedMeshRenderer` 的 Prefab/场景对象 + - 设置 `Quality`(保留三角形比例,0.3 = 留 30%) + - 可选保留边缘、表面曲率等 + - 输出:减面后的 `.asset` Mesh,并可选替换原 `sharedMesh` +2. 菜单:`Tools/VAT Pipeline/2. Bake Selected VAT`(`SelectiveVATBakerWindow`) + - 选择对象上的 `Animation` 组件 + - 勾选要烘焙的 `clip`(默认匹配 `Idle / Ready / Run / Die`) + - 选择目标 Shader(BuiltIn / URP) + - 输出:`*_VAT.prefab` + `*_VATClips.asset` + `*_VAT_Atlas.exr` + Material + +### 方案 B:批量小怪 —— 一键烤全部 + +- 把美术 prefab(含 `Animation` + `SkinnedMeshRenderer`)整理到 + `Assets/Resources/Role/Monster/*.prefab` +- 菜单:`Tools/OctoberStudio/VAT/Bake ALL Monsters in Resources` +- 产物默认输出到:`Assets/_VAT_Baking/Output//` + +### 方案 C:从原始 FBX 批量烤(带 @Run.FBX 附加动画文件) + +- 把美术 `.FBX` 放在 `Assets/_External/Monsters//` 下 +- 同目录放一份 `@Run.FBX`(Legacy 动画) +- 菜单:`Tools/OctoberStudio/VAT/Bake Selected Monsters (Run-only)` + +### 辅助工具 + +- `Tools/OctoberStudio/VAT/Orientation Calibrator`:交互式调整朝向/缩放/偏移并 apply 回 prefab +- `Tools/OctoberStudio/VAT/Monster Orientation Check`:批量体检 `_External/Monsters` 与 `_External/Bosses` 下所有 FBX 的站立朝向,输出报告 + +--- + +## 四、运行时使用 + +烘焙产物 `*_VAT.prefab` 内已挂好 `VATAnimationController`,通过它切换/播放 Clip: + +```csharp +var ctrl = instance.GetComponent(); +ctrl.Play("Run"); // 按 Clip 名播放 +ctrl.PlayByIndex(0); // 或按索引 +ctrl.SetSpeed(1.5f); +``` + +实际播放靠 Shader 在顶点阶段从 `_AnimMap` 采样 → 不再走 `SkinnedMeshRenderer` 蒙皮,CPU 几乎为零。 + +--- + +## 五、注意事项与坑点 + +1. **必须 Legacy 动画**:批量烤 FBX 那条流程要求 FBX 的 `animationType = 1 (Legacy)`,不是 Generic / Humanoid。Mecanim 流程请改用 `SelectiveVATBakerWindow` + 手动 prefab。 +2. **顶点数 × 帧数 ≤ 单贴图限制**:贴图宽 = 顶点数,高 = 帧数总和。顶点过多请先减面。 +3. **朝向**:源 FBX 若是 Z-up,会以 X=-90° 旋转烘入 Mesh 节点;如不符预期请用 `OrientationCalibrator`。 +4. **URP 阴影**:`AnimMapShader` 默认无阴影投射,需要阴影请用 `AnimMapWithShadowShader`,并确认 Project Settings 中阴影距离/级联。 +5. **采样精度**:动画位置编码为 EXR HALF,位移过大(>1000 单位)会出现可见抖动,模型请在烘焙前居中到原点附近。 + +--- + +## 六、菜单速查 + +| 菜单 | 入口脚本 | 功能 | +|---|---|---| +| `Tools/VAT Pipeline/1. Decimate SkinnedMesh` | `MeshDecimatorWindow` | 减面 | +| `Tools/VAT Pipeline/2. Bake Selected VAT` | `SelectiveVATBakerWindow` | 选择性 VAT 烘焙 | +| `Tools/OctoberStudio/VAT/Bake mon1001 (Test Sample)` | `MonsterVATBatchBaker` | 测试单只 | +| `Tools/OctoberStudio/VAT/Bake ALL Monsters in Resources` | `MonsterVATBatchBaker` | 批量 Resources 怪物 | +| `Tools/OctoberStudio/VAT/Bake Selected Monsters (Run-only)` | `MonsterVATBatchBaker` | 选中 FBX 烘焙 | +| `Tools/OctoberStudio/VAT/Import Monster Package Auto Clean` | `ImportMonsterPackageAndClean` | 导入并清理 monster.unitypackage | +| `Tools/OctoberStudio/VAT/Import Role Package Auto Clean` | `ImportMonsterPackageAndClean` | 导入并清理 role.unitypackage | +| `Tools/OctoberStudio/VAT/Orientation Calibrator` | `OrientationCalibrator` | 朝向校准 | +| `Tools/OctoberStudio/VAT/Monster Orientation Check` | `MonsterOrientationCheck` | 批量朝向体检 | + +--- + +## 七、致谢 + +- VAT 烘焙核心来源:[chenjd / AnimMap-Baker](https://github.com/chenjd/AnimMap-Baker) 的思路与 Shader 框架 +- 减面:[Whinarn / UnityMeshSimplifier](https://github.com/Whinarn/UnityMeshSimplifier)