Unity材质批量处理:一键替换与创建,告别手动拖拽

📅 2026/8/7 5:51:46
Unity材质批量处理:一键替换与创建,告别手动拖拽
1. 项目概述告别重复劳动用脚本解放双手在Unity项目开发中尤其是涉及大量美术资源时材质球的管理和设置绝对是一个高频且磨人的环节。想象一下这样的场景美术同学交付了上百个模型每个模型都带着一个默认材质但你需要根据项目规范统一将它们替换为URP或HDRP管线下的特定材质或者需要批量修改材质的某个属性比如将金属度统一调为0.5。手动操作意味着你需要一个个选中模型在Inspector面板里拖拽材质球或者一个个打开材质文件进行修改。这不仅效率低下而且极易出错一旦需求变更返工成本巨大。这正是“别再手动拖材质了”这个标题直击的痛点。它指向的解决方案是利用Unity强大的Editor脚本功能实现材质球的一键批量创建与替换。这不仅仅是写几行代码那么简单它背后是一套提升团队协作效率、保证资源规范性的工程化思维。对于任何经历过中型以上Unity项目开发的程序、TA技术美术甚至是有心的美术同学来说掌握这项技能都是刚需。它能将数小时甚至数天的重复劳动压缩到一次点击和几秒钟的等待中。本文将从一个实际开发者的角度手把手拆解如何从零构建这样一个实用的编辑器工具。我们会从需求分析、脚本设计、核心API详解一直讲到实际应用中的各种“坑”和优化技巧并提供可直接复用的完整源码。无论你是想快速解决手头问题的开发者还是希望深入理解Unity Editor扩展机制的学习者这篇文章都将提供一条清晰的路径。2. 核心需求与设计思路拆解在动手写代码之前我们必须先明确这个工具要解决的具体问题是什么以及如何设计才能让它既强大又易用。盲目开始编码很容易做出一个“一次性”脚本难以应对复杂多变的实际需求。2.1 需求场景深度剖析批量处理材质的需求远不止“替换”这么简单它通常伴随着以下几个具体场景管线迁移与统一项目从内置渲染管线升级到URP/HDRP需要将场景中所有旧材质批量替换为新管线下的对应材质。新材质可能需要继承旧材质的部分属性如主贴图、颜色。材质预设化为特定类型的模型如所有石头、所有树木创建材质预设Material Preset然后批量将散落的材质实例应用为这些预设。属性批量调整需要将选中的上百个材质球的“平滑度”统一调低或者为所有透明材质的“渲染队列”设置为“Transparent”。资源规范化整理美术提供的模型材质命名混乱、路径随意需要批量收集这些材质重命名后放入指定文件夹并更新模型的引用。基于规则的材质创建根据模型名称、所在文件夹等信息自动为其创建并分配特定类型的材质例如所有在“Props/Metal”文件夹下的模型自动使用“Metal_Default”材质。一个健壮的工具应该能覆盖以上大部分场景或者至少提供清晰的扩展接口。2.2 工具设计核心思路基于上述场景我们的工具设计应遵循以下原则以资产Asset和游戏对象GameObject为操作核心用户可能想处理Project窗口中的材质文件也可能想处理Hierarchy窗口或场景中的模型对象。工具需要同时支持这两种入口。提供“匹配与替换”与“批量创建”两种模式模式一查找并替换。在选定对象中查找所有使用了特定材质或符合某些条件材质的渲染器将其材质替换为目标材质。这是最直接的需求。模式二创建并分配。为选定的模型如所有MeshFilter或SkinnedMeshRenderer自动创建新的材质球可根据模板并分配给它。这常用于初始化或规范化。操作可预览与可撤销在Unity Editor中执行任何批量操作都必须支持Undo撤销。同时在执行前最好能给用户一个预览告知将影响多少个对象避免误操作。保持灵活性与可扩展性通过设计良好的MaterialProcessor基类或接口让后续可以轻松添加新的处理规则比如“根据纹理名称设置材质属性”、“复制材质属性到新材质”等。我们的实现将围绕一个核心的EditorWindow展开它提供图形界面内部则依赖一系列静态工具方法来执行具体的材质处理逻辑。3. 核心API与关键技术点解析要实现批量操作我们必须深入理解Unity Editor API中关于资产、对象选择和序列化操作的几个关键部分。这是整个工具的基石。3.1 如何获取要处理的对象Selection类一切始于选择。用户可能在Project窗口选择了多个材质球文件也可能在Hierarchy或Scene视图选择了多个游戏对象。UnityEditor.Selection类是我们的入口。using UnityEditor; using UnityEngine; // 获取当前在Project窗口选中的资产材质、模型等 Object[] selectedAssets Selection.GetFiltered(typeof(Material), SelectionMode.Assets); // 获取当前在Hierarchy/Scene窗口选中的游戏对象 GameObject[] selectedGameObjects Selection.gameObjects;SelectionMode.Assets确保我们只拿到Project视图中的资产。Selection.gameObjects则直接拿到场景中的对象。一个完整的工具通常需要同时处理这两种输入并可能将它们合并到一个统一的对象列表中进行处理。注意Selection是静态类它反映的是Editor的当前状态。在菜单项触发的方法中可以直接使用它来获取用户选择的内容。3.2 如何遍历与修改材质Renderer与Material拿到游戏对象后我们需要找到其上的渲染组件MeshRenderer,SkinnedMeshRenderer等然后获取或设置其材质。foreach (GameObject go in selectedGameObjects) { // 获取对象上包括子物体所有的Renderer Renderer[] renderers go.GetComponentsInChildrenRenderer(true); foreach (Renderer renderer in renderers) { // 获取共享材质列表修改会影响所有使用该材质的对象 Material[] sharedMats renderer.sharedMaterials; // 获取实例材质列表修改会创建该对象独有的材质实例 // Material[] instanceMats renderer.materials; for (int i 0; i sharedMats.Length; i) { Material oldMat sharedMats[i]; // 在这里判断oldMat是否需要被替换 if (ShouldReplace(oldMat)) { // 替换材质 sharedMats[i] targetMaterial; // 重要修改数组后必须重新赋值回renderer renderer.sharedMaterials sharedMats; // 记录Undo操作 Undo.RecordObject(renderer, Replace Material); } } } }关键抉择sharedMaterialsvsmaterialssharedMaterials指向Project中的材质资产文件。修改它所有使用该材质的模型都会改变。适用于全局性的材质规范替换。materials属性获取器getter会为当前渲染器创建材质的实例如果之前是共享的。修改的是实例不影响其他对象。适用于需要为特定模型单独调整材质的情况。在批量替换工具中我们绝大多数情况应该使用sharedMaterials因为我们希望进行的是资产级别的统一管理。误用materials会导致大量材质实例被创建严重膨胀项目体积。3.3 如何创建与保存新材质AssetDatabase类在“批量创建”模式下我们需要在代码中生成新的材质球并保存为资产文件。using UnityEditor; using UnityEngine; // 1. 创建材质实例在内存中 Material newMaterial new Material(shader); // 或基于一个模板材质创建 // Material newMaterial new Material(templateMaterial); // 2. 设置材质属性 newMaterial.SetTexture(_MainTex, defaultTexture); newMaterial.SetColor(_Color, Color.white); // 3. 定义保存路径 string folderPath Assets/Materials/Generated/; if (!AssetDatabase.IsValidFolder(folderPath)) { AssetDatabase.CreateFolder(Assets/Materials, Generated); } string materialPath folderPath materialName .mat; // 4. 保存为资产 AssetDatabase.CreateAsset(newMaterial, materialPath); AssetDatabase.SaveAssets(); // 将更改写入磁盘 AssetDatabase.Refresh(); // 刷新编辑器使新文件可见重要细节AssetDatabase.CreateAsset只能将对象保存到Assets目录下的路径。保存前务必确保目标文件夹存在否则会失败。AssetDatabase.SaveAssets()和AssetDatabase.Refresh()通常需要一起调用以确保更改持久化且编辑器状态更新。3.4 如何实现撤销操作Undo类在编辑器脚本中任何修改场景对象或资产的操作都必须支持撤销。这是Editor工具专业性的体现。// 在修改对象属性之前记录它的状态 Undo.RecordObject(renderer, Replace Material on renderer.name); renderer.sharedMaterial newMaterial; // 如果是创建并保存了新资产也需要记录 // 但AssetDatabase.CreateAsset的撤销比较复杂通常我们通过操作前备份、操作后提供撤销选项如删除生成的文件来实现。对于批量操作对每个被修改的对象调用Undo.RecordObject是标准做法。虽然这可能在一次操作中产生多条撤销记录但这是Unity Editor的标准行为用户可以通过一次撤销命令回退所有更改。4. 工具实现分步构建编辑器窗口理论铺垫足够现在我们来搭建一个名为MaterialBatchProcessor的编辑器窗口。我们将分步骤实现其核心功能。4.1 创建EditorWindow与基础UI首先创建一个继承自EditorWindow的类并添加一个菜单项来打开它。using UnityEditor; using UnityEngine; using System.Collections.Generic; using System.IO; public class MaterialBatchProcessor : EditorWindow { // 单例模式便于访问 private static MaterialBatchProcessor window; [MenuItem(Tools/材质工具/批量材质处理器)] public static void ShowWindow() { window GetWindowMaterialBatchProcessor(); window.titleContent new GUIContent(批量材质处理); window.Show(); } private void OnGUI() { DrawMainUI(); } private void DrawMainUI() { EditorGUILayout.Space(10); EditorGUILayout.LabelField(批量材质创建与替换工具, EditorStyles.boldLabel); EditorGUILayout.Space(5); // ... 更多UI控件将在下面添加 } }4.2 设计UI布局与功能分区我们的工具界面需要清晰地区分两种主要模式和一些配置选项。// 在类内部定义变量 private enum ProcessMode { Replace, Create } private ProcessMode currentMode ProcessMode.Replace; // 替换模式所需变量 private Material sourceMaterialToFind; // 要查找的源材质可选 private Material targetMaterialToAssign; // 要分配的目标材质 // 创建模式所需变量 private Shader shaderForNewMaterial; // 新材质的Shader private Material templateMaterial; // 材质模板可选 private string newMaterialNamePrefix NewMat_; private string saveFolderPath Assets/Materials/; // 操作范围 private bool includeChildren true; private bool processSelectedAssets true; private bool processSelectedGameObjects true; private void DrawMainUI() { // 1. 模式选择 currentMode (ProcessMode)EditorGUILayout.EnumPopup(处理模式, currentMode); EditorGUILayout.Space(10); EditorGUILayout.LabelField(操作设置, EditorStyles.boldLabel); // 2. 根据模式绘制不同设置 switch (currentMode) { case ProcessMode.Replace: DrawReplaceModeUI(); break; case ProcessMode.Create: DrawCreateModeUI(); break; } EditorGUILayout.Space(10); EditorGUILayout.LabelField(操作范围, EditorStyles.boldLabel); processSelectedGameObjects EditorGUILayout.Toggle(处理选中的游戏对象, processSelectedGameObjects); processSelectedAssets EditorGUILayout.Toggle(处理选中的材质资产, processSelectedAssets); if (processSelectedGameObjects) { includeChildren EditorGUILayout.Toggle(包含子物体, includeChildren); } EditorGUILayout.Space(20); // 3. 执行按钮 GUI.enabled IsExecuteButtonEnabled(); if (GUILayout.Button(执行批量处理, GUILayout.Height(30))) { ExecuteBatchProcess(); } GUI.enabled true; // 4. 状态信息显示区域可选 EditorGUILayout.Space(10); EditorGUILayout.HelpBox(GetStatusMessage(), MessageType.Info); } private void DrawReplaceModeUI() { EditorGUILayout.HelpBox(替换模式将找到的材质替换为目标材质。若留空“查找材质”则替换所有材质。, MessageType.None); sourceMaterialToFind (Material)EditorGUILayout.ObjectField(查找材质 (可选), sourceMaterialToFind, typeof(Material), false); targetMaterialToAssign (Material)EditorGUILayout.ObjectField(目标材质, targetMaterialToAssign, typeof(Material), false); } private void DrawCreateModeUI() { EditorGUILayout.HelpBox(创建模式为选中的模型创建新材质并分配。, MessageType.None); templateMaterial (Material)EditorGUILayout.ObjectField(材质模板 (可选), templateMaterial, typeof(Material), false); if (templateMaterial null) { shaderForNewMaterial (Shader)EditorGUILayout.ObjectField(Shader, shaderForNewMaterial, typeof(Shader), false); } newMaterialNamePrefix EditorGUILayout.TextField(材质名前缀, newMaterialNamePrefix); saveFolderPath EditorGUILayout.TextField(保存路径, saveFolderPath); if (GUILayout.Button(选择文件夹...)) { string path EditorUtility.SaveFolderPanel(选择材质保存文件夹, saveFolderPath, ); if (!string.IsNullOrEmpty(path)) { // 将绝对路径转换为相对于项目的路径 saveFolderPath Assets path.Replace(Application.dataPath, ); } } }4.3 实现核心处理逻辑这是工具的心脏。我们将根据模式编写具体的处理函数。private void ExecuteBatchProcess() { if (EditorUtility.DisplayDialog(批量处理确认, $即将执行批量{currentMode}操作此操作可能无法撤销。是否继续, 继续, 取消)) { int processedCount 0; // 收集所有需要处理的渲染器 ListRenderer allRenderersToProcess new ListRenderer(); // 处理选中的游戏对象 if (processSelectedGameObjects Selection.gameObjects.Length 0) { foreach (GameObject go in Selection.gameObjects) { Renderer[] renderers includeChildren ? go.GetComponentsInChildrenRenderer(true) : go.GetComponentsRenderer(); allRenderersToProcess.AddRange(renderers); } } // 处理选中的材质资产这种需求较少通常是找到使用该材质的对象 // 注意直接替换资产文件本身是危险的这里我们选择查找场景中使用该材质的对象 if (processSelectedAssets sourceMaterialToFind ! null) { // 这是一个更高级的功能在场景中查找使用特定材质的所有渲染器 // 为了简化这里我们提示用户 if (EditorUtility.DisplayDialog(查找材质使用情况, 查找场景中使用该材质的对象功能较复杂建议通过游戏对象选择来处理。是否跳过资产处理, 跳过, 取消)) { // 跳过 } else { return; // 用户取消 } } if (allRenderersToProcess.Count 0) { EditorUtility.DisplayDialog(无对象可处理, 未找到任何可处理的渲染器。请确保已选中游戏对象。, 确定); return; } // 根据模式执行 switch (currentMode) { case ProcessMode.Replace: processedCount ExecuteReplace(allRenderersToProcess); break; case ProcessMode.Create: processedCount ExecuteCreate(allRenderersToProcess); break; } Debug.Log($批量处理完成共处理了 {processedCount} 个渲染器。); // 刷新编辑器确保更改可见 AssetDatabase.Refresh(); } } private int ExecuteReplace(ListRenderer renderers) { if (targetMaterialToAssign null) { EditorUtility.DisplayDialog(错误, 请指定目标材质。, 确定); return 0; } int count 0; foreach (Renderer renderer in renderers) { Material[] mats renderer.sharedMaterials; bool changed false; for (int i 0; i mats.Length; i) { // 如果未指定源材质则替换所有否则只替换匹配的 if (sourceMaterialToFind null || mats[i] sourceMaterialToFind) { if (mats[i] ! targetMaterialToAssign) // 避免重复赋值 { Undo.RecordObject(renderer, $Replace Material on {renderer.name}); mats[i] targetMaterialToAssign; changed true; count; } } } if (changed) { renderer.sharedMaterials mats; EditorUtility.SetDirty(renderer); // 标记对象为“脏”确保更改被保存 } } return count; } private int ExecuteCreate(ListRenderer renderers) { if (templateMaterial null shaderForNewMaterial null) { EditorUtility.DisplayDialog(错误, 请指定材质模板或Shader。, 确定); return 0; } // 确保保存文件夹存在 if (!AssetDatabase.IsValidFolder(saveFolderPath)) { string parentFolder Path.GetDirectoryName(saveFolderPath.TrimEnd(/)); string folderName Path.GetFileName(saveFolderPath.TrimEnd(/)); if (!string.IsNullOrEmpty(parentFolder) !string.IsNullOrEmpty(folderName)) { AssetDatabase.CreateFolder(parentFolder, folderName); } else { EditorUtility.DisplayDialog(错误, 保存路径无效。, 确定); return 0; } } int count 0; int materialIndex 0; foreach (Renderer renderer in renderers) { Material[] mats renderer.sharedMaterials; bool changed false; for (int i 0; i mats.Length; i) { // 为每个材质槽位创建新材质简单策略 Material newMat; if (templateMaterial ! null) { newMat new Material(templateMaterial); } else { newMat new Material(shaderForNewMaterial); } // 设置一个可区分的名字 string newMatName ${newMaterialNamePrefix}{renderer.name}_{materialIndex}.mat; // 确保名字在文件夹内唯一简单处理 string fullPath Path.Combine(saveFolderPath, newMatName).Replace(\\, /); fullPath AssetDatabase.GenerateUniqueAssetPath(fullPath); // 保存材质资产 AssetDatabase.CreateAsset(newMat, fullPath); Undo.RecordObject(renderer, $Assign New Material to {renderer.name}); mats[i] newMat; changed true; count; } if (changed) { renderer.sharedMaterials mats; EditorUtility.SetDirty(renderer); } } AssetDatabase.SaveAssets(); // 保存所有新创建的材质资产 return count; }4.4 添加辅助方法与状态检查为了让工具更友好我们还需要一些辅助函数。private bool IsExecuteButtonEnabled() { switch (currentMode) { case ProcessMode.Replace: return targetMaterialToAssign ! null (processSelectedGameObjects || processSelectedAssets); case ProcessMode.Create: return (templateMaterial ! null || shaderForNewMaterial ! null) processSelectedGameObjects; // 创建模式通常只对GameObject有效 default: return false; } } private string GetStatusMessage() { int selectedObjCount processSelectedGameObjects ? Selection.gameObjects.Length : 0; string modeDesc currentMode ProcessMode.Replace ? 替换 : 创建; string targetDesc currentMode ProcessMode.Replace ? (targetMaterialToAssign ! null ? $为目标材质 [{targetMaterialToAssign.name}] : (目标材质未指定)) : $到路径 [{saveFolderPath}]; return $就绪。模式[{modeDesc}]。将处理选中的 {selectedObjCount} 个游戏对象。{targetDesc}; }至此一个功能完整、界面清晰的批量材质处理器就搭建完成了。用户可以通过菜单打开窗口选择模式设置参数然后一键处理选中的对象。5. 高级技巧与实战避坑指南有了基础工具我们可以探讨一些更深入的应用场景和实践中必然会遇到的“坑”。5.1 性能优化处理大量对象当需要处理场景中成千上万个对象时直接循环可能会造成编辑器卡顿。我们可以使用EditorUtility.DisplayProgressBar来显示进度并考虑分帧处理。private IEnumerator ExecuteReplaceWithProgress(ListRenderer renderers) { int total renderers.Count; for (int i 0; i total; i) { // 更新进度条 if (EditorUtility.DisplayCancelableProgressBar(批量替换材质, $正在处理 {i1}/{total}, (float)i / total)) { // 用户取消了 break; } // 处理单个renderer的逻辑同上 ProcessSingleRenderer(renderers[i]); // 每处理N个对象让出一帧防止卡死UI对于极大数量 if (i % 100 0) { yield return null; } } EditorUtility.ClearProgressBar(); AssetDatabase.Refresh(); }在ExecuteBatchProcess中我们可以通过EditorCoroutineUtility.StartCoroutineOwnerless来启动这个协程需要Unity 2019.3。对于更早版本可以考虑使用EditorApplication.update事件来模拟分帧。5.2 材质属性继承与迁移在管线升级或材质预设化时我们通常不希望丢失原有材质的属性如主纹理、颜色、浮点参数。Material类提供了CopyPropertiesFromMaterial方法但它是浅拷贝且不总是可靠。更稳健的做法是手动复制关键的、通用的属性private void CopyMainProperties(Material source, Material destination) { // 检查并复制纹理 if (source.HasProperty(_MainTex) destination.HasProperty(_MainTex)) { destination.SetTexture(_MainTex, source.GetTexture(_MainTex)); destination.SetTextureScale(_MainTex, source.GetTextureScale(_MainTex)); destination.SetTextureOffset(_MainTex, source.GetTextureOffset(_MainTex)); } // 检查并复制颜色 if (source.HasProperty(_Color) destination.HasProperty(_Color)) { destination.SetColor(_Color, source.GetColor(_Color)); } // 复制浮点属性 string[] floatPropertyNames { _Metallic, _Glossiness, _Smoothness, _BumpScale }; foreach (var name in floatPropertyNames) { if (source.HasProperty(name) destination.HasProperty(name)) { destination.SetFloat(name, source.GetFloat(name)); } } // 复制关键字Toggle状态 // 注意这需要知道源材质激活了哪些关键字比较复杂。通常对于预设替换目标材质的关键字是预设好的。 }5.3 处理预制件Prefab的嵌套引用这是一个大坑。当你直接修改一个从预制件实例化出来的对象的sharedMaterial时你修改的是实例上的引用。这通常不会影响预制件资产本身但行为可能有些微妙。如果你希望修改也应用到预制件资产你需要通过PrefabUtility.GetCorrespondingObjectFromSource找到对应的预制件根对象然后修改其材质并调用PrefabUtility.ApplyPrefabInstance。这非常复杂且容易破坏预制件结构。在批量工具中一个安全的原则是明确告知用户此工具不会自动修改预制件资产。对于需要修改预制件内部材质的情况建议用户直接在Project窗口中选中预制件文件进行处理我们的工具支持处理选中的资产但需要扩展“查找使用该材质的对象”的功能到预制件文件内部。5.4 常见问题排查QAQ1: 执行后场景中的材质球变成了粉红色Missing Material。A:最常见的原因是目标材质球targetMaterialToAssign的Shader与当前渲染管线不兼容。例如在URP项目中使用了一个内置管线的Standard Shader材质。请确保目标材质的Shader是正确的。在替换前可以在代码中加入校验if (targetMaterialToAssign.shader ! null)。Q2: 处理完成后材质球引用变成了“None”空引用。A:可能是在替换或创建材质的过程中材质资产未能正确保存或路径错误。检查AssetDatabase.CreateAsset的路径参数是否正确以及AssetDatabase.SaveAssets()是否被调用。同时确保没有在循环中意外地将材质引用设置为null。Q3: 撤销Undo操作没有生效。A:确保在修改任何UnityEngine.Object如Renderer,Material的属性前都调用了Undo.RecordObject。并且对于sharedMaterials这样的数组属性是先修改本地数组副本再整体赋值回去赋值操作应该在RecordObject之后。Q4: 我想根据模型的名字自动选择不同的目标材质怎么扩展A:这正是工具可扩展性的体现。你可以在ExecuteReplace或ExecuteCreate函数中加入一个规则判断逻辑。例如Material GetTargetMaterialBasedOnName(GameObject gameObject) { if (gameObject.name.Contains(Metal)) return metalMaterial; if (gameObject.name.Contains(Wood)) return woodMaterial; return defaultMaterial; }然后将targetMaterialToAssign替换为对这个函数的调用。更高级的做法是设计一个IMaterialRule接口和一组规则类在UI上让用户配置。Q5: 运行时报错“Invalid path: ”A:在创建材质时保存路径字符串可能首尾有空格或者包含了Assets之外的路径。使用Path.Combine和fullPath.Trim()来规范路径并用AssetDatabase.GenerateUniqueAssetPath来生成最终路径。6. 源码整合与使用示例将上述所有代码块按逻辑顺序整合到一个C#脚本文件中并命名为MaterialBatchProcessor.cs将其放在项目的Editor文件夹下例如Assets/Editor/MaterialBatchProcessor.cs。Unity会自动识别并编译它。使用步骤在Unity编辑器中点击顶部菜单栏的Tools - 材质工具 - 批量材质处理器。在弹出的窗口中选择处理模式替换或创建。替换模式拖入或选择目标材质选中场景中需要换材质的模型点击“执行批量处理”。创建模式指定新材质的Shader或模板材质设置保存路径和名称前缀选中场景中的模型点击执行。新材质将被创建并自动分配给选中的模型。这个工具已经具备了生产环境使用的核心功能。你可以根据自己的项目需求在此基础上继续扩展例如添加“备份原材质”功能。增加“仅处理特定层Layer的对象”筛选。实现更复杂的材质属性映射规则。将配置如常用替换规则保存为ScriptableObject方便团队共享。记住好的编辑器工具不是一蹴而就的它往往随着项目需求不断迭代。从这个批量材质处理器开始你可以逐步构建起一整套提升美术和程序工作效率的Unity Editor工具链。