Unity自定义编辑器窗口开发:多场景管理与批量烘焙工具实战

📅 2026/8/5 14:09:34
Unity自定义编辑器窗口开发:多场景管理与批量烘焙工具实战
1. 项目概述为什么我们需要自定义编辑器窗口在Unity开发中我们每天打交道最多的就是那几个默认的编辑器窗口Hierarchy、Project、Inspector、Scene和Game。这些窗口功能强大但面对日益复杂的项目特别是大型开放世界、多场景协作或者需要频繁执行特定批量操作时它们就显得有些力不从心了。比如你想快速查看并修改分布在十几个场景中的同类型物体的某个参数或者需要一个专门的工具来管理游戏内的任务系统、对话树又或者想一键处理上百个预制体的光照贴图设置。这时候Unity提供的EditorWindow就成了我们扩展编辑器、提升生产力的利器。EditorWindow允许我们创建完全自定义的编辑器界面将零散的功能集成到一个专属面板中。这不仅仅是“方便”那么简单它直接关系到开发流程的优化、团队协作的效率以及项目长期维护的成本。一个设计良好的自定义编辑器窗口能将原本需要数小时的手动操作压缩到一次点击将容易出错的流程标准化让策划和美术同学也能安全地进行一些数据配置工作。今天我们就深入聊聊EditorWindow的进阶应用并聚焦于一个非常具体且高频的需求多场景编辑与优化。这是构建大型、可流式加载世界的基石也是提升编辑器内工作流的关键。2. 核心需求解析多场景编辑的痛点与机遇在深入代码之前我们必须先理解“多场景编辑”到底要解决什么问题。传统的Unity工作流中我们通常一次只编辑一个.unity场景文件。但对于一个庞大的游戏世界把所有内容塞进一个场景会导致编辑器卡顿、版本控制冲突、团队协作困难。因此将世界分割成多个小场景如“森林区域A”、“城镇广场”、“地下城入口”成为标准做法。然而这带来了新的挑战视觉断层编辑“森林区域A”时看不到与之相邻的“森林区域B”难以保证地形、光照、遮挡的衔接自然。资源管理繁琐需要反复打开、关闭场景来调整不同区域的内容。全局性操作困难想为所有场景统一烘焙光照、生成导航网格或者批量修改某个出现在所有场景中的环境参数变得异常麻烦。运行时加载逻辑复杂需要在代码中精确管理场景的加载、卸载和激活状态。Unity自2017.3版本左右开始大力推广的多场景编辑Multi-Scene Editing功能就是为了在编辑器内解决前三个问题。它允许你在Hierarchy窗口中同时加载多个场景像编辑单个场景一样查看和操作它们。但这只是基础要真正驾驭它并构建出高效的场景管理工具就需要我们自己的EditorWindow出马了。3. 工具选型与架构设计3.1 为何选择EditorWindow而非其他方案扩展Unity编辑器主要有几种方式自定义Inspector、Property Drawer、EditorWindow以及最新的UI Toolkit运行时UI。对于场景管理这种需要全局视图、复杂交互和独立操作空间的任务EditorWindow是最佳选择。自定义Inspector专注于单个游戏对象或资产视野受限。Property Drawer用于美化特定数据类型的Inspector显示功能单一。UI Toolkit运行时UI更适用于游戏内UI虽然也可用于编辑器但EditorWindow尤其是基于IMGUI在编辑器扩展中生态更成熟与现有Editor API集成更直接。我们将创建一个名为SceneManagerWindow的窗口它需要实现以下核心模块场景列表视图显示项目中所有场景资产并能进行加载、卸载、激活操作。多场景烘焙控制台集成光照Lightmapping、导航网格NavMesh、遮挡剔除Occlusion Culling的批量烘焙功能。场景依赖分析器分析场景间的引用关系预防资源丢失。快捷操作面板提供一键保存所有场景、设置活动场景等常用功能。3.2 核心API梳理实现上述功能我们需要深入理解Unity提供的几个关键APIUnityEditor.SceneManagement.EditorSceneManager这是编辑器场景管理的核心。它提供了OpenScene打开场景、SaveScene保存场景、CloseScene关闭场景等方法并且支持OpenSceneMode.Additive叠加模式来实现多场景同时打开。UnityEngine.SceneManagement.SceneManager这个在运行时使用用于游戏运行时的场景加载卸载。在编辑器工具中我们主要用EditorSceneManager。Lightmapping、UnityEditor.AI.NavMeshBuilder、UnityEditor.OcclusionCulling这些类提供了以编程方式执行烘焙的接口是实现批量处理的关键。EditorUtility和AssetDatabase用于显示进度条、刷新资源、查找场景文件等辅助操作。注意在编辑器脚本中所有相关类都必须放在Assets目录下名为Editor的文件夹中或其子目录否则编译会报错。4. 实现详解构建SceneManagerWindow4.1 创建基础窗口与UI布局首先我们创建基础的窗口类。这里使用传统的IMGUI系统因为它对于快速构建编辑器工具仍然非常高效和灵活。using UnityEditor; using UnityEngine; using UnityEngine.SceneManagement; using System.Collections.Generic; using System.IO; using System.Linq; public class SceneManagerWindow : EditorWindow { // 单例访问点 [MenuItem(Tools/Advanced/Scene Manager)] public static void ShowWindow() { var window GetWindowSceneManagerWindow(); window.titleContent new GUIContent(场景管家); window.Show(); } private Vector2 _sceneListScrollPos; private ListSceneAsset _allSceneAssets; private Dictionarystring, bool _sceneLoadState new Dictionarystring, bool(); private void OnEnable() { RefreshSceneList(); EditorApplication.update OnEditorUpdate; } private void OnDisable() { EditorApplication.update - OnEditorUpdate; } void OnGUI() { DrawToolbar(); EditorGUILayout.Space(); DrawSceneListPanel(); EditorGUILayout.Space(); DrawBatchOperationsPanel(); } }OnEnable中我们刷新场景列表并订阅EditorApplication.update以便实时更新UI状态比如场景加载后按钮状态需要改变。OnGUI方法定义了窗口的基本布局一个工具栏、一个场景列表面板和一个批量操作面板。4.2 实现场景列表与加载管理这是工具的核心交互部分。我们需要扫描项目中的所有.unity文件并以列表形式展示同时显示其当前在编辑器中的状态已加载/未加载是否为活动场景。private void RefreshSceneList() { // 查找所有.unity文件 string[] sceneGuids AssetDatabase.FindAssets(t:Scene); _allSceneAssets new ListSceneAsset(); _sceneLoadState.Clear(); foreach (string guid in sceneGuids) { string path AssetDatabase.GUIDToAssetPath(guid); SceneAsset sceneAsset AssetDatabase.LoadAssetAtPathSceneAsset(path); if (sceneAsset ! null) { _allSceneAssets.Add(sceneAsset); // 检查该场景是否已加载到Hierarchy中 Scene scene SceneManager.GetSceneByPath(path); _sceneLoadState[path] scene.IsValid() scene.isLoaded; } } } private void DrawSceneListPanel() { EditorGUILayout.LabelField(场景列表, EditorStyles.boldLabel); _sceneListScrollPos EditorGUILayout.BeginScrollView(_sceneListScrollPos, GUILayout.Height(300)); Scene activeScene SceneManager.GetActiveScene(); foreach (var sceneAsset in _allSceneAssets) { string path AssetDatabase.GetAssetPath(sceneAsset); Scene scene SceneManager.GetSceneByPath(path); bool isLoaded scene.IsValid() scene.isLoaded; bool isActive scene.path activeScene.path; EditorGUILayout.BeginHorizontal(); // 显示场景名称和状态 string status isLoaded ? (isActive ? [活跃] : [已加载]) : [未加载]; EditorGUILayout.LabelField(${sceneAsset.name} {status}, GUILayout.Width(200)); // 加载/卸载按钮 GUI.enabled !isLoaded; if (GUILayout.Button(加载, GUILayout.Width(50))) { EditorSceneManager.OpenScene(path, OpenSceneMode.Additive); } GUI.enabled isLoaded; if (GUILayout.Button(卸载, GUILayout.Width(50))) { EditorSceneManager.CloseScene(scene, false); // false表示不移除只是卸载 } GUI.enabled true; // 设置为活动场景按钮 GUI.enabled isLoaded !isActive; if (GUILayout.Button(激活, GUILayout.Width(50))) { EditorSceneManager.SetActiveScene(scene); } GUI.enabled true; // 在Project窗口中定位 if (GUILayout.Button(定位, GUILayout.Width(50))) { EditorGUIUtility.PingObject(sceneAsset); } EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); // 全局操作按钮 EditorGUILayout.BeginHorizontal(); if (GUILayout.Button(刷新列表)) { RefreshSceneList(); } if (GUILayout.Button(保存所有场景)) { EditorSceneManager.SaveOpenScenes(); } if (GUILayout.Button(关闭所有非活跃场景)) { CloseAllNonActiveScenes(); } EditorGUILayout.EndHorizontal(); } private void CloseAllNonActiveScenes() { Scene activeScene SceneManager.GetActiveScene(); ListScene scenesToClose new ListScene(); for (int i 0; i SceneManager.sceneCount; i) { Scene scene SceneManager.GetSceneAt(i); if (scene.IsValid() scene.path ! activeScene.path) { scenesToClose.Add(scene); } } // 需要从后往前关闭避免索引变化 for (int i scenesToClose.Count - 1; i 0; i--) { EditorSceneManager.CloseScene(scenesToClose[i], true); // true表示从Hierarchy移除 } }这段代码实现了场景的动态列表展示并根据场景的当前状态是否加载、是否活跃来启用或禁用对应的操作按钮。CloseAllNonActiveScenes函数演示了如何安全地批量操作场景——注意循环顺序从后往前关闭可以避免因场景移除导致的索引错乱问题。4.3 实现批量烘焙功能这是“场景优化”的核心。我们将光照、导航和遮挡剔除的烘焙集成到一个面板中并提供批量处理的选项。private bool _foldoutBatchBake false; private bool _bakeLighting true; private bool _bakeNavMesh true; private bool _bakeOcclusion false; private void DrawBatchOperationsPanel() { _foldoutBatchBake EditorGUILayout.Foldout(_foldoutBatchBake, 批量烘焙工具, true); if (_foldoutBatchBake) { EditorGUI.indentLevel; _bakeLighting EditorGUILayout.Toggle(烘焙光照贴图, _bakeLighting); _bakeNavMesh EditorGUILayout.Toggle(烘焙导航网格, _bakeNavMesh); _bakeOcclusion EditorGUILayout.Toggle(烘焙遮挡剔除, _bakeOcclusion); EditorGUILayout.HelpBox(注意批量烘焙将针对所有当前已加载的场景进行。确保已加载需要烘焙的场景集。, MessageType.Info); if (GUILayout.Button(执行批量烘焙, GUILayout.Height(30))) { BakeAllLoadedScenes(); } EditorGUI.indentLevel--; } } private void BakeAllLoadedScenes() { // 1. 保存所有场景防止数据丢失 if (!EditorSceneManager.SaveOpenScenes()) { EditorUtility.DisplayDialog(警告, 保存场景失败烘焙已取消。, 确定); return; } // 2. 记录当前活动场景烘焙后恢复 Scene originalActiveScene SceneManager.GetActiveScene(); try { // 3. 遍历所有已加载场景依次设置为活动场景并执行烘焙 for (int i 0; i SceneManager.sceneCount; i) { Scene scene SceneManager.GetSceneAt(i); if (!scene.IsValid() || !scene.isLoaded) continue; EditorSceneManager.SetActiveScene(scene); string sceneName scene.name; // 更新进度条 float progress (float)i / SceneManager.sceneCount; if (EditorUtility.DisplayCancelableProgressBar(批量烘焙, $正在处理场景: {sceneName}, progress)) { Debug.LogWarning(批量烘焙被用户取消。); break; } Debug.Log($开始处理场景: {sceneName}); // 烘焙光照贴图 if (_bakeLighting) { Debug.Log($ - 开始烘焙光照贴图...); // 注意这里调用的是静态方法针对当前活动场景 Lightmapping.BakeAsync(); // 异步烘焙更友好 // 如果需要同步等待可以循环检查 Lightmapping.isRunning while (Lightmapping.isRunning) { System.Threading.Thread.Sleep(100); if (EditorUtility.DisplayCancelableProgressBar(批量烘焙, $场景 {sceneName}: 光照烘焙中..., progress)) { Lightmapping.Cancel(); break; } } } // 烘焙导航网格 (需要确保Navigation窗口的设置已配置好) if (_bakeNavMesh) { Debug.Log($ - 开始烘焙导航网格...); // UnityEditor.AI.NavMeshBuilder.BuildNavMesh(); // 旧API UnityEditor.AI.NavMeshBuilder.BuildNavMeshAsync(); // 异步构建 // 同样可以等待异步操作完成 } // 烘焙遮挡剔除 if (_bakeOcclusion) { Debug.Log($ - 开始烘焙遮挡剔除...); // 注意Occlusion Culling的烘焙API可能因Unity版本而异 // 一种常见做法是调用UnityEditorInternal.OcclusionCulling的静态方法但这不是公开API。 // 更稳妥的方式是执行菜单命令模拟用户点击。 // 这里仅作示意实际生产环境需更严谨的处理。 // EditorApplication.ExecuteMenuItem(Window/Rendering/Occlusion Culling); // 然后找到Bake按钮并触发... Debug.LogWarning(遮挡剔除的编程化烘焙需要调用内部API或模拟点击此处略过。建议在Occlusion Culling窗口手动烘焙。); } // 保存该场景的烘焙结果 EditorSceneManager.SaveScene(scene); } } catch (System.Exception e) { Debug.LogError($批量烘焙过程中发生错误: {e.Message}); } finally { // 恢复活动场景 EditorSceneManager.SetActiveScene(originalActiveScene); EditorUtility.ClearProgressBar(); AssetDatabase.Refresh(); // 刷新资源数据库让新生成的Lightmap等资源显示出来 Debug.Log(批量烘焙流程结束。); } }重要提示上述代码中的光照和导航烘焙是示意性的。Lightmapping.BakeAsync()和NavMeshBuilder.BuildNavMeshAsync()是异步操作在实际工具中你需要实现更完善的异步任务管理和进度反馈或者使用同步方法并妥善处理编辑器卡顿。遮挡剔除的API在较新版本的Unity中可能有所变化且部分功能可能未完全公开生产级工具需要查阅对应版本的官方文档或使用EditorApplication.ExecuteMenuItem来触发烘焙。4.4 场景依赖分析与资源管理一个高级的场景管理工具还应该能预警风险。例如检测场景中是否有丢失的引用Missing References或者分析场景之间通过DontDestroyOnLoad对象产生的隐性依赖。private void DrawDependencyPanel() { if (GUILayout.Button(分析场景依赖与丢失引用)) { AnalyzeSceneDependencies(); } } private void AnalyzeSceneDependencies() { StringBuilder report new StringBuilder(); report.AppendLine( 场景依赖分析报告 ); foreach (var sceneAsset in _allSceneAssets) { string path AssetDatabase.GetAssetPath(sceneAsset); report.AppendLine($\n--- 分析场景: {sceneAsset.name} ({path}) ---); // 打开场景以附加模式避免关闭其他已打开的场景 Scene scene EditorSceneManager.OpenScene(path, OpenSceneMode.Additive); GameObject[] rootGOs scene.GetRootGameObjects(); int missingRefCount 0; // 遍历场景根物体及其所有子物体 foreach (var root in rootGOs) { CheckGameObjectForMissingReferences(root, ref missingRefCount, report); } if (missingRefCount 0) { report.AppendLine( √ 未检测到丢失的引用。); } else { report.AppendLine($ ! 警告: 发现 {missingRefCount} 个丢失的引用。); } // 关闭场景不保存 EditorSceneManager.CloseScene(scene, false); } // 将报告输出到控制台和一个可滚动的文本区域 Debug.Log(report.ToString()); // 这里可以扩展为将report显示在窗口内的一个TextArea中 } private void CheckGameObjectForMissingReferences(GameObject go, ref int count, StringBuilder report) { Component[] components go.GetComponentsComponent(); foreach (var comp in components) { if (comp null) { // 这是一个丢失的组件引用例如脚本被删除 count; report.AppendLine($ 丢失的组件在: {go.name} (GameObject)); continue; } // 使用SerializedObject来检查所有序列化属性 SerializedObject so new SerializedObject(comp); SerializedProperty prop so.GetIterator(); while (prop.NextVisible(true)) { if (prop.propertyType SerializedPropertyType.ObjectReference) { if (prop.objectReferenceValue null prop.objectReferenceInstanceIDValue ! 0) { // objectReferenceValue为null但InstanceID不为0表示引用丢失 count; report.AppendLine($ 丢失的引用: {go.name}/{comp.GetType().Name}.{prop.name}); } } } } // 递归检查子物体 foreach (Transform child in go.transform) { CheckGameObjectForMissingReferences(child.gameObject, ref count, report); } }这个分析功能会遍历场景中的每一个组件通过SerializedObject检查其所有对象引用属性。如果发现引用目标丢失例如一个public GameObject变量曾经指向某个预制体但现在该预制体被删除了就会记录下来。这对于在项目资源结构发生变动后快速定位问题场景非常有用。5. 进阶技巧与性能优化5.1 使用UI Toolkit重构界面虽然IMGUI快速灵活但在构建复杂、动态的编辑器窗口时UI Toolkit在性能、样式控制和复杂布局方面更有优势。从Unity 2021 LTS开始UI Toolkit for Editor已经相当成熟。你可以创建一个UXML文件定义界面结构一个USS文件定义样式然后在CreateGUI方法中加载它们。// 在SceneManagerWindow类中添加 public class SceneManagerWindow : EditorWindow { private VisualElement _root; private ListView _sceneListView; private ListSceneInfo _sceneListData new ListSceneInfo(); private void CreateGUI() { _root rootVisualElement; // 加载UXML和USS var visualTree AssetDatabase.LoadAssetAtPathVisualTreeAsset(Assets/Editor/SceneManagerWindow.uxml); var styleSheet AssetDatabase.LoadAssetAtPathStyleSheet(Assets/Editor/SceneManagerWindow.uss); _root.styleSheets.Add(styleSheet); VisualElement uxmlContainer visualTree.Instantiate(); _root.Add(uxmlContainer); // 获取UI元素并绑定事件 _sceneListView _root.QListView(scene-list-view); SetupListView(); } private void SetupListView() { // 设置ListView的数据源、模板和绑定 _sceneListView.makeItem () new Label(); _sceneListView.bindItem (element, index) { (element as Label).text _sceneListData[index].name; }; _sceneListView.itemsSource _sceneListData; _sceneListView.selectionChanged OnSceneSelected; } }使用UI Toolkit后列表渲染效率更高尤其是当场景数量成百上千时。同时你可以轻松实现搜索过滤、多选、拖拽排序等高级交互。5.2 异步操作与进度反馈批量烘焙是耗时操作必须做好异步处理和用户反馈。除了使用EditorUtility.DisplayProgressBar更现代的做法是利用EditorAsyncOperation如果API可用或者配合async/await与IEnumerator协程通过EditorCoroutineUtility.StartCoroutine来保持编辑器响应。using System.Collections; using Unity.EditorCoroutines.Editor; private IEnumerator BakeScenesCoroutine(ListScene scenesToBake) { for (int i 0; i scenesToBake.Count; i) { Scene scene scenesToBake[i]; EditorSceneManager.SetActiveScene(scene); // 异步烘焙光照示例伪代码需根据实际API调整 // var bakeOperation Lightmapping.BakeAsync(); // while (!bakeOperation.isDone) // { // EditorUtility.DisplayProgressBar(批量烘焙, $烘焙 {scene.name}..., (i bakeOperation.progress) / scenesToBake.Count); // yield return null; // 等待一帧 // } yield return new EditorWaitForSeconds(0.5f); // 模拟耗时 } EditorUtility.ClearProgressBar(); } // 在按钮事件中启动协程 private void OnBatchBakeButtonClicked() { EditorCoroutineUtility.StartCoroutine(BakeScenesCoroutine(loadedScenes), this); }5.3 场景配置的保存与加载一个专业的工具应该能记住用户的常用场景组合。我们可以利用EditorPrefs或者ScriptableObject来保存“场景配置集”Scene Setup。[System.Serializable] public class SceneSetupConfig { public string configName; public Liststring scenePaths; // 需要加载的场景路径 public string activeScenePath; // 活动场景路径 } public class SceneSetupAsset : ScriptableObject { public ListSceneSetupConfig configs; } // 在窗口中提供保存和加载配置的功能 private void SaveCurrentSetup(string configName) { SceneSetup[] setups EditorSceneManager.GetSceneManagerSetup(); SceneSetupConfig config new SceneSetupConfig(); config.configName configName; config.scenePaths setups.Select(s s.path).ToList(); config.activeScenePath SceneManager.GetActiveScene().path; // 将config添加到ScriptableObject或保存为JSON }这样美术可以快速加载“室外关卡”配置策划可以加载“剧情关卡”配置极大提升切换上下文的速度。6. 常见问题与排查技巧实录在实际开发和使用自定义场景管理工具时我踩过不少坑这里分享几个最典型的问题1烘焙后场景变“粉红”丢失材质。原因这通常是因为烘焙过程尤其是光照烘焙修改了场景中某些渲染器的材质或材质属性但场景在烘焙后没有成功保存或者保存过程中出现错误。排查首先检查Console窗口是否有任何错误或警告。然后在烘焙前务必使用EditorSceneManager.SaveOpenScenes()保存所有修改。烘焙完成后立即检查场景文件是否已更新时间戳。如果问题依旧尝试关闭Unity并删除Library文件夹中的ShaderCache和SourceAssetDB等缓存目录然后重新打开项目。问题2批量烘焙导航网格时只有第一个场景生效。原因导航网格NavMesh数据在Unity中默认是全局的烘焙后存储在项目设置相关的资产中。当你在编辑器内切换活动场景时导航网格设置可能被覆盖。解决方案在批量烘焙每个场景前确保该场景的导航网格设置在Navigation窗口是正确的并且使用NavMeshBuilder.BuildNavMeshAsync()时它作用于当前场景的静态几何体。更可靠的做法是为每个场景单独烘焙并保存导航网格数据资产通过NavMeshBuilder.BuildNavMeshForMultipleScenes这个内部API但需谨慎使用然后在运行时动态加载。问题3自定义窗口在打开时卡顿尤其是场景很多时。原因OnGUI每帧调用如果在其中执行了昂贵的操作如频繁调用AssetDatabase.FindAssets或遍历所有场景的根物体就会导致卡顿。优化缓存数据在OnEnable中获取场景列表并监听AssetDatabase.OnPostprocessAllAssets事件只在资产发生变化时刷新缓存。分帧/异步加载对于成百上千的场景不要一次性全部加载信息。使用ListView的虚拟化技术或者分帧遍历场景来获取其状态。减少序列化操作SerializedObject的创建和遍历比较耗时依赖分析这类功能应在用户显式点击按钮时执行并显示进度条。问题4工具脚本在构建后报错。原因编辑器脚本放在Editor文件夹下的使用了UnityEditor命名空间中的类这些类在运行时构建后的游戏中不存在。解决方案确保所有编辑器相关的代码都放在Editor目录下并且用#if UNITY_EDITOR和#endif预编译指令包裹整个类或特定方法。对于通过[MenuItem]调用的方法Unity会自动处理但工具窗口类本身需要条件编译。#if UNITY_EDITOR using UnityEditor; public class SceneManagerWindow : EditorWindow { // ... 所有代码 } #endif问题5多场景编辑时Inspector中看不到某些对象的引用。原因这是Unity编辑器的一个已知限制。在编辑模式下跨场景的组件引用比如场景A中的一个脚本引用了场景B中的一个GameObject在Inspector中可能无法正确显示或选择。变通方案尽量避免在编辑期建立跨场景的对象引用。如果必须可以考虑使用间接引用如通过字符串名称或GUID在运行时动态查找或者将需要共享的对象放在一个常驻场景如“Manager”场景中并通过DontDestroyOnLoad或场景加载管理来访问。