《黑暗之潮》中次时代技术的应用经验及技术

2021-03-10 15:18:23 浏览数 (1)

  • 接下来一个小系列会给大家带来2020Unity线上技术大会有关游戏开发的相关内容,虽然已经有很多人整理过了,但我还是从我的角度出发,将演讲的知识要点提炼出来。

本文根据林若峰在2020年Unity线上技术大会的演讲视频整理而成。 B站演讲视频见:https://www.bilibili.com/video/BV1ca4y1W7wN

《黑暗之潮》是一款顶视角的次世代手游,虽然锁了视角,但实际对画质和战斗细节的要求很高。游戏采用了PBR的渲染,场景当中有不少的动态光影效果,场景的细节也相当丰富。

1 《黑暗之潮》项目面临的挑战

  • PBR的次世代渲染技术。运用新的技术,在画面表现以及实现上会有难度。
  • 适配更多的机型。适配更多的机型,以扩大受众面。
  • 高强度的战斗系统。大量的野怪和特效。
  • 复杂的战斗机制。上百的技能搭配,更考验性能和资源的合理运用。
  • 工作流的简化。减少开发过程中的冗余,繁多,复杂,容易出错的环节,简化工作流。

2 渲染管线的选择和定制的经验分享

2.1 选择URP管线

为了解决次时代渲染的问题,选用URP的渲染管线。主要原因是:

  • 原生适合移动平台的PBR渲染管线。虽然标榜为PBR渲染管线,实际上也支持非PBR的内容渲染。
  • 拥有非侵入式的修改能力。在不修改源码的情况下即可进行比较自由的定制。
  • URP也提供源码。能主动掌握控制权,遇到疑难杂症的时候比较容易定位和修改。
  • 源码的结构和框架好。比较容易扩展。
  • URP的性能比Builtin内置管线好。

2.2 为什么要定制渲染管线

  • 解决渲染层级问题。

内置的渲染管线调整渲染层级的方式只有修改Renderqueue这一条路子,而且极不稳定。特效或者渲染层级要求比较多的时候,Shader和C#层面的代码都要写的非常复杂。而且还需要一套复杂的层级管理Manager,以便在开发期进行调试和调整。

  • 解决渲染性能问题。

内置的渲染管线在某些效果上只能通过Shader Pass去实现。而内置管线是按照Object绘制的,所以多个多Pass的物体在内置管线的渲染过程中势必会打断合批。

而URP则可以做到按PASS绘制,最大程度的减少SetPassCall。

如上所示,两个同样的实现效果,内置管线需要5次SP,而URP只需要1次。

  • 减小Blit操作。

Unity的内置管线为了兼容性,会在渲染的某些情况下加入Blit操作,并且无法关闭。Blit会进行一次全屏拷贝,相当的消耗性能和带宽。而URP因为可定制的原因,可以明确知道什么时候需要调用Blit,进行手动管理。

  • 便于特效实现。

URP本身的一些效果(比如扭曲、空气扰动等)只对不透明物体生效。但某些特效就是需要使用半透明的效果(比如火焰,浓烟),进行管线定制可以覆盖这些效果,让美术品质更高。

2.3 URP默认的管线流程

看下下面这张流程图(开启了光影效果的前提下)。

MainLight Shadowmap。顾明思议就是主光源的阴影图。主光源一般是场景中的平行光。

Additional Light Shadowmap。附加光源的阴影图。附加光源一般是点光源或者聚光灯,当然也包括那些超出上限的平行光。

Depth Prepass。深度测试,用来进行Early Z判定,优化片段着色器的剔除。但这在URP中其实并没有执行上述效果,只是简单的渲染了一张深度图。

RenderOpaque。绘制不透明物体。

RenderSkybox。绘制天空盒。

Copy Color。如果开发者在渲染管线的设置中打开了Color Pictures的选项,就会执行该步操作,把渲染结果复制到一张RT中,供后期使用。

RenderTransparent。绘制透明物体。

Post Processing。全屏后处理。

Render UI。绘制UI。

Final Blit。将最后的结果复制到缓冲区。

2.4 如何定制URP的内置管线

使用RenderObject。通过URP以及实现好的RenderFeature和RenderPass,可以在不用任何代码的情况下进行定制。

先明确设定一个layer,以及这个layer需要在哪一个具体的时间点进行渲染。还可以在选择透明物体渲染之前,去做RenderFeature,并做一些额外的设置,。比如绘制图层的时候,选择需要使用哪个材质球,也可以选择不进行重载等。

对渲染状态进行重载。比如对深度进行重载来决定是否需要写入深度或者做深度测试。相关的重载还包括模板缓存,摄像机参数,不同Layer的物体使用不同的FOV参数,甚至是Camera的变换矩阵等等。

2.5 《黑暗之潮》怎么使用RenderObject

解决半透明物体渲染的不确定性。单独使用了一个RenderObject,选中地表的那一层layer,让它在透明物体之前去渲染这一整层,就能保证会在所有技能特效之前去渲染地面的裂纹。

辅助其他的自定义RenderPass。后面讲RenderFeature和RenderPass的时候具体说明。

透明物体实现扭曲的效果。将复制ColorTexture的时机往后挪,挪到透明物体之后,用单独的Pass额外的去渲染这些需要扭曲的效果的特效,完成正确的渲染。

2.6 自定义RenderFeature和RenderPass

这是URP提供的比RenderObject更高一层级的自定义。

RenderFeature可以做到在任意一个时间点插入自己想要的渲染操作,让开发者拥有更强的控制能力。

在RenderFeature里面可以通过手动调用CommandBuffer底层渲染接口,实现非常多的效果。

切换RT的时候,通过RenderBuffer的LoadStore操作来进行性能优化。

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

实际上在很多时候,我们能知道这个操作是不必要的,每次渲染新的一帧时,肯定要对屏幕上所有的像素进行重绘,或者类似做后效的时候,肯定需要对所有的像素进行重新绘制,RT之前本来保存什么样的信息,完全就没有任何意义。

这时候我们可以告诉GPU,你不需要帮我们把RT上面的内存加载到片上内存,自然而然这个加载就不需要有任何的带宽开销,我们就可以省掉一大部分的带宽开销。

同样,对于写操作也是一样,如果说一个深度图,这个深度只是拿来做深度测试,深度的结果不需要写回RT里面,那我们就可以在切换RT的时候告诉GPU,渲染结果不需要写回RT内容。

2.7 《黑暗之潮》用RenderFeature做了什么效果

平面阴影。这是一种作假的阴影渲染方式,它只适用于游戏大部分都是平地的情况,正好《黑暗之潮》就是这样一款游戏。

平面阴影有一个优点,大家可以看到下面的截图,阴影是非常锐利、非常清晰的,它的整个的渲染质量很高,不会出现任何的锯齿。还有一个比较大的好处是,因为它不需要去额外渲染shadowmap,在渲染地表的时候也不会需要对shadowmap进行采样,这样的话,这个渲染的整体开销要比使用shadowmap省非常非常多的。

实现也非常容易,直接添加一个Shadow RenderFeature,把需要有阴影的角色用一个特殊的shader绘制一遍就可以了。

实现沙盘地图地块描边。描边需要严丝合缝地对应这个区块范围,同时,区块下半部分,墙、山体不能有描边。所以不能使用法线往外扩的传统方式来渲染描边。

我们采用的流程是这样的。首先我们用一个纯色去渲染这个地块,渲染出来了之后我们对这个渲染结果进行降采样,缩分辨率,在比较低分辨率的情况下,再利用BoxFilter进行模糊操作,这样做的好处是,可以利用尽可能小的带宽开销来对这个结果进行模糊操作。 然后再将模糊完毕的结果进行升采样提高分辨率,最后再用透明的颜色绘制一次地块,就把中间这个区域扣除了,只剩下外面的描边,这样就可以实现刚才描边的效果,并且还能实现从描边从靠近物体的部分往外慢慢渐变渐影的柔和的过渡效果。

自定义Renderer。URP只内置了两个渲染器,一个是Forward,也就是我们常说前向渲染器,另外一个就是2DRenderer,主要是用来渲染2D物体的。(在最新版的URP当中,也集成了defer Renderer延迟渲染器)

虽然说我们想要自定义Renderer,但并不意味着我们所有东西必须要从头开始做,因为URP里面已经实现了各种各样的Pass,是可以直接使用的。所以我们只需要对这些Pass进行重新组合就能完成对Renderer的自定义。

《黑暗之潮》对ForwardRenderer一些自定义修改。将提到的全屏Blit操作,变为可选项。

省掉Final Blit。做后效时,这个后效不可避免对全屏所有的像素进行操作,正常情况下,会在这个后效计算完毕之后渲染UI,最后通过Frame Blit去复制到FramBuffer里面。

在做后效的时候,计算完毕后直接将结果写入FrameBuffer里。最后在渲染UI的时候,把这个UI直接在FrameBuffer上进行绘制。这样就可以省掉最后这个Blit的操作。

分开场景和UI的分辨率。接上面的一条,因为3D场景在RT上面渲染,渲染完毕之后,通过后效复制到FrameBuffer上面,UI是直接在FrameBuffer上面绘制的,所以说UI的分辨率是不受降分辨率的影响的。这样就可以对高低配机型进行场景分辨率的区分而UI不受影响。

2.8 《黑暗之潮》最后的渲染管线

前半部分基本没什么区别。只是在渲染不透明物体之后加入了ECS渲染模型。然后增加一个Copy Depth,把不透明物体的深度给复制到一张单独的RT上面。

这个Pass不是每次渲染都会有,只有开启沙盘地图的时候才会用,因为沙盘地图在渲染水体的时候会需要那张深度图。

接下来我们就会去渲染地表上的透明物体,渲染所有的平面阴影以及ECS物体的平面阴影,绘制沙盘地图的描边。

最后再去渲染透明物体,也就是特效这些东西,渲染完特效我们会在这个时候进行copy color,把整个渲染结果复制到一张单独的RT上面。

这个RT是进行了降分辨率操作,实际上抓取的并不是全屏,大概只有1/4屏幕的分辨率的颜色信息。

渲染完扭曲之后,再会对整个屏幕进行后效处理,后效处理完毕之后,结果可以直接写在FrameBuffer屏幕缓冲区里面,最后再去对UI直接进行绘制,完成整个渲染流程。

2.9 URP的性能优势

动态光照。URP的所有动态光照是在一个Pass里面完成计算的。所以在添加动态光源的时候,不需要把场景里面所有的物体再去渲染一遍。这是内置管线无法匹敌的。

Color Texture替代GrabPass。Builtin管线里面做类似于空气扰动类的效果,必须要使用GrabPass无这个功能的。GrabPass完全没有办法预知当前渲染屏幕会被全屏抓屏几次,而且抓取是不会降分辨率的,真的就是全屏抓取。非常非常废资源的,尤其是在移动平台上面,基本不大能忍受。单Pass的ColorTexture就可以通过一次抓取来完成所有需要扭曲操作的渲染,减少带宽无疑性能就会高的非常多。

SRP Batcher。单这一条就已经不能拒绝URP的使用。实际上DrawCall里面,开销最大的就是SetPassCall,SRP Batcher的原理就是通过降低SetPassCall的数量来去打造性能提升,它降低的并不是DrawCall的数量。

DrawCall合批的三种方式:

  • Dynamic Batching。它是通过CPU来降低DrawCall的,我们降低DrawCall的目的也是为了降低CPU开销,相互抵消,没啥意义。
  • 静态合批Static Batching。对降低DrawCall和提升性能都很有效。但有三个问题,一是必须是静态物体,二是场景内存的占用会提高非常多,三是无法进行LOD剔除了。
  • GPU Instancing。只对网格Mesh以及Materia均一致的情况下才能生效,应用的范围比较窄。

把所有的渲染当中所需要用到的参数变量拆分成几个若干个Constant为Buffer分别保存。

比如保存的是全局的静态参数,有一些可能保存的是当前这一帧数据,剩下的一个Buffer保存的是当前这个材质球特有的参数,这样做好处比较明显。

假如同一个Shader物体,它实际上变化的就只有它的模型以及材质球上的参数。而它Shader的program,以及渲染状态,这些都是不需要改变的。所以说我们一次DrawCall基本只需要传一些参数,ConstantBuffer的内容,再去绑定一个Mesh的指针就可以完成了,这样整个DrawCall的开销就会非常低。

2.10 性能对比

通过RenderDoc抓取一次DrawCall的渲染流程进行对比。

可以看到,不开SRP Batcher的情况下,渲染流程非常长。

通过测试场景比对。这里的低配25W面和280DC,实际上已经是内置管线接近高配才能流畅运行的了。

Profiler结果比对。这是在一个骁龙450SoC上进行的测试,这是一个非常低端的处理器。主线程Render Camera是4.3毫秒,在下面渲染线程Camera的开销是14毫秒。

关闭SRP Batcher之后,主线程的Render Camera的开销已经直接涨到7.8毫秒,渲染线程实际提交的过程中渲染开销就已经达到了22毫秒。

3 DOTS技术栈在商业项目中的实际运用

3.1 对DOTS的误解

以下几条都是误解。

  • 没有用到多线程,所以不需要用DOTS。
  • 没有大规模集群,用不到DOTS。
  • ECS学习和重构成本高。用不了DOTS。

实际上DOTS是指Data-Oriented Tech Stack,意思实际上就是面向数据的开发栈。它主要是由三个组件组成的,ECS、JobSystem、Burst。这三个组件是可以相互独立使用。

最后一个误解是,大家会觉得用ECS之后,所有东西都应该用ECS来写,就会想UI的业务逻辑怎么用ECS实现。这大可不必,混合使用没有一点问题。

3.2 《黑暗之潮》DOTS的例子

渲染大量的怪物。

我们游戏里面怪物通常有一个特点,一组怪由几名精英配合一两种大量的存在的爪牙组成的,大家可以看到右面的图只有三种怪,如果说用默认的SkinMeshRenderer的话,就有一个非常严重的问题,没有办法合批。画面上面有多少个怪,有多少个DrawCall而且Animator开销也不小。还有一个问题,GameObject为.Instantiate开销也是比较大的,如果说我要同时刷出来三四十只怪的话,肯定会卡顿,用ECS就能比较好的解决这三个问题。

使用ECS先把整个动画信息去烘焙到一张动画贴图上面,在GPU当中进行蒙皮操作,我们再通过JobSystem和Burst实现视锥剔除和动画系统的更新。最后我们再在面向对象那块业务逻辑那块控制ECS Enity就可以了。也就是说ECS的部分,我们只是提供渲染的和动作的结构,其他部分业务逻辑还是完全用面向对象去实现的,相当于各取所长。

3.3 性能优势

DrawCall小,实例化快。

因为是采用了GPU蒙皮,整个DrawCall的数量下降到有几种怪就是几个DrawCall。实例化也是非常快,ECS基本上就是无感的,在极端机上消耗,即便同时刷一千只怪也不足1毫秒,借助Burst力量类似于视锥剔除这些计算量比较大的操作,在低端机上也是可以忽略不计的。 动画更新流畅。

看下面的截图,演示整个动画更新阶段,也是同样在骁龙450 SoC上测的,100只怪左右的情况,动画整个更新过程只用了0.008毫秒,这就是忽略不计,根本不需要考虑的一个量级。通过ECS,我们画面上怪物的渲染完全取决于GPU本身的渲染性能,CPU的开销完全不需要去考虑了,所以也不会出现卡顿。

Jobsystem实现了怪物击飞效果。

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

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

通过Burst实现射线技能。

射线这个东西看上去很简单,实际上会对整个场景以及所有的怪物和其他对象产生交互。射线打到墙上需要实时产生反映,就需要每帧对整个场景进行射线检测,整个计算过程实际上开销是比较大的。通过Burst把这个东西做成了一个Job,通过Job.Run的方法去直接进行调用,而且直接在当前线程进行操作。

使用起来跟一个静态方法没有太大的差别,还有像大家看到的这个技能,会有大量的子弹,对这些子弹我们同样需要进行运行轨迹的计算。通过Burst非常有效的把这两个计算开销降的非常低,Burst开启之后,它的性能提升基本能上百倍,通过刚才也提到Job.Run的方式实现同步调用,我们在整个计算流程当中不需要开额外的线程,直接在当前线程,单个静态方法直接调用就可以了,也是非常方便的。

下图是开启和不开启Burst效果的差别,左边是开启,右边是不开启。

我们在一个计算体系化模型工具中测试,左边只用241毫秒,右边用了20毫,真是一百倍的差别。而不是说它用了多线程所以更快,大家可以看到每个线程都快了100倍,如果算总耗时,这边用了143秒,这边只用了1秒钟,如果把所有线程的时间加起来,就是100倍的差别,效果非常明显。

4 工作流的简化和改善

引入了AssetGraph这个工具,这个工具是Unity开发的一个节点式的自动化资源导入流程的工具,非常好用。

Prefab制作。

以往这个Prefab的制作都是交给美术同学,美术需要把模型导入Unity,再按规范创建材质和Prefab。在采用PBR流程之后,这个创建过程就会麻烦非常多,首先贴图多了很多张,跟各式各样的PBR的设置,是非常繁杂的。尤其是ECS的单位,我们还需要对这个动画进行烘焙。这是一个非常复杂而且操作量非常大的操作,非常的耗时,而且容易出错。

通过这个工具,进行节点自定义,完成之后,我们就可以实现一键就能够创建所有的角色的Prefab。美术也只需要做完了之后把FBX和贴图文件按照我们定好的规范,放到指定的目录下就可以,他们连Unity都不需要开,美术非常喜欢这个功能。

Animator状态机。

以前需要手动建,现在可以一键自动。

场景导出。

我们在导出场景的时候有些时候需要对渲染物件进行渲染设置,来达到最佳的渲染性能,具体的设置方式实际上是技术团队根据Profiling的结果进行不断的迭代和调整才能形成一个调整的方案。每一次调整,都需要去修改美术资源,如果说这个都需要美术去进行操作,整个工作量会非常的大。美术那边没法接受,所以说我们需要把这个过程稍微自动化一下。

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

第一步,会检查美术设置的LOD的选项是否正确,把美术那些临时物件给剔除,碰撞Fix Mesh Collider ReadWrite这些设置是否正确,还会把LOD的点面工具的临时脚本给删掉,最后会对ShadowMask去进行一些设置,因为URP里面没有shadowMask,这是我们自己实现的,所以会需要一些额外的设置。然后会根据Prefab的结果去进行一些详细的设置,比如Instancing的设置该怎么设?哪些物体适合Instancing,那些适合,我们都会去进行设置。我们会对整个场景进行分簇会看哪些物体适合进行Static Batch,Static Batch不是所有物体都会适合,我们会进行一些选择。

剩下一些物体适合转换成ECS hybrid方式渲染,我们会转换成hybrid,最后我们再把每一个簇进行Bounding Volume的计算就完成整个场景流程的导出。在场景导出完毕之后,整个场景就是这样一个空场景的状态, 里面只剩下记录的节点,进入这个范围之后再进行动态的加载,正常状态只有簇的Prefab以及静态合并的Mesh。

0 人点赞