GAMES104 笔记 -如何构建游戏世界和游戏引擎中的渲染实践

游戏世界

如何构建一个游戏世界?

这个世界需要哪些东西

这些东西如何被描述

彼此之间如何被组织和调动起来

1.动态物

2.静态物

3.环境(地形系统,支撑静态物和动态物的”托盘“)

4.一些其他的物体

这些物体其实可以抽象为GameObject, 缩写为GO。对于这些 GameObject 的描述基本可以分为两部分:属性(Property)和行为(Behavior

定义一个无人机类,然后在类里定义:
血量,位置,燃料,电量等这些属于是无人机的属性( Property);
移动,搜寻这些是无人机的行为( Behavior)。

如果我们想要一个可以攻击的无人机的话,就需要添加一个攻击的行为和子弹的属性.用面向对象编程的思想的话,我们只需要添加一个攻击型无人机类且继承无人机类,增加 fire 接口和子弹属性即可即可

但是这样的方法并不适用于所有的情况,随着我们的游戏世界越来越复杂,定义的物体越来越多,物体之间的所谓的父子关系变得并不那么清晰了。比如我们有一个Class 车,有一个Class 船,现在你需要创建一个水陆两栖坦克,那么这个水陆两栖坦克是继承于车还是继承于船呢?

对于这个问题现代游戏引擎的答案是 组件化。

Component

组件化的意思就是将对象的行为拆分为各式各样的组件(component)

比如对于图中的挖掘机来说,他与图中下方四辆的区别只是将挖掘机的铲子部件换成了其他的部件而已。

因此我们可以利用组合的方式,通过Component(组件),来自定义组合或更改他们的能力.我们将无人机的行为和属性抽象成不同的组件,比如TransformComp(位置),ModelComp(外形),MotorComp(运动),AIComp,PhysicsComp 等

Unreal 和 Unity 中的 Object 与上述的 GameObject 并不相同。Unreal 中 AActor 是类似 GameObject 的概念,UObject 更类似于高级语言中的 Object。

Tick

至此我们可以去构建一个游戏世界了,但是这个世界仍然是静止的,我们该如何让这个游戏世界里的物体都活过来也就是动起来呢?-tick函数

游戏世界中每隔1/30秒就让这个游戏世界动一次,每个GO中的每个component都tick一遍

因此在写component基类 时候需要派生tick函数。在实际中,现代游戏引擎更多的通常不是逐对象逐组件去 Tick ,而是逐系统,比如先去做所有的和碰撞相关的事情,再去做所有和动画相关的事情。

我们以汉堡店的例子来举例,有五个员工来做汉堡,假设每个员工都做一个汉堡的话,每个人都要进行烤面包,烤肉饼,洗蔬菜,煎鸡蛋,最后拼装这一系列步骤,这样得生产效率不高。流水线的方式,每个人负责相应的步骤,最后再组装起来,会更有效率。

在计算机中,我们会尽可能的将component数据集中在一起,这样处理的时候可以一批处理,读写处理效率最高。

EventSystem

现在我们游戏世界中的GO可以动起来了,但是他们之间没有交互,比如我驾驶坦克A开火射向了坦克B,坦克B应该受到伤害,但是每个GO之间都是自己的tick,如何让坦克B知道自己被坦克A击中了呢?

坦克开了一炮,生成一个新GameObject,炮弹,且拥有一个初速度,因此炮弹根据tick函数不断更新位置,直到打中一个GameObject爆炸,然后在它的爆炸函数中查询周边对象,如果是士兵,飞机,坦克这种GameObject则对其造成伤害扣血,这种写法叫hardcode,早期游戏逻辑是这样的。

但随着游戏世界变得越来越复杂之后,hardcode就不行了,由此引出 Event(事件),不再是粗暴的敲别人的门然后告诉他你被我击中了,而是相当于写了一封邮件,然后放在其家门口的邮箱中等待其下一个tick时处理。

  • 注册event

  • 发送event

  • 接受/绑定的GO执行对应的回调函数。

在实际的事件传递中,时序很重要,如游戏的回放功能,他并不是录下了你的操作,而是记录了各个用户的输入,然后再重新执行了一遍,如果各用户是依次执行,则没有问题,但如果用户同时进行同一事件的操作则会有问题,比如你打算和你女朋友分手,因此写了一封分手信,需要送到她寝室,再送信的路上你发现了你女朋友也拿着一封信在路上走,那么此时是谁甩了谁呢?

因此不能让GO之间直接发送event,而是需要引入一个 “邮局”,各个操作先发到邮局,邮局去保证时序接着进行发送,常用引擎中的类似 PreTick,PostTick 的函数都是为了处理时序问题。

Scene Manage

一个游戏中会有很多的GO,比如我们想做的射击游戏中会有成百上千个GO对象,我们需要对其进行管理

  • 每个GO都有自己的unique ID(UID)

  • 每个GO在场景中都有自己的position

1.最简单的管理方式就是不管理,以手雷爆炸为例:当爆炸时,我们对场景中每个GO都查询一遍,并判断GO位置和爆炸位置之间的距离与手雷的爆炸半径进行比较。这样写对于小规模的游戏完全没有问题,但是如果场景中有大量的GO的话,这就是一个灾难了

2.将世界分为很多均匀小格子,就相当于有了个门牌号,接着我们在爆炸的位置去找邻近的格子。但实际场景中物体分布是不均匀的,将场景划分为均匀小格子是又慢又浪费的。

3.可以通过分层的方式进行划分。

常用的场景管理分层结构为:

Rendering概述

游戏引擎并不只是rendering引擎,但rendering是游戏引擎中最重要的一个环节,也是游戏中的最重要的一个环节。但是游戏引擎中的渲染和计算机图形学的渲染侧重点是不同的。

计算机图形学:
为了明确的解决某单一问题,比如透明物体或者是水面等明确的需求
并不关注硬件如何实现,而是更多关注算法或数学上的正确性,比如辐射度算法是一个通过模拟光学理论得到的模型,计算机需要计算几天才能得到一帧漂亮结果。
没有性能的要求,图形学中30帧即为Real-time,10帧为interactive。

引擎渲染:
游戏需要对庞大数目的物体进行渲染,物体种类繁多,且融合了大量渲染效果,复杂度很高,比如游戏中我们需要渲染水体,角色,植被,云彩等等,我们需要运用不同的图形学算法对其物体进行渲染,以及光照运算和各种后处理,游戏引擎的rendering模块中需要包含这么多东西,是一个all in one的组合。
游戏需要面对硬件处理问题,因此需要深度去适配当代硬件
游戏在不同场景和不同质量的显示器硬件上运行需要有稳定的帧率,即场景切换时仍要求保证稳定帧率,即使显示器等硬件质量的不断提升,也需要在其上拥有稳定的帧率
游戏CPU端仅有10%-20%分配给了rendering,剩下的分配给GamePlay系统。

Rendering

现代游戏渲染是通过CPU+GPU合作处理模式。CPU准备好数据渲染数据后将其提交到GPU,GPU设置好渲染状态后开始处理CPU所提交的数据。

首先的计算就是投影和光栅化,主要是关于矩阵的计算:

  • 透视投影

  • 正交投影

  • 屏幕空间的三角形离散成一系列像素

其次就是shading的计算,涉及到一些计算:

  • 常量访问,比如需要知道屏幕的长宽,像素个数,需要访问常数

  • 数学计算(加减乘除),比如冯模型需要知道法线,光线,眼睛,并计算光有衰减百分比

  • 纹理采样

纹理采样其实是rendering过程中非常复杂/昂贵的一个环节,假设我们现在在3D空间内有一个砖墙,当我们离砖墙十分近时,可以看到其上面的一个个pixel(像素);但当我们离墙十分远时,我们在屏幕空间上的一个pixel,可能包含了砖墙上的很多pixel。

当我们通过屏幕上的pixel找在texture上对应的位置时,texture上的对应位置不一定在像素点上,因此我们需要取四个像素点进行插值处理,也就是双线性插值;甚至可能在不同层之间进行插值处理,也就是Mipmap三线性插值处理。

如果说有8个像素点,我们需要进行7次插值,上下两层每层进行3次双线性插值,层与层之间再进行一次三线性插值,共计7次。仅仅是为了取出一个fragment的颜色,就需要消耗大量的计算,因此我们就有必要解GPU的工作方式,让游戏更顺畅得运行。

GPU

GPU是显卡的处理器,称为图形处理器(Graphics Processing Unit,即GPU)与CPU类似,只不过GPU是专为执行复杂的数学和几何计算而设计的,这些计算是图形渲染所必需的。

SIMD & SIMT

在CPU中使用的时SIMD,单指令多数据的处理方式,比如处理一个vector4的加法,在计算时,将X,Y,Z,W同时进行加法运算,即一个指令完成四个加法运算,即x,y,z,w的加法运算。SIMD适合于进行矩阵运算,坐标运算等。

SIMT,单指令多线程的处理方式,其核心是将core做的足够小,从而能够内置很多个core,这样在执行c=a+b这条指令时,各个core上都执行这条指令,但是我们每个core上的a和b的数据是不同的。

假设我们的SIMT有100个core,比起SIMD一次计算4个数,SIMT则可以计算4*100个,大大提高了算力。

因此我们在设计Shading代码时,要尽可能使用相同代码处理,让每个核访问自己的数据。

GPU架构

Fermi是第一个完整的GPU计算架构。

GPU中放置了很多个内核,但又将其分成了一组一组的形式,一组内核称为GPC(Graphics Progressing Cluster),图形处理集群。

在GPC中可以看到很多的SM,SM中存在很多小的内核,这些内核是指令的直接执行者,如果是N卡这些核则叫做CUDA,给SM一条指令,CUDA核们就开始工作。

其中有专门的Texture Unit进行纹理采样的计算.我们的很多运算是分散到GPU上的一个个SM中处理的,除了并行处理,还可以互相交换数据。

数据从CPU到GPU

现代CPU的架构是冯诺依曼架构:数据与计算分离;这种架构的问题就是计算需要准备好数据,因为找数据是特别慢的,数据在不同units之间搬来搬去也是特别慢的。比如从CPU的主内存往显卡显存上传数据的话,速度是十分慢的。

由于现代引擎中render和logic是不同步的,但是如果有哪个render步骤需要等back-force的话,这样做会导致有半帧或者一帧的画面和逻辑不同步的现象出现。
因此在设计代码时,尽量保证数据的单向传输(CPU -> GPU),避免计算同步问题,且不要从显卡中读取数据。

Cache

CPU查找数据时,首先从Cache中查找,再从内存中查找,而Cache访问速度是内存访问的100倍。因此我们在处理数据时,尽可能使用连续数据,从而避免在内存上寻找数据浪费时间。

数据在cache上叫做cache hit,不在则叫cache miss.引擎的架构是根据平台硬件决定的。

Renderable

GameObject是通过引擎中提供的component来进行渲染的,有一个叫mesh component的,其在不同引擎叫法不同,例如Unity中的Renderer、SkinMeshRenderer组件。

这个component里面存储的数据叫做Renderable(可绘制的东西),因为一旦拿到这些数据,就可以将一个GO给绘制出来,因此称其为Renderable.

Renderable Data

  • Mesh(头盔,枪,人物)

  • Mesh上的material(枪mesh的金属material,人物mesh上的皮肤material和衣服上的布material)

  • Material上的Texture(材质上有不同的花纹,需要提供图片数据)

  • normal(用来处理一些无法用mesh表达的细节)

Mesh数据

Mesh提供了单位的网格数据,网格是由一个个顶点数据组成的三角面的集合。

那么这里就涉及到两个问题:

顶点数据都包含了什么?

顶点数据应该包括:

  • 顶点的位置

  • 顶点的normal朝向

  • uv坐标

  • rgb值

  • ……

顶点数据如何组成三角面?

接着利用每三个顶点可以形成一个triangle,把形成的triangle们放在一起就形成了mesh的外观形状,如图中的头盔,这是最简单的表达方式。

而这种表达方式的数据存储方式是很浪费的,我们没有对顶点数据进行处理,因此N个三角形需要N * 3个顶点数据,但四个顶点就可以组成两个相邻三角形,因此有很多顶点数据是重复的。
如果对于opengl和directX有一点了解的话会知道应该用vertex data 和 index data去定义。

这样做是为了通过将所有顶点放在一起,不再存储三角形的各个顶点数据,而是存储三角形的顶点索引值(index)。通过调取顶点顶点索引值来使用顶点,从而避免了重复存储顶点数据,大部分模型中顶点数量大约是三角形数量的一半,而一个三角形有三个顶点,因此使用index的方法理论上让计算存储量节约了6倍左右。除了通过index引用的方式表示,还有Triangle Strip的表示方式:顶点列表中,连续三个顶点表示一个三角面,这样就省去了index数据,并且对缓存友好。

另外我们之所以在vertex上存储normal朝向,是因为如果通过临近三角形的normal来求三角形上顶点normal朝向的话,当你在渲染像正方形这样的物体时,会发现折线部分上的顶点normal朝向是错误的。

Material模型

我们在rendering里面定义的material只是视觉材质,也就是看起来像塑料,金属等,而不是物理材质,像是其弹性,摩擦等是物理方面的.从图形学中经典的phong模型到现在的PBR模型,以及一些特殊的效果,比如半透明的效果,工业界中已经积累了很多的material模型。

Texture

texture起到了很重要的作用,因为大多数时候我们判断一个物体的材质,第一时间是通过其texture来判断的,而不是根据材质的参数。比如这个生锈的铁球,我们是根据roughness的texture来区分哪些部分是光滑的哪些部分是生锈的,所以texture也是material重要的一种表达方式。

Shader代码

现在我们拥有了mesh,matrial,texture等,需要通过shader才能将物体给绘制出来。shader是source code,但是在引擎中又会被当做数据进行处理

GPU流程

  • 首先将物体经过mvp变换

  • 接着经过vertex buffer或index buffer我们将mesh数据上传

  • 上传material参数

  • 上传texture参数

  • 让显卡按照shader代码逻辑,对vertex和fragment进行处理

但是这样绘制仍然有问题,因为一个物体可能不同部分会有不同的material,GPU作为一个状态机,只会保留最后Material所提交的状态进行渲染,如果按照我们上述逻辑执行,那么物体绘制出后只有一种材质,如图右边,因此我们需要使用submesh。

对于存在多个材质的对象,我们会对mesh进行切分(通过offset、count确定index)为不同的submesh,每个submesh有对应的材质,纹理,shader,但是我们会把vertex和triangle放在一个大的buffer里进行管理,至此一个完整的复杂对象渲染就处理完成了。

但如果我们需要绘制大量这样的复杂GO,如果每个单位都独立存储一份完整的渲染数据,这样的开销太过巨大。这些单位的材质、Mesh、纹理都有重复部分,因此较好的数据组织方式是对渲染资源数据创建资源池。我们把所有的mesh放到一个pool中,所有的shader放到一个pool中,所有的texture放到一个pool中。因此当你绘制一个场景时,各个角色在绘制时只是通过一个指引指向了各自所需要的renderable数据。

这些数据我们都可以看作是类里的定义,以图中士兵为例,我们定义了一个士兵,也知道了它的renderable,因此在场景中渲染出它可以看作是资源在场景中的实例化。

渲染的整体分为三个步骤:

  • CPU提交渲染数据;

  • GPU设置渲染状态;

  • GPU渲染

对于相同材质的对象来说,每次都处理三个流程是极为耗时的,我们可以为材质相同的对象跳过步骤(2)。我们将场景中的物体按照材质进行排序,将相同材质物体放在一起,从而只需要设置一次材质,大大减少了GPU设置渲染状态的耗时从而提升了速度。Unity中的SRP Batcher.

除了简化GPU渲染状态设置,我们还可以对数据的提交进行优化:对于完全相同的物体,只是在场景中的位置不同。就可以将这一类对象的渲染数据一起提交到GPU,减少数据提交次数。Unity中的GPU Instance

Visibility Culling

我们知道相机所看到的范围其实是一个frustum,因此我们对于处于frustum内的物体进行渲染,之外的物体则忽略掉,也就是culling掉。

那么如何判断一个物体是否在frustum内呢?

Bound Volume(包围盒)

一个物体可能非常的复杂有成千上万的顶点和面,因此我们往往会给每个物体定义一个包围盒或者包围球,这样问题就简化为如何判断包围盒或者包围球和视锥体的内外关系即判断顶点与平面的关系。

面方程为:

Ax+By+Cz+D=0

其中xyz代表平面上的一点,ABC为平面法线,D的值即为平面法线和平面内任意一点的点乘结果取负。这样我们即可以使用一个四维向量 Vector4=(A,B,C,D)来表示一个平面。

例如假设有个平面平行于xz平面且正面向上,那么其法线即为(0,1,0),因此A=0,B=1,C=0。

若该平面过点(0,5,0),那么x=0,y=5,z=0,可解得D=-5。

因此过点(0,5,0)法线为(0,1,0)的平面方程为0x+1y+0z-5=0,这个平面用Vecotr4向量表示即为(0,1,0,-5)。

//一个点和一个法向量确定一个平面
public static Vector4 GetPlane(Vector3 normal, Vector3 point)
{
    return new Vector4(normal.x, normal.y, normal.z, -Vector3.Dot(normal, point));
}

对于frustum的六个面我们分别用上下左右远近来表示,远和近两个平面的normal是可以通过相机的forward朝向得到的,而关于上下左右四个面的话,则可以通过相机的position以及远或近平面的四个顶点得到:

Sence Manage

有了包围盒后我们确实可以去判断是否被culling掉,最简单的方法就是将所有包围盒都进行判断,但这样的话面对拥有数量众多GO的游戏效率是十分低的,因此我们可以通过对场景中的GO进行划分管理,比如经典的四叉树划分,BVH划分等,预先剔除摄像机覆盖范围外的对象。

BVH算法在工业界广泛使用,因为现代游戏场景内动的物体比较多,因此当GO移动后也就是节点变动,我们需要重新构建树状结构,此时要考虑重新构建的成本一定要很低,而BVH恰好在此有很多优势,因此BVH适用于开阔动态场景。

PVS

我们将一个大的游戏场景划分为一系列的子场景,如图,相邻的子场景之间设置portal(也就是真实世界中的门),当你站在一个子场景时,通过portal(门或窗)只能看见有限的子场景.

随着硬件的不断发展,现如今是通过GPU进行culling操作,比如early-Z

纹理压缩

纹理是renderable中的一个重要数据,我们日常使用的图片压缩格式(如PNG、JPEG等),有很好的压缩或显示效果,但在游戏引擎中我们无法使用这些效果比较好的压缩算法,因为无法满足游戏引擎的需求:快速随机访问像素。

在游戏引擎中通常采用block思想:

将纹理划分为多个小块(block),然后进行压缩。

以DXTC格式举例,对于每个划分的小块,取得其中最亮和最暗的像素点,那么我们就可以通过插值处理从而求得二者中间一系列的颜色

建模工具

多边形建模

雕刻性构造

扫描

程序化建模

Cluster Rendering

其核心思想是将模型分成多个Cluster(每个cluster有32\64个triangle),根据这些Cluster与摄像机的远近来展示不同的细节。这要处理的好处在于:

  • 现代GPU已经可以基于数据,动态生成几何细节(曲面细分Tessellation),而不是像原先的管线将mesh数据上传。

  • 因此当你将每个cluster大小确定好后,由于它的计算都是高效一致的,以相同的Cluster结构让GPU来并行处理时,提高了效率

  • 可以对模型进行Cluster剔除

mesh shader:用一个基于数据可以凭空生成几何,并且可以根据cluster与相机间的距离选择不同的精度的算法。这样GPU处理的都是大小一致的几何体,并行处理使得高效。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值