695 lines
33 KiB
C#
695 lines
33 KiB
C#
/*
|
||
* 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();
|
||
}
|
||
}
|
||
}
|