从零开始手敲次世代游戏引擎(十七)

到目前为止,我们学习了Windows和Linux环境下,Direct X和OpenGL两种图形API的基本编程。(Linux+DirectX的组合不存在)

本来按照计划,我们还要继续进行Vulkan的学习,以及MacOS/Android/IOS三种平台。(PS4/PSV由于NDA关系就不在这里作具体叙述了。相关的代码我会写,但是暂时不公开。今后考虑个方法单独提供给有相关开发资质的人,或是等这两个平台都过时了再开放。。。)

但是考虑到我们在这个图形支线任务已经耗费了将近十篇的功夫,可能再这么写下去很多读者要觉得无聊了。所以我们暂且将这个支线任务挂起来,回去推推主线。毕竟,老刷怪也是很无聊。

到目前为止,我们都是以比较平的方式探索了这些图形API的用法,目的是为了进行我们引擎相关模块的设计。那么,我们现在来总结一下到目前为止我们看到的。

首先,我们在文章(七)文章(八)当中探讨了Windows平台和Linux平台上的基本绘图上下文——窗口的创建。这看来是无论使用哪种图形API,都必须要走的一步。

事实上,还有两种情况我们没有考虑:

  1. 全屏绘制。恰恰对于主机,往往是没有所谓的窗口管理系统的,而是直接进行全屏绘制。这是因为主机一般是单任务系统,而且窗口管理系统也会产生很多额外的开销
  2. 无屏(Off Screen)渲染。比如PS Now这种服务,还有Nvidia的串流服务。很明显,随着云计算的进步,以及互联网的发展,在云端玩游戏这种方式已经正在走上历史舞台。这种方式下,渲染既不是发生在窗口,也不是发生在全屏,而仅仅是一块内存Buffer

根据到目前为止我们所学的,我们可以认识到一个粗略的游戏引擎工作流程如下

  1. 我们首先需要一个建立一个跨平台的模块,它能够在不同的操作系统+图形API环境当中,为我们创建这个基本的上下文。(可能是窗口,可能是全屏FrameBuffer,也可能是Off Screen Buffer)
  2. 然后,我们需要对平台的硬件能力进行查询和遍历,找到平台硬件(这里特指GPU)所能够支持的画布格式,并且将1所创建的上下文的FrameBuffer格式指定为这个格式,GPU才能够在上面作画。
  3. CPU使用平台所支持的图形API创建绘图所需要的各种Heap/Buffer/View,生成资源描述子(RootSignature或者Descriptor),将各种资源的元数据(Meta Data)填入描述子,并传递给GPU
  4. CPU根据场景描述信息进行顶点数据/索引/贴图/Shader等的加载,并将其展开在GPU所能看到的(也就是在描述子里面登记过的)Buffer当中
  5. 帧循环开始
  6. CPU读取用户输入(在之前的文章当中还未涉及),并更新用户可操作场景物体的位置和状态
  7. CPU执行游戏逻辑(包括动画、AI),并更新对应物体的位置和状态
  8. CPU进行物体的裁剪,找出需要绘制的物体(可见的物体)
  9. CPU将可见物体的位置和状态翻译成为常量,并把常量上传到GPU可见的常量缓冲区
  10. CPU生成记录GPU绘图指令的Buffer (CommandList),并记录绘图指令
  11. CPU创建Fence,以及相关的Event,进行CPU和GPU之间的同步
  12. CPU提交记录了绘图指令的Buffer(CommandList),然后等待GPU完成绘制(通过观察Fence)
  13. CPU提交绘制结果,要求显示(Flip或者Present)
  14. 帧循环结束

然后再让我们看一下我们在文章(四)当中所做的顶层设计:

1。输入管理模块,用来获取用户输入
2。策略模块,用来执行策略
3。场景管理模块,用来管理场景和更新场景
4。渲染模块,用来执行渲染和画面输出
5。音频音效模块,用来管理声音,混音和播放
6。网络通信模块,用来管理网络通信
7。文件I/O模块,用来管理资源的加载和参数的保存回复
8。内存管理模块,用来调度管理内存上的资源
9。驱动模块,用来根据时间,事件等驱动其它模块
10。辅助模块,用来执行调试,log输出等辅助功能
11。应用程序模块,用来抽象处理配置文件,特定平台的通知,创建窗口等需要与特定平台对接的部分

对着这个设计,我们来对上面的14个步骤进行一下划分:

1-2,这个应该划分到(11。应用程序模块)当中。因为根据目前我们在支线的经验,无论是DirectX,还是OpenGL,他们的平台上下文创建的部分都是一套独立的API(Direct X: Win32+DXGI; OpenGL: Xlib/XCB+OpenGL Loader)。况且这部分对平台(操作系统)的依赖性很强,标准化程度低。将其从图形渲染模块剥离出来可以让图形渲染模块有更好的平台无关性。

3-4,这个看起来应该是(4。渲染模块)的初始化(Initialize)方法当中完成的。这看起来似乎没什么问题。但是4里面是根据场景描述信息进行的资源加载,到目前为止我们都是只画了一个几何体,中间也不变化;但是实际游戏的场景是变化的:

  1. 传统的游戏是分章节(关卡)的,并且关卡都限制在一个已知的尺寸之内(因为受我们可以使用的Heap的尺寸,也就是内存的限制)。传统的游戏在关卡之间会读盘(Load),在这个期间我们可以销毁图形渲染模块并根据新的场景描述信息重新创建它
  2. 对于近年的OpenWorld游戏,则要求无缝动态加载场景。这样的话,4当中的Heap的分配以及各种资源的描述子也是动态变化的。在这种模式下,我们不能销毁渲染模块并重新创建,而是需要动态去改变这些Heap以及描述子的能力

既然我们标榜开发次世代引擎,那么显然我们应该支持第二种情况。所以,步骤4并不能放在渲染模块的Initialize当中,而是应该放在(5.帧循环)之后。

然而,如我们开篇所述,游戏属于软实时系统,虽然不是人命关天,但是一帧所需的处理时间仍然是有着十分苛刻的要求。Heap的创建,资源的加载都是十分耗时的工作,不可能在单帧当中完成。而且这些工作也不是时时刻刻需要进行的,因此放在帧循环当中也是不合适的。

那么应该将其放在什么地方呢?很显然,这应该是一个独立在我们帧循环之外的步骤,也就是说,它和我们的帧渲染应该是一个并行的关系。但是同时,我们的帧渲染会需要用到这些资源,所以两者虽然是并行的关系,但在某些方面又有着相互牵制,或者说串行(serial)的关系。

另外,我们从之前的支线还可以得到一个经验,那就是这些资源的创建本身与资源的绑定并不是一回事情。GPU是依靠一个描述子查找表(在DX12当中称为RootSignature)来访问绘图相关的资源的。这一点在高版本的OpenGL,特别是Vulkan(虽然为了换个心情我们支线还没推进到那里)当中也有着很明显的体现。

因此,首先,我们可以肯定的是,对于没有登记到描述子表当中的资源,我们可以在任何时刻对其进行加载、卸载以及改变;其次,对于已经登记到描述子表当中,但是描述子表自身还未提交给GPU(这种隐含着我们有多个描述子表)的情况下,我们也是可以修改的;最后,对于登记到GPU正在使用的描述子表当中的资源,当GPU还未使用或者已经使用完毕的时候,我们也是可以改变它的(更换一个描述子很多情况下只是更换一个地址,是一种很快的操作)。

所以显而易见地,为了实现上面这种工作方式,我们至少需要:

1. 一个能够管理所有描述子,以及其代表的资源(buffer)的模块。在我们的顶层设计当中,最适合的是(8。内存管理模块)

2.一种能够以任意速率分别驱动不同模块进行工作,大部分时间采用并行的方式,在需要时能够实现模块间协作(串行的方式)的计算模式(执行模式)。并且这种执行模式还应该方便进行调试,减少出现condition racing的可能性(9。驱动模块)

其实,以上讨论的东西,也是驱动图形API发展成今天这个样子的主因之一。之前的图形API简单易用,但是完全封装了内存管理与执行模式,导致应用程序在这方面控制力很弱。而最新的API开始将这些暴露给应用程序,使得应用程序可以根据自己的需要进行这方面的深度优化。

内存管理和执行模式也是一个操作系统的灵魂。所以这方面的设计我们可以借鉴操作系统设计方面的经验。所幸的是作为一个游戏引擎所面临的可能性相对于一个操作系统来说要特定和局限得多,因此我们不必考虑得过于复杂,而且可以做更多有针对性的优化。

下一篇我们将具体进行内存管理方面的梳理和实现。

— (EOF)–

本作品采用知识共享署名 4.0 国际许可协议进行许可。

发表评论

Fill in your details below or click an icon to log in:

WordPress.com 徽标

您正在使用您的 WordPress.com 账号评论。 注销 /  更改 )

Facebook photo

您正在使用您的 Facebook 账号评论。 注销 /  更改 )

Connecting to %s

%d 博主赞过: