【VFX工具链源码拆解 02】告别机械劳动(上):一键统改百个子粒子颜色与批量重命名
(本文为《Unity VFX 提效工具源码拆解》系列第 2 篇,承接第 1 篇《【VFX工具链源码拆解 01】总体架构:大型项目特效流程的程序化落地》的编辑器主控台搭建。)
粒子颜色批处理与批量重命名看起来是两个独立按钮,解决的却是同一类工程问题:当一次美术修改涉及几十个对象时,如何把可确定的重复操作交给工具,同时保留可检查、可撤销的边界。本文从ColorPaletteView与BatchRenameView的源码出发,拆解粒子模块写入、层级排序和磁盘资产改名的实现方式。
一、两个重复劳动背后的工程问题
特效换皮并不只是把一个颜色拾取器改成冰蓝色。一个包含三十多个子粒子系统的斩击特效,可能同时有主刀光、刀刃流光、火星、命中爆点、地面痕迹和余烟。每个节点的Start Color、Color over Lifetime与Color by Speed都可能有不同模式。逐项展开 Inspector、判断模块状态、编辑颜色或渐变,再逐一比对结果,既打断创作,也很容易漏掉折叠层级中的子节点。
重命名有相似的放大效应。Hierarchy 中选择几十个对象后,选择集合不是按树的视觉遍历顺序提供的;用它直接编号,结果可能和美术预期的从上到下顺序不同。Project 窗口里的序列帧、材质或预制体数量上升到数百时,如果每一次改名都立即触发资源导入,操作时间会被重复的 AssetDatabase 工作放大。
这两类问题都需要先确定一套稳定的处理顺序:颜色工具以一个根对象为入口遍历子树,并对每个粒子系统写入同一组配置;重命名工具则把选择集分成场景对象和持久化资产,分别使用适合其生命周期的修改方式。工具只自动化明确的规则,不替代对最终颜色、材质和文件名的人工核对。
二、粒子模块不是普通字段:结构体接口与原生写入
ParticleSystem.main、colorOverLifetime和colorBySpeed返回的是模块结构体。它们不是一份需要改完后再整体赋回的 C# 配置副本;Unity 的模块结构体充当所属粒子系统的脚本接口,模块属性的 setter 会把值交给引擎侧处理。
这里需要区分两件容易混淆的事。普通 C# 代码中,若属性返回一个结构体副本,再尝试改其中的可变字段,可能触发CS1612,因为修改的是临时值。Unity 的粒子模块则是带有引擎所有者的特殊接口,startColor本身是模块属性。不能简单断言ps.main.startColor = Color.red在所有 Unity/C# 组合中必然编译失败;更稳妥、也与 Unity 文档示例及本项目源码一致的写法,是先取模块局部变量,再调用属性 setter:
varmain=ps.main;main.startColor=Color.red;这里不需要再写ps.main = main。局部变量保存的是模块接口,属性写入会更新它所属的粒子系统。采用局部变量还把模块边界写清楚,避免把这种 API 误读成普通结构体字段的原地改值。
粒子颜色模块接收ParticleSystem.MinMaxGradient,它把颜色输入和取值模式放在同一个值类型中。ParticleSystemGradientMode在源码中覆盖五种模式:Color使用单色,TwoColors在两种颜色之间随机取值,Gradient使用一条渐变,TwoGradients在两条渐变之间随机取值,RandomColor从渐变色标中随机选色而不在相邻色标之间插值。
ColorPaletteView.GetMinMaxGradient()根据面板模式构造对应的MinMaxGradient。它将编辑器输入转换成粒子模块可以接受的设置,而不是对每个粒子临时计算显示颜色:
privateParticleSystem.MinMaxGradientGetMinMaxGradient(ParticleSystemGradientModemode,Colorc1,Colorc2,Gradientg1,Gradientg2){switch(mode){caseParticleSystemGradientMode.Color:returnnewParticleSystem.MinMaxGradient(c1);caseParticleSystemGradientMode.TwoColors:returnnewParticleSystem.MinMaxGradient(c1,c2);caseParticleSystemGradientMode.Gradient:returnnewParticleSystem.MinMaxGradient(g1);caseParticleSystemGradientMode.TwoGradients:returnnewParticleSystem.MinMaxGradient(g1,g2);caseParticleSystemGradientMode.RandomColor:returnnewParticleSystem.MinMaxGradient(g1){mode=ParticleSystemGradientMode.RandomColor};default:returnnewParticleSystem.MinMaxGradient(c1);}}需要留意 UI 与转换函数之间的边界:当前ShowIf条件没有在RandomColor模式下显示渐变编辑字段,但转换函数仍会读取g1。因此该模式使用的是已保存或已有的渐变值;若要让美术在该模式下直接编辑色标,需要同步调整 Inspector 的显示条件。
三、从一个根节点应用到整棵粒子树
ColorPaletteView.ApplyColorToAllParticles()以targetVFXRoot为入口,调用GetComponentsInChildren<ParticleSystem>(true)收集根对象及所有子层级中的粒子系统。参数true表示把未激活的 GameObject 也纳入搜索;它解决的是 inactive 子节点容易被遗漏的问题。
源码在遍历前调用Undo.RecordObjects(pSystems, "Batch Change Particle Colors"),然后对每个系统分别取得模块接口并写入设置。初始颜色总会被应用;生命周期与速度颜色的开关状态则会被同步到面板布尔值:
varpSystems=targetVFXRoot.GetComponentsInChildren<ParticleSystem>(true);if(pSystems.Length==0)return;Undo.RecordObjects(pSystems,"Batch Change Particle Colors");foreach(varpsinpSystems){varmain=ps.main;main.startColor=GetMinMaxGradient(startColorMode,startColor1,startColor2,startGradient1,startGradient2);varcol=ps.colorOverLifetime;col.enabled=enableLifetime;if(enableLifetime)col.color=GetMinMaxGradient(lifetimeMode,Color.white,Color.white,lifetimeGradient1,lifetimeGradient2);varcbs=ps.colorBySpeed;cbs.enabled=enableSpeed;if(enableSpeed)cbs.color=GetMinMaxGradient(speedMode,Color.white,Color.white,speedGradient1,speedGradient2);}enableLifetime或enableSpeed为false时,源码会把对应模块直接设为禁用;这不是“跳过该模块并保留原状态”。因此,只想统一初始颜色时,应确认这两个开关的状态,避免无意关闭已有模块。批量写入前记录的 Undo 对象让整组粒子设置可以通过 Unity Undo 回退;最后的SaveColors()保存当前调色配置。
录制界面:“特效调色盘”中的目标根节点、颜色配置与粒子效果预览。
操作步骤:指定特效根节点,选择色板颜色或编辑颜色、渐变模式,核对生命周期和速度颜色开关,再点击“一键应用所有激活的颜色模块”。
结果与原理:
ApplyColorToAllParticles()遍历根节点下的粒子系统,统一写入初始颜色,并按开关更新生命周期及速度颜色模块;预览中的子粒子随之改变。点击色板本身只改变输入值,批量写入发生在应用按钮执行时;未勾选的两个可选模块会按现有实现被禁用。
四、快捷色板只改输入,应用按钮才改粒子
DrawInteractivePalette()将 Odin Inspector 布局与 Unity 原生 IMGUI 组合起来。GUILayoutUtility.GetRect()申请一条高三十像素的区域,代码按CustomPalette.Count平分宽度,再用EditorGUI.DrawRect()绘制色块。鼠标按下落在某块区域时,方法更新startColor1并把startColorMode切换为Color:
if(Event.current.type==EventType.MouseDown&&colorRect.Contains(Event.current.mousePosition)){startColor1=CustomPalette[i];startColorMode=ParticleSystemGradientMode.Color;GUI.FocusControl(null);Event.current.Use();}点击色块只改变面板输入,不会立即遍历场景。真正的批量写入发生在ApplyColorToAllParticles()。这一区分让取色与应用分成两个明确动作,便于先确认方案,再一次性修改对象。
CustomPalette的变化通过[OnValueChanged("SaveColors", includeChildren: true)]触发保存;SaveColors()使用JsonUtility.ToJson(this)写入EditorPrefs的VFXStudio_ColorPalette_V3键,LoadColors()再用JsonUtility.FromJsonOverwrite()恢复。应用颜色后源码也会调用SaveColors(),因此面板配置可跨编辑器会话保留。需要注意,这里持久化的是工具配置,不是把调色盘保存成独立资源文件。
五、重命名先分流:场景对象与磁盘资产走不同路径
BatchRenameView.ExecuteBatchRename()从Selection.objects读取混合选择集,用 Unity 原生EditorUtility.IsPersistent()区分持久化资产与场景对象。Project 资产进入projectAssets;场景中的GameObject则从Selection.gameObjects收集到sceneObjects。
两类对象的改名语义不同。场景分支在useHierarchyOrder为真时,先按GetHierarchyIndex()排序,再调用Undo.RecordObjects(),最后直接设置GameObject.name。Project 资产分支按GetOriginalName()排序,通过AssetDatabase.GetAssetPath()取得路径,并调用AssetDatabase.RenameAsset()修改资产文件名。直接修改已加载资产对象的name不等于改磁盘文件名。
六、四位宽家谱编码如何还原层级顺序
Transform.GetSiblingIndex()只表示节点在当前父节点下的索引。若只按这一项排序,不同父节点下的两个子节点都可能是索引0,因此无法比较它们在整棵树中的先后位置。
BatchRenameView.GetHierarchyIndex()从当前节点向根节点回溯,把每层的GetSiblingIndex()以ToString("D4")格式化,并用StringBuilder.Insert(0, ...)将上层索引放在前面。每段编码对应一层父子路径:
| Hierarchy 路径 | 编码 |
|---|---|
| 根节点索引 0 | 0000 |
| 根节点 0,子节点 2 | 00000002 |
| 根节点 1,子节点 0 | 00010000 |
| 根节点 1,子节点 3,孙节点 0 | 000100030000 |
在同一棵 Hierarchy 中,各层索引都处于四位范围时,固定宽度让字符串字典序与逐层数值顺序一致。比较先从根层开始;根节点相同,再比较子节点;父节点编码是子节点编码的前缀,所以父节点会排在其后代之前。这相当于把树的路径编码成可排序的键,最终得到 Hierarchy 的深度优先显示顺序。
D4是最小宽度,不会把超过四位的数截断。若某一级索引达到10000,该段会变成五位,和四位段直接拼接后不再具有统一段宽,字典序就可能偏离预期。该方法也没有加入场景标识;跨多个 Scene 选中对象时,不同场景可能产生相同路径键。因此它适用于同一场景层级内、各层同级数量少于一万的批量排序,不应描述为无条件覆盖所有编辑器选择集。
privatestringGetHierarchyIndex(Transformtransform){StringBuilderpath=newStringBuilder();while(transform!=null){path.Insert(0,transform.GetSiblingIndex().ToString("D4"));transform=transform.parent;}returnpath.ToString();}录制界面:Hierarchy 中选中的特效节点,以及“批量重命名”面板的参数和名称预览。
操作步骤:选中待命名节点,配置基础名称、前后缀和编号,启用“使用 Hierarchy 顺序”,核对旧名与新名预览后点击“执行批量重命名”。
结果与原理:工具用
GetHierarchyIndex()沿父链生成排序键,再由GenerateNewName()生成名称;执行后,Hierarchy 节点按该排序获得连续编号,并登记场景对象的 Undo,减少鼠标点选次序对命名结果的影响。
七、名称生成与磁盘资产批处理
GenerateNewName()统一处理前缀、核心名称、编号和后缀。keepOriginalName决定核心名称来自旧名还是baseName;保留旧名时,removePrefixCount与removeSuffixCount通过Mathf.Min()限制裁剪范围,避免Substring()越界。开启addNumbering后,ToString($"D{numberDigits}")将编号补齐到指定最小位数,例如两位格式会生成01、02。这是最小位数格式,不会截断更长的编号。
Project 资产批处理的关键不是跳过改名,而是减少逐项导入的重复成本。Unity 文档将AssetDatabase.StartAssetEditing()描述为暂停自动导入,使多项资源操作可以集中后再恢复导入;它不等同于合并所有磁盘写入,也不保证固定倍数的加速。源码对projectAssets按原文件名排序,在批量改名时调用StartAssetEditing(),逐项执行RenameAsset(),然后在finally中调用StopAssetEditing()并SaveAssets():
varassetsToRename=projectAssets.OrderBy(m=>GetOriginalName(m)).ToArray();AssetDatabase.StartAssetEditing();try{for(inti=0;i<assetsToRename.Length;i++){stringoriginalName=GetOriginalName(assetsToRename[i]);stringnewName=GenerateNewName(originalName,i);stringassetPath=AssetDatabase.GetAssetPath(assetsToRename[i]);stringerrorMsg=AssetDatabase.RenameAsset(assetPath,newName);if(string.IsNullOrEmpty(errorMsg))successCount++;elsefailCount++;}}finally{AssetDatabase.StopAssetEditing();AssetDatabase.SaveAssets();}StartAssetEditing()与StopAssetEditing()必须配对。若重命名循环抛出异常,finally仍会恢复 AssetDatabase 的自动导入状态;缺少StopAssetEditing()会使导入继续暂停,后续资产操作可能表现为编辑器无响应。调用RenameAsset()返回的错误字符串也不能忽略:源码用空字符串记为成功,否则计入失败,最后弹出汇总结果。
场景对象分支通过Undo.RecordObjects()支持撤销;Project 资产分支没有调用Undo.RecordObjects(),不要把场景重命名的撤销能力扩展到磁盘资产。资产批处理前应检查预览和目标选择,执行后核对返回的失败数量。
八、执行顺序与预览边界
BatchRenameView.DrawPreview()将sceneObjects与projectAssets拼接后只显示前十项,并在超出时提示剩余数量。它减少了 Inspector 每帧绘制大量行的开销。但当前预览列表没有复用执行阶段的排序逻辑:场景执行可按 Hierarchy 路径排序,Project 资产执行则按原名排序;两条分支还各自从编号索引0开始。因此混合选择时,预览展示顺序和最终改名编号不一定完全一致。批量操作应以执行代码的分支规则为准,并在大批量改名前核对对象范围。
粒子颜色也有清晰边界:模块值写入成功不代表最终像素一定呈现目标色。粒子顶点色仍会与材质颜色、Shader 的顶点色读取方式、贴图及混合方式共同影响画面。若模块配置已更新而画面仍不符合目标,应进一步检查材质和渲染设置;这正是下一篇材质批处理继续解决的问题。
九、本篇总结
ColorPaletteView将根节点下的粒子系统收集为一次批处理,通过MinMaxGradient统一颜色模式,以模块接口写入初始色、生命周期色和速度色,并用 Unity Undo 留出回退入口。BatchRenameView则先区分场景对象与持久化资产,再分别采用 Hierarchy 路径排序和按原名排序;资产改名期间暂停自动导入,并在finally中恢复 AssetDatabase。
这两类工具的核心都不是减少按钮数量,而是把输入范围、处理顺序、撤销能力和失败反馈明确化。只要这些约束被写进实现,批量操作才既快又可核对。
下一篇预告:第 3 篇转向材质批处理,解析字典映射替换、粒子拖尾材质和不同渲染管线的纹理槽识别。
[点击阅读第 3 篇:【VFX工具链源码拆解 03】告别机械劳动(下):材质字典映射批处理与智能纹理槽识别]
参考文档: Unity ParticleSystem.MainModule.startColor;Microsoft C# 错误 CS1612;Unity AssetDatabase.StartAssetEditing。