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