(本文为《Unity VFX 提效工具源码拆解》系列第 3 篇,承接第 2 篇《【VFX工具链源码拆解 02】告别机械劳动(上):一键统改百个子粒子颜色与批量重命名》的粒子改色与批量重命名。)
一套特效的材质整理通常不是单个槽位赋值。一个包含多层粒子、模型和拖尾的 Prefab,可能同时使用多种主体材质与拖尾材质;逐个展开 Hierarchy 替换容易漏项。贴图装配也有类似问题:建文件夹、建材质、选 Shader、判断主纹理属性名,这些步骤跨越 Project、Inspector 和多个渲染管线。本文从 BatchMaterialView 与 VFXStudioUtils.GenerateMaterialForVFX 的实现出发,说明如何把这些操作收敛成可核对的批处理流程,并指出自动化仍需人工确认的边界。
一、材质工作流中容易遗漏的环节
特效换皮时,常见需求是把一组旧材质按用途替换成对应的新材质。简单的全量替换无法保留不同粒子层之间的材质差异;手工逐个替换则需要在 Hierarchy 中反复展开节点、检查 Renderer,工作量随层级和材质种类增加。更隐蔽的问题来自 ParticleSystemRenderer:它除了 sharedMaterials 中的常规材质槽,还单独暴露 trailMaterial。如果工具只遍历 Renderer 的材质数组,粒子主体会更新,拖尾仍会保留旧材质。
从贴图生成材质时,工作量集中在另一组重复步骤:创建目录和 .mat,选定项目实际使用的 Shader,再把贴图放入正确的纹理属性。不同 Shader 的属性名并不统一,Built-in 常见 _MainTex,URP 常见 _BaseMap,HDRP 中可能使用 _BaseColorMap 或 _UnlitColorMap,自定义 Shader 也可能采用其他名称。把某一管线的槽位规则硬编码成唯一标准,会让材质看似创建成功,却没有正确接入贴图。
预览同样有上下文差异。球体适合观察大多数表面材质,但刀光、法阵和光柱等特效纹理往往需要在平面或圆柱上检查。每次进入 Unity 内置预览再手动切换网格,会把验证过程变成额外操作。
这三类问题的共同原因,是资产引用、组件槽位、Shader 属性和预览面板分散在不同的编辑界面。工具的目标不是替 Shader 推断全部语义,而是把稳定、可描述的规则聚合起来:扫描真实槽位、让美术显式配置映射、按已知属性名优先装配贴图,并为预览提供快捷入口。

录制界面:Unity 的 Hierarchy 右键菜单与"批量材质替换"工具面板。
操作步骤:在 Hierarchy 中选中待处理的特效根节点,右键选择"批量材质替换",随后核对目标列表、子节点范围与替换模式。
结果与原理 :快捷入口打开并聚焦工具窗口,将当前选中对象传给
BatchMaterialView.LoadTargets()。后续扫描和替换围绕这份目标列表执行,减少逐个拖入对象的准备步骤。
二、批量替换:共享引用与独立拖尾槽位
BatchMaterialView 将替换拆成扫描和执行两个阶段。ScanMaterials() 根据 includeChildren 决定扫描目标自身,还是调用 GetComponentsInChildren<Renderer>(true) 遍历包含非激活对象在内的子层级。每个 Renderer 的常规材质从 sharedMaterials 收集;若组件是 ParticleSystemRenderer,再单独读取 trailMaterial。字典以材质对象为键去重,初始值设为 null,界面中未填写新材质的映射项表示保留原值。
csharp
Renderer[] renderers = includeChildren
? go.GetComponentsInChildren<Renderer>(true)
: go.GetComponents<Renderer>();
foreach (var r in renderers)
{
if (r == null) continue;
if (r.sharedMaterials != null)
{
foreach (var mat in r.sharedMaterials)
{
if (mat != null && !materialMapping.ContainsKey(mat))
{
materialMapping.Add(mat, null);
}
}
}
if (r is ParticleSystemRenderer psr && psr.trailMaterial != null)
{
if (!materialMapping.ContainsKey(psr.trailMaterial))
{
materialMapping.Add(psr.trailMaterial, null);
}
}
}
这里使用的是 Unity 原生的 Renderer.sharedMaterials,而不是 Renderer.materials。访问 Renderer.materials 会为该 Renderer 实例化材质,使其与原共享材质成为不同对象;编辑器批处理若意图替换资产引用,使用它可能产生未受管理的实例材质,无法按预期保留磁盘材质引用。sharedMaterials 保留共享材质引用,适合建立"旧材质到新材质"的替换关系。
还需要注意,Unity 返回的 sharedMaterials 是数组副本。修改 sharedMats[i] 只改了本地数组,因此 ExecuteReplace() 在检测到数组项变化后,会执行 r.sharedMaterials = sharedMats 将整个数组写回。这个赋值更新的是 Renderer 的材质引用集合;它不会自动修改材质资源本身的 Shader 或纹理参数。Unity Renderer.materials;Unity Renderer.sharedMaterials
模式分为 ReplaceMode.ForceReplace 和 ReplaceMode.DictionaryMapping。前者用 forceMaterial 统一替换非空的常规材质槽;后者通过 materialMapping.TryGetValue(oldMat, out newMat) 查找替换目标,仅当映射值非空且与旧材质不同才写回。字典映射适合多材质换皮,因为每种旧材质可以对应不同新材质,留空项保持原材质。
拖尾槽必须单独处理。源码在 r is ParticleSystemRenderer psr 分支中读取和设置 psr.trailMaterial,不把它误认为 sharedMaterials 数组的一项。当前实现只在已有 trailMaterial 非空时替换;若拖尾槽原本为空,批处理不会自动为它创建新引用。这是替换规则的边界,而不是扫描遗漏。
ExecuteReplace() 中的 modifyCount 在 Renderer 有任意槽位变化时增加一次,因此它统计的是发生改动的渲染组件数量,不是材质槽位总数。对场景对象,源码调用 Unity 原生 Undo.RecordObjects、EditorUtility.SetDirty 和 EditorSceneManager.MarkSceneDirty;对 Project 中的 Prefab 资产,则走独立的磁盘资产编辑流程。

录制界面:"批量材质替换"面板中的材质映射字典,以及粒子与拖尾效果。
操作步骤:选择"映射按需替换",核对"包含所有子节点材质",点击"扫描提取材质",为需要改动的旧材质填写新材质,最后点击"确认并执行替换"。
结果与原理 :
ScanMaterials()去重收集常规材质和独立的拖尾材质,ExecuteReplace()根据字典改写sharedMaterials,并单独处理ParticleSystemRenderer.trailMaterial。映射留空的项目保留原引用,从而按用途替换,避免把不同槽位全部改成同一材质。
三、磁盘 Prefab:隔离加载、保存与释放
直接对 Project 中的 Prefab 资产对象当作场景实例修改,会混淆资产对象与场景对象的生命周期。BatchMaterialView.ExecuteReplace() 先用 EditorUtility.IsPersistent(go) 判断输入是否为持久化资产。若是 Prefab,使用 AssetDatabase.GetAssetPath(go) 取得路径,再由 PrefabUtility.LoadPrefabContents(prefabPath) 将内容加载到独立的隔离 Scene 中;修改组件后,调用 PrefabUtility.SaveAsPrefabAsset(rootToProcess, prefabPath) 写回原资产。
隔离加载适合批量编辑磁盘 Prefab,不要求逐个打开 Prefab Mode,也不会把加载出来的根对象当作当前场景实例。它仍然会修改并保存目标 .prefab 文件,所以"后台"描述的是编辑方式,不是无磁盘副作用。实际项目中应将目标路径、版本控制状态和批量范围纳入操作检查。
释放必须放在 finally 中。Unity 文档说明,LoadPrefabContents 会创建用于承载 Prefab 内容的隔离 Scene;UnloadPrefabContents 负责释放内容和该 Scene。若遍历或保存时抛出异常而未卸载,临时对象和 Scene 会留在编辑器内存中。源码的关键结构如下:
csharp
bool isPrefabFile = EditorUtility.IsPersistent(go);
GameObject rootToProcess = go;
if (isPrefabFile)
{
string prefabPath = AssetDatabase.GetAssetPath(go);
rootToProcess = PrefabUtility.LoadPrefabContents(prefabPath);
}
try
{
Renderer[] renderers = includeChildren
? rootToProcess.GetComponentsInChildren<Renderer>(true)
: rootToProcess.GetComponents<Renderer>();
// 扫描并替换 Renderer 的材质槽
if (isPrefabFile)
{
string prefabPath = AssetDatabase.GetAssetPath(go);
PrefabUtility.SaveAsPrefabAsset(rootToProcess, prefabPath);
}
}
finally
{
if (isPrefabFile)
{
PrefabUtility.UnloadPrefabContents(rootToProcess);
}
}
核心约束是成对使用 PrefabUtility.LoadPrefabContents 与 PrefabUtility.UnloadPrefabContents;保存成功与否都不应跳过释放。Unity 的 API 文档也明确将 UnloadPrefabContents 定义为释放先前加载内容及其隔离 Scene 的操作。Unity PrefabUtility.LoadPrefabContents;Unity PrefabUtility.UnloadPrefabContents
四、贴图转材质:先匹配语义,再使用属性枚举兜底
VFXStudioUtils.GenerateMaterialForVFX(string texturePath, Shader specificShader = null) 把常规装配步骤集中到一个方法中。它读取当前激活场景路径,将材质放到场景同级的 materials 目录;若没有已保存场景,则以 Assets 为目录并使用 Untitled 作为场景名。目标文件名从 {sceneName}_01.mat 开始,由 AssetDatabase.GenerateUniqueAssetPath 避免覆盖同名资产。
Shader 优先使用传入的 specificShader。没有指定时,方法从 EditorPrefs 的 DDSSelector_DefaultShader 读取默认名称,调用 Shader.Find 查找;若查找失败,再尝试 Standard。这一步选择的是项目配置或调用方提供的 Shader,并不保证它适合当前渲染管线,也不保证材质外观已经符合特效需求。
真正容易出错的是主纹理槽识别。直接用 ShaderUtil 取第一个纹理属性,会把贴图写进遮罩、溶解等辅助槽的风险。源码先按语义白名单顺序检查九个常见属性名:
csharp
string[] commonTexNames = new string[] {
"_MainTex", "_BaseMap", "_BaseColorMap", "_UnlitColorMap",
"_Texture", "_MainTexture", "_Tex", "_Diffuse", "_ColorMap"
};
bool assigned = false;
foreach (string propName in commonTexNames)
{
if (newMat.HasProperty(propName))
{
newMat.SetTexture(propName, tex);
assigned = true;
break;
}
}
白名单把常见主纹理语义放在优先级前面,避免属性声明顺序决定结果。只有白名单全部未命中时,方法才调用 Unity 原生 ShaderUtil.GetPropertyCount、ShaderUtil.GetPropertyType 和 ShaderUtil.GetPropertyName 遍历 Shader 属性,并将贴图写入首个 ShaderUtil.ShaderPropertyType.TexEnv 槽。
这是一种有边界的二级启发式策略:高频属性名命中时,能够绕过排在前面的遮罩或溶解纹理;白名单未命中时,兜底逻辑仍然选择第一个纹理槽,无法保证自定义 Shader 的第一个 TexEnv 一定是主贴图。遇到多个无标准命名的纹理槽时,应为项目补充白名单或提供显式槽位配置,而不能把兜底结果当成 Shader 语义推断。
贴图通过 AssetDatabase.LoadAssetAtPath<Texture2D>(texturePath) 读取。资源类型或路径不匹配时,tex 为 null,源码仍会创建材质资产,但不会设置纹理。最终由 AssetDatabase.CreateAsset、AssetDatabase.SaveAssets 持久化,并通过 EditorGUIUtility.PingObject 在 Project 窗口中定位材质。DDSSelectorView 与 VFXCategoryOverview 的调用点把贴图路径和可选 Shader 传入该方法。

录制界面:资源库中的贴图右键菜单与 Project 中新生成的材质资产。
操作步骤:选中贴图,右键展开"一键生成特效材质",选择全局默认 Shader 或指定 Shader。
结果与原理 :
GenerateMaterialForVFX()创建材质,优先按常见主纹理属性名匹配槽位,再以纹理属性枚举兜底;保存后在 Project 中定位新材质。此操作负责生成和赋图,不会自动将材质赋给场景物体。
五、材质预览网格:反射接入内置编辑器
内置 MaterialEditor 的预览网格选择字段 m_SelectedMesh 是私有字段,没有面向此工具需求的公开 setter。VFXCategoryOverview.SetMaterialPreviewMesh(int meshTypeIndex) 通过反射取得字段并改写索引,随后调用 Repaint() 刷新预览。UI 按钮分别传入 0、1、2 和 4,对应球体、立方、圆柱和平面。
csharp
private void SetMaterialPreviewMesh(int meshTypeIndex)
{
if (VFXStudioWindow.GlobalPreviewEditor == null) return;
var editorType = VFXStudioWindow.GlobalPreviewEditor.GetType();
var field = editorType.GetField(
"m_SelectedMesh",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
if (field != null)
{
field.SetValue(VFXStudioWindow.GlobalPreviewEditor, meshTypeIndex);
VFXStudioWindow.GlobalPreviewEditor.Repaint();
}
}
这里访问的是 Unity 内部实现细节,不是稳定的公开扩展点。代码对空编辑器和字段查找失败做了保护;若后续 Unity 版本更改字段名称或实现,按钮可能不再切换预览。因此反射适合作为受控编辑器工具的便利入口,升级 Unity 后仍需验证该行为。

录制界面:材质资源的预览区域及"强制切换预览"按钮。
操作步骤:选中材质,依次点击"球体""立方""圆柱"或"平面",比较同一贴图在不同几何表面上的表现。
结果与原理 :
SetMaterialPreviewMesh()通过反射改写内置MaterialEditor的m_SelectedMesh,并请求重绘。变化的是预览所用网格,不会因此改写材质的贴图或 Shader 参数。
六、落地时需要保留的边界
sharedMaterials替换的是 Renderer 所持有的材质引用;它不会自动改写材质资源内部参数。- 字典映射中的空值意味着跳过该材质;现有
trailMaterial才会进入拖尾替换分支。 PrefabUtility.SaveAsPrefabAsset会把修改写回磁盘 Prefab。finally负责释放隔离 Scene,不提供事务回滚。- 纹理白名单覆盖常见名称,但自定义 Shader 的槽位语义仍需项目配置;
ShaderUtil兜底只找第一个纹理属性。 GenerateMaterialForVFX创建材质并尝试赋图,不会判断贴图颜色空间、混合模式、透明队列或 Shader 的其他业务参数。
材质批处理的可靠性来自对引用边界和生命周期的明确区分:场景对象需要撤销与场景脏标记,磁盘 Prefab 需要隔离加载、保存和释放,Shader 装配需要优先使用语义化属性名并保留兜底边界。将这些规则集中到 BatchMaterialView 与 VFXStudioUtils.GenerateMaterialForVFX,才能减少重复点击,同时让替换结果可追踪、可核查。
参考文档
- Unity
Renderer.materials - Unity
Renderer.sharedMaterials - Unity
PrefabUtility.LoadPrefabContents - Unity
PrefabUtility.UnloadPrefabContents - Unity
ShaderUtil.GetPropertyType
下一篇预告:第 4 篇将拆解 Timeline 资产与场景绑定分离后,原生复制无法自动重定向轨道绑定的问题,并分析一键克隆与层级相对路径重映射。