在开始之前,我们先做一个小广告。 ILRuntime 是我制作的。 这是一个已经在大量商业项目中得到验证的C#热更新解决方案。 图中四款游戏均采用ILRuntime进行热更新。 ,如果您对C#语言热更新解决方案感兴趣,可以在下面的Github地址了解更多。
这就是我们在《黑暗潮汐》游戏中遇到的挑战。
为什么要定制渲染管线?
《暗潮》使用的是URP。 URP是一种更适合移动平台开发的PBR渲染管线。 非 PBR 的东西也可以用它来渲染。
我们更看重的是URP具有非侵入式修改的能力。 无需修改URP源码即可进行更多定制。 还有一点就是URP拥有全部C#源码游戏图片,整个渲染过程基本可以我们自己控制。 当问题或错误出现时,更容易找到它们。
源码结构清晰,组织也非常合理,所以我们对其进行扩展和定制也比较容易。 最关键的一点是,URP的性能比Builtin内置的管道要好。
很多人会问,为什么我们需要定制渲染管线呢? 是不是因为URP有坑或者有无法实现的东西,所以必须定制? 事实上并非如此unity射线检测检测不到物体,因为每个项目都有其独特的需求。 为了更好地满足这些需求,需要对渲染管道进行一些定制。
比如下图中,角色释放了带有火焰效果的技能,但火焰效果的特效却渲染在地面的裂缝上,这是一种不正确的表演。 正确来说,这火焰的特效应该是在这些地面裂缝的上方。
过去我们只能修改Builtin pipeline中不稳定的Renderqueue。 这有一个主要缺点。 我们必须创建一个新的着色器或编写更复杂的逻辑。 一旦引入新的Shader,刚刚做的事情可能需要重新做一次,这是非常麻烦的,而且容易出现重复出现的问题。
在Builtin中,有些效果只能通过Shader Pass来实现。 例如,如果我们想给这个物体添加一个笔画,我们需要给角色的Shader添加一个额外的Pass。 这样,在渲染过程中,批处理将不可避免地被多个通道中断。
你可以看到右边的图片。 渲染object1时,如果它的Shader有多个Pass,我们首先渲染Pass1,然后通过Set Pass调用渲染Pass2,最后通过Set Pass调用渲染Pass3。 之后,我们渲染第二个。 对象,重复刚才的操作,然后Pass1,Pass2,Pass3。
渲染这两个对象时,会出现很多DrawCall,每次切换DrawCall的成本比较大。
实际上有一种更好的方法可以以管道方式渲染这两个通道。 渲染Pass1时,一次性渲染所有对象1、2和3。 完成后,通过 Set Pass Call 渲染 Pass2。 事实上,这三个物体一共需要两次Pass来渲染,所以效率会很高。 很少。
还有一个问题。 Unity 是一个通用引擎。 为了兼容,渲染过程中某些情况下可能会添加Blit操作。 Blit操作相当于复制全屏结果。 这个开销对于移动平台来说是非常大的,因为移动平台的带宽非常有限。
在我们的项目中,因为我们对整个渲染流程有了清晰的认识,所以我们知道什么情况下可以使用Blit,什么情况下可以去掉。 如果去掉的话,游戏的性能将会得到很大的提升,并且会减少很多的带宽开销。
另外,我们每个项目都有一些项目特有的效果,如下图。 对于URP本身,默认情况下,扭曲和空气扰动效果仅对不透明物体生效。 火焰在这个地方会显得更加突兀,因为它们不受它的影响。 干扰效应。 我们对其进行了定制,以便空气湍流效应也会影响火焰。
URP渲染管线
请看下面的流程图。 当动态光影开启时,URP首先渲染主光源的Shadowmap,然后渲染附加光源的Shadowmap。 URP中的主光源主要指充当太阳光的一堆定向光源,而附加光源则指除定向光源栈之外的动态光源,如点光源、聚光灯等。
渲染完这两个阴影贴图后,URP 将执行 Depth Prepass 操作。
一般来说,Depth Prepass的主要作用就是提前渲染整个场景中所有物体的深度。 稍后渲染不透明物体时,可以直接使用深度结果进行深度测试。 您可以使用 Early Z 删除必要的源。 在 AlphaTest 期间,直到稍后进行比较才能确定该像素的深度unity射线检测检测不到物体,因此如果没有 Depth Prepass,Early Z 将在这些地方失败。
但在URP中,Depth Prepass没有这个功能。 它只是将场景中所有物体的深度渲染到一个单独的RT中。 此 RT 用于后续效果。
完成Depth Prepass后,会绘制所有不透明物体,然后绘制天空盒,然后进行Copy Color操作。 如果用户在渲染管线设置中开启Color Pictures功能,它会将当前的渲染结果复制到一个独立的RT中供以后使用。
然后绘制所有透明物体,然后全屏进行后期效果处理。 如果还有UI,则会绘制该UI,然后当前所有的渲染结果都会经过上次的Blit操作,复制到屏幕缓冲区中。
如何自定义内置URP?
首先,URP提供了RenderObject,也就是URP默认实现的RenderFeature和RenderPass工具。 通过这些,无需编写一行代码即可定制渲染管道。 我们可以清楚地指定一个图层以及该图层将被渲染的具体时间点。
我们需要在渲染透明物体之前做RenderFeature,并为渲染做一些额外的设置,比如绘制涂层,需要使用哪个彩球,或者重载一些渲染状态和深度来决定这个东西是否应该是深度已测试。 ;还可以设置模板缓存方式和相机参数。
在《黑暗之潮》中,我们使用RenderObject来执行这些操作:
首先解决地面裂缝等透明物体的渲染。 我们单独使用RenderObject,选择地面上的图层,让它在透明物体之前渲染整个图层,这样地面的裂缝就可以在所有技能效果之前渲染出来。
其次,RenderObject辅助其他自定义RenderPass。
第三,实现透明物体的扭曲效果。 我们把复制ColorTexture的时机往后移,放在透明物体后面,用单独的Pass来额外渲染这些需要扭曲的特效,最终完成正确的渲染。
RenderFeature和RenderPass的自定义
这是 URP 提供的比 RenderObject 更高级别的定制。 通过RenderFeature,您可以在任何时间点插入您想要的自定义渲染操作。 你有更强的控制能力,可以手动调用CommandBuffer。 底层渲染接口实现了很多效果。
使用RenderPass时,可以通过切换RT和RenderBuffer的LoadStore操作来进行一些性能优化。
现在的移动GPU基本都采用tilebase架构。 GPU拥有片上内存,其所有渲染结果都直接操作片上内存,而不是直接操作显存,从而减少了频繁读取显存带来的带宽开销。 渲染的时候,我们需要提前告诉GPU。 既然我们已经切换到了RT,是否需要先将RT原来保存的色彩系统加载到片内存储器中,然后再进行后续的渲染操作。
在“Dark Tide”项目中,RenderFeature被用来创建平面阴影。
首先橙光游戏,这个阴影是假阴影渲染方式,只能在平坦的地面上使用。 《黑暗之潮》就是这样一款游戏。
其次,阴影非常锐利清晰,渲染质量非常高,没有任何锯齿现象。
第三,不需要渲染额外的阴影贴图,渲染表面时也不需要对阴影贴图进行采样,因此总体成本比使用阴影贴图要少得多。
第四,这种效果用RenderFeature很容易实现。 只需添加一个Shadow RenderFeature并用特殊的阴影绘制需要阴影的角色即可。
下图是使用RenderFeature实现的沙盒图绘图描边效果。
该笔划需要与该块的范围精确对应。 方块的下半部分、墙壁和山体部分不能有笔划,因此无法使用传统的扩展法线方法来渲染笔划。
我们的流程:首先使用纯色渲染绘图,然后对渲染结果进行下采样,锁定分辨率,然后在分辨率比较低的情况下使用BoxFilter进行模糊操作。
这样做的优点是可以用尽可能小的带宽开销来模糊结果。 最后,对模糊结果进行上采样以提高分辨率,然后用透明颜色绘制一次绘图,并扣除中间区域,仅保留外部笔画。 这样不仅可以达到需要的描边效果,还可以达到从靠近物体的部分向外慢慢逐渐淡化的柔和过渡效果。
《暗潮》的自定义渲染器及最终渲染管线流程
某些效果或要求需要更深入的定制才能实现。 在URP中,提供了Render渲染器。 它是一个带有两个内置渲染器的抽象层。 一个是Forward,也就是前向渲染器,另一个是2DRenderer。 一些2D游戏可能会选择此渲染器。
最新版本的URP还集成了defer Renderer延迟渲染器。 在《暗潮》中,我们可以注册Renderer,实现一些通过RenderFeature无法完成的事情。
URP的一个优点是,虽然需要自定义Renderer,但并不是一切都要从头开始,因为各种Passes已经在URP中实现了,可以直接使用。 我们只需要重新排列这些Pass即可完成Renderer。 的定制。
我们修改了 ForwardRenderer 的基础知识并对其进行了定制。
比如做后期效果的时候,不可避免的要对全屏的所有像素进行操作。 一般情况下,如果需要后期渲染UI,UI会在后效计算完成后渲染,最后通过Frame Blit复制到FramBuffer中。
这两个过程可以合并吗? 这绝对是可能的。 当我们最终渲染UI时,我们可以直接在FrameBuffer上绘制UI,这样可以省去最后一次Blit操作。
这还具有将 3D 场景的渲染分辨率与 UI 的渲染分辨率分开的优点。 以前如果因为配置高低导致整个渲染结果的分辨率降低,那么UI的分辨率也会降低。 然而,UI 对分辨率非常敏感。 一旦分辨率降低,肉眼可见,会影响游戏质量。 影响力很大。
通过刚才介绍的方法,将3D场景渲染到RT上,然后通过After Effects复制到FrameBuffer中。 UI直接在FrameBuffer上绘制,因此UI的分辨率不受分辨率降低的影响。
《黑暗之潮》最终的渲染管线如下流程图所示。
前半部分与默认的 URP 没有太大区别。 主要是在渲染不透明物体后添加ECS模型渲染。 我们还有一个复制深度,它将不透明对象的深度复制到单独的 RT。
该Pass并不是每次渲染时都可用,而是仅在沙盒贴图开启时使用。 接下来渲染表面不透明物体,渲染所有平面阴影和ECS物体的平面阴影,绘制沙盘图的笔画,最后渲染透明物体。 渲染完特效后,执行复制颜色,将整个渲染结果复制到单独的RT中。
该RT已执行分辨率降低操作。 事实上,捕捉到的并不是全屏,而只是屏幕分辨率1/4左右的颜色信息。 此信息用于失真等效果,因为这些效果需要分辨率。 不是特别高。 渲染完扭曲后,我们会对整个屏幕进行后期效果处理,将结果直接写入FrameBuffer屏幕缓冲区中,最后直接绘制UI,完成整个渲染过程。
URP性能优势及对项目的影响
首先,URP是单通道前向渲染管道,所有动态光照都在一次通道中计算。 通过单通道渲染,只要更好地控制同一场景中可以看到的光源数量,就可以达到良好的渲染效果,并且成本相对于多通道灯光渲染具有巨大优势。 经测试,目前主流终端和中高端终端都没有问题。
其次,URP使用单通道颜色纹理来代替GrabPass。 以前,我们必须使用 GrabPass 在内置管道中创建空气扰动效果。 虽然这个功能非常方便简单,但是却有一个非常严重的问题。 使用GrabPass后,无法预测当前渲染屏幕会被全屏捕获多少次。 而且这种捕获不会降低分辨率,尤其是在移动平台上。 通过单个Pass的ColorTexture,所有需要畸变操作的渲染都可以通过一次捕获完成,性能要高很多。
第三,通过RT定制LoadStore操作,进一步降低带宽。
第四,可以根据实际情况去掉一些不必要的Blit操作。
最后是SRP Batcher,仅凭这一点并不能拒绝URP的使用。
SRP Batcher对项目有什么具体影响?
内置管道中有三种批处理方法。 第一个是Dynamic Batching,对batching的要求比较严格,对三角形的数量要求比较高,通过CPU减少DrawCall。 我们减少DrawCall的目的也是为了减少CPU开销,相互的意义已经消失了。 在某些特定情况下,Dynamic Batching 可以提高性能,但大多数情况下并没有太大效果。
其次,Static Batching对于减少DrawCall、提高性能非常有效,但最大的问题是它只对静态对象起作用,对动态对象没有效果。 而且,进行静态批处理后,整个场景的内存占用会明显增加。 另外,次世代的游戏场景已经非常复杂。 LOD是必不可少的功能,但Static Batching对LOD非常不友好。
最后是 GPU 实例化。 此方法只有在网格和材质一致的情况下才能生效。 适用范围比较窄。 对于普通对象,例如房屋和场景,没有办法对其进行批处理。
上述三种批处理方式如果用在下一代游戏中,会受到一定的限制,性能优化也会非常困难。 SRP Batcher可以很好的解决这个问题。
在DrawCall中,最昂贵的是SetPassCall。 SRP Batcher的原理是通过减少SetPassCall的次数来提高性能。 渲染时需要的参数变量被分割成几个ConstantBuffer分别保存。 比如保存全局静态参数,有的可能保存当前这一帧数据,剩下的Buffer存储当前材质球特有的参数。
这样做的好处是显而易见的。 如果我们谈论同一个Shader对象,它实际上只改变模型和材质球上的参数。 至于Shader的程序和渲染状态,这些不需要改变。 因此,一次DrawCall基本上只需要ConstantBuffer的内容,然后绑定一个Mesh指针就可以完成。 整个DrawCall的开销很低。
您可以在下图中看到比较。 左侧的 SRP Batcher 已打开,但右侧未打开。 这张图是通过RenderDoc捕获的DrawCall渲染过程。 纹理绑定到左侧,并传递一些顶点指针。 最后通过BannerBuffer更新ConstantBuffer数据,就可以直接绘制了。
然而,在不开启SRP Batcher的情况下,整个渲染过程非常漫长,需要进行大量的设置、更改Shader程序、更改大量的渲染状态。 该列表下面有一个很长的部分。 比较列表的长度可以看出两个DrawCall的性能开销差异有多大。
我们也测试了它。 具有三堆动态光源的测试场景。 在顶级配置中,该场景具有40W三角面和500dc; 中间配置简化,三角面32W,400dc; 低配有25W三角面和280dc,三速机模型测试有明显提升。 虽然低配置是25W三角面和280DrawCall,但在Builtin的项目中其实高配置能流畅运行已经是一个标准了。
看看这个Profiler的结果,我们在Snapdragon 450 SoC上进行了测试,这是一款非常低端的处理器。 我们的主线程Render Camera是4.3毫秒,下面的渲染线程Camera的开销是14毫秒。
关闭SRP Batcher后,对于同一个场景、同一个东西、同一个视角,主线程的Render Camera开销直接增加到了7.8毫秒,渲染线程开销达到了22毫秒。 22毫秒相当于说只有场景,没有任何技能效果,没有其他角色,没有任何业务逻辑,不太可能跑30帧。
DOTS技术栈在《暗潮》中的应用
接下来我会分享一些关于DOTS技术栈商业应用的信息。
大多数开发人员都存在一些常见的误解。 关于DOTS技术栈,很多人说我们项目中没有使用多线程,所以没有必要使用DOTS。 或者说必须在大规模集群模拟中使用DOTS才能获得比较大的提升。 另外,大家感觉使用ECS的成本非常高,因为ECS是一个全新的东西。 将当前项目转换为ECS成本非常高,并且可能无法使用DOTS。 这三者或多或少都是误会。
首先你要明白什么是DOTS?
DOTS其实全称为Data-Oriented Tech Stack,其实就是面向数据的开发栈。 它主要由三个组件组成,ECS、JobSystem、Burst。 这三个组件可以相互独立使用,针对不同的应用场景可以选择其中任意一个组件。
如果需要使用JobSystem,可以在ECS中使用,也可以不在ECS中使用。 它可以用在任何需要并行计算的地方。 对于爆裂来说也是如此。 它不需要与ECS一起使用或与并行计算捆绑在一起。 它的作用只是对一些复杂的计算进行编译器优化,以达到性能的提升。 只要计算量大,就可以使用Burst,也可以使用同步方法。
关于ECS,大家都会认为一切都可以用ECS来写,就会思考如何用ECS来实现UI的业务逻辑。 无需。 并非所有事情都必须使用 ECS 完成。 相反,您可以根据项目需求选择其中合适的部分写入ECS中。 剩下的可以根据项目需求与ECS和传统OOP结合。
我给大家展示一下我们在《暗潮》中使用ECS的例子。
我们通过ECS渲染大量的怪物。 《暗潮》游戏中的怪物通常都有一个特性。 一群怪物由数名精英和一两个大量的爪牙组成。 如果您使用默认的 SkinMeshRenderer,则无法将它们批处理在一起。 屏幕上有多少个怪物就会有多少个。 有多少个 DrawCall? Animator的成本不小,GameObject.Instantiate的成本也比较大。 如果同时刷出三十、四十只怪物的话,肯定会有卡顿。
使用ECS,首先将整个动画信息烘焙成动画图,在GPU上进行蒙皮操作,然后通过JobSystem和Burst实现视锥体剔除和动画系统更新,最后使用传统的OOP游戏逻辑来控制ECS的Enity即可。 ECS部分只提供了渲染和动作的结构,其他部分的业务逻辑完全采用面向对象的方法实现,各有所长。
ECS最大的好处是性能。 因为我们使用 GPU 蒙皮,所以 DrawCall 的数量减少到只有几个 DrawCall。 实例化也非常快。 ECS基本上不敏感。 就算同时刷出一千只怪物,也需要不到1毫秒的时间。 借助 Burst power,在低端机器上可以忽略视锥体剔除等计算密集型操作。 的。
通过ECS,怪物在屏幕上的渲染完全取决于GPU本身的渲染性能。 无需考虑CPU开销,也不会有卡顿。
我们通过Jobsystem实现了将怪物击飞到空中的效果。 怪物被击落悬崖。 如果撞到了墙,肯定会被墙挡住,这需要一些物理计算。 如果直接使用Unity的Ragdoll系统,其物理计算非常复杂,会对低端机器造成比较大的性能负担。 我们简化了这个过程。 当所有怪物被击飞到空中时,它们使用预先制作的动画,只需要计算它们的轨迹。
我们首先使用Job并行计算怪物的分析轨迹,然后使用Unity提供的多线程Raycast方法进行射线检测,以确定它是否撞到了墙壁或地面。 最后,如果还有一些非ECS对象,可以在计算完成后通过单独的Job来同步所有GameObject的位置。
我们通过爆裂来实现射线技能。 这看起来很简单。 事实上,它需要与整个场景以及所有怪物和其他物体进行交互。 照射到墙壁上的光线可以实时反射。 整个场景每一帧都需要进行光线检测,整个计算过程比较昂贵。
通过Burst,我们将其做成一个Job,直接通过Job.Run方法调用。
另外,这个技能的子弹数量较多,子弹需要计算其运行轨迹。 Burst可以有效地将这两个计算开销降低到很低的水平,性能基本上可以提高数百倍。 结合Job.Run方法实现同步调用,我们在整个计算过程中不需要开启额外的线程。 在当前线程中直接调用单个静态方法。
你可以看看开启和不开启Burst效果的区别。 左侧打开,右侧关闭。 在计算系统建模工具中测试,左侧仅耗时241毫秒,右侧耗时20毫秒。 如果算总时间的话,左边用了143秒,右边只用了1秒。 所有线程的时间加起来相差100倍,效果非常明显。
工作流程简化和改进
由于我们采用PBR流程,Prefab的制作会比较麻烦。 过去,Prefab的制作是留给艺术系学生的。 艺术生需要将模型导入Unity,然后标准化材质和Prefab的创建。
采用PBR流程后,创建过程会麻烦很多。 纹理较多,各种PBR设置也非常复杂。 尤其是ECS单元,需要烘焙动画,非常耗时且容易出错。
为了解决这个问题,我们引入了AssetGraph,它是一个基于节点的工具,用于自动化资源导入过程。 通过自定义节点,可以完全根据项目的需要自定义资源的导入,然后一键创建所有角色的Prefabs。 艺术家可以从工作中解放出来。 完成后只需将FBX和纹理文件放入指定位置即可。 目录。
导出场景时,有时需要对渲染对象进行渲染设置,以达到最佳的渲染性能。 具体的设置方法需要技术团队根据profiling结果不断迭代调整,形成调整方案。 每次调整都需要修改美术资源,工作量非常大。
为了提高切换场景的加载速度,需要对场景进行分块、聚类。 从右边的截图中可以看到,这些蓝绿色的框是我们聚类和切割的结果,它们显示了块Bounding Volume。
结合整个场景的导出流程,按照流程图进行操作。
第一步,检查美术设置中的LOD选项是否正确,去掉临时美术对象,检查一些碰撞Fix Mesh Collider ReadWrite设置是否正确,删除LOD点面工具的临时脚本,然后制作ShadowMask 的一些设置。
因为URP中没有shadowMask,所以需要根据Prefab的结果进行一些细节设置。 例如,如何设置Instancing设置? 我们将设置哪些对象适合实例化,哪些不适合。 另外,还对整个场景进行聚类,看看哪些对象适合Static Batch来进行一些选择。
其余对象适合转换为 ECS 混合渲染,并将转换为混合渲染。 最后会计算每个簇的Bounding Volume,完成整个场景的导出过程。
场景导出后,整个场景处于空场景的状态,只剩下错误的节点。 我们将输入这个范围,然后动态加载它。 下图是我们生成的各个簇的Prefab以及静态合并后的Mesh。 。