【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:

csharp 复制代码
var main = ps.main;
main.startColor = Color.red;

这里不需要再写 ps.main = main。局部变量保存的是模块接口,属性写入会更新它所属的粒子系统。采用局部变量还把模块边界写清楚,避免把这种 API 误读成普通结构体字段的原地改值。

粒子颜色模块接收 ParticleSystem.MinMaxGradient,它把颜色输入和取值模式放在同一个值类型中。ParticleSystemGradientMode 在源码中覆盖五种模式:Color 使用单色,TwoColors 在两种颜色之间随机取值,Gradient 使用一条渐变,TwoGradients 在两条渐变之间随机取值,RandomColor 从渐变色标中随机选色而不在相邻色标之间插值。

ColorPaletteView.GetMinMaxGradient() 根据面板模式构造对应的 MinMaxGradient。它将编辑器输入转换成粒子模块可以接受的设置,而不是对每个粒子临时计算显示颜色:

csharp 复制代码
private ParticleSystem.MinMaxGradient GetMinMaxGradient(
    ParticleSystemGradientMode mode, Color c1, Color c2, Gradient g1, Gradient g2)
{
    switch (mode)
    {
        case ParticleSystemGradientMode.Color:
            return new ParticleSystem.MinMaxGradient(c1);
        case ParticleSystemGradientMode.TwoColors:
            return new ParticleSystem.MinMaxGradient(c1, c2);
        case ParticleSystemGradientMode.Gradient:
            return new ParticleSystem.MinMaxGradient(g1);
        case ParticleSystemGradientMode.TwoGradients:
            return new ParticleSystem.MinMaxGradient(g1, g2);
        case ParticleSystemGradientMode.RandomColor:
            return new ParticleSystem.MinMaxGradient(g1)
            {
                mode = ParticleSystemGradientMode.RandomColor
            };
        default:
            return new ParticleSystem.MinMaxGradient(c1);
    }
}

需要留意 UI 与转换函数之间的边界:当前 ShowIf 条件没有在 RandomColor 模式下显示渐变编辑字段,但转换函数仍会读取 g1。因此该模式使用的是已保存或已有的渐变值;若要让美术在该模式下直接编辑色标,需要同步调整 Inspector 的显示条件。

三、从一个根节点应用到整棵粒子树

ColorPaletteView.ApplyColorToAllParticles() 以 targetVFXRoot 为入口,调用 GetComponentsInChildren<ParticleSystem>(true) 收集根对象及所有子层级中的粒子系统。参数 true 表示把未激活的 GameObject 也纳入搜索;它解决的是 inactive 子节点容易被遗漏的问题。

源码在遍历前调用 Undo.RecordObjects(pSystems, "Batch Change Particle Colors"),然后对每个系统分别取得模块接口并写入设置。初始颜色总会被应用;生命周期与速度颜色的开关状态则会被同步到面板布尔值:

csharp 复制代码
var pSystems = targetVFXRoot.GetComponentsInChildren<ParticleSystem>(true);
if (pSystems.Length == 0) return;

Undo.RecordObjects(pSystems, "Batch Change Particle Colors");

foreach (var ps in pSystems)
{
    var main = ps.main;
    main.startColor = GetMinMaxGradient(
        startColorMode, startColor1, startColor2, startGradient1, startGradient2);

    var col = ps.colorOverLifetime;
    col.enabled = enableLifetime;
    if (enableLifetime)
        col.color = GetMinMaxGradient(
            lifetimeMode, Color.white, Color.white, lifetimeGradient1, lifetimeGradient2);

    var cbs = 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:

csharp 复制代码
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 选中对象时,不同场景可能产生相同路径键。因此它适用于同一场景层级内、各层同级数量少于一万的批量排序,不应描述为无条件覆盖所有编辑器选择集。

csharp 复制代码
private string GetHierarchyIndex(Transform transform)
{
    StringBuilder path = new StringBuilder();
    while (transform != null)
    {
        path.Insert(0, transform.GetSiblingIndex().ToString("D4"));
        transform = transform.parent;
    }
    return path.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():

csharp 复制代码
var assetsToRename = projectAssets.OrderBy(m => GetOriginalName(m)).ToArray();
AssetDatabase.StartAssetEditing();
try
{
    for (int i = 0; i < assetsToRename.Length; i++)
    {
        string originalName = GetOriginalName(assetsToRename[i]);
        string newName = GenerateNewName(originalName, i);
        string assetPath = AssetDatabase.GetAssetPath(assetsToRename[i]);
        string errorMsg = AssetDatabase.RenameAsset(assetPath, newName);
        if (string.IsNullOrEmpty(errorMsg)) successCount++;
        else failCount++;
    }
}
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 篇:【VFX工具链源码拆解 03】告别机械劳动(下):材质字典映射批处理与智能纹理槽识别

参考文档 : Unity ParticleSystem.MainModule.startColor;Microsoft C# 错误 CS1612;Unity AssetDatabase.StartAssetEditing。

相关推荐
郝学胜-神的一滴2 小时前
游戏设计:原型与开发 18:Unity入门的第一课
人工智能·程序人生·游戏·ui·unity·游戏引擎
信息安全失业大专人员3 小时前
每日了解一个智能体-WorkBuddy
开发语言·ai·c#·智能体
呆呆敲代码的小Y3 小时前
【实战精选面试题】C# 面试题详细解答,按难易程度排序
android·java·游戏·c#·游戏开发·unity面试题·c#面试题
淡海水4 小时前
09-01-内存-Unity资源生命周期全景
unity·c#·游戏引擎·.netcore·gc
Solange__7 小时前
AI世界模型、3D场景生成和游戏引擎怎么选?按5类任务比较交互、物理与导出
开发语言·人工智能·游戏·平面·3d·自然语言处理·游戏引擎
Solange__7 小时前
物流园区怎么用AI生成3D调度预演?从道路底图、车辆转弯到网页评审
图像处理·人工智能·游戏·3d·ai·自然语言处理·关卡设计
椒颜皮皮虾྅7 小时前
让 C# 读懂整张图:DeploySharp 驱动 PaddleOCR v4/v5/v6,打通 OCR 全流程
开发语言·人工智能·深度学习·学习·c#·ocr·cuda
昂好的day7 小时前
C# 同步异步与多线程:Thread / Task / 线程池一次讲透(上篇)
开发语言·windows·c#
LF男男8 小时前
Flower Defense—UIFacade
unity