共计 5390 个字符,预计需要花费 14 分钟才能阅读完成。
【USparkle 专栏】如果你深怀绝技,爱“搞点钻研”,乐于分享也博采众长,咱们期待你的退出,让智慧的火花碰撞交错,让常识的传递生生不息!
一、技术设计背景
Unity 引擎自带的粒子系统始终是 CPU 端计算的,这里是指粒子系统以下三大步骤都是在 CPU 计算。
粒子系统的次要 3 个开销大的步骤:
- 每个发射器每帧创立新粒子实例
- 每个粒子实例每帧更新粒子地位、色彩等状态
- 每个发射器的绘制提交与发射器之间渲染排序
起初硬件的倒退 GPU 晋升的更快,而理论我的项目中经常也是 CPU 瓶颈居多。所以有了基于 ComputeShader 与 GPUInstance 技术的 GPU 粒子系统。比方 Unreal Engine 有 CPU 和 GPU 2 套,较新版 Unity 也有 VFX。然而抉择本人写一套次要是这几个考量。
- ComputeShader 与 GPUInstance 联合的技术曾经开发过很屡次最高收益的性能了,比方海量植被渲染、大世界的 GPUTerrain、RealtimeVirtualTexture 等,所以算比拟有把握。
- 现有我的项目曾经上线,心愿现有美术资源不做人工批改,就能实现与引擎的粒子系统性能统一、算法统一、逻辑架构统一并实现一键批量转换,所以本人按 Unity 的算法写到 ComputeShader 更适合些。
- 通用的 GPU 粒子更合乎大量发射器产生大量粒子的模式,而理论游戏很少用到这种模式。不管角色技能还是 FPS 的设计,子弹碰撞成果、弹孔成果等等,全都是发射器数量多,但每个反射器创立的粒子数很少,所以须要用本人定制优化的非凡排序进步性能。
根底性能的面板数据
二、单个简单粒子模式
这种模式尽管游戏内不太罕用,然而性能晋升最大,也是开发最简略直观的。而且 GitHub 曾经有 Demo,我就不反复写这种模式的代码了。如果感觉我这里说的不够具体,没有根底代码局部有点晕的同学能够下载这份很短但残缺的源码。
https://github.com/Robert-K/gpu-particles
具体的做法分 3 个步骤:
- 在 C# 脚本中,每帧对这个发射器计算这一帧须要创立的粒子数(依据粒子系统上每秒多少个和 Burst 参数),而后须要创立多少个 Dispatch、多少个线程数,因为这种模式发射器数量很少,粒子数很大,比方全地图烟雾、全图落叶等。所以 CPU 计算发射数的工作量非常少,没必要让 GPU 计算。
- 把这种粒子系统看成粒子数量是固定的,比方 N,这 N 就是粒子系统里粒子下限参数。创立长度为 N 的 StructuredBuffer,寄存 Particle 实例信息的 Struct。因为每个实例生命完结程序不固定,所以须要一个可用粒子池的 AppendBuffer 来记录 Particle 数组里哪些 Index 粒子可被拿来复用。
- 每帧对所有粒子实例更新,每个 ComputeShader 线程解决一个粒子实例。所以不论以后多少个粒子在渲染都是按 N 来做的。这种粒子个别都是循环 N,根本就是要渲染的全副,只有设置正当,其实并不会节约不可见粒子的空循环,比再用 Buffer 治理无效粒子,渲染时再跳转反而性能更好。
局部要害代码:
Buff 内粒子实例数据
粒子数据与可用粒子对象池索引变量
这里须要留神:dead 与 alive 其实对于 C# 那边同一份 Buffer 数据。只是在创立粒子的 Kernel 里生产,在 Init 与 Update 的 Kernel 里 Append,因为死亡或初始化都要把粒子设置为可用,就是把 Index 还给 Buffer。
创立粒子是耗费可用的粒子 Index
更新时,如果生命到期就把粒子的 Index 还给可用 Buffer
渲染的时候,数量逻辑一样按粒子系统的设置 maxCount 作为 InstanceCount。其中不可见的粒子用 col=pInst.alive*pInst.color,实现暗藏。这种模式绝大部分时候绘制的粒子数量就靠近 maxCount,所以根本都是 alive=true 的,很少空计算。
以下是测试后果渲染 20w 个粒子,这种性能晋升是微小的。Unity 的 CPU 计划 107 帧 VS GPU 实现计划 1661 帧。
色彩不同是因为,Demo 的作者在对色彩随生命变动的突变图转图形时,没思考用线性空间导致的,不影响性能比照。
单个简单粒子 CPU/GPU 计划帧数比照
右边是抓帧证实渲染的粒子数量一样
三、多发射器的简略粒子
这个模式才是我真正为我的项目开发的模式,也是更能写出性能大收益的模式,老老实实的写很容易负优化。这是因为 GPU 中的半透明与 CPU 中的半透明对象很难一起高性能排序,通用引擎为了通用与相对正确,据我粗略理解,这个问题是无解的(高性能的解),前面会讲如何定制优化,先看性能比照。
独自 200 个子弹碰撞特效,每个有 6 个发射器,所以一共 1200 粒子反射器,但来回切换激活 同时只显示 50% 左右(前面按每帧 600 个粒子更新来算)。Unity CPU 版是 373FPS,本计划是 2461FPS。如果用上个计划的那个 GitHub Demo 之间做这种,会发现只有 100 多帧,负优化。所以我没有拿那个源码用,而是本人从新设计了一套合乎具体我的项目的计划。
很多发射器实例的模式下
性能比照:Unity CPU 粒子(上)
vs 本计划 GPU 粒子(下)
这是因为单个简单粒子模式是每个粒子发射器都创立一个含有粒子数据的 Buff,每帧通过 Dispatch ComputeShader 更新这些粒子,也就是说,这样须要 600 次 Dispatch,性能天然就差了。
所以第一步改良就是申请一个专用的大 Buff 来寄存以后激活的所有发射器的粒子数据。对于这种数据组织个别有 2 种模式:一种是间接寻址,一种是每个粒子发射器定长数组占用,而后通过 Offset 获取本人在 Buffer 内的数据。
这里采纳第二种,每种发射器最多同时存在 32 个粒子实例,这样能够满足大部分战斗中重复呈现的大量及时性特效。然而咱们下面说 Particles 是依据粒子创立死亡保护的对象池,数据是无序的。过后是同一个粒子发射器,一次 DrawIndirect,所以不须要在意程序。但当初这个数据里有不同的发射器创立的粒子,渲染时也须要拜访不同的 Index 来获取对应数据。所以须要一个 RWStructuredBuffer<uint> particlesIndexer; 来记录每个发射器,蕴含的粒子在 Particles 数组中的 Index。每个发射器占 32 位元素,同样渲染的时候,须要用另一个 RWStructuredBuffer<uint> emitterCounter;,这个变量就是用在 DrawMeshInstancedIndirect(Mesh mesh, int submeshIndex, Material material, Bounds bounds, ComputeBuffer bufferWithArgs, int argsOffset); 这个 API 里的 bufferWithArgs,配合前面 argsOffset 就能实现每个发射器不同的偏移了。
更新函数中,是这样把以后帧须要渲染的活着的粒子写入这 2 个 Buffer 的。
这样尽管每帧对粒子的 Update 在一次 Dispatch 后就执行完了,但渲染的时候,每个发射器独自执行 DrawCall 还是会性能很差。从 Nsight 工具能够看到十分恐怖的切换 Shader 次数,工夫很快是因为我是 3080 显卡,在一般显卡中这个性能是不具备事实可用性的。
每个粒子发射器一次 DrawCall 的 GPU 切换状况
四、半透明排序与合批渲染
这是整个技术的关键所在也是最大的矛盾点,目前的 DrawIndirect API 每次调用都只能传一个 AABB,引擎会依据这个 AABB 核心参加场景里其余对象进行排序,所以一次 DrawIndirect 绘制的所有粒子领有同一个程序,要么全副在某对象前,要么全副在某对象后渲染。当初每个粒子发射器独自一个 DrawCall 的状况下排序失常了(和 Unity 自带 CPU 粒子一样,逐发射器排序失常,不思考多个发射器之间逐粒子排序),但性能不行。
如果所有同材质发射器合并成一个 DrawCall,那么排序又会不失常,因为它们两头呈现场景的半透明对象无奈交叉到这个 DrawCall 里。这也是为什么 Unity 的 GPUInstance 文章都是不拿半透明做例子,因为 Opaque 的排序不正确不影响画面成果,有 Depth 保障最终程序。通明材质是没有写 Depth 的,除非用了深度剥离技术。但这说远了,个别不会这样做的,所以如何合批是重点。
先看下 Unity 自身是如何合批粒子的,通过简略测试就能发现,如果 ab 是雷同的粒子发射器的不同实例,c 是不同的粒子反射器,ab 间隔凑近,而 c 在 ab 前或在 ab 后,那么只有 2 个 DrawCall;如果 c 在 ab 两头就会有 3 个 DrawCall。所以引擎是排序后才把相邻的又雷同的反射器合批渲染。但咱们渲染数据是在 GPU,如果让 CPU 排序后要合批,则须要搬运 Buffer 内数据后合并到一起,很简单且要改引擎。如果在 GPU 内排序更不可能,GPU 内只能粒子本人排序,无奈与场景上对象排序,这些对象都在 CPU。所以通用引擎很难解决这个问题。
但做定制开发就轻松多了。首先察看下这些我的项目中的特效,同一种特效总是呈现在世界空间地位相机的中央,比方一个人开枪的特效总是在他枪口左近,而子弹的碰撞特效又总是在后方某个地位,不同的玩家是不同的,所以只有用玩家 ID+ 粒子发射器 Prefab 品种做 Key 来分组,Key 雷同的一次性渲染就能够了。但这个性能很高,须要就义精确度,比方同一个人在玻璃后开几枪,再跑玻璃后面开几枪,那么先创立出的玻璃后的粒子也会一起渲染到玻璃下面。然而这问题不大,因为这些特效都是 0.5 秒之内就隐没的,不会长期停留在跑动和下次开枪时,但墙上的弹孔是个特例他们会停留 30 秒,所以这个计划不好。
另一个更好的办法是依据世界空间把 1 立方米内的雷同粒子发射器 Prefab 的所有粒子做一次 Draw,因为地位很凑近所以它们按同一个地位参加排序根本是正确的,比较简单的是用 long 类型把这些信息计算到一起且不反复。假如这里场景范畴是正负 5000 米,全副合批发射器用这个治理 Dictionary<long,ParticleEmitterBatch> activeEmitterTypes;。
依据地位与发射器类型计算合批渲染的编号
分组发射器数据结构
最初介绍该计划的次要数据。因为改用这种合批,这里有和下面批改的中央。
按类型与空间合批渲染的更新形式
- CreatingEmitter:发射器创立粒子时要传一份发射器数据让粒子初始化时能够晓得如何初始化,比方这个粒子 life 要从发射器的 lifeMin 与 lifeMax 之间随机取一个。
- Emitters:所有发射器类型数据,因为更新每个粒子时,怎么更新是来自这个数据比方 色彩随生命变动,是把开始色彩和最初色彩记录到发射器的,如果反复的记录到每个粒子那么很节约空间。
- Particles:所有粒子,外面有激活的有不激活的,渲染哪些是 ParticlesIndexer 的值来这里取。
- ParticlesIndexer:每种发射器记录占 MAX_COUNT_PER_EMITTERKIND(我用 2048)个元素,记录本人创立的粒子在 Particles 数组中的实在地位。
- EmitterCounter:用在 DrawIndirect 的粒子数量设置,Graphics.DrawMeshInstancedIndirect(quadMesh, 0, item.material, item.aabb, emitterCounter, (5 item.emitterBatchID) 4,item.mpb);
- freePool_a 与 freePool_c 是同一份粒子索引可用池,在不同阶段散布做生产与增加,保护粒子实例的复用。
该计划的次要数据
最初看下最终落地成果,从原来开枪掉 18 帧变成只掉 5 帧,至此优化几轮的开枪降帧问题终于有点稳住了,之前是基本不能与 CSGO 相比,他们优化的太好了。
最终落地我的项目
连发 35(常见弹夹)后降帧比照
五、GPU 的优化
这个 GPU 粒子次要性能是优化 CPU 瓶颈,对于 GPU 的性能优化顺便提下,停火会有大量重叠的多层的大屏幕面积的火焰、烟雾,导致 Overdraw 问题十分大,察看 CSGO 与 COD 有几个简略优化技巧:
- 特效屏幕空间占比尽量小
- 用 8 边形代替 Quad 作为粒子 Mesh,能够大幅度缩小 PS
- 如果是多层 Billboard 叠加,能够离线叠加成序列帧特效,多做几组随机播放
- 近相机的特效要呈现十分快、隐没也十分快,并存工夫要短,不要缓缓隐没
- 对于这种短生命周期的粒子不要用引擎默认激进规定,每帧去判断发射器是否可见。而是在发射时判断以后是否可见(视锥、hiz 等),如果不可见间接不创立出粒子,因为创立出的也很快隐没,这帧不可见根本能够当作他从出世到死亡都不可见。
这是侑虎科技第 1413 篇文章,感激作者 jackie 偶然不帅供稿。欢送转发分享,未经作者受权请勿转载。如果您有任何独到的见解或者发现也欢送分割咱们,一起探讨。(QQ 群:465082844)
作者主页:https://www.zhihu.com/people/jackie-93-85-85
再次感激 jackie 偶然不帅的分享,如果您有任何独到的见解或者发现也欢送分割咱们,一起探讨。(QQ 群:465082844)