渲染引擎开发笔记1

引擎基本架构

Posted Kongouuu's Blog on November 18, 2021

前言

想要开发一个渲染玩具来实践一些渲染知识,不过在学龙书的路上都是用着偏有针对性的(没泛用性)的程序结构,所以需要重新鼓捣一下。本来想着类似于LearnOpenGL那样去进一步的封装,比如说把贴图,VBO,根参数等全部都封装好,加个stb读纹理,加个assimp读模型,不过感觉又好像差点什么。总之先一步一步来开始搓。

这里我选择称呼我的项目为一个渲染引擎,因为注重的点是渲染。 不叫渲染器是因为渲染器他会是我引擎内调用的子系统 :)

正文

基础架构

引擎大体架构

读过游戏引擎架构的都知道,游戏引擎是特别复杂的。从比较高纬度的角度去考虑,主要就是两部分:

  1. 渲染引擎本体: 首先我们需要一个应用本体,简单来说就是 [初始化+Game Loop]
  2. 图形接口: 这里我们要先对图形API进行封装,然后再进行一度程度上的管线设置。

这时候就可以在Visual Studio里面创造两个类: Application , Renderer

引擎运行流程

初始化

初始化大概分为三个部分:

  1. 首先我们需要在应用开启的时候去初始化所有的子系统(例如日志、窗口)。

  2. 所有子系统初始化结束之后我们就需要开始加载资源,例如贴图和物体。

  3. 在加载完资源后我们需要去对我们的图形管线进行一个初步的设置。

更新

更新的话参考传统的 Game Loop就行了

  1. 接收消息
  2. 更新资源
  3. 绘制

当然实际上还会稍微再复杂一点,不过暂时就这么考虑就可以了。

初步的设想差不多是这种感觉:

其他子系统

除了渲染子系统以外,还有几个比较关键的系统是要提前先考虑的:

  1. Log系统
  2. 窗口系统
  3. 消息系统

LOG系统

spdlog

Log系统应该是最需要优先考虑的。我们在开发的过程中一定会出一大堆问题,如果没有Log的话Debug起来难度实在会太高。

这里首先是建立了一个中间类来更方便的调用我们的Log,在类内部建立两个静态的Logger实例。一个是用于表示我们的dll,一个是前端程序。这里会根据编译的环境来选择使用哪一个记录器:

窗口

窗口和DX12

窗口的话因为我想使用DX12来做简单的渲染引擎,所以窗口就是一个普通的Win32窗口。在窗口类的设计上其实不太需要去考虑和渲染API之间耦合不耦合,我没有任何理由去设计一个可以搭DX12和Opengl的窗口,毕竟两者之间的绑定是很深的(还有就是GL系列都可以glfw)。因此在这里实际上是在写一个DX12Window类。

所以窗口这里的工作是初始化窗口,然后初始化DX12

消息

由于我们的窗口类他只是一个子系统,所以我们在设计上不能在窗口的 MainWndProc() 这里对操作进行太多的干涉。窗口系统内部只是提供了消息的输入的口,不提供我们本身对信息的回应。所以这里在接收消息后会转接给消息系统来处理。

顺便一提整个win32窗口的流程是这样子的:

  1. Peek: 运行时会反复的查看硬件那里是否有新的消息过来
  2. Translate: 这是一个可选项,实际上做的就是把我们键盘相关的消息,比如WM_KEYDOWN,翻译成WM_CHAR后塞进我们的消息队列。如果我们需要用到实际的ASCII字符才需要把这个环节启动。还有就是如果调用了Translate,那么一次的KEYDOWN消息会变成两个消息(KEYDOWN + CHAR)
  3. Dispatch: 这个环节就是把我们目前排在队列中的消息,转发到我们在建立窗口时绑定了的MsgProc函数去处理。如果没有Dispatch那么消息就转发不到窗口侧
  4. MsgProc: 这里需要写我们针对每个不同的消息应该怎么处理

消息系统

介绍

之前在学DX12龙书的时候不会特别去注意这一部分,因为在实现单独功能的时候,我们可以直接的去对系统传递到窗口的任意消息去自定义操作。但如果我们想要降低依赖性来让引擎更加的 独立, 那么我们就需要自己去制定一个消息系统。

首先目标就是让窗口侧的信息处理只负责收信息然后转发到我们的引擎主程序,所以这里的消息系统比起一个实际上存在的中间件(含有.h和.cpp的类),更像是对信息的包装。

这里主要有两个工作要做:

  1. 把信息进行归类,让从窗口侧传到引擎主程序后,主程序可以更舒适的处理这些信息。
  2. 设法让窗口能把信息传到主程序

分类

这里我们把所有的消息通过两个层级来处理,首先我们判断消息属于哪一种大类Category,然后再细分成具体操作。这样我们后面的处理会更加的灵活。之后只需要把两个Enum包装为一个消息的数据结构就可以了。

实际转发的时候就是针对每一个窗口接收的消息类型,在窗口内部打包成对应的数据结构后转发。

image-20211118205521552

传递和委托

我们在这个阶段最主要的工作,就是在窗口处理消息的时候把对应的消息传送给应用。这里一个很简单的想法就是,在窗口处理每个消息的时候,去调用应用那里的接收函数,并且把消息本身当成参数传递。

简单来说就是:

1
2
3
4
5
6
7
8
9
10
11
LRESULT Window::MsgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
	{
		switch (msg)
		{
			case WM_KEYDOWN:
			    Create Keydown Event e;
			    Application.OnEvent(e);
			    return 0;
		}
		return 0;
	}

这种感觉。不过这样的设计有个小问题:

  • 窗口是在应用内的:在设计和运行两方面去考虑,我们都是应用含有一个窗口这么一个思路,也就是窗口是应用的一个组件。 那么这里的消息传递就是把一个内部的消息传到父级组件里的行为。但是我们的窗口不应该能直接调用父级组件的函数,这样的设计不太合理。通俗点说就是这样需要让窗口持有应用的指针,但这种互相依赖的设计并不适合。

因此这里比起对父级组件的调用,我们应该给窗口类设置一个委托(函数指针 std::function),然后在应用那一侧去指定消息的回调函数。这样解决了一个互相依赖的耦合问题,代码上也比较好看。还有就是虽然不太确定,但根据网上的信息来看委托确实比直接对对象的函数调用比较快,尤其是在信息处理这种需要频繁调用的情况。

泛用性考虑

当然,可以的话我们尽量不要把所有的东西都塞到Application类里面去管理整个管线。

我们的整体的一个管线肯定是可以写死的。用一套固定的流程去决定什么时候加载物体、更新物体、绘制物体。不过在固定管线内部,有的东西可以写死,例如窗口的创建,又有一些需要我们在不同的场景下去自定义的设置,例如贴图的加载。

从整体流程上来看会是这种感觉:

既然管线内部有的地方是固定的,有的地方是自定义的,那我们可以进一步的把结构拆分为三部分:

  1. 自定义程序: 继承自引擎本体,现阶段可以当作写自定义场景加载的
  2. 渲染引擎本体
  3. 图形接口

泛用性考虑

这里要做的是一个简单的渲染引擎,所以自然会有使用这个渲染引擎的实际程序。一个初步的设想就是我的渲染引擎曝露出一个可以自定义设置场景的接口,例如一个 LoadScene() 函数,然后每次要加载不同场景的时候只要重新的去写这个函数就可以了。(当然可以动态的加载物体是更好的,不过尽量从简单的开始做)

这里我直接参考的ChernoHazelEngine的处理方式,把代码再划分成两个部分。

  1. DLL形式存在的引擎
  2. exe形式存在的程序
DLL形式的引擎和exe程序

实际上用单个Project(exe)做一个渲染引擎也是可以的,不过这样的分配法也让引擎本身更加的独立于用户的自定义操作。试着去把程序和引擎分开可以更加的了解怎么样编写引擎可以提供更多的空间给使用引擎的程序。

就像DX12龙书的设计一样,引擎内部需要一个Application的原型,这样可以保证整个引擎本身的运行流程,并且决定继承自自己的程序应该拥有多大程度的操作。我们首先需要在引擎内部有一个应用类,然后通过在外部继承的应用子类去自定义部分的逻辑来运行引擎:

也就是说,引擎内部的Application会固定整个渲染引擎的管线,并且处理掉可以写死的部分。然后我们继承出来的应用再去做什么加载场景,贴图的操作。

学到的小知识

宏的使用

我的客户端侧肯定是要调用引擎侧的函数,要从动态链接库输出的函数需要打上 __declspec(dllexport)宏,然后在客户端侧如果需要用到这个函数,需要声明一个相同的函数并且打上 __declspec(dllimport)宏。

当然,上面这个操作是很繁琐的,这相当于对于每个需要用到的函数,我都需要在DLL侧声明一次带宏的,然后在客户端侧进行一样的操作。这个问题可以通过再外接一层宏来解决,这里是这么处理的:

  1. 首先,我们先外接一层宏,取名叫Core.h,这个宏把繁琐的 __declspec(dllexport)替换成了FOO_API

  2. 接下来,我们在这一层宏里面设置一个关键的条件:如果是动态库那里在加载FOO_API,就把他替换成export, 如果是客户端在加载FOO_API,就替换成import

  3. 这里决定是哪一侧在读这一层宏的就是我们可以在设定里找到的Preprocessor里面的参数。如果我有添加我#ifdef的东西,那么这里会指向上面的内容,如果我这一侧没设,就会指向下面。

通过上面的操作,我们只需要include这一层封装,就可以让两侧的代码共享相同的头文件, 可喜可贺。

Pre-compiled Header

有很多官方的库比如说std是会被我们经常使用的。如果我们很多的文档都去include比如std::string库的话,我们每次编译都要花大把时间去编译这些不会被我们改动的库。使用PCH的话我们可以把我们不会去改动的官方库提前进行编译后储存起来,这样之后每次要编译项目的时候都不需要去花时间编译这些了。