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