读书笔记(五十九) 《游戏引擎架构》#4 低阶渲染器(6)

已发布在微信公众号上,点击跳转

背景:

作为游戏开发从业者,从业务到语言到框架到引擎,积累了一些知识和经验,特别是在看了几遍《游戏引擎架构》、《游戏引擎原理与实践》后对引擎架构的理解又深入了些。近段时间有对引擎剖析的想法,正好借这两本书对游戏引擎的架构做一个完整的分析。

《游戏引擎架构》用简明、清楚的方式阐述引擎架构,知识覆盖了游戏引擎架构的庞大领域,巧妙地平衡了广度与深度,并且提供了足够的细节。《游戏引擎原理与实践》有两册内容比较详尽,代码示例展示比较多,内容比较丰富。

我结合这两本书以及自己的经验,写一些自己的知识总结以帮助自己学习引擎知识,文章中我会深入分析游戏引擎的历史、架构、模块,并通过引擎开发实践来完成对引擎知识的掌握。

游戏引擎知识面深而广,所以对这系列的文章书编写范围做个保护,不对细节进行过多的阐述,重点剖析的是架构、流程以及模块的运作原理。

概述:

本系列文章对引擎中的重要的模块和库进行详细的分析,我挑选了十五个库和模块来分析:

本篇内容为列表中的第8个部分的第6节。

正文:

简单回顾下前文

前几篇文章我们聊了GPU在计算机硬件主板中的位置与结构,知道了CPU、GPU的通信介质,手机上的主板结构。然后聊了下显卡的历史,图形驱动的历史,知道了GPU和图形接口在历史长河汇总的来龙去脉。接着聊了CPU的硬件架构,GPU硬件架构,以及GPU软件架构中的各个细节,其中还对比了GPU与CPU的相似与差异,大体明白了GPU是如何工作的。

下面我们开始这篇内容

本次内容会围绕GPU来写,从硬件架构到软件驱动再到引擎架构,目标是帮大家理解GPU硬件的运作原理,理解图形接口的架构,理解引擎低阶渲染器的架构。

目录:

上篇聊了下图形驱动程序架构,它封装了各类显卡的驱动程序,根据描述的GPU指令和数据的流程图,我们能更好的理解驱动程序与GPU之间的责任划分与配合。接着为了能更好的理解低阶渲染器,先以统计和描述OpenGL接口的方式来了解和熟悉图形接口,本篇就继上一篇内容。

二、引擎渲染架构

本节重点介绍引擎中低阶渲染器的架构,包括渲染的封装关系、图形API的分类、引擎渲染流程的拆解、以及渲染器的设计结构,层层理解渲染架构是如何设计的。

一、封装关系

游戏的渲染从业务到引擎到图形API到操作系统到驱动程序到硬件经过层层的封装,每一层封装都是有意义的,每一层的封装的目的都有相通之处,都是更好的为上层提供服务。

游戏引擎需要封装图形API,让引擎开发者不用关心底层的图形API,图形API则要封装硬件驱动API,让引擎渲染器开发者不需要关心底层的硬件驱动API。如下图:

(渲染API层层封装图)

从上图中,我们看到每种硬件都有自己的驱动程序,因此操作系统中有很多硬件的驱动程序,图形API就封装了所有的硬件驱动接口,为引擎调用提供了方便。而图形驱动程序又有很多种,这使得图形API在调用时也会遇到不同平台带来的麻烦,因此引擎封装了所有平台的图形API,为引擎其他模块使用图形API时提供了方便。就这样层层封装,图形驱动程序封装了硬件驱动,引擎封装了图形驱动,最终更方便了开发者使用游戏引擎开发游戏。

二、图形API分类

前文中我们描述了很多OpenGL的接口,我们可以通过对OpenGL接口的统计和描述,可以将所有的图形API可以划分成四部分:

(图形API模块划分图)

上图展示了四部分的内容,四部分包括:资源、状态、缓冲、接口

资源: 1.Shader着色器,相关图形接口 2.纹理贴图,相关图形接口 3.采样器,相关图形接口

状态: 1.视口,相关图形接口 2.光栅化,相关图形接口 3.Blend混合,相关图形接口

缓冲: 1.缓冲区,包括颜色缓冲、深度缓冲、模板缓冲,相关图形接口 2.顶点,相关图形接口 3.帧缓冲区对象(FBO),相关图形接口

接口: 1.图元绘制,相关图形接口 2.开关、查询、判断等通用接口,相关图形接口 3.其他非通用特殊接口

对其精简后可理解为如下:

现在,我们大致了解了图形API接口以及这些接口的分类,这样我们就能知道它到底可以做哪些事情,又是怎么去封装和定义驱动程序接口的。接着下面我们就来拆解下渲染流程以及渲染器。

三、引擎渲染流程拆解

渲染流程是整个引擎的核心,使用UE4可以轻松实现好的渲染效果,但它的架构较封闭难以实现自定义光照模型,而Unity渲染架构比较开放,但默认渲染效果一般,用户需要按照Unity的规则自己去实现一套好的效果。对于渲染流程和材质上的设计两个引擎都有优缺点,如果我们想得到希望的结果,还要看我们对游戏引擎的掌握程度。

首先,我们来拆解下渲染的简易流程,对一个物体从加入渲染到最终渲染的过程进行拆解。

(引擎中简易的渲染流程拆解示意图)

如上图所示,一个物体的传统渲染流程如下: 1.渲染前置裁剪 2.前期数据整理 3.裁剪相机外物体 4.渲染准备工作 5.提交渲染指令

人们常说GPU中的渲染管线,实际上在GPU之前,引擎中的渲染管线非常重要,它就是引擎的渲染程序的执行过程。在引擎代码执行过程中,一个物体从加入渲染到最终提交渲染,这中间要经过以上5个阶段,包括前置裁剪,前期数据整理,裁剪相机外物体,渲染准备工作,提交渲染指令。

渲染前置裁剪有很多方法,包括遮挡剔除裁剪,九宫格剔除裁剪,远距离物体剔除裁剪等。这些裁剪算法的应用可以根据业务场景不同而有所选择。

前置数据整理,包括视口参数,节点整理,物体排序等。

裁剪相机外物体,引擎一般用AABB包剔除,因为这是最快的剔除方法而且还可以多线程计算剔除,每个要渲染的物体都会计算一个长方体AABB范围,与相机的正交或透视范围进行相交计算。

渲染准备工作,为渲染做准备,包括数据检查,Shader解析,深度图准备,色彩空间设置,相机设置等,在真正渲染场景内物体前做好准备工作。

提交渲染指令,把所有渲染的指令提交给GPU,这里有两种情况,一种是立即调用图形API设置渲染状态并最后告诉GPU指令提交完毕,另一种是开线程提交。

(引擎逻辑渲染流程图)

上图为商业引擎中的逻辑渲染管线(实际上还有很多节点为了编辑和展示场景服务的,我暂时忽略它们),这里我用红色标出了3个关键节点,包括剔除相机外物体,渲染ShowMaps,调用渲染指令,它们在这个逻辑渲染管线中是比较重要的。

引擎会从循环总入口开始执行逻辑管线,先检查数据与参数防止后面的逻辑执行出现异常,接着针对每个视口做渲染(多视口通常用于编辑器中多个窗口的渲染),然后由于收集了所有Camera,对每个Camera都执行一次渲染逻辑,紧接着是UI渲染,由于UI的特殊结构设计,它会与普通物体分开渲染(这里主要涉及到UI的渲染合批以及合批算法,会在后面的章节中详细展开分析),最后渲染结束清理数据。

(逻辑渲染管线分两种的图)

这里我着重提一下渲染Camera这个节点,它有两条线路可走。

条是我们自定义的渲染管线,这在Unity商业引擎中就有这样的可编程逻辑渲染管线,通过可编程逻辑渲染管线,我们可以制定包括渲染物体的顺序、针对某些特殊物体的渲染、各种渲染效果的自定义处理,这么做的结果可以让我们去除掉多余的渲染操作提高性能,增加定制的渲染功能,让逻辑渲染管线更符合我们当下的项目。同时Unity引擎在此基础上打造了更加方便更加易于使用的逻辑渲染管线,即URP和HDRP。当然可编程渲染管线不是万能,因为引擎没有完全放手这块逻辑,它保留了通用的和固定逻辑的功能模块,从而减轻大家的编程负担(如果要重写整个管线工作量就太大了)。

另一条是引擎固定的逻辑渲染管线,这条管线上包含了渲染需要的所有操作,包括数据准备、节点整理、相机外物体裁剪、渲染参数设置、深度图渲染、阴影图渲染、渲染状态提交等,通常是引擎根据所有渲染功能汇总编写的一套通用逻辑渲染管线。

(渲染管线中的三个要点图)

这里的逻辑渲染管线都有三个要点,第一个是剔除相机外物体,引擎会根据物体大小计算一个AABB包围盒,通过包围盒与相机正交或透视的计算得出它是否需要渲染。第二个是ShdowMaps绘制,在渲染物体前我们需要知道物体之间的遮挡关系才能计算出阴影,所以先让GPU跑一遍渲染管线计算每个像素点上物体的遮挡关系(这里简单介绍一下,后面在渲染效果章节再详细说明)。

第三个是设置和提交渲染状态,引擎把物体归类为不透明物体、半透明物体、以及天空盒和其他,把相同的几何物体放在一起渲染减少了渲染指令重复提交的开销,提高了运行效率。在提交渲染指令时有两个方法,两个方法最终都是调用相应平台的图形API,一是直接调用图形API,每当需要设置渲染状态、提交渲染指令时,直接调用相应平台的图形API,将操作立即告诉GPU(GPU中也有自己的队列)。二是将要改变的渲染状态和指令推入进线程队列中,再在另一个线程里取出队列中的渲染状态和指令,接着调用图形API通知GPU,这样做就是为了不阻塞主线程。

通常渲染引擎会把渲染线程和引擎主线程拆分开来,渲染线程单独处理渲染状态改变的指令,主线程和渲染线程之间则用队列通信,这个逻辑功能在Unity和Unreal中都有使用。

四、渲染器的设计结构

前面我们介绍了逻辑渲染管线,主要说明的是逻辑渲染管线中做了什么事情,有哪些关键点,逻辑渲染管线最终都会嗲用图形API去提交渲染指令,这些都是引擎中渲染器要做的事情,下面我们来介绍下渲染器的封装与结构设计。

不同硬件通过不同驱动程序进行管理,图形API封装了很多驱动程序让开发者不必关心硬件驱动程序。引擎也是同样的作用,由于平台有很多种,图形API也有很多种,因此引擎需要封装图形API为渲染器,让开发者不用关心底层的图形API,这样引擎制作的游戏都可以发布到多个平台。

(图形API封装图)

为了拥有一个易用的多平台的可扩展的渲染器,引擎必须将图形API封装,也就是用对象的方式将部分渲染数据存储起来,在执行时立即使用并能减少不少渲染指令。

由于每个平台的API不同,因此封装时首先将不同平台的API封装成统一的接口,这要求我们在封装时不仅仅只是检查参数这么简单,还需要我们针对性的用面向对象的方式去设计图形API封装结构。其次每个平台都有自己特殊的功能和接口,需要额外做一些封装提供给引擎使用。

其中所有封装都可以归纳分类为4种类型,资源、缓冲、状态、接口。

渲染资源属于API层的资源类型,原始的API使用起来比较繁琐,为了简化它的使用,游戏引擎都会再封装一次。资源类型的接口包括创建资源、设置资源、加载资源、销毁资源。

缓冲对象的目的是更方便开发者把内存数据复制到显存中,状态对象的目的是为了记录当前状态并保留一份在主存中,以减少同一个绘制管线中的重复设置,接口的目的就是为了更方便开发者调用图形API。

(渲染指令的对象设计图)

渲染器是对图形API的进一步封装,用于提高资源的使用效率。通常我们用图形API实现一个功能要经过多个步骤。经过整合的渲染器接口,可以通过一两个步骤就可以实现原本需要多个步骤的功能。渲染器功能一般包括渲染窗口相关信息、创建资源、设置资源、销毁资源、获得显卡信息、完成渲染流程等。因此渲染器需要抽象出各种接口,以供子类去实现,这样就可以兼容各种渲染API。

上图中详细举例了渲染状态、渲染缓冲、渲染资源、逻辑状态信息。将他们在引擎中封装成对象,就是为了更好的加快渲染器中处理资源的速度,减少设置资源时耗费的CPU时间。同时为了达到这个目标,我们就需要管理这些资源对象。

额外话题,前面说到不同图形API不同平台设备有很多差异,这里就举个例子,简单介绍下DX12中的指令队列原理。

(DX12与GPU引擎协作原理)

现在大部分新式GPU都包含多个功能的独立引擎,例如复制引擎、计算引擎、3D引擎,把原本提交的GPU指令大杂烩拆分并交给不同的引擎,这样各大引擎可以根据自身情况做优化从而使得执行效率变得更高,而且拆分后引擎可以并行执行指令也使得整体指令执行效率变高。

在使用DX12时,每个线程都会填入复制、计算和3D队列这三个队列,其中3D队列可以驱动另外两个GPU引擎,计算队列可以驱动计算和复制引擎,而复制队列只能驱动复制引擎。

(GPU中三大引擎工作原理,来源:微软官网)

上图说明了DX中的多引擎队列设计是如何跨多个GPU引擎协同工作的。它包括在必要时进行引擎间同步处理,因为引擎间有相互依赖的情况。 复制引擎会复制转译所需的几何,3D引擎会等候这些复本完成,并透过几何呈现预先传递。计算引擎会使用此结果并分派结果,同时复制引擎上一直在拷贝需要的资源,最终被3d引擎使用并进行最终绘制。

参考资料:

《OpenGL ES3.0 编程指南》作者:金斯伯格

《游戏引擎架构》作者:杰森.格雷戈瑞

《游戏引擎原理与实践》作者:程东

《GPU 引擎》

https://docs.microsoft.com/zh-tw/windows/win32/direct3d12/user-mode-heap-synchronization

《CPU体系结构》

https://my.oschina.net/fileoptions/blog/1633021

已发布在微信公众号上,点击跳转

· 读书笔记

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    读书笔记(五十九) 《游戏引擎架构》#4 低阶渲染器(6)

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)