UnityVATTool/Assets/Plugins/AnimMapBaker/Scripts/Editor/SelectiveVATBakerWindow.cs

695 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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();
}
}
}