feat: initial VAT pipeline (decimation + vertex animation texture baking) toolkit
This commit is contained in:
commit
aeb24d2694
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal 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
|
||||||
218
Assets/Common/Scripts/Editor/CharacterVATConverter.cs
Normal file
218
Assets/Common/Scripts/Editor/CharacterVATConverter.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Assets/Common/Scripts/Editor/CharacterVATConverter.cs.meta
Normal file
11
Assets/Common/Scripts/Editor/CharacterVATConverter.cs.meta
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f91ea5c3ce2e6a54190cf8a9ab2fb004
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
368
Assets/Common/Scripts/Editor/EnemyVATConverter.cs
Normal file
368
Assets/Common/Scripts/Editor/EnemyVATConverter.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Assets/Common/Scripts/Editor/EnemyVATConverter.cs.meta
Normal file
11
Assets/Common/Scripts/Editor/EnemyVATConverter.cs.meta
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 907bea91edeac0e41897dfdc7a7ce743
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
125
Assets/Common/Scripts/Editor/ImportMonsterPackageAndClean.cs
Normal file
125
Assets/Common/Scripts/Editor/ImportMonsterPackageAndClean.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 410e208d9c7e2fe49bdacb510b91c66d
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
148
Assets/Common/Scripts/Editor/MonsterOrientationCheck.cs
Normal file
148
Assets/Common/Scripts/Editor/MonsterOrientationCheck.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Assets/Common/Scripts/Editor/MonsterOrientationCheck.cs.meta
Normal file
11
Assets/Common/Scripts/Editor/MonsterOrientationCheck.cs.meta
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5874804b9c848b5408ec1045d693f6fd
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
421
Assets/Common/Scripts/Editor/MonsterVATBatchBaker.cs
Normal file
421
Assets/Common/Scripts/Editor/MonsterVATBatchBaker.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Assets/Common/Scripts/Editor/MonsterVATBatchBaker.cs.meta
Normal file
11
Assets/Common/Scripts/Editor/MonsterVATBatchBaker.cs.meta
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 042cd68b7950219468cdbd4875f1f571
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
200
Assets/Common/Scripts/Editor/OrientationCalibrator.cs
Normal file
200
Assets/Common/Scripts/Editor/OrientationCalibrator.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Assets/Common/Scripts/Editor/OrientationCalibrator.cs.meta
Normal file
11
Assets/Common/Scripts/Editor/OrientationCalibrator.cs.meta
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 22c57c07cb6329049a0aff762e60d2e3
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
Assets/Plugins/AnimMapBaker/Documentation.pdf
Normal file
BIN
Assets/Plugins/AnimMapBaker/Documentation.pdf
Normal file
Binary file not shown.
7
Assets/Plugins/AnimMapBaker/Documentation.pdf.meta
Normal file
7
Assets/Plugins/AnimMapBaker/Documentation.pdf.meta
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a808e9721af7ae64697f4bb71d3b4cb6
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Assets/Plugins/AnimMapBaker/Scripts.meta
Normal file
8
Assets/Plugins/AnimMapBaker/Scripts.meta
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5fea0bab86abec74aac8d8298be78ae0
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
211
Assets/Plugins/AnimMapBaker/Scripts/AnimMapBaker.cs
Normal file
211
Assets/Plugins/AnimMapBaker/Scripts/AnimMapBaker.cs
Normal 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
|
||||||
|
|
||||||
|
}
|
||||||
12
Assets/Plugins/AnimMapBaker/Scripts/AnimMapBaker.cs.meta
Normal file
12
Assets/Plugins/AnimMapBaker/Scripts/AnimMapBaker.cs.meta
Normal 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:
|
||||||
8
Assets/Plugins/AnimMapBaker/Scripts/Editor.meta
Normal file
8
Assets/Plugins/AnimMapBaker/Scripts/Editor.meta
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e754dc049ab8a644abf6ea8b312ef7a1
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
219
Assets/Plugins/AnimMapBaker/Scripts/Editor/AnimMapBakerWindow.cs
Normal file
219
Assets/Plugins/AnimMapBaker/Scripts/Editor/AnimMapBakerWindow.cs
Normal 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
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b0821312d64995540ae7372c5af049c0
|
||||||
|
timeCreated: 1501143281
|
||||||
|
licenseType: Free
|
||||||
|
MonoImporter:
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 75d526be0cd3b80439918e8073322fb2
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@ -0,0 +1,694 @@
|
|||||||
|
/*
|
||||||
|
* SelectiveVATBakerWindow
|
||||||
|
* 选择性 VAT 烘焙:
|
||||||
|
* 1. 让用户从 Animation 组件的全部 clip 里挑几个(默认按名字匹配 Idle/Run/Die)
|
||||||
|
* 2. 可选:对 SkinnedMeshRenderer.sharedMesh 做减面(UnityMeshSimplifier)
|
||||||
|
* 3. 调用 AnimMapBaker 烘焙 VAT 贴图
|
||||||
|
* 4. 输出:减面 mesh + AnimMap 贴图 + Material + 静态 Prefab
|
||||||
|
*
|
||||||
|
* 全程不修改原始 prefab,只在场景实例上做临时改动,try/finally 还原。
|
||||||
|
*/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.Rendering;
|
||||||
|
using UnityMeshSimplifier;
|
||||||
|
using OctoberStudio.VAT;
|
||||||
|
|
||||||
|
namespace OctoberStudio.VATPipeline
|
||||||
|
{
|
||||||
|
public class SelectiveVATBakerWindow : EditorWindow
|
||||||
|
{
|
||||||
|
private const string BuiltInShader = "chenjd/BuiltIn/AnimMapShader";
|
||||||
|
private const string URPShader = "chenjd/URP/AnimMapShader";
|
||||||
|
|
||||||
|
private static readonly int MainTexPropID = Shader.PropertyToID("_MainTex");
|
||||||
|
private static readonly int AnimMapPropID = Shader.PropertyToID("_AnimMap");
|
||||||
|
private static readonly int AnimLenPropID = Shader.PropertyToID("_AnimLen");
|
||||||
|
private static readonly int AnimYStartPropID = Shader.PropertyToID("_AnimYStart");
|
||||||
|
private static readonly int AnimYSpanPropID = Shader.PropertyToID("_AnimYSpan");
|
||||||
|
|
||||||
|
// Exact-match clip names (case-insensitive). Add more if needed.
|
||||||
|
// 同时匹配 Idle / Ready: R02 这类模型无独立 Idle 时,Ready 当作 Idle 使用
|
||||||
|
private static readonly string[] DefaultClipNames = { "Idle", "Ready", "Run", "Die" };
|
||||||
|
|
||||||
|
// Baker 直接把"面相机"的朝向烤入 prefab 内部 Mesh 节点(原来要在运行时再 calibrate)
|
||||||
|
// - Y=180: 让 mocap 面相机(典型 Maya Y-up FBX,人物原本背对相机)
|
||||||
|
// - X=-90: Z-up FBX 校正(典型 3ds Max Biped 模型,如 mon9020),让躺着的模型立起来
|
||||||
|
private const float BakedMeshFacingY = 180f;
|
||||||
|
private const float DefaultBakedMeshFacingX = 0f;
|
||||||
|
|
||||||
|
private GameObject _target;
|
||||||
|
private Vector2 _scroll;
|
||||||
|
private string _outputRoot = "Assets/_VAT_Baking/Output";
|
||||||
|
|
||||||
|
private bool _enableDecimation = true;
|
||||||
|
[Range(0.05f, 1f)] private float _quality = 0.25f;
|
||||||
|
private bool _preserveBorderEdges = true;
|
||||||
|
private bool _preserveSurfaceCurvature = false;
|
||||||
|
// 输出 EXR (16bit half, ZIP 压缩) 替代 .asset。Git 友好、磁盘体积小,VRAM 不变(仍 RGBAHalf)
|
||||||
|
private bool _useEXR = true;
|
||||||
|
// Atlas 模式: 所有 clip 拼到 1 张图,生成 1 个 material + 1 个 VATClipDatabase
|
||||||
|
private bool _atlasMode = true;
|
||||||
|
|
||||||
|
// Z-up FBX 校正: 3ds Max Biped 系怪物(主轴 = Z)烤入 Mesh.localEulerAngles.x = -90 让模型站立
|
||||||
|
// GUI 上提供 "Auto Z-up Detect" 按钮,自动判定并设值
|
||||||
|
private float _bakedMeshFacingX = DefaultBakedMeshFacingX;
|
||||||
|
|
||||||
|
/// <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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 371a94149b1651c4ca497ac788def754
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
154
Assets/Plugins/AnimMapBaker/Scripts/VATAnimationController.cs
Normal file
154
Assets/Plugins/AnimMapBaker/Scripts/VATAnimationController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7514a420a764f5f4aa89a81684157108
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
59
Assets/Plugins/AnimMapBaker/Scripts/VATClipDatabase.cs
Normal file
59
Assets/Plugins/AnimMapBaker/Scripts/VATClipDatabase.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Assets/Plugins/AnimMapBaker/Scripts/VATClipDatabase.cs.meta
Normal file
11
Assets/Plugins/AnimMapBaker/Scripts/VATClipDatabase.cs.meta
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 46705c51f321a5d47883154cf9246185
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Assets/Plugins/AnimMapBaker/Shaders.meta
Normal file
8
Assets/Plugins/AnimMapBaker/Shaders.meta
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 11e8177631838ab488a6f6ce56410b02
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
104
Assets/Plugins/AnimMapBaker/Shaders/AnimMapShader.shader
Normal file
104
Assets/Plugins/AnimMapBaker/Shaders/AnimMapShader.shader
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a29e7330fcdae8849bd07e990b12f5de
|
||||||
|
timeCreated: 1501152148
|
||||||
|
licenseType: Free
|
||||||
|
ShaderImporter:
|
||||||
|
defaultTextures: []
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4dfd79683771c3f49a2a3fcf41ca09df
|
||||||
|
timeCreated: 1501152148
|
||||||
|
licenseType: Free
|
||||||
|
ShaderImporter:
|
||||||
|
defaultTextures: []
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
137
README.md
Normal file
137
README.md
Normal 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 # 内置管线(BuiltIn)VAT Shader
|
||||||
|
│ │ └── AnimMapWithShadowShader.shader # 带阴影版本
|
||||||
|
│ └── Documentation.pdf # 原始作者文档
|
||||||
|
└── Common/
|
||||||
|
└── Scripts/Editor/ # 上层批量入口(Tools/OctoberStudio/VAT/...)
|
||||||
|
├── MonsterVATBatchBaker.cs # 一键批量烘焙(按 Resources/Role/Monster 全量)
|
||||||
|
├── EnemyVATConverter.cs # Enemy 类型 prefab → VAT Variant
|
||||||
|
├── CharacterVATConverter.cs # Character 类型 prefab → VAT Variant
|
||||||
|
├── MonsterOrientationCheck.cs # FBX 朝向(站立/Z-up)一键体检
|
||||||
|
├── OrientationCalibrator.cs # 朝向 + 缩放 + 偏移交互式校准
|
||||||
|
└── ImportMonsterPackageAndClean.cs # 自动导入 unitypackage 并清理夹带文件
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、依赖
|
||||||
|
|
||||||
|
| 依赖 | 版本 / 来源 | 必需性 |
|
||||||
|
|---|---|---|
|
||||||
|
| Unity | 2022 LTS 及以上(建议 2022.3+) | 必需 |
|
||||||
|
| [UnityMeshSimplifier (Whinarn)](https://github.com/Whinarn/UnityMeshSimplifier) | 任意稳定版 | **必需**(减面用) |
|
||||||
|
| 渲染管线 | BuiltIn / URP 均可,Shader 分别提供 | 必需 |
|
||||||
|
|
||||||
|
**安装 UnityMeshSimplifier**(任选其一):
|
||||||
|
|
||||||
|
- 通过 UPM 直接拉 git:在 `Packages/manifest.json` 加:
|
||||||
|
```json
|
||||||
|
"com.whinarn.unitymeshsimplifier": "https://github.com/Whinarn/UnityMeshSimplifier.git"
|
||||||
|
```
|
||||||
|
- 或本地克隆后 `file:` 引用:
|
||||||
|
```json
|
||||||
|
"com.whinarn.unitymeshsimplifier": "file:../_thirdparty/UnityMeshSimplifier"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、典型使用流程
|
||||||
|
|
||||||
|
### 方案 A:单只手动 —— 一步到位(推荐)
|
||||||
|
|
||||||
|
1. 菜单:`Tools/VAT Pipeline/1. Decimate SkinnedMesh`
|
||||||
|
- 选择带 `SkinnedMeshRenderer` 的 Prefab/场景对象
|
||||||
|
- 设置 `Quality`(保留三角形比例,0.3 = 留 30%)
|
||||||
|
- 可选保留边缘、表面曲率等
|
||||||
|
- 输出:减面后的 `.asset` Mesh,并可选替换原 `sharedMesh`
|
||||||
|
2. 菜单:`Tools/VAT Pipeline/2. Bake Selected VAT`(`SelectiveVATBakerWindow`)
|
||||||
|
- 选择对象上的 `Animation` 组件
|
||||||
|
- 勾选要烘焙的 `clip`(默认匹配 `Idle / Ready / Run / Die`)
|
||||||
|
- 选择目标 Shader(BuiltIn / URP)
|
||||||
|
- 输出:`*_VAT.prefab` + `*_VATClips.asset` + `*_VAT_Atlas.exr` + Material
|
||||||
|
|
||||||
|
### 方案 B:批量小怪 —— 一键烤全部
|
||||||
|
|
||||||
|
- 把美术 prefab(含 `Animation` + `SkinnedMeshRenderer`)整理到
|
||||||
|
`Assets/Resources/Role/Monster/*.prefab`
|
||||||
|
- 菜单:`Tools/OctoberStudio/VAT/Bake ALL Monsters in Resources`
|
||||||
|
- 产物默认输出到:`Assets/_VAT_Baking/Output/<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)
|
||||||
Loading…
x
Reference in New Issue
Block a user