【干货分享】利用技术优点,打造次时代手游

发布时间:2024-07-08 14:44:03

11月16日,主题为“创新思,享未来”的中国Unity线上技术大会正式拉开帷幕。在17日晚的游戏专场中,掌趣科技技术专家(ILRuntime作者)林若峰以“Unity2019新特性在次时代手游《黑暗之潮》中的应用经验及技术分享”为主题发表演讲,分别介绍了URP和DOTS这两项技术的优势,以及它们在《黑暗之潮》上的实际应用效果。

以下为演讲内容(节选):

大家好,我叫林若峰,目前任职于掌趣科技担任技术专家,主要在天马时空这边负责次世代手游《黑暗之潮》的客户端技术,以及公司客户端框架的开发和维护。

从Unity2018开始,Unity引入了不少的新技术,今天我主要给大家分享关于DOTS和URP这两项技术在实际商业项目当中运用的一些心得和体验。

首先简单介绍一下《黑暗之潮》。这是一款顶视角的次世代手游,目前处于内测阶段。虽然是一款锁定视角的游戏,但对画质和战斗细节的要求还是挺高的,采用了PBR渲染,场景当中有不少的动态光影效果,细节方面相当的丰富。

这是本次PPT分享的内容概要。

正题开始之前先打一个小广告,ILRuntime是我制作的,这是一个C#热更解决方案,目前已经在大量商业项目中得到了验证,图中这四款游戏都是采用的ILRuntime进行的热更,大家如果对C#语言的热更方案感兴趣,可以在下面的Github地址上进一步的了解。

这是我们在《黑暗之潮》这款游戏当中遇到的挑战。

为什么要自定义渲染管线?

《黑暗之潮》选用了URP,URP是一个比较适合移动平台开发的PBR渲染管线,非PBR的东西也是可以拿它来渲染。

我们比较看重的一点是,URP拥有非侵入式修改的能力,在不修改URP源码的情况下,可以对它进行比较多的定制。另一点是URP有全部的C#源码,整个渲染过程基本能全部掌控在我们自己手里面,当出现问题或者遇到bug时,比较容易能够查到。

源码的结构清晰,组织也非常合理,所以我们扩展和自定义起来相对来说是比较容易的。最关键的一点,URP的性能比Builtin内置管线要更好。

很多人会问,为什么需要对渲染管线进行自定义,是不是因为URP有什么坑或者说是有些什么不能实现的,所以我们必须得去自定义呢?实际上不是的,因为每个项目都有各自独特的需求,在更好地满足这些需求的情况下,就需要对渲染管线去进行一些定制。

举个例子,下图角色释放了一个火焰效果的技能,但火焰效果的特效被渲染在了地面的裂纹之上,这是一个错误的表现。正确的是,这个火焰的特效应该盖在这些地表裂痕的上方。

以前我们在Builtin管线中只能通过修改不稳定的Renderqueue,这么做有一个比较大的弊端,我们要新建一个Shader,或者要写比较复杂的逻辑。一旦引入了新的Shader,有可能刚才做的这些东西需要重新做一遍,非常麻烦,且容易反复出问题。

在Builtin当中,一些效果其实只能通过Shader Pass去实现。比如说我们想给这个物体增加一个描边,就需要在角色的Shader里面去额外的增加一个Pass。这样在渲染过程中,势必就会被多Pass给打断合批。

可以看到右边这幅图,在渲染object1的时候,如果说它的Shader有多个Pass,我们首先渲染Pass1,然后通过一个Set Pass call渲染Pass2,Set Pass call渲染Pass3,完了我们再渲染第二个物体,又把刚才的操作重复一遍,然后Pass1、Pass2、Pass3。

在渲染这两个物体的时候,就会有非常多的DrawCall,而且每次DrawCall切换开销都是比较大的。

实际上有更好的方式,用流水线的方式渲染这两个Pass。在渲染Pass1的时候,一口气把所有的object1、2、3全渲染了,完毕之后通过一次Set Pass Call去渲染Pass2,实际上三个物体总共需要两个Pass就可以渲染完毕,效率会高不少。

还有一个问题,Unity是一个通用引擎,为了兼容性,在渲染的过程中,可能会在一些情况加入Blit操作。Blit操作相当于把全屏的结果进行一次复制,这个开销对移动平台来说非常大,因为移动平台的带宽是很有限的。

我们的项目,因为对整个渲染流程比较清楚,知道哪些情况下可以使用Blit,哪些情况可以把它去掉。去掉的话,游戏的性能有比较大的改进,也能降低很多的带宽开销。

另外,我们每个项目都有一些项目特有的效果,比如下图,对于URP本身,默认情况下,扭曲、空气扰动效果只对不透明物体生效,火焰在这个地方就会显得比较突兀,因为不受扰动效果影响。我们对它进行定制,最后实现空气扰动效果同样能够对火焰产生影响。

URP的渲染管线

看下面的流程图,在开启了动态光影的情况下,URP首先去渲染主光源的Shadowmap,再去渲染附加光源的为Shadowmap。主光源在URP里主要指充当太阳光的一栈方向光,附加光源指除了那栈方向光以外的,比如点光源、射灯之类的动态光源。

渲染完这两张shadowmap之后,URP会进行Depth Prepass的操作。

通常来说,Depth Prepass最主要的作用是,预先把整个场景所有物体的深度渲染一次,后面再进行不透明物体渲染的时候,直接使用深度的结果去进行深度测试,就可以利用Early Z把一些不必要的片源去掉。在AlphaTest的时候,这个像素的深度要在比较后期才能够决定,所以如果没有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工具,通过这些,在不写一行代码的情况下,可以对渲染管线进行定制。我们可以明确指定某一个layer,以及layer在哪个具体的时间点进行渲染。

我们需要在透明物体渲染之前去做RenderFeature,以及对渲染进行一些额外的设置,比如绘制涂层,需要使用哪个彩色球,或者对一些渲染状态、深度进行重载,来决定这个东西是否做深度测试;还可以设置模板缓存的方式,摄像机的参数。

在《黑暗之潮》当中,我们利用RenderObject进行了这些的操作:

第一,解决地面裂纹这些透明物体的渲染。我们单独使用了RenderObject,选中了地表那一层layer,让它在透明物体之前去渲染这一整层,这样就能在所有技能特效之前去渲染地面的裂纹。

第二,RenderObject去辅助其他的自定义RenderPass。

第三,对透明物体实现扭曲的效果。我们把复制那张ColorTexture的时机往后挪,挪到透明物体之后,并且用单独的Pass额外去渲染这些需要扭曲效果的特效,最终完成正确的渲染。

RenderFeature和RenderPass的自定义

这是URP提供给的比RenderObject更高一层级的自定义,通过RenderFeature可以做到在任意一个时间点插入自己想要的自定义的渲染操作,拥有更强的控制能力,并且可以通过手动调用CommandBuffer底层渲染接口,实现非常多的效果。

使用RenderPass的时候,可以通过操作切换RT,RenderBuffer的LoadStore操作来进行一些性能优化。

现在移动GPU里基本都采用了tile base架构,GPU有个片上内存,它所有的渲染结果直接对片上内存进行操作,而不是直接对显存进行操作,从而减少频繁读显存所带来的带宽开销。在渲染的时候我们需要提前告诉GPU,现在切换了一个RT,是否需要把RT本来保存的颜色系统首先加载到片上内存,然后再进行接下来的渲染操作。

《黑暗之潮》项目当中利用RenderFeature做了平面阴影。

第一,这个阴影是一种作假的阴影渲染方式,大部分都是平地的情况下才能够使用,正好《黑暗之潮》就是这样一款游戏。

第二,这个阴影非常锐利、非常清晰,渲染质量很高,不会出现任何的锯齿。

第三,不需要额外渲染shadowmap,渲染地表的时候也不需要对shadowmap进行采样,因此整体开销要比使用shadowmap省非常多。

第四,这个效果用RenderFeature非常容易实现,直接添加一个Shadow RenderFeature,把需要有阴影的角色用特殊的shadow绘制一遍就可以。

下图是拿RenderFeature实现的沙盘地图地块描边效果。

这个描边需要严丝合缝对应这个区块范围,区块下半部分,墙、山体部分又不能有描边,所以没办法用传统法线往外扩的方式去渲染描边。

我们的流程:先用一个纯色去渲染地块,之后对渲染结果进行降采样,锁分辨率,在分辨率比较低的情况下,再利用BoxFilter进行模糊操作。

这样做的好处,可以利用尽可能小的带宽开销来对这个结果进行模糊操作。最后将模糊完毕的结果进行升采样提高分辨率,再用透明的颜色绘制一次地块,就把中间这个区域扣除了,只剩下外面的描边,既能实现需要的描边效果,还能实现从靠近物体的部分往外慢慢渐变渐影的柔和的过渡效果。

自定义Renderer及《黑暗之潮》最终渲染管线流程

有一些效果或者需求必须更深层次的自定义才能够实现。在URP当中,提供了Render渲染器,它是一个抽象层,内置了两个渲染器,一个是Forward,也就是前向渲染器,另外一个是2DRenderer,一些2D游戏可能会选择这个渲染器。

最新版的URP当中,还集成了一个defer Renderer延迟渲染器,在《黑暗之潮》中我们可以对Renderer进行寄存,通过这个实现一些通过RenderFeature做不到的事情。

URP有一个好处,虽然要自定义Renderer,但不是所有东西必须从头开始,因为URP里面已经实现了各种各样的Pass,可以直接使用,我们只需要对这些Pass进行重新编排,就能完成Renderer的自定义。

我们对ForwardRenderer基础进行修改做到了自定义。

比如要做后效,不可避免对全屏所有的像素进行操作,正常情况下,如果后面还需要渲染UI,会在后效计算完毕之后渲染UI,最后通过Frame Blit复制到FramBuffer里面。

这两个过程能否合并?肯定是可以的,我们最后渲染UI的时候,把UI直接在FrameBuffer上面去进行绘制,就可以省掉最后这个Blit的操作。

这么做还有一个好处,可以把3D场景的渲染分辨率和UI的渲染分辨率分开。以前如果受制高低配,对整个渲染结果的分辨率进行降分辨率操作的话,UI也会跟着一起被降分辨率,但是UI对分辨率非常敏感,一降分辨率就肉眼可见,对游戏的品质影响很大。

经过刚才介绍的方式,3D场景在RT上面渲染,之后通过后效复制到FrameBuffer上面,UI直接在FrameBuffer上面绘制,所以UI的分辨率不受降分辨率的影响。

《黑暗之潮》最终渲染管线如下面流程图展示。

前半部分跟默认的URP没有太大的区别,主要是在渲染不透明物体之后,加入ECS模型渲染。我们还有一个Copy Depth,把不透明物体的深度给复制到一张单独的RT上面。

这个Pass不是每次渲染都会有,而是只有开启沙盘地图的时候才会用。接下来渲染地表的不透明物体,渲染所有的平面阴影以及ECS物体的平面阴影,绘制沙盘地图的描边,最后再渲染透明物体。渲染完特效再进行Copy color,把整个渲染结果复制到一张单独的RT上面。

这个RT是进行了降分辨率操作,实际上抓取的并不是全屏,大概只有1/4屏幕的分辨率的颜色信息,这个信息给类似于扭曲效果去使用,因为这些效果对分辨率的要求并不是特别高。渲染完扭曲之后,我们会对整个屏幕进行后效处理,把结果可以直接写在FrameBuffer屏幕缓冲区里面,最后再去对UI直接进行绘制,完成整个渲染流程。

URP性能优势及对项目的影响

第一,URP是一个单Pass的前向渲染管线,所有的动态光照是在一个Pass里面完成计算的。通过单Pass的方式渲染,只要同场景里面同时能看见光源的数量有一个比较好的控制,就能够实现很好的渲染效果,而且开销相比多Pass光照渲染有非常大的优势。经过测试,在目前主流的终端机以上,中高端机都是没有任何问题的。

第二,URP采用了单Pass的Color Texture去替代GrabPass。之前我们在Builtin管线里面做类似于空气扰动效果,必须要使用GrabPass,这个功能虽然非常方便简单,但有一个非常严重的问题,使用GrabPass之后没有办法预知当前渲染屏幕会被全屏抓屏几次,而且这个抓取不会降分辨率,尤其是在移动平台上面。通过单Pass的ColorTexture,就可以通过一次抓取完成所有需要扭曲操作的渲染,性能高非常多。

第三,通过RT自定义LoadStore操作,进一步减少带宽。

第四,可以根据实际情况去掉一些不必要的Blit操作。

最后,SRP Batcher,仅这一条就已经不能拒绝URP的使用。

SRP Batcher对项目有什么具体的影响?

内置管线中有三种方式进行合批,第一是Dynamic Batching,它对合批有比较严格的要求,对三角面数要求比较高,以及它是通过CPU降低DrawCall。我们降低DrawCall的目的也是为了降低CPU开销,相互意义已消。在一些特定的情况下,Dynamic Batching才能够有性能提升,绝大多数情况下没有太大作用。

第二,静态合批Static Batching,这个对降低DrawCall和提升性能都很有效,但最大的问题是它只对静态物体生效,对于动态物体没有任何效果。而且进行静态合批之后,整个场景的内存占用会提高非常多。另外,次世代的游戏场景都已经非常复杂了,LOD是一个不可或缺的功能,但Static Batching对LOD是非常不友好的。

最后就是GPU Instancing,这种方式只对网格Mesh以及Material均一致的情况下才能生效,应用范围比较窄,对于普通的物件,比如房子,场景,没有办法对它进行合批。

上面三种合批方式如果用于次世代游戏有些捉襟见肘,性能方面的优化会非常的困难。SRP Batcher就能很好的解决这个问题。

DrawCall里面,开销最大的就是SetPassCall。SRP Batcher的原理是通过降低SetPassCall的数量来打造性能提升,通过渲染中所需要用到的参数变量拆分成若干个ConstantBuffer分别保存,比如保存的是全局的静态参数,有一些可能保存的是当前这一帧数据,剩下的一个Buffer保存的是当前这个材质球特有的参数。

这样做好处比较明显,如果说同一个Shader物体,它实际上变化的就只有模型以及材质球上的参数。至于Shader的program,以及渲染状态,这些都是不需要改变的。所以一次DrawCall基本只需ConstantBuffer的内容,再绑定一个Mesh的指针就可以完成,整个DrawCall的开销非常低。

可以看下图的对比。左边是开启了SRP Batcher,右边没有开启。这个图是通过RenderDoc抓取的一次DrawCall渲染流程,左边绑定了一个贴图,传了一些顶点的指针,最后通过一个BannerBuffer把ConstantBuffer数据更新一下,可以直接去绘制。

但是在不开启SRP Batcher的情况下,整个渲染流程非常的长,会进行非常多的设置,还会去更改Shader program,更改非常多的渲染状态。这个列表下面还有很长一段,通过对比列表的长度就能说明这两个DrawCall之间的性能开销差别有多大。

我们也进行了测试。一个测试场景,有三栈动态光源,这个场景在顶配的情况下,有40W三角面,以及500dc;中配进行简化,有32W三角面和400dc;低配是25W三角面和280dc,三档机型上测试都有比较大幅度的提升。低配虽然是25W三角面和280DrawCall,实际上在Builtin的项目里面已经是一个高配才能比较流畅运行的标准了。

看一下这个Profiler的结果,我们是在骁龙450SoC上进行的测试,这是一个非常低端的处理器。我们的主线程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里面用,只要是需要并行计算的地方都可以使用。Burst也一样,不需要配合ECS使用,不需要跟并行计算捆绑使用,它的作用仅仅是对于一些复杂的计算进行编译器优化,来达到性能提升。只要是计算密集型的东西,都可以使用Burst,同步方法也是可以的。

关于ECS,大家会觉得所有东西都可以用ECS来写,会想UI的业务逻辑怎么用ECS实现。大可不必。用ECS不是所有东西全部都要用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力量类似于视锥剔除这些计算量比较大的操作,在低端机上也是可以忽略不计的。

通过ECS,画面上怪物的渲染完全取决于GPU本身的渲染性能,CPU的开销完全不需要去考虑,也不会出现卡顿。

我们通过Jobsystem实现了怪物击飞的效果。怪物被打下悬崖,如果碰到墙壁必须要被墙壁挡下来,需要进行一些物理运算,如果直接使用Unity的Ragdoll系统,它的物理计算非常复杂,对于低端机会造成比较大的性能负担。我们把这个过程简化了,所有的怪物在被击飞的时候,使用的是预先制作好的动画,只需要计算它的运行轨迹就行了。

我们首先用Job去并行计算怪物的分析轨迹,再通过Unity提供的多线程Raycast方法去进行射线检测来判断它是否撞到墙或者碰到地面。最后如果还有一些非ECS对象,可以在计算完毕之后通过一个单独的Job把所有GameObject的位置同步一下。

我们通过Burst实现了射线技能。这个看上去很简单,实际上需要对整个场景以及所有的怪物和其他对象产生交互,射线打到墙上能够实时产生反映,需要每帧对整个场景进行射线检测,整个计算过程开销比较大。

通过Burst我们把这个做成了一个Job,通过Job.Run的方法直接进行调用。

另外这个技能有大量的子弹,子弹需要进行运行轨迹的计算,通过Burst非常有效地把这两个计算开销降的非常低,性能提升基本能上百倍,结合Job.Run的方式实现同步调用,我们在整个计算流程中不需要开额外的线程,直接在当前线程,单个静态方法直接调用。

可以看一下开启和不开启Burst效果的差别,左边是开启,右边是不开启。在一个计算体系化模型工具中测试,左边只用241毫秒,右边用了20毫。如果算总耗时,左边用了143秒,右边只用了1秒钟,把所有线程的时间加起来,就是100倍的差别,效果非常明显。

工作流的简化和改善

随着我们采用PBR流程,Prefab的制作就会比较麻烦,而且以往Prefab的制作都是交给美术同学,美术需要把模型导入Unity,再规范创建材质和Prefab。

在采用PBR流程之后,这个创建过程就会麻烦了非常多,贴图多了很多张,跟各式各样的PBR的设置是非常繁杂的。尤其是ECS的单位,需要对动画进行烘焙,非常的耗时,而且容易出错。

为了解决这个问题,我们引入了AssetGraph,这个工具是一个节点式的自动化资源导入流程的工具。通过自定义节点,可以完全根据项目的需求对资源的导入进行自定义,然后一键创建所有角色的Prefab,美术就能从工作中解放出来,只需要做完了之后把FBX和贴图文件放到指定的目录下。

导出场景的时候,有时需要对渲染物件进行渲染设置,来达到最佳的渲染性能。具体的设置方式是技术团队根据Profiling的结果进行不断的迭代和调整,才能形成一个调整的方案。每一次调整,都需要去修改美术资源,整个工作量非常大。

为了提升切换场景的加载速度,需要对场景进行切块和分簇,可以从右边的截图看到,这些蓝绿色的盒子就是我们分簇切块之后的结果,它所展示的分块Bounding Volume。

结合整个场景的导出流程,按照流程图的方式做。

第一步,检查美术设置的LOD的选项是否正确,把美术临时物件剔除,检查一些碰撞Fix Mesh Collider ReadWrite设置是否正确,把LOD的点面工具的临时脚本给删掉,然后对ShadowMask进行一些设置。

因为URP里面没有shadowMask,所以需要根据Prefab的结果去进行一些详细的设置,比如Instancing的设置该怎么设?哪些物体适合Instancing,哪些不适合,我们都会设置。另外,也对整个场景进行分簇,看哪些物体适合Static Batch进行一些选择。

剩下一些物体,适合转换成ECS hybrid方式渲染,会转换成hybrid,最后再把每一个簇进行Bounding Volume计算,完成整个场景流程的导出。

场景导出完毕之后,整个场景就是一个空场景的状态,里面只剩下错的节点,我们就会进入这个范围之后再进行动态加载,下图就是我们生成的每一簇的Prefab以及静态合并的Mesh。

以上就是本次分享的全部内容。