feat: initial VAT pipeline (decimation + vertex animation texture baking) toolkit

This commit is contained in:
cursor 2026-05-28 19:35:09 +08:00
commit aeb24d2694
35 changed files with 3576 additions and 0 deletions

40
.gitignore vendored Normal file
View File

@ -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

View File

@ -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);
}
/// <summary>
/// 直接在 prefab asset 上做朝向 + 缩放 + pivot 校正,无需中转场景。
/// </summary>
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<MeshFilter>();
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<CharactersDatabase>(DatabasePath);
if (db == null) { Debug.LogError("Database not found"); return; }
var template = AssetDatabase.LoadAssetAtPath<GameObject>(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<GameObject>(TemplatePath);
var vatSrc = AssetDatabase.LoadAssetAtPath<GameObject>(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<RendererAdapter>();
{
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<CharacterBehavior>();
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<CharactersDatabase>(DatabasePath);
if (db == null) { Debug.LogError("Database not found"); return false; }
var newPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f91ea5c3ce2e6a54190cf8a9ab2fb004
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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 <Name>_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<string, string> Stage1EnemyPrefabs = new Dictionary<string, string>
{
{ "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<GameObject>(srcPath);
if (src == null)
{
Debug.LogWarning($"[EnemyVATConverter] 源 prefab 不存在: {srcPath}");
missing++;
continue;
}
var vatMonPath = $"{VATBakedDir}/{map.monName}/{map.monName}_VAT.prefab";
var vatMon = AssetDatabase.LoadAssetAtPath<GameObject>(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<EnemiesDatabase>(SourceDBPath);
if (src == null)
{
Debug.LogWarning($"[EnemyVATConverter] 源 DB 不存在: {SourceDBPath}");
return;
}
if (AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(OutputDBPath) != null)
AssetDatabase.DeleteAsset(OutputDBPath);
AssetDatabase.CopyAsset(SourceDBPath, OutputDBPath);
AssetDatabase.Refresh();
var dbVAT = AssetDatabase.LoadAssetAtPath<EnemiesDatabase>(OutputDBPath);
if (dbVAT == null) throw new Exception("无法加载复制后的 DB");
// 构建 enumName → _VAT prefab 路径 的快查表
var nameToPath = new Dictionary<string, string>();
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<GameObject>(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<GameObject>(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<GameObject>(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<EnemyBehavior>();
if (behavior == null) throw new Exception("EnemyBehavior 组件不存在");
// 加 EnemyVATAttachment 组件
var attach = instance.GetComponent<EnemyVATAttachment>();
if (attach == null) attach = instance.AddComponent<EnemyVATAttachment>();
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<EnemiesDatabase>(SourceDBPath);
if (src == null)
{
Debug.LogWarning($"[EnemyVATConverter] 源 DB 不存在: {SourceDBPath},跳过 DB 生成");
return;
}
// 复制原 asset 文件作为 base
if (AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(OutputDBPath) != null)
AssetDatabase.DeleteAsset(OutputDBPath);
AssetDatabase.CopyAsset(SourceDBPath, OutputDBPath);
AssetDatabase.Refresh();
var dbVAT = AssetDatabase.LoadAssetAtPath<EnemiesDatabase>(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<GameObject>(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<EnemiesDatabase>(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<EnemiesSpawner>(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}");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 907bea91edeac0e41897dfdc7a7ce743
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 410e208d9c7e2fe49bdacb510b91c66d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<Entry>();
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<Entry> 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<GameObject>(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<SkinnedMeshRenderer>(true);
var mfs = fbxAsset.GetComponentsInChildren<MeshFilter>(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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5874804b9c848b5408ec1045d693f6fd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 042cd68b7950219468cdbd4875f1f571
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<OrientationCalibrator>("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<MeshRenderer>();
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<MeshFilter>();
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");
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 22c57c07cb6329049a0aff762e60d2e3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: a808e9721af7ae64697f4bb71d3b4cb6
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5fea0bab86abec74aac8d8298be78ae0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
/// <summary>
/// 保存需要烘焙的动画的相关数据
/// </summary>
public struct AnimData
{
#region FIELDS
private int _vertexCount;
private int _mapWidth;
private readonly List<AnimationState> _animClips;
private string _name;
private Animation _animation;
private SkinnedMeshRenderer _skin;
public List<AnimationState> 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<AnimationState>(anim.Cast<AnimationState>());
_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
}
/// <summary>
/// 烘焙后的数据
/// </summary>
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;
}
/// <summary>
/// 烘焙器
/// </summary>
public class AnimMapBaker{
#region FIELDS
private AnimData? _animData = null;
private Mesh _bakedMesh;
private readonly List<Vector3> _vertices = new List<Vector3>();
private readonly List<BakedData> _bakedDataList = new List<BakedData>();
#endregion
#region METHODS
public void SetAnimData(GameObject go)
{
if(go == null)
{
Debug.LogError("go is null!!");
return;
}
var anim = go.GetComponent<Animation>();
var smr = go.GetComponentInChildren<SkinnedMeshRenderer>();
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<BakedData> 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
}

View File

@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: d58f874be9d391e43866e5e334ddf986
timeCreated: 1501134718
licenseType: Free
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e754dc049ab8a644abf6ea8b312ef7a1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<AnimMapBakerWindow>();
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<SkinnedMeshRenderer>())
{
EditorUtility.DisplayDialog("err", "SkinnedMeshRender is null!!", "OK");
return null;
}
var smr = _targetGo.GetComponentInChildren<SkinnedMeshRenderer>();
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<MeshRenderer>().sharedMaterial = mat;
go.AddComponent<MeshFilter>().sharedMesh = _targetGo.GetComponentInChildren<SkinnedMeshRenderer>().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
}

View File

@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: b0821312d64995540ae7372c5af049c0
timeCreated: 1501143281
licenseType: Free
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,180 @@
using System.IO;
using UnityEditor;
using UnityEngine;
using UnityMeshSimplifier;
namespace VATPipeline.EditorTools
{
/// <summary>
/// Step 1 of VAT pipeline: decimate (reduce polygons on) a SkinnedMesh while
/// preserving bone weights / bind poses so the animation still aligns with skeleton.
/// </summary>
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<MeshDecimatorWindow>("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<SkinnedMeshRenderer>();
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<SkinnedMeshRenderer>();
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;
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 75d526be0cd3b80439918e8073322fb2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,694 @@
/*
* SelectiveVATBakerWindow
* VAT
* 1. Animation clip Idle/Run/Die
* 2. SkinnedMeshRenderer.sharedMesh UnityMeshSimplifier
* 3. AnimMapBaker VAT
* 4. mesh + AnimMap + Material + Prefab
*
* prefabtry/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;
/// <summary>
/// 让 MeshRenderer 走最干净的 GPU Instancing 路径:
/// LightProbe / ReflectionProbe / Shadow / MotionVectors 都关掉,避免每实例不同的 probe 系数
/// 把 instance 拆出 batch。Survivors 是 2.5D 无场景光照,影子用 sprite,这些都不需要。
/// </summary>
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<AnimationClip> _allClips = new List<AnimationClip>();
private readonly Dictionary<AnimationClip, bool> _selected = new Dictionary<AnimationClip, bool>();
[MenuItem("Tools/VAT Pipeline/2. Selective VAT Baker", priority = 2)]
public static void ShowWindow()
{
var win = GetWindow<SelectiveVATBakerWindow>("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<SelectiveVATBakerWindow>();
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<Animation>();
var smr = _target.GetComponentInChildren<SkinnedMeshRenderer>();
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<Animation>();
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<SkinnedMeshRenderer>();
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<SkinnedMeshRenderer>();
var anim = _target.GetComponent<Animation>();
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<AnimationClip>();
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<BakedData> 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<MeshFilter>();
mf.sharedMesh = meshForBake;
var mr = meshGO.AddComponent<MeshRenderer>();
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<BakedData> 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<VATClipEntry>(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<VATClipDatabase>();
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<MeshFilter>();
mf.sharedMesh = meshForBake;
var mr = meshGO.AddComponent<MeshRenderer>();
ConfigureBatchFriendlyRenderer(mr);
mr.sharedMaterial = mat;
var ctrl = prefabGO.AddComponent<VATAnimationController>();
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<Texture2D>(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<Texture2D>(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();
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 371a94149b1651c4ca497ac788def754
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,154 @@
using System;
using UnityEngine;
namespace OctoberStudio.VAT
{
/// <summary>
/// 用 MaterialPropertyBlock 控制 VAT Atlas 上的当前 clip 与播放时间偏移。
/// 不切换 material → 不破坏 SRP Batcher / GPU Instancing,适合大量同种角色同屏。
///
/// 使用方式:
/// 1. 挂在含 MeshRenderer 的 GameObject 上 (或子物体 View 上,把 meshRenderer 指过来)
/// 2. 拖入 clipDatabase
/// 3. 调 Play("Idle" / "Run" / "Die")
/// </summary>
[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;
/// <summary>当前播放的 clip 名(可能为空字符串)</summary>
public string CurrentClip => _hasCurrent ? _current.name : string.Empty;
/// <summary>当前左右朝向(+1 / -1)</summary>
public int CurrentFacingSign => _currentFacingSign;
protected virtual void Awake()
{
if (meshRenderer == null) meshRenderer = GetComponentInChildren<MeshRenderer>(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);
}
/// <summary>
/// 切到指定 clip。如果 name 不存在,会按 fallback 链尝试(Idle→Ready→Stand→Wait)。
/// </summary>
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;
}
/// <summary>
/// 查找 clip,Idle 找不到时按 fallback 链 (Ready/Stand/Wait) 继续找。
/// </summary>
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;
}
/// <summary>
/// 设置左右朝向。sign >= 0 → facingRightY,sign < 0 → facingLeftY。
/// 同一朝向重复调用是 no-op。
/// </summary>
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;
}
/// <summary>停止 (实际是 freeze 在最后一帧)。</summary>
public void Stop()
{
if (meshRenderer == null) return;
meshRenderer.GetPropertyBlock(_mpb);
_mpb.SetFloat(AnimLoopID, 0f);
// 让 t 趋于 >=1: timeOffset 设很久以前即可
_mpb.SetFloat(AnimTimeOffsetID, -1e6f);
meshRenderer.SetPropertyBlock(_mpb);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7514a420a764f5f4aa89a81684157108
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace OctoberStudio.VAT
{
/// <summary>
/// 一个 VAT Atlas 上每段 clip 的元数据。
/// yStart/ySpan 是归一化到 [0,1] 的纵向区段 (= clipFrames / atlasHeight),
/// shader 通过它们计算 atlas 采样的 V 坐标。
/// </summary>
[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;
}
/// <summary>
/// 一个角色一份。Bake atlas 时由工具自动生成,运行时由 VATAnimationController 引用。
/// </summary>
[CreateAssetMenu(menuName = "OctoberStudio/VAT/VAT Clip Database", fileName = "VATClipDatabase")]
public class VATClipDatabase : ScriptableObject
{
[Tooltip("整张 atlas 贴图。所有 clip 段沿 Y 轴堆叠。")]
public Texture2D atlas;
[Tooltip("所有动画段的元数据。")]
public List<VATClipEntry> clips = new List<VATClipEntry>();
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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 46705c51f321a5d47883154cf9246185
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 11e8177631838ab488a6f6ce56410b02
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
}
}
}

View File

@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: a29e7330fcdae8849bd07e990b12f5de
timeCreated: 1501152148
licenseType: Free
ShaderImporter:
defaultTextures: []
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
}
}
}

View File

@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 4dfd79683771c3f49a2a3fcf41ca09df
timeCreated: 1501152148
licenseType: Free
ShaderImporter:
defaultTextures: []
userData:
assetBundleName:
assetBundleVariant:

137
README.md Normal file
View File

@ -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 # 内置管线BuiltInVAT 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`
- 选择目标 ShaderBuiltIn / 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/<name>/`
### 方案 C从原始 FBX 批量烤(带 @Run.FBX 附加动画文件)
- 把美术 `<name>.FBX` 放在 `Assets/_External/Monsters/<name>/`
- 同目录放一份 `<name>@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<VATAnimationController>();
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)