/*
* SelectiveVATBakerWindow
* 选择性 VAT 烘焙:
* 1. 让用户从 Animation 组件的全部 clip 里挑几个(默认按名字匹配 Idle/Run/Die)
* 2. 可选:对 SkinnedMeshRenderer.sharedMesh 做减面(UnityMeshSimplifier)
* 3. 调用 AnimMapBaker 烘焙 VAT 贴图
* 4. 输出:减面 mesh + AnimMap 贴图 + Material + 静态 Prefab
*
* 全程不修改原始 prefab,只在场景实例上做临时改动,try/finally 还原。
*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using UnityMeshSimplifier;
using OctoberStudio.VAT;
namespace OctoberStudio.VATPipeline
{
public class SelectiveVATBakerWindow : EditorWindow
{
private const string BuiltInShader = "chenjd/BuiltIn/AnimMapShader";
private const string URPShader = "chenjd/URP/AnimMapShader";
private static readonly int MainTexPropID = Shader.PropertyToID("_MainTex");
private static readonly int AnimMapPropID = Shader.PropertyToID("_AnimMap");
private static readonly int AnimLenPropID = Shader.PropertyToID("_AnimLen");
private static readonly int AnimYStartPropID = Shader.PropertyToID("_AnimYStart");
private static readonly int AnimYSpanPropID = Shader.PropertyToID("_AnimYSpan");
// Exact-match clip names (case-insensitive). Add more if needed.
// 同时匹配 Idle / Ready: R02 这类模型无独立 Idle 时,Ready 当作 Idle 使用
private static readonly string[] DefaultClipNames = { "Idle", "Ready", "Run", "Die" };
// Baker 直接把"面相机"的朝向烤入 prefab 内部 Mesh 节点(原来要在运行时再 calibrate)
// - Y=180: 让 mocap 面相机(典型 Maya Y-up FBX,人物原本背对相机)
// - X=-90: Z-up FBX 校正(典型 3ds Max Biped 模型,如 mon9020),让躺着的模型立起来
private const float BakedMeshFacingY = 180f;
private const float DefaultBakedMeshFacingX = 0f;
private GameObject _target;
private Vector2 _scroll;
private string _outputRoot = "Assets/_VAT_Baking/Output";
private bool _enableDecimation = true;
[Range(0.05f, 1f)] private float _quality = 0.25f;
private bool _preserveBorderEdges = true;
private bool _preserveSurfaceCurvature = false;
// 输出 EXR (16bit half, ZIP 压缩) 替代 .asset。Git 友好、磁盘体积小,VRAM 不变(仍 RGBAHalf)
private bool _useEXR = true;
// Atlas 模式: 所有 clip 拼到 1 张图,生成 1 个 material + 1 个 VATClipDatabase
private bool _atlasMode = true;
// Z-up FBX 校正: 3ds Max Biped 系怪物(主轴 = Z)烤入 Mesh.localEulerAngles.x = -90 让模型站立
// GUI 上提供 "Auto Z-up Detect" 按钮,自动判定并设值
private float _bakedMeshFacingX = DefaultBakedMeshFacingX;
///
/// 让 MeshRenderer 走最干净的 GPU Instancing 路径:
/// LightProbe / ReflectionProbe / Shadow / MotionVectors 都关掉,避免每实例不同的 probe 系数
/// 把 instance 拆出 batch。Survivors 是 2.5D 无场景光照,影子用 sprite,这些都不需要。
///
private static void ConfigureBatchFriendlyRenderer(MeshRenderer mr)
{
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
mr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off;
mr.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion;
mr.allowOcclusionWhenDynamic = false;
}
private readonly List _allClips = new List();
private readonly Dictionary _selected = new Dictionary();
[MenuItem("Tools/VAT Pipeline/2. Selective VAT Baker", priority = 2)]
public static void ShowWindow()
{
var win = GetWindow("Selective VAT Baker");
if (win._target == null && Selection.activeGameObject != null)
{
win._target = Selection.activeGameObject;
win.RefreshClipsFromTarget();
}
}
[MenuItem("Tools/VAT Pipeline/3. Quick Bake Selection (Idle+Run+Die)", priority = 3)]
public static void QuickBakeSelection()
{
if (Selection.activeGameObject == null)
{
EditorUtility.DisplayDialog("Quick Bake", "No GameObject selected in scene.", "OK");
return;
}
var win = CreateInstance();
win._target = Selection.activeGameObject;
win.RefreshClipsFromTarget();
win.MatchDefaults();
// 自动检测 Z-up FBX,无需手动设置 X 旋转
win._bakedMeshFacingX = AutoDetectZUp(win._target) ? -90f : 0f;
try
{
win.BakeAll();
}
catch (Exception e)
{
Debug.LogException(e);
EditorUtility.DisplayDialog("Quick Bake Failed", e.Message, "OK");
}
finally
{
EditorUtility.ClearProgressBar();
DestroyImmediate(win);
}
}
private void OnSelectionChange()
{
if (_target == null && Selection.activeGameObject != null)
{
_target = Selection.activeGameObject;
RefreshClipsFromTarget();
Repaint();
}
}
private void OnGUI()
{
EditorGUILayout.LabelField("Selective VAT Baker", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"1) 拖入场景中的角色 GameObject(必须含 Animation + SkinnedMeshRenderer)\n" +
"2) 勾选要烘焙的动画(默认按名字匹配 Idle/Run/Die)\n" +
"3) 可选:开启 Mesh 减面(推荐 0.3~0.6)\n" +
"4) 点击 Bake,会输出:减面 mesh + AnimMap 贴图 + Mat + 静态 Prefab",
MessageType.Info);
EditorGUI.BeginChangeCheck();
_target = (GameObject)EditorGUILayout.ObjectField("Target GameObject", _target, typeof(GameObject), true);
if (EditorGUI.EndChangeCheck())
{
RefreshClipsFromTarget();
}
if (_target == null)
{
EditorGUILayout.HelpBox("请拖入场景中的目标 GameObject", MessageType.Warning);
return;
}
var anim = _target.GetComponent();
var smr = _target.GetComponentInChildren();
if (anim == null || smr == null)
{
EditorGUILayout.HelpBox("目标必须含 Animation 组件和子物体上的 SkinnedMeshRenderer", MessageType.Error);
return;
}
EditorGUILayout.Space();
EditorGUILayout.LabelField($"Mesh: {smr.sharedMesh?.name} Verts={smr.sharedMesh?.vertexCount}", EditorStyles.miniLabel);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Output", EditorStyles.boldLabel);
_outputRoot = EditorGUILayout.TextField("Output Folder", _outputRoot);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Mesh Decimation", EditorStyles.boldLabel);
_enableDecimation = EditorGUILayout.Toggle("Enable Decimation", _enableDecimation);
using (new EditorGUI.DisabledScope(!_enableDecimation))
{
_quality = EditorGUILayout.Slider("Quality (0.05=粗暴, 1=不减)", _quality, 0.05f, 1f);
_preserveBorderEdges = EditorGUILayout.Toggle("Preserve Border Edges", _preserveBorderEdges);
_preserveSurfaceCurvature = EditorGUILayout.Toggle("Preserve Surface Curvature", _preserveSurfaceCurvature);
}
EditorGUILayout.Space();
EditorGUILayout.LabelField("Output Format", EditorStyles.boldLabel);
_useEXR = EditorGUILayout.Toggle(new GUIContent("Use EXR (recommended)", "16bit half EXR + ZIP, 体积小 Git 友好, VRAM 不变"), _useEXR);
_atlasMode = EditorGUILayout.Toggle(new GUIContent("Atlas Mode (recommended)", "所有 clip 拼到 1 张图; 输出 1 个 material + 1 个 VATClipDatabase, 运行时切 clip 不切 material"), _atlasMode);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Mesh Facing (烤入 prefab 的 Mesh 节点)", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"Y=180 让模型面相机。X 校正用于解决躺着的模型:\n" +
" - Y-up FBX (Maya/Unity 标准): X = 0\n" +
" - Z-up FBX (3ds Max Biped, 如 R02_S_body01_world / mon9020): X = -90\n" +
"不确定就点 Auto Z-up? 由 bounds 自动判断。",
MessageType.Info);
using (new EditorGUILayout.HorizontalScope())
{
EditorGUILayout.LabelField($"Y (face camera) = {BakedMeshFacingY}°", GUILayout.Width(200));
_bakedMeshFacingX = EditorGUILayout.FloatField(new GUIContent("X 校正", "Z-up FBX (3ds Max Biped) 设 -90; Y-up FBX 设 0"), _bakedMeshFacingX);
}
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.Space(204);
if (GUILayout.Button("Y-up (X=0)", GUILayout.Width(100))) _bakedMeshFacingX = 0f;
if (GUILayout.Button("Z-up (X=-90)", GUILayout.Width(100))) _bakedMeshFacingX = -90f;
if (GUILayout.Button("Auto Detect", GUILayout.Width(100))) _bakedMeshFacingX = AutoDetectZUp(_target) ? -90f : 0f;
}
EditorGUILayout.Space();
EditorGUILayout.LabelField($"Animation Clips ({_allClips.Count})", EditorStyles.boldLabel);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Select All")) SetAll(true);
if (GUILayout.Button("Select None")) SetAll(false);
if (GUILayout.Button("Match Idle/Run/Die")) MatchDefaults();
if (GUILayout.Button("Refresh")) RefreshClipsFromTarget();
}
_scroll = EditorGUILayout.BeginScrollView(_scroll, GUILayout.Height(220));
foreach (var c in _allClips)
{
if (c == null) continue;
if (!_selected.ContainsKey(c)) _selected[c] = false;
_selected[c] = EditorGUILayout.ToggleLeft($"{c.name} ({c.length:F2}s, fps={c.frameRate})", _selected[c]);
}
EditorGUILayout.EndScrollView();
var selectedCount = _selected.Count(kv => kv.Value);
EditorGUILayout.LabelField($"Selected: {selectedCount} clips");
EditorGUILayout.Space();
using (new EditorGUI.DisabledScope(selectedCount == 0))
{
if (GUILayout.Button("Bake VAT (Decimate + Bake + Save Prefab)", GUILayout.Height(40)))
{
try
{
BakeAll();
}
catch (Exception e)
{
EditorUtility.DisplayDialog("Bake Failed", e.ToString(), "OK");
Debug.LogException(e);
}
finally
{
EditorUtility.ClearProgressBar();
}
}
}
}
private void RefreshClipsFromTarget()
{
_allClips.Clear();
_selected.Clear();
if (_target == null) return;
var anim = _target.GetComponent();
if (anim == null) return;
foreach (AnimationState st in anim)
{
if (st.clip != null && !_allClips.Contains(st.clip)) _allClips.Add(st.clip);
}
MatchDefaults();
}
private void SetAll(bool v)
{
foreach (var c in _allClips) _selected[c] = v;
}
private void MatchDefaults()
{
foreach (var c in _allClips)
{
_selected[c] = DefaultClipNames.Any(k => string.Equals(c.name, k, StringComparison.OrdinalIgnoreCase));
}
}
// 通过 SMR.sharedMesh.bounds 判定 FBX 主轴: Z 最大 → Z-up → 需 X=-90 校正
private static bool AutoDetectZUp(GameObject target)
{
if (target == null) return false;
var smr = target.GetComponentInChildren();
if (smr == null || smr.sharedMesh == null) return false;
var size = smr.sharedMesh.bounds.size;
float x = Mathf.Abs(size.x), y = Mathf.Abs(size.y), z = Mathf.Abs(size.z);
return z > y && z > x;
}
// ============= BAKE PIPELINE =============
private void BakeAll()
{
var smr = _target.GetComponentInChildren();
var anim = _target.GetComponent();
if (smr == null || anim == null) throw new Exception("Missing SMR or Animation");
var selectedClips = _selected.Where(kv => kv.Value && kv.Key != null).Select(kv => kv.Key).ToList();
if (selectedClips.Count == 0) throw new Exception("No clip selected");
EditorUtility.DisplayProgressBar("VAT", "Preparing folders...", 0f);
var outDir = Path.Combine(_outputRoot, _target.name).Replace("\\", "/");
EnsureFolder(outDir);
// 1) Decimation
Mesh meshForBake = smr.sharedMesh;
Mesh decimatedAsset = null;
if (_enableDecimation)
{
EditorUtility.DisplayProgressBar("VAT", "Decimating mesh...", 0.1f);
decimatedAsset = DecimateMesh(smr.sharedMesh, outDir);
meshForBake = decimatedAsset;
}
// 2) Backup state then mutate scene instance
var originalSharedMesh = smr.sharedMesh;
var originalClips = new List();
foreach (AnimationState st in anim) originalClips.Add(st.clip);
try
{
// Replace SMR mesh in scene instance
if (_enableDecimation) smr.sharedMesh = decimatedAsset;
// Strip clips down to only selected
foreach (var c in originalClips)
{
if (!selectedClips.Contains(c)) anim.RemoveClip(c);
}
// ensure default clip is one of the selected (avoids null default)
// 优先级: Idle > Ready > 第一个
if (selectedClips.Count > 0)
{
var defaultClip = selectedClips.FirstOrDefault(c => string.Equals(c.name, "Idle", StringComparison.OrdinalIgnoreCase))
?? selectedClips.FirstOrDefault(c => string.Equals(c.name, "Ready", StringComparison.OrdinalIgnoreCase))
?? selectedClips[0];
anim.clip = defaultClip;
}
// 3) Bake
EditorUtility.DisplayProgressBar("VAT", "Baking AnimMaps...", 0.3f);
var baker = new AnimMapBaker();
baker.SetAnimData(_target);
var bakedList = baker.Bake();
if (bakedList == null || bakedList.Count == 0)
throw new Exception("AnimMapBaker returned empty list");
// 4) Save assets
var shader = ResolveShader();
if (shader == null) throw new Exception("Cannot find VAT shader (chenjd/BuiltIn/AnimMapShader or chenjd/URP/AnimMapShader)");
Texture mainTex = smr.sharedMaterial != null ? smr.sharedMaterial.mainTexture : null;
if (_atlasMode)
{
BakeAndSaveAtlas(bakedList, shader, mainTex, meshForBake, outDir);
}
else
{
BakeAndSavePerClip(bakedList, shader, mainTex, meshForBake, outDir);
}
}
finally
{
// Restore scene instance state
smr.sharedMesh = originalSharedMesh;
// Re-add removed clips (AddClip with name overwrites, so safe to add all)
foreach (var c in originalClips)
{
if (anim.GetClip(c.name) == null) anim.AddClip(c, c.name);
}
}
}
// ============= PER-CLIP MODE (一个 clip 一张图,旧路径) =============
private void BakeAndSavePerClip(List bakedList, Shader shader, Texture mainTex, Mesh meshForBake, string outDir)
{
var mapClipPairs = new List<(BakedData baked, Texture2D map, Material mat)>();
float progressStep = 0.6f / bakedList.Count;
float progress = 0.35f;
foreach (var data in bakedList)
{
EditorUtility.DisplayProgressBar("VAT", $"Saving {data.Name}...", progress);
progress += progressStep;
var fileName = SanitizeFileName(data.Name);
Texture2D animMap = SaveAnimMap(data, outDir, fileName);
var mat = new Material(shader);
mat.enableInstancing = true; // 默认开 GPU Instancing,VAT 主要目标就是合批
if (mainTex != null) mat.SetTexture(MainTexPropID, mainTex);
mat.SetTexture(AnimMapPropID, animMap);
mat.SetFloat(AnimLenPropID, data.AnimLen);
mat.SetFloat(AnimYStartPropID, 0f);
mat.SetFloat(AnimYSpanPropID, 1f);
var matPath = Path.Combine(outDir, fileName + ".mat").Replace("\\", "/");
AssetDatabase.CreateAsset(mat, matPath);
mapClipPairs.Add((data, animMap, mat));
}
EditorUtility.DisplayProgressBar("VAT", "Building prefab...", 0.95f);
// Root + Mesh 两层结构: Root 是中立 transform (供运行时 SetFacing 旋转),Mesh 上烤入 (X=校正, Y=180 面相机)
var prefabGO = new GameObject(_target.name + "_VAT");
var meshGO = new GameObject("Mesh");
meshGO.transform.SetParent(prefabGO.transform, false);
meshGO.transform.localEulerAngles = new Vector3(_bakedMeshFacingX, BakedMeshFacingY, 0f);
var mf = meshGO.AddComponent();
mf.sharedMesh = meshForBake;
var mr = meshGO.AddComponent();
ConfigureBatchFriendlyRenderer(mr);
// 默认材质: 优先 Idle,其次 Ready,再次第一个
var defaultPair = mapClipPairs.FirstOrDefault(p => string.Equals(ExtractClipName(p.baked.Name), "Idle", StringComparison.OrdinalIgnoreCase));
if (defaultPair.mat == null) defaultPair = mapClipPairs.FirstOrDefault(p => string.Equals(ExtractClipName(p.baked.Name), "Ready", StringComparison.OrdinalIgnoreCase));
if (defaultPair.mat == null) defaultPair = mapClipPairs[0];
mr.sharedMaterial = defaultPair.mat;
var prefabPath = Path.Combine(outDir, _target.name + "_VAT.prefab").Replace("\\", "/");
PrefabUtility.SaveAsPrefabAsset(prefabGO, prefabPath);
DestroyImmediate(prefabGO);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
// 不再弹 modal dialog (会阻塞 MCP 主线程通信),改用 console log。
Debug.Log($"[VAT] Bake Done (Per-Clip): Baked {bakedList.Count} clip(s) for '{_target.name}'\nOutput: {outDir}\nPrefab: {prefabPath}");
}
// ============= ATLAS MODE (所有 clip 拼一张图,推荐) =============
// 把 N 个 BakedData(每个 RGBAHalf 的 w×h_i)沿 Y 轴堆叠到大图 (width = max(w_i), height = Σh_i),
// 生成 1 个 material(_AnimMap 指向 atlas)+ 1 个 VATClipDatabase + 1 个含 VATAnimationController 的 prefab。
private void BakeAndSaveAtlas(List bakedList, Shader shader, Texture mainTex, Mesh meshForBake, string outDir)
{
// 1) Atlas 尺寸
int atlasW = bakedList.Max(b => b.AnimMapWidth);
int atlasH = bakedList.Sum(b => b.AnimMapHeight);
const int bytesPerPixel = 8; // RGBAHalf
EditorUtility.DisplayProgressBar("VAT", $"Composing atlas {atlasW}x{atlasH}...", 0.4f);
// 2) 拼大图: 直接操作 raw bytes,避免逐像素 SetPixel 的开销
var atlasBytes = new byte[atlasW * atlasH * bytesPerPixel];
var clips = new List(bakedList.Count);
int currentY = 0;
foreach (var data in bakedList)
{
int w = data.AnimMapWidth;
int h = data.AnimMapHeight;
int strideAtlas = atlasW * bytesPerPixel;
int strideClip = w * bytesPerPixel;
// 按行复制 (大图的 atlasW 可能 ≥ clipW;clipW 不一致时右侧留 0,但实际同一 mesh 下都相等)
for (int row = 0; row < h; row++)
{
Buffer.BlockCopy(data.RawAnimMap, row * strideClip,
atlasBytes, (currentY + row) * strideAtlas,
strideClip);
}
var entry = new VATClipEntry
{
// bakedData.Name 形如 "R02_S_body01_world_Idle.animMap"; 取最后一个下划线后的 token 当 clip 名
name = ExtractClipName(data.Name),
yStart = (float)currentY / atlasH,
ySpan = (float)h / atlasH,
length = data.AnimLen,
frameCount = h,
loop = true,
};
clips.Add(entry);
currentY += h;
}
// 3) 写 atlas 贴图
EditorUtility.DisplayProgressBar("VAT", "Writing atlas EXR...", 0.7f);
var atlasFileName = _target.name + "_VAT_Atlas";
var atlas = SaveAtlasTexture(atlasBytes, atlasW, atlasH, outDir, atlasFileName);
// 4) 生成 material (引用 atlas)
EditorUtility.DisplayProgressBar("VAT", "Creating material...", 0.82f);
var mat = new Material(shader);
mat.enableInstancing = true; // 默认开 GPU Instancing,VAT 主要目标就是合批
if (mainTex != null) mat.SetTexture(MainTexPropID, mainTex);
mat.SetTexture(AnimMapPropID, atlas);
// 默认 clip: 优先 Idle,其次 Ready,再次第一个
var defaultEntry = clips.FirstOrDefault(c => string.Equals(c.name, "Idle", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrEmpty(defaultEntry.name)) defaultEntry = clips.FirstOrDefault(c => string.Equals(c.name, "Ready", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrEmpty(defaultEntry.name)) defaultEntry = clips[0];
mat.SetFloat(AnimYStartPropID, defaultEntry.yStart);
mat.SetFloat(AnimYSpanPropID, defaultEntry.ySpan);
mat.SetFloat(AnimLenPropID, defaultEntry.length);
var matPath = Path.Combine(outDir, atlasFileName + ".mat").Replace("\\", "/");
AssetDatabase.CreateAsset(mat, matPath);
// 5) 生成 VATClipDatabase
EditorUtility.DisplayProgressBar("VAT", "Creating clip database...", 0.88f);
var db = ScriptableObject.CreateInstance();
db.atlas = atlas;
db.clips.AddRange(clips);
var dbPath = Path.Combine(outDir, _target.name + "_VATClips.asset").Replace("\\", "/");
AssetDatabase.CreateAsset(db, dbPath);
// 6) 生成 prefab (Root + Mesh 两层)
// Root 中立 transform,供运行时 VATAnimationController.SetFacing 旋转
// Mesh 子节点上烤入 (X=校正, Y=180 面相机),并挂 MeshFilter + MeshRenderer
// VATAnimationController 挂在 Root 上,facingTransform=Root 自己
EditorUtility.DisplayProgressBar("VAT", "Building prefab...", 0.95f);
var prefabGO = new GameObject(_target.name + "_VAT");
var meshGO = new GameObject("Mesh");
meshGO.transform.SetParent(prefabGO.transform, false);
meshGO.transform.localEulerAngles = new Vector3(_bakedMeshFacingX, BakedMeshFacingY, 0f);
var mf = meshGO.AddComponent();
mf.sharedMesh = meshForBake;
var mr = meshGO.AddComponent();
ConfigureBatchFriendlyRenderer(mr);
mr.sharedMaterial = mat;
var ctrl = prefabGO.AddComponent();
ctrl.meshRenderer = mr;
ctrl.clipDatabase = db;
// 优先 Idle,无则 Ready,再无则第一个
ctrl.defaultClip = defaultEntry.name;
ctrl.facingTransform = prefabGO.transform;
// 用户偏好: 站立时播 Ready (R02 类 mocap 模型,Ready 通常比 Idle 更自然)。
// 仅当 Ready 被烤了才启用,否则 idleClipOverride 留空,Play("Idle") 走默认逻辑。
var hasReady = clips.Any(c => string.Equals(c.name, "Ready", StringComparison.OrdinalIgnoreCase));
ctrl.idleClipOverride = hasReady ? "Ready" : string.Empty;
var prefabPath = Path.Combine(outDir, _target.name + "_VAT.prefab").Replace("\\", "/");
PrefabUtility.SaveAsPrefabAsset(prefabGO, prefabPath);
DestroyImmediate(prefabGO);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
// 不再弹 modal dialog (会阻塞 MCP 主线程通信),改用 console log。
Debug.Log($"[VAT] Atlas Bake Done: {atlasW}x{atlasH} RGBAHalf (~{(atlasBytes.Length / 1024f / 1024f):F2} MB)" +
$"\nClips: {string.Join(", ", clips.Select(c => c.name))}" +
$"\nidleClipOverride={(hasReady ? "Ready" : "")}" +
$"\nOutput: {outDir}\nPrefab: {prefabPath}");
}
private static string ExtractClipName(string bakedDataName)
{
// "R02_S_body01_world_Idle.animMap" -> "Idle"
var s = bakedDataName;
int dot = s.LastIndexOf('.');
if (dot > 0) s = s.Substring(0, dot);
int us = s.LastIndexOf('_');
return us >= 0 ? s.Substring(us + 1) : s;
}
private Texture2D SaveAtlasTexture(byte[] raw, int w, int h, string outDir, string fileName)
{
if (_useEXR)
{
var path = Path.Combine(outDir, fileName + ".exr").Replace("\\", "/");
var tmp = new Texture2D(w, h, TextureFormat.RGBAHalf, false);
tmp.LoadRawTextureData(raw);
tmp.Apply(false, false);
var bytes = tmp.EncodeToEXR(Texture2D.EXRFlags.CompressZIP);
DestroyImmediate(tmp);
File.WriteAllBytes(Path.GetFullPath(path), bytes);
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
ConfigureVATTextureImporter(path);
return AssetDatabase.LoadAssetAtPath(path);
}
else
{
var path = Path.Combine(outDir, fileName + ".asset").Replace("\\", "/");
var tex = new Texture2D(w, h, TextureFormat.RGBAHalf, false);
tex.LoadRawTextureData(raw);
tex.Apply(false, false);
tex.filterMode = FilterMode.Point;
tex.wrapMode = TextureWrapMode.Clamp;
AssetDatabase.CreateAsset(tex, path);
return tex;
}
}
private Mesh DecimateMesh(Mesh src, string outDir)
{
var simplifier = new MeshSimplifier();
simplifier.PreserveBorderEdges = _preserveBorderEdges;
simplifier.PreserveSurfaceCurvature = _preserveSurfaceCurvature;
simplifier.Initialize(src);
simplifier.SimplifyMesh(_quality);
var dst = simplifier.ToMesh();
dst.name = src.name + "_dec" + Mathf.RoundToInt(_quality * 100);
dst.RecalculateBounds();
var assetPath = Path.Combine(outDir, SanitizeFileName(dst.name) + ".asset").Replace("\\", "/");
AssetDatabase.CreateAsset(dst, assetPath);
return dst;
}
private static Shader ResolveShader()
{
var useURP = GraphicsSettings.renderPipelineAsset != null;
return Shader.Find(useURP ? URPShader : BuiltInShader);
}
private static void EnsureFolder(string assetPath)
{
assetPath = assetPath.Replace("\\", "/");
if (AssetDatabase.IsValidFolder(assetPath)) return;
var parts = assetPath.Split('/');
string current = parts[0];
for (int i = 1; i < parts.Length; i++)
{
var next = current + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next))
{
AssetDatabase.CreateFolder(current, parts[i]);
}
current = next;
}
}
private static string SanitizeFileName(string name)
{
foreach (var c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_');
return name;
}
// 把烘焙数据写到磁盘并返回 import 后的 Texture2D 引用。
// EXR 路径: 写入 .exr,触发 TextureImporter (RGBAHalf / Point / Uncompressed / Non-readable / no mipmaps)
// Asset 路径: 直接 CreateAsset Texture2D (原方案,兼容老工程)
private Texture2D SaveAnimMap(BakedData data, string outDir, string fileName)
{
if (_useEXR)
{
var path = Path.Combine(outDir, fileName + ".exr").Replace("\\", "/");
var tmp = new Texture2D(data.AnimMapWidth, data.AnimMapHeight, TextureFormat.RGBAHalf, false);
tmp.LoadRawTextureData(data.RawAnimMap);
tmp.Apply(false, false);
// 16bit half + ZIP 压缩 (~50% 磁盘体积)。OutputAsFloat 不加 = 16bit。
var bytes = tmp.EncodeToEXR(Texture2D.EXRFlags.CompressZIP);
DestroyImmediate(tmp);
var sysPath = Path.GetFullPath(path);
File.WriteAllBytes(sysPath, bytes);
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
ConfigureVATTextureImporter(path);
return AssetDatabase.LoadAssetAtPath(path);
}
else
{
var path = Path.Combine(outDir, fileName + ".asset").Replace("\\", "/");
var animMap = new Texture2D(data.AnimMapWidth, data.AnimMapHeight, TextureFormat.RGBAHalf, false);
animMap.LoadRawTextureData(data.RawAnimMap);
animMap.Apply(false, false);
AssetDatabase.CreateAsset(animMap, path);
return animMap;
}
}
// VAT 贴图的 importer 配置: 不能 sRGB / mipmap / NPOT 缩放 / 压缩 / 滤波,运行时不需要 readable
private static void ConfigureVATTextureImporter(string assetPath)
{
var ti = AssetImporter.GetAtPath(assetPath) as TextureImporter;
if (ti == null) return;
ti.textureType = TextureImporterType.Default;
ti.sRGBTexture = false;
ti.alphaSource = TextureImporterAlphaSource.FromInput;
ti.alphaIsTransparency = false;
ti.mipmapEnabled = false;
ti.isReadable = false;
ti.filterMode = FilterMode.Point;
ti.wrapMode = TextureWrapMode.Clamp;
ti.npotScale = TextureImporterNPOTScale.None;
// 默认平台
var def = ti.GetDefaultPlatformTextureSettings();
def.textureCompression = TextureImporterCompression.Uncompressed;
def.format = TextureImporterFormat.RGBAHalf;
ti.SetPlatformTextureSettings(def);
// 显式覆盖常见平台,避免移动端 import 时被默认压缩 (ASTC/ETC 不支持 RGBAHalf)
foreach (var platName in new[] { "Standalone", "Android", "iPhone", "WebGL" })
{
var ps = ti.GetPlatformTextureSettings(platName);
ps.overridden = true;
ps.textureCompression = TextureImporterCompression.Uncompressed;
ps.format = TextureImporterFormat.RGBAHalf;
ti.SetPlatformTextureSettings(ps);
}
ti.SaveAndReimport();
}
}
}