2017 年 12 月 23 日,来自网易游戏资深开发工程师高原在又拍云 Open Talk 第 39 期活动上作了题为《次世代游戏技术》的演讲,以下是演讲是实录:

一、次世代的概念

什么是“次世代”,其实到目前为止没有绝对的定义,很多已经出的游戏号称次世代游戏,比如网易的网游《天下3》,次世代的主机游戏 Xbox1、Pxboxpro 等,甚至还有次世代的手游,所以次世代的游戏未必是下一代的技术,而更多指的是比较前沿的技术,或者已经存在但是并未在行业当中铺展开的技术。

提到次世代,必须要提的是次世代三大引擎,UNREAL(虚幻4)、CRYENGINE、FROSTBITE。这些引擎目前都是商用引擎,你可以通过购买或者授权获得使用。目前网易使用的包括 UNREAL、CRYENGINE 等。

多人在线、电影级画质、虚拟现实技术、体感游戏、4K/HDR 、云游戏等,这些是我们在提到次世代会想起的一些名词。 这么多技术其实人一辈子都学不完,即便我们学完了,下一个次世代又会出现新的要学习的事物。

百度对“次世代”概念是这样解释的:

1、模型面数增加,我把它归纳为 Geometry Complexity,几何的复杂度,这部分主要是数据;

2、采用法线贴图来描绘物体表面细节的凸凹变化;颜色贴图来表现物体的颜色和纹理;高光 贴图来表现物体在光线照射条件下体现出的质感,增加贴图的大小;这个概念我觉得是比较过时的,目前即便是我们的手游项目,也都用了这三张贴图,所以绝大部分的游戏都是这样做的,这个大概是指 PS2 到 PS3 的时代转换所指的次世代的概念。

3、次世代游戏已经创造了特殊光照效果、半透明、流动性,就是 Lighting & Shading 的部分,这部分主要是计算。

换句话说,我们现在面临的两大挑战:一是数据量更大,二是计算更加复杂。

二、PBR 与贴纸

我们项目中用了很多贴图,一张普通的 PBR 材质能到以下多种贴图:

1、BaseMap ,就是通常所说的 map 反射贴图 DiffuseMap ,是物体本来的底色;

2、NormalMap , 法线贴图;

3、MixMap ,这个比较特殊,PBR有很多参数,这些参数我们很难用一个固定的值去表示,可能每一个相互点不同。所以我们用一张贴图去表示。这张贴图,RGBA四个通道,分别存的是四个不同的值,不同的 shader 值不同,其主要存的是物体的粗糙程度。

4、LightingMap ,光照贴图,这个是对于场景当中的一些静态物体,阴影是烘焙上去的,所以这时候我们用一张光照贴图去表现它的光照;

5、NoiseMap ,可以实现一些特殊的效果,主要涉及一些带有破浪的水面,或者一些随机性的物质如水面、岩浆、瀑布等;

6、EmissionMap ,自发光贴图,颜色贴图、法线贴图以及光照贴图这里都有,而且这是我在做项目当中最简单的一张 PBR 材质所用的贴图数量。

下面来看一下这些光照的渲染特点:

image.png

大家觉得哪个画质更好呢?第一个是生化 4 在 PS2 上的效果,这些墙、梁以及人身上的头发、衣服,用了同一张贴纸,因为它表面基本没有任何粗糙程度的变化,而且这团火焰基本对周围的光照没有影响,因为它表面基本没有任何粗糙程度的变化,所以这里没有动态的火,这团火焰没有动态光,

然后来看生化危机 5 ,很明显这些地面有了凹凸不平的感觉,而且很明显看到这个人物的阴影以及光照更加明显,这说明他使用了法线贴纸。最主要是你看到这个树的阴影是非常细的,这说明生化危机 5 在当时那个时代它的画质绝对是世界领先的。

生化危机 7 我只截了一个人的画面出来,这个面部是非常细腻的,而且发丝也会动,在游戏里面是动态运算的。人的眼眸有阴影,这个阴影是 AO ,应该是环境光挡住算出来的。整个面部非常柔和,基本上接近真实人的表现。当然有人说这个画质更好是因为它的场景更大,场景大意味着渲染的负担就更重,而生化 7 主要是一些室内的场景,所以他的渲染负担是比较轻的,因此在人物这种渲染上会加大力度。

三、底层技术介绍

(一) RunTime = 设备+资源+执行

image.png

如上图,左边是渲染的数据,也就是刚才说的数据。Vertex Buffer 是顶点缓存,Const Buffer 一般是针对 Shader,接着是各种 texture,然后经过这条管线渲染到 Device(硬件),可以理解为显示器,但实际上它是对显示器的抽象或者对显卡的抽象。中间这一是条 pipeline,这是一条管线,管线中有各种状态包括 material 、shader、render states 各种渲染状态。无论用什么图形库,用哪个引擎,这条线是不会变的。

其实我们抽象一下左边是各种资源,我们可以叫做数据;而中间就是执行 execution,最右边是设备。所以我们主要探讨的问题就是这三个问题,资源、执行及设备。

image.png

我们说一下引擎的 RunTime = 设备+资源+执行。这里说的是 RunTime,不是整个的引擎或者整个游戏,整个游戏包含了上层的 game play 的逻辑以及其他的东西,引擎还有各种各样的通讯链,而且这些通讯链其实才是占到我们整个项目的主要部分。这里只是说最核心的这个部分其实就是设备、资源加执行。我们程序员做的工作就是抽象出设备,然后管理资源以及优化执行。

设备其实是对硬件的抽象,可以理解为显卡或者是显示器,但是市面上各种各样厂商出的以及不同的型号的显卡太多,由程序员直接编程,要考虑很多硬件兼容的问题。微软以及其他公司出了一套图形库,制定了响应的标准,如图:

image.png

DirectX 是微软出的标准;OpenGL、OpenGL ES 两者基本属于一类;Metal,Metal 是苹果执行的;Vulkan 是比较新的图形库。这些硬件如果说支持 DX9 的硬件,那么就必须支持这套 API ,否则就不能说自己是符合 DX9 标准的文件。DX 有几代呢?现在可能还在用的 DX9、DX11、DX12,9 之前的基本已经很少用了;OpenGL2、3、4都有在用;OpenGL ES 主要是 ES2 和 ES3;Vulkan只出了一代。

图形库有这样一个特点,近几年的趋势是这个图形库会越来越薄,换句话说是越来越底层,它所负责的职责是越来越少的。然后功能点上可能有一些增加,但是它抽象级别是越来越低的。其实像编程语言或者一些框架,总体的一个趋势肯定是越来越人性化,但唯独这个图形库是越来越难用,也就是说你越往后学,你就觉得它越变态。

虽然图形库能帮你做 Device 的一层抽象,但是世界上这么多的图形库,所以我们必须还要对他们进行一次抽象。这些东西为什么要这么多呢?其实就是各个产商都想搞自己的一套,而且这些还不跨平台,DX 只针对微软平台,Metal 只在平台上,OpenGL 号称跨平台,但实际上并不一定,Vulkan 现在目标是要跨平台,应该做先进的图形库,但是现在就是看它后续的发展。

image.png

目前来说这么多图形库,要对它做一次抽象,以便做出一个更统一的一层接口。所以我们在它上面再做一层,抽象出一些概念, IDevice(设备)、IDevice Context、IShader、ITexture、IRenderState、ISampler 。这些概念都是针对各个平台通用的,像 IDevice 针对的如果是 DX 平台,它的就是 ID3D,Device Context 的意思。

所有在 Device 上层的部分,我们称之为渲染器的前端,此是相对于后端来说的,就好比我们的网站的前端与后端。虽然我今天介绍的主要是客户端,但是客户端相对于服务器来说就是前端,但是针对客户端引擎部分来说,它有分为前端与后端。

在渲染器前端这个之前,我们抽象出用户接口,也就是说引擎用户是直接到这一层。比如说IEntity 可能代表一个实体,IModel 代表模型,IParticleSystem 代表一个粒子系统,一个人物、一个骨骼等。这些抽象级别非常高的,我们普通用户是可以看得懂的。

(二)CPU 与 GPU

image.png

其实渲染器对于前端和后端还有一些区别,前端主要是运行在 CPU 上,叫 Mainly on CPU,而后端主要是在 GPU 上,也就是显卡上运行,叫做 Mainly on GPU。GPU 和 CPU 的特点,CPU 相当于一架跑车,跑得飞快;GPU 相当一千辆马车,它的特点是在有很多条线上,他们可以并行执行。CPU,我们最新的处理器就是8核、16个线程,不停地计算,这个CPU 就跑满了,但是 CPU 是可以上千、上万个线程的,当然它的线程是比较小的,它适合跑得是大量的并且不需要同步并行的任务。

CPU 和 GPU 之间是用总线进行数据交换,这个是所有游戏引擎的最大的焦点和痛点。总线的传输速度和带宽是非常有限的,而且很慢。我们在上面传输命令以及数据,也就是说这个数据如果你决定要把它从 CPU 搬到 GPU 去算,你必须走这条总线,这个总线像一个高速公路一样,如果车很多的话就会被堵死。

然后中间会走什么呢?我们可以认为是一条命令队列或者消息队列,它跟我们 CS 架构是一模一样的,中间是消息队列。客户端往这边发消息,然后服务器接收,同时服务器可能也会应答它,可能给它发一个信号去应答它某些状态。这就是整个渲染结构的核心。

为什么说这个是痛点呢?比如说这个队列现在是空的,那么 GPU 此时就是闲置的。如果这个队列是满的,并且有任务在做,但是 GPU 没做完,CPU 又没法进行下一步的操作,怎么办呢?这种情况在以前的游戏当中不明显,但是在现在的游戏引擎当中是最要命。CPU 和 GPU 是有等待,没有办法跑满的。我们可以看一个游戏 CPU 的占用率了解这个引擎设计得如何,如果这个 CPU 占用率不高,说明 CPU 在很多时候都是在空等,是在等待 GPU 的状态。

Render To Raget

那怎么解决呢?首先说现在的游戏为什么会遇到这些问题。这里列举几个次世代引擎的技术,第一个是 Shadow Map 以及 Cascade Shadow Map,就是层级的应用;SSAO 是环境光遮挡;HDR 、Bloom、Motion Blur,现在很多游戏基本都是同时开这三个;Deffered Shading,好多大厂在使用。这些技术有一个共通点是他们都需要有一个 render to target,需要在显存上开辟一块纹理,把我们的东西画到这张纹理上,并把这张纹理作为一个结果,进行下一步的操作。

image.png

Shadow Map 算法是把我们的阴影画到一张贴图上,然后当我们渲染整个场景的时候,使用这张贴图的信息去查询这个像素点是否在阴影中, render to target 的问题在这里就显现出来了。

第一个这两端是伪代码,我们只渲染一个物体,并且带有 Shadow Map,第一步在 render to target 产生一个 Shadow Map,这一步调用了 render to target 的算法,就是绘制到纹理的方法。第二步利用 Shadow Map 绘制这个物体。这一步里面 Shadow Map 是作为输入的,换句话说在这第一步里面 render to target 是指 GPU 在往这个纹理里面写东西。而这一步里面实际上 GPU 是在利用这个 Shadow Map 里面的信息去绘制它。也就是说 GPU 是既在写,又在读。在 DX11 之前这样的操作是完全被 GPU 禁止的,纹理是不可能既同时读,又同时写。

那怎么办呢?第一种图形库的做法,render to target 绘制完成之后,让 CPU 给 GPU 发一个命令,绘制一张 Shadow Map,并把数据或命令传输过去。然后 GPU 会去画这张图,画完之后就会通知 CPU 画好了,也就是说这个函数会返回。返回之后我们再进行下一步的操作,也就是渲染这个场景,再往 GPU 发送命令。这是一种图形库的实验方法。

另一种图形库是异步的,也就是说 DX11,执行发东西、渲染 Shadow Map ,然后立即返回,此时 CPU 做自己的事情,互相不阻碍,是一个异步的过程。但是下一步调用了 renderObject,在读这张纹理的时候,GPU 会再检测。所以现在这个方法先不能执行,CPU 会阻塞在这一层,也就是说你无论是这一步阻塞,还是这一步阻塞,CPU 总是会阻塞。

在这一部分当中,generate Shadow Map 和 Shadow Map 返回之间的这段过程当中,CPU 是闲置的。有没有什么其他办法去控制这个问题?就是多线程渲染。我们再开一条线程,这条线程中我们让这一段跑点其他的东西,可以是跟这个业务不相关的,比如说去计算别的动画之类的东西,这样就可以做到 CPU 和 GPU 都处于跑满的状态,此时我们对硬件的压榨程度是最高的。

多线程渲染

image.png

此时多线程渲染的另一个问题出现了,以上这段代码是最常见的渲染代码,先后设置VertexBuffer、IndiceBuffer、 Shader 、AlphaBlend true 为 true(透明渲染)、

DrawIndex(绘制)。如果跑单线程没有任何问题。但是在跑多线程就会出现问题,突然会有一个插进来的另外的线程,然后 SetAlphaBlend false,也就是说我本来想画一个透明的,结果现在变成不透明的。

很多人说可以加锁,加同步,对于游戏引擎来说,这个问题是可以解决的。但是加锁对一个程序的性能伤害很大。另外锁要控制在什么力度,是加在什么地方,这些都是需要考虑的问题。

注意一个引擎如果想跑到30帧一秒的话,一帧的渲染的时间也就是0.03秒,如果60帧的话就更短,这么短的时间还要加锁,这个消耗代价非常大。而且我们引擎也不可能跑满0.03秒,因为我们要给用户层,也就是写 game play 的那一层留出足够的空间。所以留给引擎这一层的时间就更少了,加锁在空等零点零几秒都是绝对不可以被原谅的。

RenderState

然后我们看一下各代图形库的演变过程,我们去寻找一下这个问题可能会有的解决方案在未来。

image.png

第一个还说这个AlphaBlend,这种东西叫 render State 就是渲染状态,在DX9里面,渲染状态是这样的,如果你想设置一个透明的,你需要如上图这样设置。

image.png

而在 DX11 中,需要如上图这样设置,又验了刚才那句话,图形库是越来越难了。它主要是先构造了这样一个结构体,然后这样一个结构体填充各种信息,其中填充了 AlphaBlend,然后设置为 true,然后 Alpha,Blend enable设置为true,然后来填其他东西。最后我们的目的是生成了一个 Blend State,RA 第3、第11 Blend State,我们用一个 CreateBlendState 去创造这个接口对象。最后我们调用 Device Context 的这个方法,OM SetBlendState 把它传进去,最后实现了一个传递的渲染的状态的过程。

DX11 的思路是既然这么多渲染状态,就用一个对象表示,所有的状态最后被封装成一个对象,然后传过去。这样带来什么好处?首先在 DX9 里面三条消息很有可能是被分着发送出去的。就是说以前一个消息队列。而在 DX11,这些都是本地 CPU 的数据,然后统统都塞到一个BlendState 里面,一次性发过去,我们把这三条命令合成一条命令然后传过去。这样的好处是这三条语句不会被打断了。如果 Device Context 能保证这个接口是线程安全,那么这段代码是无需加任何锁就可以运行的。但是非常不幸的是 DX11 这个接口,看它的文档告诉我们,它不是线程安全,所以这锁还是要加在这个地方。但是加锁的区域会变小一些。

CommandList

既然刚才说可以把三个渲染状态合并变成一个 State 对象,那么 Set VertexBuffer、Set IndiceBuffer、shader,以及设置渲染状态、绘制等,然后可以把这些操作打包成一个更大的对象一起发过去,这个就是 commandList 的概念,把这些链路队列当中的各种应用组成一个 List,然后以 List 整体发到 GPU 上去,这个主要是 DX12 的做法,也是未来的方向。

首先在本线程创建一个 commandList,接着一次 SetVertexBuffer,SetIndiceBuffer、SetVerexShader、SetPixelShader、SetRenderStatre 等,这个 commandList 就是一个List。注意这里 SetVertexBuffer 仅仅是把这条命令 push 到了这个 commandList,没有执行,甚至包括这个DrawIndex 也不是真正地绘制,而是把绘制命令 push 到了这个 commandList 里面。这个时候 commandList 充满了,已经包含了所有需要的 GPU 执行的各种命令。最后调用这个 commandQueue,执行这个 commandList。这个方法是线程安全,所以说这段代码也就保证了我们的线程的安全性。

所以多线程渲染在 DX12 里面是极大地被提倡。如果不用多线程渲染,是完全没有任何必要使用 DX12 。因为 DX12 还更难用,唯一的收益就是能帮你更大地提升软件的性能,这就是牺牲我们图形库的易用性换来我们性能的提升。现在 commandQueue 里面,可以理解为它充满了各种 commandList 。当然这是一个理性的状态,但是实际上执行不一定是这样,但是 commandQueue,可以理解为这时候不会出现奇怪的东西。

Fence

现在有一个问题是 GPU 是异步执行,但是 GPU 如果产生了 Shadow Map 如何传递给 CPU 呢?这时候 CPU 可以手动地设置一个 Fence,换句话说在执行这条 commandList 提交给GPU 的同时设置一个 Fence,这意味着当这个 Fence 之前的所有的 GPU 的消息队列内容被执行完的时候,这个 Fence 会被激活。

当然它不像网络服务器会自动影响,它需要 wait for Fence,你什么时候激活,CPU 这个方法什么时候返回。当然在那之前你可以做点别的事。GPU 和 CPU 的同步原理是除了Fence之外还有其他的,而且这些同步其实也是耗性能的。所以除非你必须要做这种同步,否则其实有时候像单纯往画面上绘制是不需要设 Fence 。

因此, generate ShadowMap 可以写成这样的方法,加一个 call back,最后绘制,这里面用了一个 commandList 去绘制,最后 wait for Fence,等纹理绘制完了之后 call back。这样是一个回调逻辑,但它还不是真正意义上的多线程,因为有一个 wait,也就是说这个方法仍然会被阻塞,在这个 wait 这一步会被阻塞,但它是一个回调模型。

Muti-threading & Asynchronous

image.png

如何解决上面的问题呢?现在主流引擎当中有各种各样的设计模型来解决这个问题,包括流水线的模式。这里主要介绍一个异步调用的方式,使用 JavaScript, 这个语言大家都比较熟悉,这个语言最大的优势就是异步性很好。这里是一个 ajax ,其中的 a 就是指异步,给服务器发一个消息,然后就可以干别的事情,那么什么时候执行呢?是服务器什么时候返回,什么时候去执行。

如果在 C++ 上,这个就不太好实现,因为 C++ 是没有 JavaScript 这么好的异步性,但是我们可以去实现一套机制。我们搞了一个 Job Dispatcher ,把 generate ShadowMap 当做一个作业分发到 Dispatcher,然后由它来分配去找一条线程来执行这个动作。完成之后会执行后面的回调,是一个 λ 函数。

也就是说这两个方法在哪个行程执行是不一定的,这个是由 Job Dispatcher 决定的,会给你找一条线程执行,这个Job Dispatcher 可以理解为一个线程池,但是它会有一些分发的策略。

Job Dispatcher Pattern

image.png

我们把各种任务都分别看成是各种 Job ,游戏里面各种任务可以变成各种 Job ,后面有一个 Job 队列,我们把一个一个 Job 拿到 Job Dispatcher 中分发到不同的线程去执行,这里维护的是一个线程池。那么 Job 分发的颗度有多大呢?是把整个场景的绘制算作一个 Job 还是更新一个小的粒子就算一个 Job 。

image.png

这里我们可以引进一个 Job List 的概念,实际上这里的蓝快与绿块是没有关联性的,他们没有任何数据同步的问题,并且是可以并行执行的。你可以把它放再一个 Job List,而 Job List 恰恰相反的是他们没有原则性,而且是可并行执行。

它的好处是一次可以提交一个Job List,Job Dispatcher 在分发的时候更有可能把这个 List 里面各个蓝块分发到不同的线程上去执行。这里举一个很简单的例子,比如说要更新一个 List 系统,有一万个 List,然后可以分成五个小任务,然后组成一个 Job List。这样每个 Job 只负责2000 个 List 就可以了。如果你这个 List 系统之间没有作用力,其实这个是完全可以用这种模型去实现的。

四、次世代不止关注画质

image.png

今天总是在讲画质如何,各种阴影,各种环境光遮挡,PBR。但实际上次世代是不是只有画质?当然不一定。如果你关注游戏业,特别是全球的游戏业,今年 PGA 获奖的这个作品,《塞尔达传说荒野之息》,这个游戏画面唯一可能让你觉得有点技术含量就是草地渲染,其他的地方渲染的技术完全是 PS3 时代的水平。应该说画质上没有取胜的地方。但是他美术风格做得很好,依然可以把画面做得很好,虽然画质不好,但是画面好。

还有一个游戏叫《女神异闻录》,是一家 PS 游戏,这个游戏也是没什么画质可言,但是它的UI 做得非常好,以至于我们很多国产厂商去学这个UI。