ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity BakeMesh粒子层级问题解决:从原理到Shader排序实战

Unity BakeMesh粒子层级问题解决:从原理到Shader排序实战

简介:这份资源聚焦Unity开发中粒子特效与UI层级冲突的常见难题,面向需要将粒子效果嵌入界面、并希望像UGUI一样灵活控制层级的开发者。内容围绕BakeMesh方案展开,对比了RenderTexture渲染与多Canvas调整Sort in Layer两种传统思路的优劣,重点说明BakeMesh可上下调整层级排序、接受Mask裁剪的特性,并附有可直接运行的演示工程。压缩包共84个文件,包含15个cs脚本、6个md说明文档、4个asmdef程序集定义、3个unity场景、2个prefab预制体以及shader、材质、rendertexture等资源,整体约758KB,结构清晰便于快速查阅。目前已有782人学习下载。通过包内Demo与UIParticle脚本,读者可掌握在特效父物体上挂载组件即可完成层级管理的用法,理解粒子与UI穿插场景下的排序与遮罩处理思路,适合具备一定Unity基础、正在处理界面特效层级问题的开发者参考。

1. BakeMesh 粒子层级问题解决:为什么你的粒子特效一烘焙就“糊成一团”

如果你做过 Unity 里的粒子特效烘焙,大概率遇到过这个场景:场景里粒子系统跑得好好的,层级分明、拖尾清晰、发光粒子该亮的地方亮,结果一执行 BakeMesh,出来的 Mesh 要么所有粒子挤在同一个渲染层里,要么拖尾和主体糊成一坨,甚至部分粒子直接消失。这不是玄学,这是粒子系统在烘焙到 Mesh 时,层级信息丢失导致的典型问题。BakeMesh 粒子层级问题解决的核心,就是搞清楚粒子系统的渲染顺序、Sorting Layer、Order in Layer 以及子发射器之间的层级关系,在烘焙成静态 Mesh 时如何被正确保留或重建。这篇文章面向的是已经用过 ParticleSystem 但被烘焙结果坑过的开发者,不管你是做特效优化、做性能降级方案,还是要把粒子效果导出给不支持粒子系统的管线用,下面的内容都能直接照着复现。

2. 粒子层级到底丢在哪:从 ParticleSystem 到 Mesh 的数据断点

2.1 粒子渲染层级的三个来源

Unity 粒子系统的层级表现,实际上由三套机制共同决定,很多人只盯着其中一套,结果烘焙出来就翻车。

第一套是 Renderer 的 Sorting Layer 和 Order in Layer。这决定了整个粒子系统作为一个渲染单元,和其他 Renderer 之间的先后关系。第二套是粒子系统内部的渲染顺序,由 ParticleSystemRenderer 的 Sort Mode 控制,常见的有 By Distance、Youngest First、Oldest First,还有按自定义轴排序。第三套是子发射器(Sub Emitter)和粒子之间的层级,比如一个粒子死亡后触发的子发射器,它的粒子应该渲染在父粒子上面还是下面。

BakeMesh 做的事情,是把当前帧的粒子状态快照成一个静态 Mesh。问题在于,MeshRenderer 只有一个 Sorting Layer 和 Order in Layer,它没有办法表达“这个 Mesh 里的三角形 A 要渲染在三角形 B 上面”这种粒子级别的层级关系。所以当你直接对 ParticleSystemRenderer 调用 BakeMesh,得到的 Mesh 虽然顶点位置对了,但渲染顺序信息已经丢了。

2.2 BakeMesh 的 API 行为与常见误用

先看一段最常见的错误写法:

// 错误示范:直接烘焙,层级信息完全丢失 ParticleSystemRenderer psr = GetComponent<ParticleSystemRenderer>(); Mesh bakedMesh = new Mesh(); psr.BakeMesh(bakedMesh, true); // 第二个参数 useTransform 传 true GameObject go = new GameObject("BakedParticle"); MeshFilter mf = go.AddComponent<MeshFilter>(); mf.mesh = bakedMesh; MeshRenderer mr = go.AddComponent<MeshRenderer>(); mr.material = psr.sharedMaterial;

这段代码能跑,Mesh 也能出来,但你会发现两个问题。第一,所有粒子三角形在 Mesh 里的顶点顺序是按照粒子系统内部数组顺序排列的,不是按照视觉层级排列的。第二,如果你原本有多个粒子系统叠加,每个都这样烘焙,它们之间的 Order in Layer 全部变成默认值,渲染顺序完全乱掉。

正确的思路是:烘焙之前先记录层级信息,烘焙之后重建层级。具体来说,需要记录每个粒子系统的 Sorting Layer、Order in Layer,以及粒子系统内部的 Sort Mode 对应的实际渲染顺序。如果 Sort Mode 是 By Distance,还需要记录相机位置,因为烘焙后的 Mesh 是静态的,相机一动,原本的排序就失效了。

2.3 用自定义顶点色通道保留粒子层级

一个在实际项目中验证过的做法是:在烘焙时,把粒子的层级信息写入 Mesh 的顶点色通道或者 UV2、UV3 通道,然后在 Shader 里根据这个信息做排序或者做深度偏移。

// 烘焙时把粒子层级写入 UV2.x ParticleSystemRenderer psr = GetComponent<ParticleSystemRenderer>(); Mesh bakedMesh = new Mesh(); psr.BakeMesh(bakedMesh, true); Vector2[] uvs = bakedMesh.uv2; ParticleSystem.Particle[] particles = new ParticleSystem.Particle[ psr.particleCount]; int count = psr.GetParticles(particles); // 按粒子系统内部的排序规则计算层级值 // 这里以 Youngest First 为例,越新的粒子层级越高 for (int i = 0; i < uvs.Length; i++) { // 每个粒子对应若干顶点,需要根据顶点数做映射 int particleIndex = i / verticesPerParticle; float layerValue = (float)particleIndex / count; uvs[i] = new Vector2(layerValue, 0); } bakedMesh.uv2 = uvs;

这段代码的关键在于 verticesPerParticle 这个值。粒子烘焙成 Mesh 后,每个粒子通常对应 4 个顶点(两个三角形组成一个 quad),但如果你的粒子用了 Mesh 类型的渲染模式,顶点数就不固定了。所以实际使用时,需要先根据 bakedMesh.vertexCount 和 particleCount 算出每个粒子的平均顶点数,或者更稳妥的做法是遍历粒子系统的 particles 数组,根据每个粒子的 position 去匹配 Mesh 顶点。

参数说明:uv2.x 存层级值,范围 0 到 1,0 表示最底层,1 表示最顶层。在 Shader 里可以用这个值做 Z 偏移,或者配合 Stencil Buffer 做分层渲染。如果层级超过 256 层,可以考虑用 uv2.x 和 uv2.y 组合成一个 16 位整数。

2.4 多粒子系统叠加时的层级重建

单个粒子系统的层级问题解决后,多粒子系统叠加是下一个坑。假设场景里有三个粒子系统:火焰、火星、烟雾。火焰在最底层,火星在中间,烟雾在最上层。烘焙成三个 Mesh 后,你需要手动设置三个 MeshRenderer 的 Sorting Layer 和 Order in Layer。

// 烘焙后重建多粒子系统的层级 public class BakedParticleLayerFix : MonoBehaviour { [System.Serializable] public class ParticleLayerConfig { public ParticleSystem ps; public string sortingLayer; public int orderInLayer; } public ParticleLayerConfig[] configs; void BakeAll() { foreach (var config in configs) { ParticleSystemRenderer psr = config.ps.GetComponent<ParticleSystemRenderer>(); Mesh mesh = new Mesh(); psr.BakeMesh(mesh, true); GameObject go = new GameObject(config.ps.name + "_Baked"); go.transform.SetParent(transform); MeshFilter mf = go.AddComponent<MeshFilter>(); mf.mesh = mesh; MeshRenderer mr = go.AddComponent<MeshRenderer>(); mr.sharedMaterial = psr.sharedMaterial; mr.sortingLayerName = config.sortingLayer; mr.sortingOrder = config.orderInLayer; } } }

这段代码的逻辑是:在 Inspector 里手动配置每个粒子系统烘焙后的 Sorting Layer 和 Order in Layer,然后批量烘焙。注意 mr.sortingOrder 的赋值,它对应的是 Renderer.sortingOrder,不是 ParticleSystemRenderer 的 orderInLayer。这两个值在粒子系统上可能不一样,烘焙前要确认清楚。

3. 避坑指南:BakeMesh 粒子层级问题排查清单

3.1 烘焙后粒子全部变成同一层

现象:烘焙出来的 Mesh 渲染时,所有粒子看起来都在同一个深度,原本前后遮挡的关系完全消失。

原因:BakeMesh 生成的 Mesh 把所有粒子三角形合并到一个 Mesh 里,MeshRenderer 只有一个 sortingOrder,无法表达粒子级别的层级。

解决:用 2.3 节的方法,把层级信息写入顶点色或 UV 通道,然后在 Shader 里根据这个值做深度偏移。如果不想改 Shader,另一个办法是把每个粒子单独烘焙成一个 Mesh,每个 Mesh 挂一个 MeshRenderer,手动设置 sortingOrder。这个做法顶点数多的时候性能很差,只适合粒子数少于 50 的场景。

3.2 拖尾粒子烘焙后断裂

现象:Trail 模块生成的拖尾,烘焙后变成一段一段的碎片,或者直接消失。

原因:Trail 粒子的顶点生成方式和普通粒子不同,BakeMesh 对 Trail 的支持取决于 Unity 版本和粒子系统的配置。部分版本里,Trail 的顶点不会被 BakeMesh 正确捕获。

解决:如果必须烘焙拖尾,先把 Trail 模块的 Ribbon Mode 改成 Strip,或者用 Mesh 类型的粒子手动模拟拖尾。另一个做法是烘焙前先调用 particleSystem.Simulate 让拖尾完全展开,再 BakeMesh。注意 Simulate 的参数要设成足够长的时间,确保拖尾完全生成。

3.3 子发射器粒子层级错乱

现象:父粒子死亡后触发的子发射器粒子,烘焙后渲染在父粒子下面,或者和父粒子混在一起。

原因:子发射器的粒子在 BakeMesh 时会被合并到同一个 Mesh 里,原本的父子层级关系丢失。

解决:把父粒子系统和子发射器分开烘焙成两个 Mesh,然后根据子发射器的触发时机,手动设置两个 MeshRenderer 的 sortingOrder。如果子发射器的粒子是动态触发的,烘焙只能捕获当前帧的状态,需要考虑是否真的适合烘焙成静态 Mesh。

3.4 烘焙后粒子颜色变暗或变亮

现象:烘焙出来的 Mesh 颜色和原始粒子效果不一致,整体偏暗或者偏亮。

原因:粒子系统的材质可能用了 Vertex Color 或者 Custom Vertex Streams,BakeMesh 不会自动把这些数据写入 Mesh 的顶点色通道。

解决:烘焙后手动把粒子的颜色写入 Mesh.colors。如果粒子用了 Custom Vertex Streams,需要根据 Stream 的类型,把数据写入对应的 UV 通道或顶点色通道。具体做法是遍历 particles 数组,读取 particle.GetCurrentColor(),然后根据顶点索引映射到 Mesh.colors。

3.5 烘焙后 Mesh 顶点数爆炸

现象:粒子数不多,但烘焙出来的 Mesh 顶点数非常大,导致内存和渲染压力飙升。

原因:BakeMesh 会把每个粒子的所有顶点都写入 Mesh,包括不可见的粒子。如果粒子系统开了 Sub Emitter 或者 Trail,顶点数会成倍增加。

解决:烘焙前先调用 particleSystem.GetParticles 获取当前活跃粒子,只烘焙活跃粒子。另一个做法是烘焙后做一次顶点合并,把相同位置的顶点合并掉。如果粒子数超过 1000,建议不要用 BakeMesh,改用 GPU Instancing 或者 VFX Graph 的方案。

4. 进阶技巧:用 Shader 做烘焙后粒子的层级排序

4.1 在 Shader 里读取 UV2 层级值做深度偏移

烘焙时把层级值写入 uv2.x 后,Shader 里可以这样用:

// 在顶点着色器里根据 uv2.x 做深度偏移 float layerValue = input.uv2.x; float depthOffset = (layerValue - 0.5) * _LayerDepthScale; output.positionCS = UnityObjectToClipPos(input.positionOS); output.positionCS.z += depthOffset;

_LayerDepthScale 是一个可调参数,控制层级之间的深度差。这个值太大会导致粒子穿模,太小则层级区分不明显。一般设成 0.001 到 0.01 之间,根据场景的深度范围调整。

4.2 用 Stencil Buffer 做精确分层

如果深度偏移不够精确,可以用 Stencil Buffer。每个层级分配一个 Stencil 值,渲染时先渲染底层,再渲染上层,上层通过 Stencil 测试决定是否覆盖下层。

// 底层粒子 Shader Stencil { Ref 1 Comp Always Pass Replace } // 上层粒子 Shader Stencil { Ref 1 Comp NotEqual Pass Keep }

这个做法的好处是层级关系绝对精确,不受深度偏移的影响。缺点是 Stencil Buffer 的位数有限,通常只有 8 位,最多支持 256 个层级。对于大多数粒子特效来说够用了。

4.3 烘焙后粒子的动态排序

如果烘焙后的 Mesh 需要根据相机位置动态调整层级,可以在 CPU 端每帧计算每个粒子的深度,然后更新 Mesh 的 uv2 通道。这个做法性能开销较大,只适合粒子数少于 200 的场景。

void UpdateLayerByCamera(Camera cam, Mesh mesh, ParticleSystem ps) { Vector3[] vertices = mesh.vertices; Vector2[] uvs = mesh.uv2; ParticleSystem.Particle[] particles = new ParticleSystem.Particle[ps.particleCount]; int count = ps.GetParticles(particles); for (int i = 0; i < count; i++) { float depth = cam.WorldToViewportPoint( particles[i].position).z; // 根据深度计算层级值,越近层级越高 float layerValue = 1.0f - depth / cam.farClipPlane; // 映射到该粒子对应的顶点 for (int j = 0; j < verticesPerParticle; j++) { int index = i * verticesPerParticle + j; if (index < uvs.Length) uvs[index].x = layerValue; } } mesh.uv2 = uvs; }

这段代码每帧更新 uv2,然后 Shader 根据 uv2 做深度偏移。注意 verticesPerParticle 需要根据实际烘焙结果计算,不能硬编码成 4。

4.4 一个验证层级是否正确的小技巧

烘焙完成后,把 MeshRenderer 的材质换成一张纯色贴图,然后从侧面观察。如果层级正确,你应该能看到粒子之间有明显的深度差,前面的粒子会遮挡后面的粒子。如果所有粒子都在同一个平面上,说明层级信息没有正确写入或者 Shader 没有正确读取。

另一个验证方法是把相机设成正交投影,从正上方往下看,然后逐个禁用 MeshRenderer,观察每一层粒子的渲染顺序是否符合预期。这个做法虽然笨,但能快速定位是哪一层出了问题。

我在实际项目里踩过最深的坑,是烘焙前忘了记录粒子系统的 Sort Mode,结果烘焙后所有粒子按顶点索引排序,和原始效果完全对不上。后来养成的习惯是:烘焙前先把 ParticleSystemRenderer 的所有参数打印一遍,包括 sortingLayerID、sortingOrder、sortMode、renderMode,确认没有遗漏再执行 BakeMesh。这个习惯帮我省了很多后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进