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

接上一篇,我们首先来看一下基本的文件IO操作。

对于C语言标准库提供了两种文件io的API。一种是C标准库当中提供的f*系列的,带有缓存功能的API,如fopen/fclose;一种是POSIX标准的,不带有缓存功能的API,如open/close。不过,如同我们之前说过,windows并不是POSIX系统,因此windows所提供的POSIX类型的API并不是系统调用,而是win32 API的简单封装。

由于我们目前主要是进行游戏资源文件的加载。这种加载基本上是只读性质的,而且大部分是顺序读取,所以带有缓存的f*系列API就比较适合我们的需要了。而且f*系列的API是属于C标准库的一部分,可移植性较好。

我们当然可以在场景管理模块里面直接使用f*系列API来加载资源。但是这么做有以下几个问题:

  1. 虽然f*系列API是C的标准库的一部分,具有良好的可移植性,但是在一些平台上它并不是系统原生的API,所提供的功能十分有限且优化不足。比如在windows平台上有功能更为强大的win32 API,而在PS4上有可以将多个文件的读写进行统一调度优化的库。如果在资源管理模块当中直接调用f*系列的API,那么如果之后我们希望直接使用平台原生的API,会变得困难;
  2. 各个平台对于文件路径的要求和处理有微妙的区别。比如windows平台有盘符的概念;而Linux平台整个文件系统在一个树状结构当中;而PSV的文件系统在路径前还有媒体标识符;PS4的文件系统虽然类似Linux系统但是是在一个严格的沙箱当中的虚拟路径,与文件实际存放路径不同。这些细节与场景管理本身并没有太多关系,应该放在一个独立的地方进行处理;
  3. f*系列API所提供的是同步阻塞型的文件访问。如果我们在场景管理模块当中直接使用这些API,那么场景管理模块在进行文件操作的时候将会失去响应。场景管理模块是与图形渲染模块,动画模块,游戏逻辑模块等紧密协作的一个模块,因此我们应该将其设计为一个快速响应的模块,不能有这样的阻塞;
  4. 如我们上一篇文章所分析的,场景模块是比较复杂的。为了降低其复杂度,增加其可维护性,我们应该尽量把松耦合的功能从其中剥离出来形成单独的模块。

基于这样的基本设计,我们定义了一个资源加载模块,AssetLoader,专门负责资源文件的加载工作。

namespace My {
    class AssetLoader : public IRuntimeModule {
    public:
        virtual ~AssetLoader() {};

        virtual int Initialize();
        virtual void Finalize();

        virtual void Tick();

        typedef void* AssetFilePtr;

        enum AssetOpenMode {
            MY_OPEN_TEXT   = 0, /// Open In Text Mode
            MY_OPEN_BINARY = 1, /// Open In Binary Mode
        };

        enum AssetSeekBase {
            MY_SEEK_SET = 0, /// SEEK_SET
            MY_SEEK_CUR = 1, /// SEEK_CUR
            MY_SEEK_END = 2  /// SEEK_END
        };

        bool AddSearchPath(const char *path);

        bool RemoveSearchPath(const char *path);

        bool FileExists(const char *filePath);

        AssetFilePtr OpenFile(const char* name, AssetOpenMode mode);

        Buffer SyncOpenAndReadText(const char *filePath);

        size_t SyncRead(const AssetFilePtr& fp, Buffer& buf);

        void CloseFile(AssetFilePtr& fp);

        size_t GetSize(const AssetFilePtr& fp);

        int32_t Seek(AssetFilePtr fp, long offset, AssetSeekBase where);

        inline std::string SyncOpenAndReadTextFileToString(const char* fileN
me)
        {
            std::string result;
            Buffer buffer = SyncOpenAndReadText(fileName);
            char* content = reinterpret_cast<char*>(buffer.m_pData);

            if (content)
            {
                result = std::string(std::move(content));
            }

            return result;
        }
    private:
        std::vector<std::string> m_strSearchPath;
    };
}

目前这个定义当中还只是包括了同步版本的API,因此这个类看起来还只是一个wrapper,似乎没有必要将其定义为一个RunTimeModule。但是为了实现上面所说的让文件操作不要阻塞调用线程,我们有两个选项∶

  1. 使用多线程。我们需要在AssetLoader当中创建工作线程池,将对同步阻塞型f*系列API的调用放到工作线程当中去执行;
  2. 使用异步文件IO API。实质上这也是多线程,只不过线程是由操作系统创建。

选项1的好处是我们可以拥有更为细致的控制权,可以精细安排这些线程的优先级以及执行方式等。缺点是我们需要写更多的代码去维护这个线程池的管理,并且因为操作系统缺乏关于我们要进行的工作的足够的信息,可能无法提供文件读写整体方面的优化;

选项二的好处是代码比较简洁,不用进行线程的管理,并且如果我们一次将多个文件读写请求发送给操作系统,可能会得到操作系统在读取方式方面深度的优化,比如通过妥善安排读取的顺序减少硬盘寻道的时间。然而缺点首先是我们可能比较难以控制文件读取的顺序,而且异步文件IO的API并没有得到标准化,在各个平台上API长得很不一样,甚至不支持。

但是不管我们支持上述两种选项的哪一种,因为是异步操作,就意味着我们的AssetLoader会以一种不同于资源管理模块的节奏进行工作。所以我们将它也定义为一个RuntimeModule。

另外一个需要注意的点就是AssetFilePtr。它被定义成为一个空指针类型。这是因为在不同的平台API当中文件的描述子的定义是不同的。我们用空指针类型来抽象整合这种不同。不过需要注意的是AssetFilePtr并不是资源的唯一识别子。资源的唯一识别子是在游戏当中用来索引资源的唯一标识,它应该具备平台无关性和时序无关性;而AssetFilePtr显然这两条都不符合。

况且,这里面还有一个隐含的问题值得我们思考,那就是游戏资源(Asset)与游戏资源文件(Asset File)是否一定是一对一的关系。如果我们将场景物体考虑为Asset的单位,比如游戏当中的主角人物,那么因为一个人物会包括模型、材质、贴图、动画、声音等许多素材,而这些素材往往是以不同格式的文件进行存放的,那么显然这不是一种一对一的关系。

另一方面,如同我们在上一篇文章所述,为了加快资源的读取速度,我们往往最终会将资源文件进行打包合并,从而多个资源可能会对应同一个资源文件,这也不是一种一对一的关系。

因此,很显然地,我们需要在某个地方进行资源与资源文件之间的映射。我们甚至需要导入或者自定义一种资源描述语法(比如URI),来唯一描述某项资源,以及资源之间的联系。

不过在此之前,我们还有一些基础性工作需要做。因为我们的AssetLoader目前只能完成文件内容的加载(以文本文件模式或者二进制模式),并不能解析文件的内容。我们下一篇将介绍如何为我们的AssetLoader设计各种文件格式的解析器(decoder)。

本文当中涉及到的AssetLoader的实现代码在GitHub的article_25当中。

参考引用

  1. http://man7.org/linux/man-pages/man7/aio.7.html
  2. https://msdn.microsoft.com/en-us/library/aa365683.aspx
  3. https://isocpp.org/wiki/faq/serialization#serialize-text-format

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

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

在上一篇我们完成了一个基本的数学库。虽然很基本,但是我们从一开始就已经导入了并行计算的方式:ispc。ispc不仅支持CPU端的并行计算,也支持GPU端的并行计算。这就意味着,如果我们再进行一些编码工作,我们可以轻易地将部分数学计算在CPU和GPU之间转移。

不过我们先不进行这方面的工作,而是进入到场景管理和资源加载。因为如果我们有了这个模块,我们就可以加载场景和资源,让图形渲染模块进行渲染了。我们急切地想看到我们的第一幅通过加载外部资源渲染出来的图。

一个游戏场景是由许多的场景物体组成的。而且很多场景物体以及组件都是在场景当中重复出现的。比如一个中世纪欧洲世界观的游戏,里面很可能会反复出现各种各样的酒桶,酒店,中世纪风格的建筑物;而一个开放世界的游戏,里面会有大量的自然景物(花,草,树木,山石湖泊)出现;至于一个赛车游戏,则会有各种逼真细致的车辆。

无论是从性能角度、资源管理角度、还是从实际游戏制作的工作流角度,我们都需要尽量地将场景物体的数量进行减少;但是与此同时,为了让游戏的画面足以吸引人,我们又要让场景看上去足够的复杂,足够的真实。

达到这个目的的一个有效的手法就是进行场景的剖分与管理。通过将场景分割为场景物体,将场景物体分割为组件,将组件进行标准化归纳统一,是这个手法的关键所在。

如上面所举的例子,游戏当中可能有千千万万个酒桶,但是这些酒桶和酒桶之间的差别,有的可能是位置与大小上的差别;有的可能是外观上的区别;有的可能可以互动(比如可以打碎,可以推拉),有的不能互动。我们的场景管理必须以尽可能简单高效的方式,对这些“看起来不太相同”但是“有很多共通点”的场景物体进行管理。

当代的商业游戏引擎在这方面已经给我们作出了很好的范例。在这些引擎当中,场景整体往往是一个地图(map),其记录了该场景当中所有不同的场景物体种类的信息,以及每个场景物体种类所对应的实例的信息。而每个场景物体则可能包括到一些组件的引用,比如网格Mesh,一些材质,碰撞盒,一些动画数据,以及一些脚本与属性,或者音频、特效等其它的什么。这些可以挂载在场景物体上的构件我们称之为“组件”(Component)。每个组件包括其自身的一些特有数据(比如一张贴图,或者一段代码),还包括一些表明其状态的数据(属性)

当然每个场景物体并不是需要包括全部这些,可能只有一部分,甚至是一个“空”物体。

为了实现这样的结构,我们至少需要定义以下几种类型与结构:

  1. 场景 (Scene)
  2. 场景物体(Object)
  3. 组件(Component)
  4. 属性(Attribute)

(参考引用1)

以上是场景的静态结构。我们同时还需要考虑场景的动态结构。所谓的动态结构,就是场景当中的这些元素是如何生成,如何消灭,如何转化,如何相互影响的。

特别是,对于开放世界游戏,由于场景很大,场景物体也十分复杂,我们需要考虑如何实现这样的场景的动态加载。这个加载不仅要做到无缝,还需要做到智能,并且足够的轻量,不影响我们正常的游戏操作和画面渲染。

我们甚至还需要考虑场景的动态生成,以及玩家行动(或者是npc的行动)对于场景所造成的改变。如何允许这些改变,如何存储和加载这些改变。比如monster hunter这个游戏的地图就是随机生成的;而minecraft这个游戏的世界就是动态生成且可以被玩家任意改变的。

除了上面这些运行时的特性之外,我们还需要考虑场景的制作手法对于场景管理方面提出的要求。当代的大型游戏都有几十人到数百人的制作团队在并行地制作着内容,是否能支持这样的团队规模进行有效的并行制作,这是评价当代游戏引擎的一个很重要的点。当然,这里面更多的是Editor和工作流的部分,但是为了支持所见即所得的设计制作方式,Editor的核心就是我们的Runtime。所以我们的场景管理器需要做好支持这种复杂工作流的准备。

可见当代场景管理是十分复杂的。但是不用太担心,千里之行始于足下,无论多复杂的系统,其运用的基本原理和构件往往是很简单的。场景管理的核心无非是资源的构造与管理,主要就是资源的加载存储与组织结构。我们接下来一步一步地来看这些部分。

首先我们来看看文件I/O部分。我们场景当中的大部分资源,都是以文件的方式被生产出来并进行存放的。自顶而下地看,首先我们会有一个或者数个场景描述文件,其中可能会包括场景物体的序列化存储,也可能场景物体是以另外的方式保存在场景描述文件之外,而场景描述文件只是记述了到这些场景物体在场景当中的位置以及包含一个到场景物体存储的引用。我们还会有大量的游戏素材,比如模型、贴图、音频视频等。

在当代的高品质游戏当中,这样的文件往往多达数十万到数百万个,而且文件尺寸的跨度也很大:从几K几十K的文件到几个G的文件都是屡见不鲜。如何高效地管理并在运行时快速地访问这些文件,是我们需要考虑的。

首先在文件所占用的空间方面。由于大部分文件系统是按照一个固定的尺寸分配物理存储空间的,当文件的尺寸不是这个固定尺寸的整数倍的时候,就会出现文件存储空间的浪费。并且,如果分配的固定尺寸越大,这个浪费就越严重。比如,对于4K大小的分配尺寸来说,每个文件的平均浪费空间为2K;而对于4M大小的分配尺寸来说,平均浪费就为2M。但是如果这个固定尺寸太小,那么会大大增加文件系统头部的开销(因为文件系统头部需要记录每个固定块的状态),这不仅是会占用空间,还会使得文件检索的速度变慢,也会加剧文件的碎片化,从而使得文件读写速度减慢。(参考引用2)

文件系统一般属于操作系统的一部分,并不是由游戏引擎来指定的。但是在一些游戏专用设备上,比如PlayStation系列,会根据游戏各个部分不同的需求分别提供好几种文件系统。有的为高速读取进行优化,比如存储游戏本体的分区;有的为高速读写小文件优化,比如临时目录;有的是比较简单的顺序文件系统,可以支持一块低功耗芯片在机器处于休眠时刻的后台下载;而有的则是基于文件的overlay文件系统,用来实现打补丁,保存存档等目的。

而作为游戏引擎,虽然不能直接操控文件系统的格式,但是可以控制资源文件打包的格式。将多个资源文件合并在一个文件当中存储,可以有效地减少文件系统当中文件的数量,加快文件检索(打开)的速度;同时可以减少系统当中需要管理的文件句柄的数量,减少系统调用的次数。但是这也会使得替换其中的单个资源变得不那么容易,不利于开发阶段资源需要频繁修改的情况。而且如果不采用一些策略妥善安排资源在包当中的顺序的话,对单个资源的小编辑可能会导致整个包需要被替换,会大大加大更新补丁的尺寸。

我们在之前的文章讨论了内存管理。相对于CPU/GPU的计算能力来说,内存是比较慢的。但是,如果和文件系统相比,那么内存真是快得飞起来了。当今大多数机械硬盘的吞吐能力大致也就是在100MBps上下。即使是固态硬盘也不过在500MBps上下。这还是连续访问情况下没有考虑文件系统自身开销的数字。随机访问的话性能会大打折扣。

前面谈到过,游戏引擎的人生是毫秒级别的。以最基本的30fps计算,一帧是33毫秒,在这个时间窗口里普通机械硬盘开足马力的话也就是能读取3MB左右的数据。3MB是一个什么样的概念呢?对于1080p的彩色画面来说,1920 x 1080 x 32bpp,大概是8MB的数据量。将其压缩成JPG,平均差不多是3MB。也就是说如果我们是从硬盘直接加载事先绘制好的画面来播放的话,差不多也就是30fps就撞上了文件系统的瓶颈。

在当代的高品质游戏当中,为了确保画面的质量,2k x 2k左右的贴图是非常常见的。而对于一些环境贴图(cube map),比如天空盒,往往会达到4k x 4k甚至更高。当然,传统的游戏关卡采用预加载的方式,也就是会有一个loading画面,资源加载时间长一点没有太大关系;然而在开放世界等需要无缝加载的当代游戏当中,这就成了一个问题。一些制作比较粗糙的游戏,在切换场景或者景物明显发生大范围变化的时候会卡会掉帧,往往这就是一个主要的原因。

因为这个问题是多方面因素造成的,解决它也是需要从多个方面来进行:

– 首先是需要想办法在保持一定品质的同时尽量减少资源文件本身的体积。这主要涉及到文件的压缩算法。总的来说计算机领域的压缩算法可以分为无损压缩和有损压缩这两个大类。对于数据量相对较小但是精度要求高的数据,比如模型的顶点数据,动画的变换矩阵数据等,一般采用无损压缩;而对于数据量较大但是精度要求不高的数据,比如贴图,音频视频,则一般采用有损压缩。(参考引用3)其中,LZMA是压缩比较高的无损压缩格式,S3贴图压缩格式是当今被GPU所广泛支持的贴图格式,而AVC/AAC则是当今视频音频压缩的主流;(参考引用4)

– 其次是需要尽可能减少对文件系统的随机访问。我们知道,在当代的外部存储系统当中也有一定尺寸的高速缓存,其工作原理与cpu和内存之间的高速缓存相似,都是在收到一次读取命令的时候通过缓存相邻的数据来起到加速的作用。如果我们频繁地读取不同的文件,或者是在同一个文件的不同位置之间频繁的切换,那么就很容易造成高速缓存的cache miss,从而大大降低I/O性能;

– 再次是要尽量避免同一个资源的反复加载。也就是说对于已经加载到内存,在后面还可能会用到的资源,要尽量将其保留在内存当中;同时还需要尽量避免同一个资源的不同副本出现在内存当中;

– 最后就是要实现资源的异步流式加载和部分加载。因为资源的加载是一个相对缓慢的过程,它必然是以远低于渲染线程的频率进行执行,所以相对于渲染线程来说它是异步的。但是同时,这些加载的资源很大一部分是需要提供给渲染线程使用的。那么,在资源尚未加载完毕的时候,渲染线程该如何进行;当资源加载完毕之后,又如何将其动态地更新到渲染线程当中;对于因为尺寸问题无法一次全部加载到内存的资源,如何实现部分加载,并随着游戏的进行改变加载的区域;如何判断已经不需要的资源,何时将其从内存卸载,如何卸载。

为了寻求解决这些问题的方法,在接下来的文章里面我们将逐一讨论不同平台上文件的基本操作、常用的压缩算法及其比较、常见的资源文件格式及其解析、异步I/O、流式加载、加载顺序决策与内存管理等内容。

参考引用:

  1. Component · Decoupling Patterns · Game Programming Patterns
  2. https://en.m.wikipedia.org/wiki/File_system
  3. https://en.wikipedia.org/wiki/Lossless_compression?wprov=sfla1
  4. https://en.m.wikipedia.org/wiki/S3_Texture_Compression

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

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

接上一篇,我们继续进行数学库方面的工作。(题图为ispc自带的延迟渲染的example,与本文没有直接关系) 本文所对应的代码在article_23分支当中。 在游戏当中,最为广泛使用的数学是线性代数。线性代数里面有两个最主要的概念,向量和矩阵。 首先我们来看看向量的定义,以一个2维向量为例(下面的定义均在Framework/GeomMath/geommath.hpp当中):

 template <typename T>     struct Vector2Type     {         union {             T data[2];             struct { T x, y; };             struct { T r, g; };             struct { T u, v; };             swizzle<Vector2Type<T>, 0, 1> xy;             swizzle<Vector2Type<T>, 1, 0> yx;         };         Vector2Type<T>() {};         Vector2Type<T>(const T& _v) : x(_v), y(_v) {};         Vector2Type<T>(const T& _x, const T& _y) : x(_x), y(_y) {};         operator float*() { return data; };         operator const float*() const { return static_cast<const float*>(data); };     };

这里我们使用了C++模板来泛化实际的数据类型,这样我们可以根据需要,使用单精度浮点,或者双精度浮点,甚至是半精度(half float)浮点等。当然我们也可以使用其它类型,如整型,定点浮点类型等。 我们使用了联合(union),来提供一种类似HLSL/GLSL等渲染语言提供的swizzle功能。为此我们还写了一个swizzle模板,可以较为方便地实现swizzle。这个swizzle模板是这么定义的(参考引用1):

 template< typename T, int ... Indexes>     class swizzle {         float v[sizeof...(Indexes)];     public:         T& operator=(const T& rhs)         {             int indexes[] = { Indexes... };             for (int i = 0; i < sizeof...(Indexes); i++) {                 v[indexes[i]] = rhs[i];             }             return *(T*)this;         }         operator T () const         {             return T( v[Indexes]... );         }     };

这个swizzle模板首先是一个不定参的可变模板。这是C++ 11之后新增加的功能,我们在之前也用到过一次。 另外一个需要注意到的是虽然class swizzle有一个成员变量 float v[],但是因为我们在Vector的定义当中是把swizzle的实例和data进行union的,因此实际上在Vector当中的任意一个swizzle实例,并不会增加额外的内存需求。 但是注意由于不同的swizzle实例我们传给它的模板参数是不一样的,所以下面的参数包的展开也是每个实例都不同的:

     T& operator=(const T& rhs)         {             int indexes[] = { Indexes... };         operator T () const         {             return T( v[Indexes]... );

所以,这部分应该是有额外的开销的。具体是怎么样的开销,我们可以通过编译输出llvm中间文件(.ll)来查看。这部分这里先不展开,放在今后的性能分析与优化的章节进行具体讨论。 回到Vector定义的代码。在构造函数之下,我们提供了两行运算符的重载:

     operator float*() { return data; };         operator const float*() const { return static_cast<const float*>(data); };

这是因为我们将会调用ispc代码进行线性代数计算,但是ispc并不支持C++ (虽然它支持部分C++的特性,比如可以在代码任何位置声明变量)。所以,我们需要将我们的向量类转换为浮点数组,来传给ispc。 其实即使不使用ispc,我们仍然需要这些类型转换。这是因为GPU也不懂得C++的类。我们需要将封装在类当中的数据作为线性的buffer传递给GPU。这个在之前图形模块的例程当中已经体验过了。 注意我们之所以定义了了两个版本,是因为我们需要顾及“左值”和“右值”这两种情况。所谓“左值”就是当变量出现在等号的左边,也就是作为被写入的对象;而“右值”则自然是等号的右边,作为被参照(读取)的对象。(更为准确的说,左值就是有名称的变量,是指阁纳数值的”容器”;而右值是被阁纳的数值本身,比如立即数,cpu寄存器当中的计算结果,一个尚未被分配名称的地址空间,等等。) 第一行是”左值“的定义。我们返回的是指向我们data内存区域的指针,之后我们可以利用这个指针修改data内保持的值; 而第二行是”右值“的定义,返回的是指向浮点常数(const float)的指针,这个指针只能用来读取data当中的变量,不能进行”写“操作。 虽然”左值“方式的定义,也可以用在”右值“ (float *也可以放在等式右边使用),但是这样做有一下一些缺点:

  1. 编译器会认为以”左值“方式定义的变量,其内容会在执行当中改变。因此编译器会在编译出来的代码当中,尝试回写变量的值,或者做一些cache的同步操作(请回想我们前面讨论内存管理的时候提到的cache对于执行速度的影响),这会显著影响代码的执行速度;
  2. 为了防止出错,以及让编译器最大限度地优化代码,对于在代码执行过程当中不会被改变的参数,我们要尽可能地将其声明为const类型。然而,如果我们将一个对象实例声明为const类型,那么这个对象实例的所有非const类型的方法都将不可以调用。所以我们必须另外准备一个const类型的方法。而const类型的方法只可以返回const类型的指针。

照此类推,我们定义了3维和4维的向量,不赘述。 接下来我们就可以定义向量之间的运算了。以向量加为例,我们首先在C++ (geommath.hpp)当中定义如下方法和运算符重载:

 template <template<typename> class TT, typename T>     void VectorAdd(TT<T>& result, const TT<T>& vec1, const TT<T>& vec2)     {         ispc::AddByElement(vec1, vec2, result, countof(result.data));     }     template <template<typename> class TT, typename T>     TT<T> operator+(const TT<T>& vec1, const TT<T>& vec2)     {         TT<T> result;         VectorAdd(result, vec1, vec2);         return result;     }

代码比较简单,就是直接调用了ispc所写的计算函数。注意我们是如何定义函数的形式参数的:所有”右值“参数都被声明成了const引用类型,而”左值“参数都被声明成了”左值“引用类型。 模板的使用有一点点复杂,用到了模板类型的模板参数。

template <template<typename> class TT, typename T>

本来,我们可以将向量的维度作为另外一个模板变量,从而定义一个通用的向量模板,看起来大概是下面这个样子:

template <typename T, int D> class Vector {    T data[D];    ...

那么,我们的运算符也可以简单地定义为下面这个样子:

 template <typename T, int D>     void VectorAdd(Vector<T, D>& result, const Vector<T, D>& vec1, const Vector<T, D>& vec2)

甚至进一步简化为下面这个样子:

 template <typename T>     void VectorAdd(T& result, const T& vec1, const T& vec2)

但是,两个方面的问题导致我们不能这么做:

  1. 因为我们导入了swizzling。对于不同维度的Vector,需要支持的swizzling的个数是不同的。这很难用一个模板去实现;(如果有实现的方法,请一定告诉我)
  2. 上面最后一种形式太泛。不仅仅是我们定义的Vector类型,我们后面定义的Matrix类型也可以匹配上。这会增加我们代码出错的几率。我们在使用模板所提供的泛化的同时,也希望尽可能限制这种意想之外的匹配;

所以我采用了下面这种定义方式:

 template <template<typename> class TT, typename T>     void VectorAdd(TT<T>& result, const TT<T>& vec1, const TT<T>& vec2)

这种方式使得只有带一个模板参数的模板可以匹配这个模板函数。我们的Vector2, Vector3, Vector4都符合这个特征,而Matrix(参见后文)就不符合这个特征。当然这仍然可能导致意想之外的匹配,但是发生的可能性已经大大降低了。 蕴含在部分代码的另外一个“黑魔法”就是countof。它被用来将向量的维度传递给ispc的代码:

 template <template<typename> class TT, typename T>     void VectorAdd(TT<T>& result, const TT<T>& vec1, const TT<T>& vec2)     {         ispc::AddByElement(vec1, vec2, result, countof(result.data));     }

这个countof在VC系列里面是自带的,但是为了可移植性,也为了学习,我们自己实现了一下。它的定义如下:

 template<typename T, size_t SizeOfArray>         constexpr size_t countof(T (&array)[SizeOfArray]) { return SizeOfArray; }

这里面同样有一点点黑魔法。我们定义了一个constexpr类型的函数。这也是C++ 11所新增的功能。这种类型的函数是在编译的时候进行计算的,计算的结果作为一个常数被带入到调用这个函数的位置并进行编译。比如sizeof()这个函数,它并不是在程序运行的时候进行的。 另外一点技巧就是countof()的参数,我们通过定义一个数组的引用来获取数组的尺寸(维度)。虽然在运行的时候我们无法直接得知一个数组的尺寸,但是在编译的时候,这个尺寸编译器是知道的。通过如上的定义方式,这个尺寸会被带入SizeOfArray这个参数当中,并且因为return SizeOfArray这个函数体,这个函数最终的结果就是实参数组的维度。 我们当然可以把这个维度作为一个变量保存在我们的Vector类当中。但是我这里想要的是保持每种类型的Vector它在内存上的尺寸与其包含的实际数据的尺寸相同。也就是说,Vector2f在内存上就应该只有2个float的大小;而Vector3f就是3个float的大小。这对于我们后面控制内存分配以及对齐方式等非常重要,所以对于这些基础数据类型定义我们要避免使用额外的类成员变量。 最后是关于运算的函数形式和运算符形式:

 // (函数形式)     void VectorAdd(TT<T>& result, const TT<T>& vec1, const TT<T>& vec2)     // (运算符形式)     TT<T> operator+(const TT<T>& vec1, const TT<T>& vec2)

我们可以只定义其中一种。出于以下考虑我定义了两种:

  1. 函数形式不需要在函数内分配内存(无论是堆还是栈),也不存在额外的拷贝操作,效率高;但是函数形式不容易实现多个计算操作在一行上面的书写;
  2. 如果将result作为函数的返回值进行返回,那么会多一次拷贝操作。如果我们在函数内分配内存并将其地址(指针)作为返回值返回,那么如果是在栈上分配的内存,则当函数结束的时候栈指针回退,我们返回的指针实际上已经是无效的(虽然我们如果立即使用,那么还是可以读到正确的值);如果我们是在堆上分配,那么我们需要在函数外释放这个内存,这个违反了谁分配谁释放的一般设计原则,容易引出内存方面的问题;
  3. 运算符形式最大的好处就是方便书写,直观。对于一些性能不是很敏感的地方我们可能更希望使用这个。

然后我们再来看一下ispc书写的部分:

export void AddByElement(uniform const float a[], uniform const float b[], uniform float result[], uniform const int count) {     foreach(index = 0 ... count)     {         result[index] = a[index] + b[index];     }     return; }

书写ispc的时候,我们遵循一个基本的原则:尽量通用化。如我们前面所述,在ispc层面我们看不到Vector和Matrix的封装,所有的都只是基本数据类型的序列。两个向量的相加就是两个向量对应的成员的相加,对于矩阵也是一样。所以,我们用ispc所写的代码,就是完成两个基本数据类型序列的相加,而不关心它到底是什么。 相对于shading语言,ispc的一个好处就是可以直接被C/C++代码调用,而不是通过一堆API将参数上传到某个地方,然后开始计算,计算完了再将结果取回来。(当然,其实这个过程是存在的,只是被ispc所隐蔽掉了。当ispc跑在CPU的向量计算单元上的时候,一部分参数需要通过CPU向量扩展命令装载到CPU的向量计算单元,在计算完了的时候再取回来(pack / unpack);而当ispc跑在GPU上的时候,需要将相关的参数展开到GPU所内看到的内存空间,再计算完成之后再取回来) 在分布式计算领域,有一个很著名的Map – Reduce模型,其实就是和这个差不多的意思。 也是因为如此,并不是所有以ispc所写的函数都能被C/C++调用。只有被”export”了的函数,才可以被C/C++调用。不仅仅如此,由于C/C++是以串行逻辑执行的,所以被”export“的函数只能有uniform类型的形参。如我们在上一篇所述,uniform类型的形参被同一批并行计算的所有”线程“(注意这里的线程与串行计算世界的线程不同,是真正可以在同一个CPU内核上同时执行的计算)所共享。而未被声明为uniform类型的变量,是每一个线程都有一个单独的拷贝,互不干涉的。 所以,实际上被export的函数起到了一个Map – Reduce的作用,或者说分发器 – 收集器的作用。注意我们下面的

 foreach(index = 0 ... count)     {         result[index] = a[index] + b[index];     }

这个foreach实际上就是一个分发器。C/C++/Java当中的for / foreach是逐个执行的,而这里的foreach实际上是生成count个“线程”(还记得这个count的由来么?它是我们在geommath.hpp当中用countof()获取的向量/矩阵的维度),然后让它们同时执行foreach循环体内的操作,也就是:

     result[index] = a[index] + b[index];

这个语句在每个“线程”当中只会被执行一次(并不会循环),每个“线程”负责一个index的执行。注意这里的index并不是uniform类型的,所以每个线程的index的值并不一样。假设count为4,那么这个语句的作用就是生成4个线程,1号线程的(index = 0),2号线程的(index = 1),照此类推。在执行一次循环的时间内,4个index的计算都会进行,就相当于通常执行4次循环的结果。 而最后的结果收集是通过result这个uniform变量进行的。因为是uniform变量,这个变量在内存上只有一个,每个线程写的是第index个数据。在这个例子当中,因为每个线程的index不同,写的地方也不同,所以不需要特别的控制(同时写就好)。而在另外一些计算当中,比如计算向量的点积:

extern void MulByElement(uniform const float a[], uniform const float b[], uniform float result[], uniform const int count); export void DotProduct(uniform const float a[], uniform const float b[], uniform float* uniform result, uniform const int count) {     *result = 0;     uniform float * uniform r = uniform new uniform float [count];     MulByElement(a, b, r, count);     foreach_active(i) {         *result += r[i];     }     delete[] r; }

因为我们最后的结果只是一个浮点数,我们需要将并列计算的向量的各个分量的积进行累加,这里就不能再并列了。因此我们使用了 foreach_active(i)这么一个收集器,将并列计算的结果进行串行化累计。因为是串行执行,这个loop会执行向量的维度次,而不是只执行1次。 好了,向量相关的基本就是这样。其它的运算也是上面所说的演绎。接下来我们看一下矩阵的定义:

 template <typename T, int ROWS, int COLS>     struct Matrix     {         union {             T data[ROWS][COLS];         };         auto operator[](int row_index) {             return data[row_index];         }         const auto  operator[](int row_index) const {             return data[row_index];         }         operator float*() { return &data[0][0]; };         operator const float*() const { return static_cast<const float*>(&data[0][0]); };     };

相对于向量的定义,我们的data从一维数组变成了二维数组,并且增加了ROWS和COLS这两个模板参数,用来定义矩阵的维度。我们当然也可以把data仍然定义为一维数组,就如同DirectXMath里面那样。但是如我上面所述,向量和矩阵在概念上就有很多不同,为了在代码上彻底区分这两个概念,避免我们定义的一些模板可以同时套用到这两个类型上,我们人为地进行了一些不兼容的定义。 比如,基于这样的定义,我们的矩阵加法就需要如下定义:

 template <typename T, int ROWS, int COLS>     void MatrixAdd(Matrix<T, ROWS, COLS>& result, const Matrix<T, ROWS, COLS>& matrix1, const Matrix<T, ROWS, COLS>& matrix2)     {         ispc::AddByElement(matrix1, matrix2, result, countof(result.data));     }     template <typename T, int ROWS, int COLS>     Matrix<T, ROWS, COLS> operator+(const Matrix<T, ROWS, COLS>& matrix1, const Matrix<T, ROWS, COLS>& matrix2)     {         Matrix<T, ROWS, COLS> result;         MatrixAdd(result, matrix1, matrix2);         return result;     }

可以看到,实质进行的计算是一样的,使用的ispc代码都是一样的,都是ispc::AddByElement()。但是在模板形参方面我们做了明显的区分,使得两者不能混用。 事实上,我们回过头来看计算机编程语言的发展:从二进制汇编到C/C++,到objectC/C#/Java,一直都有两种趋势同时在进行:

  1. 语言自身的泛化,自动化,降低编程的难度,提高可移植性
  2. 强类型,强规则,降低出错的可能性,提高程序的效率

我们这里也是同样的思路。在使用模板进行泛化,使得我们可以轻松支持不同数据类型、不同维度的向量和矩阵的同时,通过模板特化(template specialization),实现尽可能强的类型检查。 好了,关于数学库的第一次介绍就到这里。最后我们为它加上一个测试用例。因为数学库是一个核心组件,它的正确就变得尤为重要。CMake缺省集成了CTest这个广泛使用的自动化测试框架,我们来初步看看它的用法。 首先在Framework/GeomMath下面新建test文件夹,书写test.cpp如下(篇幅问题,测试内容有删减,具体的请直接看代码):

#include <iostream> #include "geommath.hpp" using namespace std; using namespace My; void vector_test() {     Vector2f x = { 55.3f, 22.1f };     cout << "Vector2f: ";     cout << x;     Vector3f a = { 1.0f, 2.0f, 3.0f };     Vector3f b = { 5.0f, 6.0f, 7.0f };     cout << "vec 1: ";     cout << a;     cout << "vec 2: ";     cout << b;     Vector3f c;     CrossProduct(c, a, b);     cout << "Cross Product of vec 1 and vec 2: ";     cout << c;     ... } void matrix_test() {     Matrix4X4f m1;     BuildIdentityMatrix(m1);     cout << "Idendity Matrix: ";     cout << m1;     float yaw = 0.2, pitch = 0.3, roll = 0.4;     MatrixRotationYawPitchRoll(m1, yaw, pitch, roll);     cout << "Matrix of yaw(" << yaw << ") pitch(" << pitch << ") roll(" << roll << "):";     cout << m1;     ... } int main() {     cout << std::fixed;     vector_test();     matrix_test();     return 0; }

然后还是和之前一样,将其加入到CMakeLists.txt的链条当中。比较特别的是和它同级的CMakeLists.txt,里面多了个add_test指令:

add_test(NAME Test_GeomMath COMMAND GeomMathTest)

这个是告诉CMake,这个是一个测试。然后在顶级的CMakeLists.txt里面需要打开测试开关:

include(CTest)

这样就好了。 不过在编译执行之前,我想先介绍一下cmake的一个简单的编译方式。 到目前为止,我们都是先用cmake生成Makefile或者Visual Studio工程,然后再用传统的Linux程序编译命令(make)或者Visual Studio的编译命令(msbuild)或者IDE进行编译的。其实,cmake对于这些也有包装,在创建编译用目录并在其中生成Makefile或者VS工程之后,我们只需要执行下面的命令就可以编译(假设我们在编译用目录顶级):

cmake --build . --config Debug --clean-first

用这种方式的好处是我们可以动态地改变编译的配置,比如我们要编译调试版,那么就是上面这个,编译Release版,只需要将 –config Debug 改为 –config Release 就可以了。 在编译完成之后,我们可以用下面的命令执行测试:

cmake --build . --config Debug --target test

如果你还是用我们之前的办法编译,那么在Windows下通过编译执行Test.vcxproj这个项目可以完成测试,而在Linux下通过make test可以编译测试用例并如下执行就可以看到结果:

[tim@iZphicesefdwajZ GameEngineFromScratch]$ ./build/Framework/GeomMath/test/GeomMathTest Vector2f: ( 55.299999,22.100000 ) vec 1: ( 1.000000,2.000000,3.000000 ) vec 2: ( 5.000000,6.000000,7.000000 ) Cross Product of vec 1 and vec 2: ( -4.000000,8.000000,-4.000000 ) Dot Product of vec 1 and vec 2: 38.000000 Element Product of vec 1 and vec 2: ( 5.000000,12.000000,21.000000 ) vec 3: ( -3.000000,3.000000,6.000000,1.000000 ) vec 4: ( 2.000000,0.000000,-0.700000,0.000000 ) vec 3 + vec 4: ( -1.000000,3.000000,5.300000,1.000000 ) vec 3 - vec 4: ( -5.000000,3.000000,6.700000,1.000000 ) normalized: ( -0.559402,0.335641,0.749598,0.111880 ) Idendity Matrix: 1.000000,0.000000,0.000000,0.000000 0.000000,1.000000,0.000000,0.000000 0.000000,0.000000,1.000000,0.000000 0.000000,0.000000,0.000000,1.000000 Matrix of yaw(0.200000) pitch(0.300000) roll(0.400000): 0.925564,0.372026,-0.070200,0.000000 -0.327580,0.879923,0.344132,0.000000 0.189796,-0.295520,0.936293,0.000000 0.000000,0.000000,0.000000,1.000000 Matrix of Rotation on Y(angle = 1.570796): -0.000000,0.000000,-1.000000,0.000000 0.000000,1.000000,0.000000,0.000000 1.000000,0.000000,-0.000000,0.000000 0.000000,0.000000,0.000000,1.000000 Matrix of Rotation on Z(angle = 1.570796): -0.000000,1.000000,0.000000,0.000000 -1.000000,-0.000000,0.000000,0.000000 0.000000,0.000000,1.000000,0.000000 0.000000,0.000000,0.000000,1.000000 Matrix of Translation on X(5.000000) Y(6.500000) Z(-7.000000): 1.000000,0.000000,0.000000,0.000000 0.000000,1.000000,0.000000,0.000000 0.000000,0.000000,1.000000,0.000000 5.000000,6.500000,-7.000000,1.000000 Vector : ( 1.000000,0.000000,0.000000 ) Transform by Rotation Y Matrix: -0.000000,0.000000,-1.000000,0.000000 0.000000,1.000000,0.000000,0.000000 1.000000,0.000000,-0.000000,0.000000 0.000000,0.000000,0.000000,1.000000 Now the vector becomes: ( -0.000000,0.000000,-1.000000 ) Vector : ( 1.000000,0.000000,0.000000 ) Transform by Rotation Z Matrix: -0.000000,1.000000,0.000000,0.000000 -1.000000,-0.000000,0.000000,0.000000 0.000000,0.000000,1.000000,0.000000 0.000000,0.000000,0.000000,1.000000 Now the vector becomes: ( -0.000000,1.000000,0.000000 ) Vector : ( 1.000000,0.000000,0.000000 ) Transform by Translation Matrix: 1.000000,0.000000,0.000000,0.000000 0.000000,1.000000,0.000000,0.000000 0.000000,0.000000,1.000000,0.000000 5.000000,6.500000,-7.000000,1.000000 Now the vector becomes: ( 1.000000,0.000000,0.000000 ) View Matrix with position(( 0.000000,0.000000,-5.000000 ) ) lookAt(( 0.000000,0.000000,0.000000 ) ) up(( 0.000000,1.000000,0.000000 ) ): 1.000000,0.000000,0.000000,0.000000 0.000000,1.000000,0.000000,0.000000 0.000000,0.000000,1.000000,0.000000 -0.000000,-0.000000,5.000000,1.000000 Perspective Matrix with fov(1.570796) aspect(1.777778) near ... far(1.000000 ... 100.000000): 0.562500,0.000000,0.000000,0.000000 0.000000,1.000000,0.000000,0.000000 0.000000,0.000000,1.010101,1.000000 0.000000,0.000000,-1.010101,0.000000 MVP: 0.562500,0.000000,0.000000,0.000000 0.000000,1.000000,0.000000,0.000000 0.000000,0.000000,1.010101,1.000000 0.000000,-0.000000,4.040404,5.000000

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

参考引用

  1. c++ vector swizzling
  2. constexpr specifier (since C++11)
  3. https://cmake.org/Wiki/CMake/Testing_With_CTest
  4. https://en.wikipedia.org/wiki/Rotation_matrix
  5. http://en.cppreference.com/w/cpp/language/template_parameters

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

接上一篇,首先继续完成Linux平台的OpenGL整合工作;然后开始场景管理方面的准备(数学库的建立)

第一步是在Platform/Linux下面建立XcbApplication.{hpp,cpp}两个文件,并从BaseApplication派生,将我们在文章(八)当中所写的使用Xcb进行窗口创建的代码统合在里面。

然后我们从XcbApplication.{hpp,cpp}派生出OpenGLApplication.{hpp,cpp},将我们在文章(十二)当中所写的查询设备所支持的Framebuffer的格式,创建绘图context的代码统合进来。并通过External/glad自动创建的glx loader,完成GLX API的加载,然后初始化我们在前一篇创建的OpenGL RHI(平台无关),完成OpenGL API的加载。

一如既往,代码在GitHub的article_22当中,想看细节的请直接参考代码,不赘述。(可以用git log查看每一步的commit,更容易理解)

在这个过程当中主要需要注意的就是glad只是加载OpenGL API,并不会创建context。而加载API需要首先创建一个临时的context。这个问题我们在文章(十二)当中已经详细阐述过。

相比较文章(十二),我在统合的时候做的一个比较大的调整就是在创建这个临时的context之前就首先遍历设备支持的framebuffer的格式,并按照我们在创建Application的时候传入的设置(GfxConfig)选择一个尽可能接近的格式进行创建。这样我们就可以避免在加载完API之后需要销毁临时context(因为framebuffer格式不同且只能在创建的时候设置,无法在创建后更改)重新创建的问题。

好了,到此为止我们已经有了一个跨平台的基本图形渲染模块了,支持windows和linux两种平台,d3d12和openGL(任意版本)两种图形API。

我们当然可以继续在图形模块上深挖下去。但是为了能够比较方便的检验我们的图形模块,也为了让这篇系列文章看起来更有趣一些,我们在这里将图形模块暂且搁置一下,来看看其它相关的模块。

为了能够绘制一些更为有趣的内容,我们需要开始构建场景管理模块。场景管理模块的主要任务如下:

  1. 与输入输出模块对接,完成场景资源的加载;
  2. 与内存管理模块对接,完成场景资源在内存上的展开与安排;
  3. 与策略模块对接,向游戏逻辑提供可编程的场景对象与参数存储;
  4. 与动画模块对接,向动画模块提供动画对象以及动画时间线数据;
  5. 与渲染模块对接,为渲染器提供网格(mesh),材质(material),光照(lighting)等信息。

不过在我们实际进入场景管理模块的编写之前,我们还有一些基础性的工作需要做。比如跨平台存储系统的接口,数学库等。

我们首先来看看数学库。

有很多已有的数学库,比如我们之前用到过的DirectXMath库,或者是GLM库。它们都不错。不过,DirectXMath库是一个不太能跨平台的库;而glm库则带有浓浓的OpenGL的味道。况且,在主机平台(PlayStation系列)上也不存在这两个库。我们当然可以在Platform目录下根据不同平台wrap不同的库,就如我们wrap图形一样。但是作为专业造轮子,我觉得与其移植一个到各种平台,还不如自己写一个跨平台的。既然手敲,就一敲到底。

其实,还有一个原因是我发现了ispc(参考引用2)这个好玩的东西。这是intel提供的一个SIMD编译器,可以以一种非常类似C语言的方式来写可以执行在llvm所支持的各种主流CPU/GPU的SIMD程序。

在之前的文章当中,我们写了一些简单的shader。其实shader就是一种SIMD程序。虽然每个shader程序自身看起来和普通的C语言程序差不多,但是实际上它是以一种并行的方式执行的。比如在A卡的GCN架构当中,每64个顶点或者像素作为一个wavefront,我们shader当中的每一条指令都是同时对这64个对象起作用的。也就是说,如果我们的shader里面做了一次乘法,实际上是对64个数据进行了同一个乘法。在N卡上也是类似的概念,只不过是32个为一组,称为一个wrap。

而CPU从686开始也有了类似的能力。首先是为了能够解码播放诸如DVD这样的视频,CPU当中增加了128bit的MMX寄存器。后来因为非视频播放领域的需求(主要是游戏)越来越大,又出现了SSE技术。今天SSE技术已经发展到第四代,也就是SSE4,同时也出现了更宽的256bit的AVX、AVX2以及512bit的AVX512技术。(参考引用4)

而这些技术在标准的C/C++当中并没有得到很好的支持。传统的编程语言都是串行思路,对于并列计算的支持并不好。虽然有诸如__m128等专门用于SIMD的数据类型,但是语言本身是串行的结构。

而ispc在保留了绝大多数C语言特点,并吸收了C++语言的一些十分方便的优点的同时,对语言进行了并列计算方面的拓展。

其实这很类似于我们写shader的时候所用的shader语言,如GLSL、HLSL、CG等。所不同的是这些都是用于GPU的,而ispc是可以跑在CPU,也可以跑在GPU。

另外一个可以类比的是OpenCL。不过OpenCL主要也是用于GPU的。虽然现在也有一些CPU的能力。

还有一个CUDA。这个也是用于GPU的,而且是N社的技术。用这个技术写的计算内核是以一种类似shader的方式加载的,它不容易与标准C/C++实现相互调用。

ispc的一般介绍请参考(参考引用2)。编译方法则参考(参考引用3)。

我在编译当中遇到的问题是当使用llvm 5.0之上的版本(如果按照本系列安装的llvm,则是最新,写此文的时候是6.0)编译的话,会出现几个dump函数未定义的错误。这些函数是一些调试用函数,在LLVM 5.0当中好像过期了。而ispc目前还是根据LLVM 3.8写的。解决的方法有两个,一个是根据ispc的文档设置环境变量执行alloy脚本让它自己下载3.8并打patch;另一个就是自己把这几个函数补上。我采用的是后者,不过我只是定义了几个空函数,解决掉编译问题,并没有具体实现它们。(因为它们的功能只是输出调试信息)。你也可以从LLVM5.0之前的代码当中把实现拷贝进来。代码如下:

#include "llvm/IR/Module.h" 
#include "llvm/IR/Value.h" 

namespace llvm { 
void Module::dump() const {}; 
void Value::dump() const {}; 
}

记得修改ispc的make文件把这个新文件放入到要build的代码文件list里面去,然后编译ispc。

如果是在Windows上面编译ispc,会遇到更多的问题。首先,官方教程在这里。但是按照本系列前面的文章所编译的llvm是没有进行安装的。但是ispc要求安装llvm(就是要bin, include, lib这种标准的文件结构)。我们可以回到llvm的out of source的build目录,重新执行下面这个CMake命令指定安装目录:

cmake -DCMAKE_INSTALL_PREFIX="E:llvm" -DCMAKE_BUILD_TYPE=Release -Thost=x64 -G "Visual Studio 15 2017 Win64" ..llvm

然后用Visual Studio打开生成的INSTALL.vsxproj,进行编译就可以完成安装。

注意这里面有几个坑。首先,visual studio事实上提供了四套工具环境(toolchain environment),一套32bit的,一套64bit的,一套32bit交叉编译64bit的,一套64bit交叉编译32bit的。

windows平台上的llvm,最终还是要依靠visual studio的linker来链接本地二进制代码。使用哪个版本的linker,这个是通过上面这个-Thost来指定的。不指定(缺省)是32bit的。

而另一方面,cmake生成的visual studio的工程文件的platform,这个是通过-G这个选项里面指定的。上面我们指定了Win64。这个是表示这个项目本身编译出来的代码是32bit还是64bit的。具体这个例子来说,就是生成的llvm和clang这个程序本身是32bit还是64bit的。不指定这个Win64,或者不指定-G选项,那么就生成的是32bit的。如上指定了,就是是64bit的。

但是llvm和clang本身也是编译器,因此其自身是多少bit的与其编译出来的代码是多少bit的又不是一回儿事情。我们自身项目在编译之后是多少bit的,这个是在我们自己到项目的CMakeLists.txt当中通过指定clang的编译参数决定的。具体的,可以通过比较article_22的最后两个commit观察,我们是怎么将我们的项目从原来的32bit改为64bit的。

还有一个坑,就是我们使用的visual studio命令行。这个命令行缺省是使用32bit的vs工具链,并且把目标架构设定为x86。在这种情况下,虽然我们的代码会被clang编译为64bit的(因为我们给clang的参数要求它这么做),而且llvm也会使用64bit的linker,但是目标架构被设置为x86,即使我们完成上面全部,我们的命令行不对,最终linker还是会说输入的obj是64bit的但是要求输出的目标代码是32bit的,这活干不了。

我们在开始菜单里可以找到visual studio的一个目录,展开就可以看到其实有好几个版本的命令行,编译64bit的程序我们需要在64bit的命令行(x64命令行)当中进行。

当然,你也可以在cmake生成vs的工程之后,用IDE进行编译,那就没有命令行版本的问题了。

另外一个问题是如ispc wiki所说,我们之前安装的GnuWin32环境当中的flex工具程序的版本比较老,不能用来编译ispc。解决的方法是单独下载一个新的版本,覆盖上去。下载链接在这里:Win flex-bison。注意需要修改文件名,”win-bison.exe” -> “bison.exe”. “win-flex.exe” -> “flex.exe”,然后覆盖到我们之前安装的GnuWin32的bin目录当中对应的文件当中去。

最后就是和Linux环境一样的问题,需要将我们的quick_fix.cpp包含到ispc.sln解决方案当中。

下面是我们用ispc写的一个向量叉乘的函数:

export void CrossProduct(uniform const float a[3], uniform const float b[3],
uniform float result[3]) {
  foreach(index = 0 ... 3) {
      int index_a = ((index + 1 == 3) ? 0 : index + 1);
      int index_b = ((index == 0) ? 2 : index - 1);
      result[index] = a[index_a] * b[index_b]
              -a[index_b] * b[index_a];
  }
}

注意其当中也出现了uniform这个关键字。这个关键字我们在之前的shader程序当中看到过,意思是这是一个”常量”。注意这个”常量”的意思与const的意思不同,其实uniform更类似static的感觉,意思是一个wavefront/wrap当中的所有处理(thread)都是共享这一个变量。对于没有uniform关键字的变量,则是每个处理(thread)单独有这个变量的一个拷贝,互不相干。

这个程序乍一看起来和普通的c语言程序好像也没啥区别,但是实际上它是并行执行的。就这个例子来说,一个三维向量当中的三个坐标:x,y,z是同时按照这个程序进行计算的。(因为128bit的向量寄存器以及运算器可以同时处理4个单精度浮点或者32bit整型)

关于ispc,我们下一篇结合数学库的计算再详细解释。现在我们先修改一下我们的目录结构,把ispc统合进来并且让cmake可以生成build 我们写的ispc程序需要的脚本。

首先在Framework/下面新建一个Geommath目录,把Common下面的geommath.h移过来。并且增加了swizzle的实现。(参考引用1)然后建立ispc目录,存放ispc格式的数学库代码。将上面的叉乘代码以CrossProduct.ispc的名字存在其中。建立include目录,用于存放ispc编译生成的C语言头文件。目录结构如下:

[tim@iZphicesefdwajZ Framework]$ tree -f .
.
├── ./CMakeLists.txt
├── ./Common
│   ├── ./Common/Allocator.cpp
│   ├── ./Common/Allocator.hpp
│   ├── ./Common/BaseApplication.cpp
│   ├── ./Common/BaseApplication.hpp
│   ├── ./Common/cbuffer.h
│   ├── ./Common/CMakeLists.txt
│   ├── ./Common/GfxConfiguration.h
│   ├── ./Common/GraphicsManager.cpp
│   ├── ./Common/GraphicsManager.hpp
│   ├── ./Common/main.cpp
│   ├── ./Common/MemoryManager.cpp
│   ├── ./Common/MemoryManager.hpp
│   ├── ./Common/Mesh.h
│   ├── ./Common/portable.h
│   └── ./Common/shader_base.h
├── ./GeomMath
│   ├── ./GeomMath/CMakeLists.txt
│   ├── ./GeomMath/geommath.h
│   ├── ./GeomMath/include
│   │   └── ./GeomMath/include/CrossProduct.h
│   ├── ./GeomMath/ispc
│   │   ├── ./GeomMath/ispc/CrossProduct.ispc
│   │   ├── ./GeomMath/ispc/DotProduct.ispc
│   │   ├── ./GeomMath/ispc/MulByElement.ispc
│   │   ├── ./GeomMath/ispc/MulByElement.isph
│   │   └── ./GeomMath/ispc/Transpose.ispc
│   ├── ./GeomMath/libGeomMath.a
│   └── ./GeomMath/test
│       └── ./GeomMath/test/test.cpp
└── ./Interface
    ├── ./Interface/IApplication.hpp
    ├── ./Interface/Interface.hpp
    └── ./Interface/IRuntimeModule.hpp

然后修改Framework下面的CMakeLists.txt,将GeomMath目录加入到编译代码树当中。在GeomMath目录当中新建CMakeLists.txt,加入如下自定义build脚本(注意windows平台下因为我们的项目目前是以32bit方式编译的,我们需要指定ispc也以32bit方式编译):

IF(${WIN32})
        SET(GEOMMATH_LIB_FILE ${CMAKE_CURRENT_BINARY_DIR}/GeomMath.lib)
        SET(ISPC_COMPILER ${PROJECT_SOURCE_DIR}/External/ispc/Release/ispc.exe)
        SET(ISPC_OPTIONS --arch=x86)
ELSE(${WIN32})
        SET(GEOMMATH_LIB_FILE ${CMAKE_CURRENT_BINARY_DIR}/libGeomMath.a)
        SET(ISPC_COMPILER ${PROJECT_SOURCE_DIR}/External/ispc/ispc)
        SET(ISPC_OPTIONS --arch=x86-64)
ENDIF(${WIN32})

SET(GEOMMATH_LIB_HEADER_FOLDER ${CMAKE_CURRENT_SOURCE_DIR}/include)

add_custom_command(OUTPUT ${GEOMMATH_LIB_FILE}
        COMMAND ${ISPC_COMPILER} ${ISPC_OPTIONS} -o CrossProduct.o -I${CMAKE_CURRENT_SOURCE_DIR}
h ${CMAKE_CURRENT_SOURCE_DIR}/include/CrossProduct.h ${CMAKE_CURRENT_SOURCE_DIR}/ispc/CrossProduc
.ispc
        COMMAND ${LIBRARIAN_COMMAND} ${GEOMMATH_LIB_FILE} CrossProduct.o
        )

add_custom_target(ISPC
                   DEPENDS ${GEOMMATH_LIB_FILE}
        )

add_library(GeomMath STATIC IMPORTED GLOBAL)
add_dependencies(GeomMath ISPC)

set_target_properties(GeomMath
        PROPERTIES
        IMPORTED_LOCATION ${GEOMMATH_LIB_FILE}
        INTERFACE_INCLUDE_DIRECTORIES ${GEOMMATH_LIB_HEADER_FOLDER}
        )

最后修改Common目录下的CMakeLists.txt,让Common链接我们的GeomMath库。由于我们移动了geommath.h,我们还需要修改一下项目顶层目录下的主CMakeLists.txt当中的头文件查找路径,否则编译会出错。

编译代码树,查看GeomMath下面是否成功生成了库文件。注意我们首先需要编译ispc。ispc的代码已经以git submodule的方式放在了我们的代码树里面,可以通过执行下面的命令获取:

D:wenliSourceReposGameEngineFromScratch>git submodule update --init --recursive

在Windows下面编译ispc不是很容易。具体的坑上面已经说过。因此我们也可以直接下载一个编译好的,下载链接在这里:Intel® SPMD Program Compiler 。注意需要将ispc.exe放在ExternalispcRelease目录下。

追记:

在最新的article_22的分支上,我已经通过CMakeLists.txt自动检测Windows的编译环境,可以自动处理这上面所写的大部分各种坑。当所使用的命令行为x64命令行环境时,会自动编译x64版本,x86命令行环境当中自动编译x86版本。不过如果是使用cmake -G “Visual Studio …”生成项目文件的话,记得指定-G “Visual Studio 15 2017 Win64“来生成x64的配置文件。

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

参考引用

  1. c++ vector swizzling
  2. Intel® SPMD Program Compiler User’s Guide
  3. ispc/ispc
  4. Streaming SIMD Extensions

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

我们继续上一篇统合我们之前的工作,并对项目结构和代码树进行整理。
我们首先将D3D12的代码和OpenGL的代码整合进来。我在整合的过程当中,主要遇到以下几个问题:

  1. 无论显卡是否支持,D3D12需要win10/windows server 2016的支持。在之前的windows版本上通过安装win10 SDK可以实现代码的编译,但是无法运行。
  2. d3dx12.h不支持clang编译。主要是在类型转换符重载方面出现问题。具体的原因目前我还没有搞得很明白。当前知道的解决方法有两个:
  1. 用cl编译;
  2. 弃用d3dx12.h。
    作为专业造轮子,我这里选择了后者。(注意d3dx12.h并不是d3d12库的一部分)
  • Platform目录下的项目按平台分类,Linux平台的代码在Windows下是不能编译的,反之亦然。因此我们需要在CMakeLists.txt当中作平台的检测,进行条件编译。

在整合的同时,我对代码树进行了整理。

– 新建Asset目录,用于存放shader、贴图、模型、场景、动画、音频等游戏资源文件。

– 新建External目录,用于存放项目依赖的第三方库或者源码。

– 将原本在windows目录下的math.h重命名为vectormath.h,防止和标准头文件重名,并移动到了common目录下,因为其是跨平台的。同时扔掉DirectXMath,我们将自己写数学库。

– 新建RHI目录,并在其下分别建立d3d目录和opengl目录,用于存放图形API的接口。这些接口是平台无关的(虽然d3d只支持windows),所以把它们从platform目录里挪了出来。

– 为了实现图形API的平台无关性,我们需要将纯粹的图形API与平台相关的图形API分开。与平台无关的部分放在RHI之下,而创建context等平台特有的部分依然留在platform之下。比如platform/windows下的d3d12application和openglapplication。

– RHI下创建Empty,表示空(无)图形API。这是用来验证我们的代码(除了特定的RHI模块之外)与任何图形API都没有绑定关系,可以在空API的情况下完成编译。也就是图形API无关性。(平台无关性通过platform目录下的empty确保)

– 在External下面导入glad。这是一个OpenGL Loader(加载器)的自动生成脚本。可以根据最新的OpenGL规格文件(xml,脚本会自动从互联网下载)自动生成OpenGL的函数签名(申明)以及运行时加载并绑定这些API的C/C++源码。通过执行glad_generate.bat就可以生或者更新GL目录当中的文件。注意需要python2.7(在本系列开头编译clang的时候已经安装)

– 将pipeline初始化代码暂时去掉。因为接下来我们要开始准备场景管理与渲染资源管理模块了。之前的支线我们是在代码里写死的模型和shader,这些都去掉。自然pipeline暂时也没办法初始化了。等待我们完成场景管理与渲染资源管理模块之后,再补上这一部分。

本篇基本上是整合之前的支线代码,就不重复贴代码了。

整理之后最终目录的结构如下。对应的代码位于GitHub的article_21的branch当中:

C:USERSTIM.AZUREADSOURCEREPOSGAMEENGINEFROMSCRATCH
│  .gitignore
│  .gitmodules
│  CMakeLists.txt
│  LICENSE
│  README.md
│
├─Asset
│  └─Shaders
│          cbuffer2.h
│          copy.ps
│          copy.vs
│          illum.hs
│          simple.hlsl
│          vsoutput2.hs
│
├─External
│  │  glad_generate.bat
│  │
│  └─GL
│      ├─include
│      │  ├─glad
│      │  │      glad.h
│      │  │      glad_glx.h
│      │  │      glad_wgl.h
│      │  │
│      │  └─KHR
│      │          khrplatform.h
│      │
│      └─src
│              glad.c
│              glad_glx.c
│              glad_wgl.c
│
├─Framework
│  │  CMakeLists.txt
│  │
│  ├─Common
│  │      Allocator.cpp
│  │      Allocator.hpp
│  │      BaseApplication.cpp
│  │      BaseApplication.hpp
│  │      CMakeLists.txt
│  │      GfxConfiguration.h
│  │      GraphicsManager.cpp
│  │      GraphicsManager.hpp
│  │      main.cpp
│  │      MemoryManager.cpp
│  │      MemoryManager.hpp
│  │      vectormath.h
│  │
│  └─Interface
│          IApplication.hpp
│          Interface.hpp
│          IRuntimeModule.hpp
│
├─Platform
│  │  CMakeLists.txt
│  │
│  ├─Empty
│  │      CMakeLists.txt
│  │      EmptyApplication.cpp
│  │
│  ├─Linux
│  │      helloengine_opengl.cpp
│  │      helloengine_xcb.c
│  │
│  └─Windows
│          cbuffer.h
│          cbuffer2.h
│          CMakeLists.txt
│          color.ps
│          color.vs
│          copy.ps
│          copy.vs
│          D3d12Application.cpp
│          d3dx12.h
│          helloengine_d2d.cpp
│          helloengine_d3d.cpp
│          helloengine_d3d12.cpp
│          helloengine_opengl.cpp
│          helloengine_win.c
│          illum.hs
│          Mesh.h
│          OpenGLApplication.cpp
│          OpenGLApplication.hpp
│          simple.hlsl
│          vsoutput.hs
│          vsoutput2.hs
│          WindowsApplication.cpp
│          WindowsApplication.hpp
│
└─RHI
    │  CMakeLists.txt
    │
    ├─D3d
    │      CMakeLists.txt
    │      D3d12GraphicsManager.cpp
    │      D3d12GraphicsManager.hpp
    │
    ├─Empty
    │      CMakeLists.txt
    │
    └─OpenGL
            CMakeLists.txt
            OpenGLGraphicsManager.cpp
            OpenGLGraphicsManager.hpp

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

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

上一篇我们完成了一个基本的内存管理模块。接下来让我们把我们前面图形支线任务的代码整合到我们的引擎代码里面。

在我们继续进行之前,首先让我们调整一下我们的CMakefileLists.txt,使得我们在Windows平台下缺省也使用类似Linux平台的编译方式(gmake + clang)

cmake_minimum_required (VERSION 3.1) 
set (CMAKE_C_COMPILER               "clang-cl")
set (CMAKE_C_FLAGS                  "-Wall")
set (CMAKE_C_FLAGS_DEBUG            "/Debug")
set (CMAKE_C_FLAGS_MINSIZEREL       "-Os -DNDEBUG")
set (CMAKE_C_FLAGS_RELEASE          "-O4 -DNDEBUG")
set (CMAKE_C_FLAGS_RELWITHDEBINFO   "-O2 /Debug")
set (CMAKE_C_STANDARD 11)
set (CMAKE_CXX_COMPILER             "clang-cl")
set (CMAKE_CXX_FLAGS                "-Wall -Xclang -std=gnu++14")
set (CMAKE_CXX_FLAGS_DEBUG          "/Debug")
set (CMAKE_CXX_FLAGS_MINSIZEREL     "-Os -DNDEBUG")
set (CMAKE_CXX_FLAGS_RELEASE        "-O4 -DNDEBUG")
set (CMAKE_CXX_FLAGS_RELWITHDEBINFO "-O2 /Debug")
project (GameEngineFromScrath)
include_directories("${PROJECT_SOURCE_DIR}/Framework/Common")
include_directories("${PROJECT_SOURCE_DIR}/Framework/Interface")
add_subdirectory(Framework)
add_subdirectory(Empty

我们强行指定了C/C++编译器为”clang-cl”。”clang-cl”是clang的一个Wrapper,可以兼容Visual Studio编译器(cl.exe)的编译选项。这个我们在之前的文章当中也交代过了。

如果之前已经用CMake生成过Visual Studio项目文件,让我们清空我们的build文件夹,或者也可以新建一个build文件夹。

C:UsersTim.AzureADSourceReposGameEngineFromScratchbuild>rm -rf *

然后我们通过参数指定CMake生成Unix Makefile

C:UsersTim.AzureADSourceReposGameEngineFromScratchbuild>cmake -G "Unix Makefiles" ..

然后我们就可以使用make命令进行编译了。

C:UsersTim.AzureADSourceReposGameEngineFromScratchbuild>make
Scanning dependencies of target Common
[ 12%] Building CXX object Framework/Common/CMakeFiles/Common.dir/Allocator.cpp.obj
clang-cl.exe: warning: unknown argument ignored in clang-cl: '-std=gnu++11' [-Wunknown-argument]
C:/Users/Tim.AzureAD/Source/Repos/GameEngineFromScratch/Framework/Common/Allocator.cpp(13,9):  warning: field 'm_szBlockSize' will be initialized
      after field 'm_szAlignmentSize' [-Wreorder]
        m_szBlockSize(0), m_szAlignmentSize(0), m_nBlocksPerPage(0),
        ^
C:/Users/Tim.AzureAD/Source/Repos/GameEngineFromScratch/Framework/Common/Allocator.cpp(13,49):  warning: field 'm_nBlocksPerPage' will be
      initialized after field 'm_pPageList' [-Wreorder]
        m_szBlockSize(0), m_szAlignmentSize(0), m_nBlocksPerPage(0),
                                                ^
2 warnings generated.
[ 25%] Building CXX object Framework/Common/CMakeFiles/Common.dir/BaseApplication.cpp.obj
clang-cl.exe: warning: unknown argument ignored in clang-cl: '-std=gnu++11' [-Wunknown-argument]
[ 37%] Building CXX object Framework/Common/CMakeFiles/Common.dir/GraphicsManager.cpp.obj
clang-cl.exe: warning: unknown argument ignored in clang-cl: '-std=gnu++11' [-Wunknown-argument]
[ 50%] Building CXX object Framework/Common/CMakeFiles/Common.dir/MemoryManager.cpp.obj
clang-cl.exe: warning: unknown argument ignored in clang-cl: '-std=gnu++11' [-Wunknown-argument]
[ 62%] Building CXX object Framework/Common/CMakeFiles/Common.dir/main.cpp.obj
clang-cl.exe: warning: unknown argument ignored in clang-cl: '-std=gnu++11' [-Wunknown-argument]
[ 75%] Linking CXX static library Common.lib
[ 75%] Built target Common
Scanning dependencies of target Empty
[ 87%] Building CXX object Empty/CMakeFiles/Empty.dir/EmptyApplication.cpp.obj
clang-cl.exe: warning: unknown argument ignored in clang-cl: '-std=gnu++11' [-Wunknown-argument]
[100%] Linking CXX executable Empty.exe
[100%] Built target Empty

由于我们在CMakeLists.txt当中打开了”-wall”开关,我们看到了更多的warning。这些warning通常不会直接引起问题,但是对于多平台的code,这些warning常常是值得关注的。比如我们这里的关于初始化顺序方面,以及返回值方面的warning。让我们对其进行修正:

C:UsersTim.AzureADSourceReposGameEngineFromScratchbuild>git diff ..FrameworkCommonAllocator.cpp
diff --git a/Framework/Common/Allocator.cpp b/Framework/Common/Allocator.cpp
index 6526691..b0c5d30 100644
--- a/Framework/Common/Allocator.cpp
+++ b/Framework/Common/Allocator.cpp
@@ -9,9 +9,9 @@
 using namespace My;

 My::Allocator::Allocator()
-        : m_szDataSize(0), m_szPageSize(0),
-        m_szBlockSize(0), m_szAlignmentSize(0), m_nBlocksPerPage(0),
-        m_pPageList(nullptr), m_pFreeList(nullptr)
+        : m_pPageList(nullptr), m_pFreeList(nullptr),
+        m_szDataSize(0), m_szPageSize(0),
+        m_szAlignmentSize(0), m_szBlockSize(0), m_nBlocksPerPage(0)
 {
 }

另外,我们在文章(二)当中编译的clang工具链是32位的。这并不影响我们的使用,但是如果我们想要升级到64位,需要按照参考引用1当中的提示,重新编译我们的工具链。

By default, the Visual Studio project files generated by CMake use the 32-bit toolset. If you are developing on a 64-bit version of Windows and want to use the 64-bit toolset, pass the “-Thost=x64“ flag when generating the Visual Studio solution. This requires CMake 3.8.0 or later.

为了分别编译Debug版和Release版,我们需要创建两个Build目录。目录名字随便,我们这里使用build/Debug, build/Release。然后在build/Debug当中执行下面的命令:

cmake -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Debug ....

在build/Release当中执行下面的命令

cmake -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release ....

就可以分别生成Debug和Release的makefile。然后在各自的目录当中执行make就可以了。

好了。如我们之前设计的,Application模块是用来抽象平台相关接口,并读取配置文件的。我们首先创建一个结构体,用来保存我们的配置:

#pragma once
#include <cstdint>
#include <iostream>

namespace My {
	struct GfxConfiguration {
		/// Inline all-elements constructor.
		/// param[in] _apiVer the API and version information
		/// param[in] r the red color depth in bits
		/// param[in] g the green color depth in bits
		/// param[in] b the blue color depth in bits
		/// param[in] a the alpha color depth in bits
		/// param[in] d the depth buffer depth in bits
		/// param[in] s the stencil buffer depth in bits
		/// param[in] msaa the msaa sample count
		/// param[in] width the screen width in pixel
		/// param[in] height the screen height in pixel
		GfxConfiguration(uint32_t r = 8, uint32_t g = 8,
			uint32_t b = 8, uint32_t a = 8,
			uint32_t d = 24, uint32_t s = 0, uint32_t msaa = 0,
			uint32_t width = 1920, uint32_t height = 1080) :
			redBits(r), greenBits(g), blueBits(b), alphaBits(a),
			depthBits(d), stencilBits(s), msaaSamples(msaa),
			screenWidth(width), screenHeight(height)
		{}

		uint32_t redBits; ///< red color channel depth in bits
		uint32_t greenBits; ///< green color channel depth in bits
		uint32_t blueBits; ///< blue color channel depth in bits
		uint32_t alphaBits; ///< alpha color channel depth in bits
		uint32_t depthBits; ///< depth buffer depth in bits
		uint32_t stencilBits; ///< stencil buffer depth in bits
		uint32_t msaaSamples; ///< MSAA samples
		uint32_t screenWidth;
		uint32_t screenHeight;

        friend std::ostream& operator<<(std::ostream& out, const GfxConfiguration& conf)
        { 
            out << "GfxConfiguration:" << 
                " R:"  << conf.redBits << 
                " G:"  << conf.greenBits <<
                " B:"  << conf.blueBits <<
                " A:"  << conf.alphaBits <<
                " D:"  << conf.depthBits <<
                " S:"  << conf.stencilBits <<
                " M:"  << conf.msaaSamples <<
                " W:"  << conf.screenWidth <<
                " H:"  << conf.screenHeight <<
                std::endl;
            return out;
        }
	};
}

然后我们修改我们的BaseApplication基类,添加一个成员变量用于保存这个配置,同时添加一个构造函数用于接受配置:

diff --git a/Framework/Common/BaseApplication.hpp b/Framework/Common/BaseApplication.hpp
index 3244a64..42bece6 100644
--- a/Framework/Common/BaseApplication.hpp
+++ b/Framework/Common/BaseApplication.hpp
@@ -1,10 +1,12 @@
 #pragma once
 #include "IApplication.hpp"
+#include "GfxConfiguration.h"

 namespace My {
        class BaseApplication : implements IApplication
        {
        public:
+        BaseApplication(GfxConfiguration& cfg);
                virtual int Initialize();
                virtual void Finalize();
                // One cycle of the main loop
@@ -15,6 +17,11 @@ namespace My {
        protected:
                // Flag if need quit the main loop of the application
                static bool m_bQuit;
+               GfxConfiguration m_Config;
+
+       private:
+               // hide the default construct to enforce a configuration
+               BaseApplication(){};
        };
 }
diff --git a/Framework/Common/BaseApplication.cpp b/Framework/Common/BaseApplication.cpp
index f61335e..a142115 100644
--- a/Framework/Common/BaseApplication.cpp
+++ b/Framework/Common/BaseApplication.cpp
@@ -1,9 +1,15 @@
 #include "BaseApplication.hpp"
+#include <iostream>
+
+bool My::BaseApplication::m_bQuit = false;
+
+My::BaseApplication::BaseApplication(GfxConfiguration& cfg)
+  :m_Config(cfg)
+{

+}

 // Parse command line, read configuration, initialize all sub modules
 int My::BaseApplication::Initialize()
 {
-       m_bQuit = false;
+    std::cout << m_Config;

        return 0;
 }

好,然后让我们编译执行这个程序。

C:UsersTim.AzureADSourceReposGameEngineFromScratchbuildDebug>make
Scanning dependencies of target Common
[ 12%] Building CXX object Framework/Common/CMakeFiles/Common.dir/Allocator.cpp.obj
[ 25%] Building CXX object Framework/Common/CMakeFiles/Common.dir/BaseApplication.cpp.obj
[ 37%] Building CXX object Framework/Common/CMakeFiles/Common.dir/GraphicsManager.cpp.obj
[ 50%] Building CXX object Framework/Common/CMakeFiles/Common.dir/MemoryManager.cpp.obj
[ 62%] Building CXX object Framework/Common/CMakeFiles/Common.dir/main.cpp.obj
[ 75%] Linking CXX static library Common.lib
[ 75%] Built target Common
Scanning dependencies of target Empty
[ 87%] Building CXX object Empty/CMakeFiles/Empty.dir/EmptyApplication.cpp.obj
[100%] Linking CXX executable Empty.exe
LINK : 没有找到 Empty.exe 或上一个增量链接没有生成它;正在执行完全链接
[100%] Built target Empty

C:UsersTim.AzureADSourceReposGameEngineFromScratchbuildDebug>EmptyEmpty.exe
GfxConfiguration: R:8 G:8 B:8 A:8 D:24 S:0 M:0 W:1920 H:1080

按Ctrl-C退出执行。

接下来我们统合文章(七)所写代码。我们在Platform/Windows之下新建两个文件,并从BaseApplication类派生出WindowsApplication类

#include <windows.h>
#include <windowsx.h>
#include "BaseApplication.hpp"

namespace My {
    class WindowsApplication : public BaseApplication
    {
    public:
        WindowsApplication(GfxConfiguration& config)
            : BaseApplication(config) {};

       	virtual int Initialize();
		virtual void Finalize();
		// One cycle of the main loop
		virtual void Tick();

        // the WindowProc function prototype
        static LRESULT CALLBACK WindowProc(HWND hWnd,
                         UINT message,
                         WPARAM wParam,
                         LPARAM lParam);
    };
}
#include "WindowsApplication.hpp"
#include <tchar.h>

using namespace My;


namespace My {
    GfxConfiguration config(8, 8, 8, 8, 32, 0, 0, 960, 540, L"Game Engine From Scratch (Windows)");
    WindowsApplication  g_App(config);
    IApplication*       g_pApp = &g_App;
}

int My::WindowsApplication::Initialize()
{
    int result;

    result = BaseApplication::Initialize();

    if (result != 0)
        exit(result);

    // get the HINSTANCE of the Console Program
    HINSTANCE hInstance = GetModuleHandle(NULL);

    // the handle for the window, filled by a function
    HWND hWnd;
    // this struct holds information for the window class
    WNDCLASSEX wc;

    // clear out the window class for use
    ZeroMemory(&wc, sizeof(WNDCLASSEX));

    // fill in the struct with the needed information
    wc.cbSize = sizeof(WNDCLASSEX);
    wc.style = CS_HREDRAW | CS_VREDRAW;
    wc.lpfnWndProc = WindowProc;
    wc.hInstance = hInstance;
    wc.hCursor = LoadCursor(NULL, IDC_ARROW);
    wc.hbrBackground = (HBRUSH)COLOR_WINDOW;
    wc.lpszClassName = _T("GameEngineFromScratch");

    // register the window class
    RegisterClassEx(&wc);

    // create the window and use the result as the handle
    hWnd = CreateWindowExW(0,
                          L"GameEngineFromScratch",      // name of the window class
                          m_Config.appName,             // title of the window
                          WS_OVERLAPPEDWINDOW,              // window style
                          CW_USEDEFAULT,                    // x-position of the window
                          CW_USEDEFAULT,                    // y-position of the window
                          m_Config.screenWidth,             // width of the window
                          m_Config.screenHeight,            // height of the window
                          NULL,                             // we have no parent window, NULL
                          NULL,                             // we aren't using menus, NULL
                          hInstance,                        // application handle
                          NULL);                            // used with multiple windows, NULL

    // display the window on the screen
    ShowWindow(hWnd, SW_SHOW);

    return result;
}

void My::WindowsApplication::Finalize()
{
}

void My::WindowsApplication::Tick()
{
    // this struct holds Windows event messages
    MSG msg;

    // we use PeekMessage instead of GetMessage here
    // because we should not block the thread at anywhere
    // except the engine execution driver module 
    if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
        // translate keystroke messages into the right format
        TranslateMessage(&msg);

        // send the message to the WindowProc function
        DispatchMessage(&msg); 
    }
}

// this is the main message handler for the program
LRESULT CALLBACK My::WindowsApplication::WindowProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    // sort through and find what code to run for the message given
    switch(message)
    {
	case WM_PAINT:
        // we will replace this part with Rendering Module
	    {
	    } break;

        // this message is read when the window is closed
    case WM_DESTROY:
        {
            // close the application entirely
            PostQuitMessage(0);
            BaseApplication::m_bQuit = true;
            return 0;
        }
    }

    // Handle any messages the switch statement didn't
    return DefWindowProc (hWnd, message, wParam, lParam);
}

注意我们的主入口(定义在Framework/Common/main.cpp)是main()而不是WinMain()。但是我们依然可以创建窗口。主要的区别是我们需要自己获取hinstance,具体请参看代码。

参考引用:

  1. Clang – Getting Started

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

从零开始手敲次世代引擎(十九)

上一篇我们实现了一个简单的基于块链的Allocator。

接下来我们来实现我们的内存管理模块:Memory Manager

根据之前我们的讨论,我们设计Memory Manager为这样一个角色,它总管着所有动态分配的内存。(但是严格来说,诸如堆栈,自动变量,Memory Manager创建之前创建的对象,以及一些全局对象,比如代表我们架构里的各种模块,也就是***Manager,并不由其管理)

这种管理,是通过管理一系列的Allocator来实现的。每种Allocator,代表了一种分配策略。Allocator以页(Page)为单位获取资源,再以块(Block)为单位分配资源。

采用这种结构的最大好处是:我们可以很方便地添加新类型的Allocator,并通过修改内存分配需求(Request)与分配器(Allocator)之间的映射关系(Allocator Lookup Policy)来快速地实现新的内存分配策略。

另外的好处是:我们可以通过一个线程与Allocator之间的绑定关系,迅速地实现线程的本地堆(Thread Local Storage)。这个堆由于为某个线程所独占,所以并不需要互锁机制,从而可以大大地加速线程的执行速度。

而且,这种结构还可以纵向拓展。如参考引用2那样,只要稍加改造,我们可以在Allocator之间形成层级关系以及兄弟(slibing)关系。

这种层级关系的意义在于,如果我们将一个很复杂的处理划分为一些单纯的短片段的话,那么每个片段的内存访问模式(access pattern)是有规律可循的。也就是说,有的片段总是倾向于频繁的小块内存使用;有的则是大块大块的使用;有的不怎么使用;有的则突发性大量使用,等等。这些不同的使用频率和使用强度,如果我们在同一个层级对其进行管理,那么状况就十分复杂,变得不确定性很强,很难预测;然而如果我们能归纳它们的特征,尽量将类似频率和强度的大量处理组织在同一个层级,那么同一个层级的互相随机叠加,此消彼长,从整体上就会呈现出一种相对的确定性。这种趋势随着并行运行的处理的数量和不确定性增加而增强。

我们的游戏引擎设计为多线程多模块异步平行执行模式。每个模块的任务类型很不一样,执行频率也不同。比如,渲染模块需要逐帧运行,涉及到大量的大块内存使用,但是这些buffer往往生命周期很短;场景加载模块则相对来说以很长的周期运行,其数据结构可能会在内存当中保持数分钟甚至数十分钟;而AI等逻辑模块则是典型的计算模块,会涉及到大量小buffer的高频分配与释放。

于此同时,游戏场景是由场景物体组成的,我们的很多模块都需要以场景物体为单位进行处理。同一个模块对于不同场景物体的处理是类似的,也就是说对于内存的访问模式是类似的。我们可以很自然地把他们组织成为一个内存管理上的兄弟关系。

好,接下来就让我们把这些想法落实到代码当中。因为我们目前还没有其它模块,我们还不需要完成上面所设计的全部内容。我们先将我们上一篇所写的Allocator组织到我们的Memory Manager当中,提供一个最基本的,单层的但是支持不同分配尺寸的,线程不安全的内存管理模块。

代码主要参考了参考引用1,结合我们的架构与命名规则进行了封装,并且进行了跨平台方面的一些改造。

#pragma once
#include "IRuntimeModule.hpp"
#include "Allocator.hpp"
#include <new>

namespace My {
    class MemoryManager : implements IRuntimeModule
    {
    public:
        template<typename T, typename... Arguments>
        T* New(Arguments... parameters)
        {
            return new (Allocate(sizeof(T))) T(parameters...);
        }

        template<typename T>
        void Delete(T *p)
        {
            reinterpret_cast<T*>(p)->~T();
            Free(p, sizeof(T));
        }

    public:
        virtual ~MemoryManager() {}

        virtual int Initialize();
        virtual void Finalize();
        virtual void Tick();

        void* Allocate(size_t size);
        void  Free(void* p, size_t size);
    private:
        static size_t*        m_pBlockSizeLookup;
        static Allocator*     m_pAllocators;
    private:
        static Allocator* LookUpAllocator(size_t size);
    };
}
#include "MemoryManager.hpp"
#include <malloc.h>

using namespace My;

namespace My {
    static const uint32_t kBlockSizes[] = {
        // 4-increments
        4,  8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48,
        52, 56, 60, 64, 68, 72, 76, 80, 84, 88, 92, 96, 

        // 32-increments
        128, 160, 192, 224, 256, 288, 320, 352, 384, 
        416, 448, 480, 512, 544, 576, 608, 640, 

        // 64-increments
        704, 768, 832, 896, 960, 1024
    };

    static const uint32_t kPageSize  = 8192;
    static const uint32_t kAlignment = 4;

    // number of elements in the block size array
    static const uint32_t kNumBlockSizes = 
        sizeof(kBlockSizes) / sizeof(kBlockSizes[0]);

    // largest valid block size
    static const uint32_t kMaxBlockSize = 
        kBlockSizes[kNumBlockSizes - 1];
}

int My::MemoryManager::Initialize()
{
    // one-time initialization
    static bool s_bInitialized = false;
    if (!s_bInitialized) {
        // initialize block size lookup table
        m_pBlockSizeLookup = new size_t[kMaxBlockSize + 1];
        size_t j = 0;
        for (size_t i = 0; i <= kMaxBlockSize; i++) {
            if (i > kBlockSizes[j]) ++j;
            m_pBlockSizeLookup[i] = j;
        }

        // initialize the allocators
        m_pAllocators = new Allocator[kNumBlockSizes];
        for (size_t i = 0; i < kNumBlockSizes; i++) {
            m_pAllocators[i].Reset(kBlockSizes[i], kPageSize, kAlignment);
        }

        s_bInitialized = true;
    }

    return 0;
}

void My::MemoryManager::Finalize()
{
    delete[] m_pAllocators;
    delete[] m_pBlockSizeLookup;
}

void My::MemoryManager::Tick()
{
}

Allocator* My::MemoryManager::LookUpAllocator(size_t size)
{

    // check eligibility for lookup
    if (size <= kMaxBlockSize)
        return m_pAllocators + m_pBlockSizeLookup[size];
    else
        return nullptr;
}

void* My::MemoryManager::Allocate(size_t size)
{
    Allocator* pAlloc = LookUpAllocator(size);
    if (pAlloc)
        return pAlloc->Allocate();
    else
        return malloc(size);
}

void My::MemoryManager::Free(void* p, size_t size)
{
    Allocator* pAlloc = LookUpAllocator(size);
    if (pAlloc)
        pAlloc->Free(p);
    else
        free(p);
}

参考引用

  1. Memory Management part 2 of 3: C-Style Interface | Ming-Lun “Allen” Chou
  2. How tcmalloc Works
  3. Memory management
  4. operator new, operator new[]

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

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

如上一篇预告的,本篇我们对游戏引擎当中的内存管理进行一些初步的探讨。
首先,关于游戏引擎内存管理的必要性,除了为了实现加载远大于物理内存容量的内容(比如开放世界游戏)之外,还有很多性能和调试方面的考虑。关于这方面在参考引用1当中有比较详细且风趣的阐述。
当然参考引用1成文时间是差不多两年以前,很多参数在今天看来已经有了一些变化。比如当代CPU和内存之间的带宽一般在几十个Gbps,而GPU与内存(显存)之间的带宽已经飙升到几百个Gbps的水准。但是这并没有改变内存访问依然远远落后于CPU/GPU计算能力的状况。
而且尤为重要的是,对于游戏引擎(运行时)来说,一切都是数个毫秒(VR游戏要求120fps)到十数个毫秒(60fps),最多也就是33毫秒(30fps)的人生的轮回。在这样的系统当中,每一个毫秒都弥足珍贵,都值得我们去拼。(当然,就如我们前一篇指出的,也并不是所有的处理都需要按照这个节奏去跑)
这也回答了在本系列为什么采用C/C++这种“中级语言”进行编程。因为这是一个在性能/控制力/可维护性上比较理想的折衷点。
这里我补充一下关于malloc/new的知识,为什么说它们比较低效。
我们知道,操作系统的主要功能就是管理计算机系统的各种硬件资源。应用程序需要使用硬件资源到时候,需要向操作系统进行申请。而这种申请的接口,就被称为系统调用。

在近代操作系统当中,出于安全方面的考虑,操作系统与用户程序不是跑在一个级别上的。操作系统拥有所有的特权,而用户程序只是跑在操作系统提供的一个虚拟环境之上。用户程序看到的内存地址并不是真正的物理内存地址,而是一个虚拟的地址空间。这个地址空间是完全为用户程序定制的,不同的用户程序,即使这个地址一样,也不是指向同一个物理内存(或者分页文件)地址。

因此,当我们调用malloc/new进行heap分配的时候,并不是我们的线程直接杀入内核,去领一块内存出来。而是我们提交一个申领申请,放在放申请单的盒子里,然后等。操作系统方面按顺序处理这些申请,处理完了将处理结果放在处理结果盒子里,然后叫我们的号让我们去领。这个过程和我们在生活当中到特权机关去办事很类似。

虽然这些系统API调用看起来都是同步的,但实际上这是一个异步操作,只不过在操作完成之前,我们的线程会被block住,操作完成了,线程unblock,函数返回,看起来就像普通函数调用那样,其实这是一个比较复杂的过程。

而且在这个过程当中的参数传递,一般情况下都会发生拷贝。这是因为操作系统和用户程序分别工作在不同的地址空间,因此直接传递指针(地址)也是没有什么意义的。

因此,提高程序在CPU端的执行效率的一个重要手段,就是要减少系统调用。在程序初始化阶段就一次申领所需的资源,然后自己内部进行分配管理,这就是一种常用的减少系统调用的方法。

参考引用4提供了一种基于块链(block chain)的内存管理方法。我们首先将它引入到我们的引擎当中。这部分属于引擎的核心内容,因此我们将文件创建在Framework/Common下面:

Allocator.hpp

#include <cstddef>
#include <cstdint>

namespace My {

    struct BlockHeader {
        // union-ed with data
        BlockHeader* pNext;
    };

    struct PageHeader {
        PageHeader* pNext;
        BlockHeader* Blocks() {
                return reinterpret_cast<BlockHeader*>(this + 1);
        }
    };

    class Allocator {
        public:
                // debug patterns
                static const uint8_t PATTERN_ALIGN = 0xFC;
                static const uint8_t PATTERN_ALLOC = 0xFD;
                static const uint8_t PATTERN_FREE  = 0xFE;

                Allocator(size_t data_size, size_t page_size, size_t alignment);
                ~Allocator();

                // resets the allocator to a new configuration
                void Reset(size_t data_size, size_t page_size, size_t alignment);

                // alloc and free blocks
                void* Allocate();
                void  Free(void* p);
                void  FreeAll();
        private:
#if defined(_DEBUG)
                // fill a free page with debug patterns
                void FillFreePage(PageHeader* pPage);

                // fill a block with debug patterns
                void FillFreeBlock(BlockHeader* pBlock);

                // fill an allocated block with debug patterns
                void FillAllocatedBlock(BlockHeader* pBlock);
#endif

                // gets the next block
                BlockHeader* NextBlock(BlockHeader* pBlock);

                // the page list
                PageHeader* m_pPageList;

                // the free block list
                BlockHeader* m_pFreeList;

                size_t      m_szDataSize;
                size_t      m_szPageSize;
                size_t      m_szAlignmentSize;
                size_t      m_szBlockSize;
                uint32_t    m_nBlocksPerPage;

                // statistics
                uint32_t    m_nPages;
                uint32_t    m_nBlocks;
                uint32_t    m_nFreeBlocks;

                // disable copy & assignment
                Allocator(const Allocator& clone);
                Allocator &operator=(const Allocator &rhs);
    };
}

上面的代码相对于参考引用4的改动主要是下面几个方面:

  1. 根据我们引擎整体的命名风格调整了变量的名字
  2. 使用更为明确的数据类型,比如uint32_t,以及移植性更好的数据类型,如size_t
  3. 添加了必须且跨平台的C++标准头文件
  4. 使用预编译指令将用于调试的代码标出,只在调试版本当中编译这些代码

Allocator.cpp

#include "Allocator.hpp"
#include <cassert>
#include <cstring>

#ifndef ALIGN
#define ALIGN(x, a)         (((x) + ((a) - 1)) & ~((a) - 1))
#endif

using namespace My;

My::Allocator::Allocator(size_t data_size, size_t page_size, size_t alignment)
        : m_pPageList(nullptr), m_pFreeList(nullptr)
{
    Reset(data_size, page_size, alignment);
}

My::Allocator::~Allocator()
{
    FreeAll();
}

void My::Allocator::Reset(size_t data_size, size_t page_size, size_t alignment)
{
    FreeAll();

    m_szDataSize = data_size;
    m_szPageSize = page_size;

    size_t minimal_size = (sizeof(BlockHeader) > m_szDataSize) ? sizeof(BlockHeader) : m_szDataSize;
    // this magic only works when alignment is 2^n, which should general be the case
    // because most CPU/GPU also requires the aligment be in 2^n
    // but still we use a assert to guarantee it
#if defined(_DEBUG)
    assert(alignment > 0 && ((alignment & (alignment-1))) == 0);
#endif
    m_szBlockSize = ALIGN(minimal_size, alignment);

    m_szAlignmentSize = m_szBlockSize - minimal_size;

    m_nBlocksPerPage = (m_szPageSize - sizeof(PageHeader)) / m_szBlockSize;
}

void* My::Allocator::Allocate()
{
    if (!m_pFreeList) {
        // allocate a new page
        PageHeader* pNewPage = reinterpret_cast<PageHeader*>(new uint8_t[m_szPageSize]);
        ++m_nPages;
        m_nBlocks += m_nBlocksPerPage;
        m_nFreeBlocks += m_nBlocksPerPage;

#if defined(_DEBUG)
        FillFreePage(pNewPage);
#endif

        if (m_pPageList) {
            pNewPage->pNext = m_pPageList;
        }

        m_pPageList = pNewPage;

        BlockHeader* pBlock = pNewPage->Blocks();
        // link each block in the page
        for (uint32_t i = 0; i < m_nBlocksPerPage; i++) {
            pBlock->pNext = NextBlock(pBlock);
            pBlock = NextBlock(pBlock);
        }
        pBlock->pNext = nullptr;

        m_pFreeList = pNewPage->Blocks();
    }

    BlockHeader* freeBlock = m_pFreeList;
    m_pFreeList = m_pFreeList->pNext;
    --m_nFreeBlocks;

#if defined(_DEBUG)
    FillAllocatedBlock(freeBlock);
#endif

    return reinterpret_cast<void*>(freeBlock);
}

void My::Allocator::Free(void* p)
{
    BlockHeader* block = reinterpret_cast<BlockHeader*>(p);

#if defined(_DEBUG)
    FillFreeBlock(block);
#endif

    block->pNext = m_pFreeList;
    m_pFreeList = block;
    ++m_nFreeBlocks;
}

void My::Allocator::FreeAll()
{
    PageHeader* pPage = m_pPageList;
    while(pPage) {
        PageHeader* _p = pPage;
        pPage = pPage->pNext;

        delete[] reinterpret_cast<uint8_t*>(_p);
    }

    m_pPageList = nullptr;
    m_pFreeList = nullptr;

    m_nPages        = 0;
    m_nBlocks       = 0;
    m_nFreeBlocks   = 0;
}

#if defined(_DEBUG)
void My::Allocator::FillFreePage(PageHeader *pPage)
{
    // page header
    pPage->pNext = nullptr;
 
    // blocks
    BlockHeader *pBlock = pPage->Blocks();
    for (uint32_t i = 0; i < m_nBlocksPerPage; i++)
    {
        FillFreeBlock(pBlock);
        pBlock = NextBlock(pBlock);
    }
}
 
void My::Allocator::FillFreeBlock(BlockHeader *pBlock)
{
    // block header + data
    std::memset(pBlock, PATTERN_FREE, m_szBlockSize - m_szAlignmentSize);
 
    // alignment
    std::memset(reinterpret_cast<uint8_t*>(pBlock) + m_szBlockSize - m_szAlignmentSize, 
                PATTERN_ALIGN, m_szAlignmentSize);
}
 
void My::Allocator::FillAllocatedBlock(BlockHeader *pBlock)
{
    // block header + data
    std::memset(pBlock, PATTERN_ALLOC, m_szBlockSize - m_szAlignmentSize);
 
    // alignment
    std::memset(reinterpret_cast<uint8_t*>(pBlock) + m_szBlockSize - m_szAlignmentSize, 
                PATTERN_ALIGN, m_szAlignmentSize);
}
#endif
My::BlockHeader* My::Allocator::NextBlock(BlockHeader *pBlock)
{
    return reinterpret_cast<BlockHeader *>(reinterpret_cast<uint8_t*>(pBlock) + m_szBlockSize);
}

上面的代码相对于参考引用4的改动主要是下面几个方面:

  1. 反映了头文件当中的变化
  2. 使用了更为高效的对齐计算算法(有前提条件,具体见代码注释)

好了。接下来我们修改我们的CMakeLists.txt,加入新文件

C:UsersTim.AzureADSourceReposGameEngineFromScratch>gvim FrameworkCommonCMakeLists.txt
add_library(Common
Allocator.cpp
BaseApplication.cpp
GraphicsManager.cpp
main.cpp
)

然后我们就可以尝试编译看看,是否可以通过。具体的编译过程在文章5已经详细叙述过了,这里就不赘述。

但是事实上我们会需要不止一种的Memory Allocator。因为我们的程序当中会使用的对象有着不同的尺寸,我们无法使用一种固定的Block Size来满足各种各样的分配尺寸需求。因为如果Block Size过小,显然无法满足需要;如果过大,则是浪费。参考引用5当中也谈到了这一点。

此外,我们某些buffer是给CPU使用,有些是给GPU使用,有些是给两者使用。有些只需要高速地读,比如贴图;有些需要高速的写,比如Rendering Target;有些则需要保证同步,比如Fence。我们需要实现这些控制。

并且,如上一篇所述,我们的各个模块将采用一种异步并行的方式执行各自的任务。近代CPU都是多核的,我们需要充分地利用这个特性,就需要多线程。但是目前我们的Allocator还不是线程安全的。线程安全的代码需要一种排他锁定的机制,但是这种机制又往往是低效和容易带来死锁问题的。我们需要平衡这些问题。

我们将在后文继续讨论这些问题并改善我们的内存管理代码。

参考引用

  1. Writing a Game Engine from Scratch – Part 2: Memory
  2. Gallery of Processor Cache Effects
  3. Game Engine Architecture
  4. Memory Management part 1 of 3: The Allocator | Ming-Lun “Allen” Chou
  5. Memory Management part 2 of 3: C-Style Interface | Ming-Lun “Allen” Chou

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

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

到目前为止,我们学习了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 国际许可协议进行许可。

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

上一篇我们将我们的图形接口从DX11升级到了DX12。但是依然只能画一个三角形。本篇我们继续完善我们的代码,让它能够绘制一个面包圈。

因为目前我们还没有文件读写的模块,为了让代码尽量简洁,我们采用实时生成的方式生成我们的面包圈顶点数据。这会需要一些线性空间的计算,所以我们重新加入之前画立方体用过的数学库。同时,我们定义了一个叫SimpleMesh的类,用来存储我们生成的模型数据。

#include "DirectXMath.h"
#include "Mesh.h"

我们还需要申请更多的堆,用来存放我们的顶点数据。我们还将使用贴图,以及进行动画。因此我们也需要为贴图(包括采样器)和动画数据(常量)申请堆。我们首先增加保存这些配置信息的全局变量。

ComPtr<ID3D12DescriptorHeap>    g_pDsvHeap;                         // an array of descriptors of GPU objects
ComPtr<ID3D12DescriptorHeap>    g_pCbvSrvHeap;                      // an array of descriptors of GPU objects
ComPtr<ID3D12DescriptorHeap>    g_pSamplerHeap;                     // an array of descriptors of GPU objects
ComPtr<ID3D12Resource>          g_pIndexBuffer;                     // the pointer to the vertex buffer
D3D12_INDEX_BUFFER_VIEW         g_IndexBufferView;                  // a view of the vertex buffer
ComPtr<ID3D12Resource>          g_pTextureBuffer;                   // the pointer to the texture buffer
ComPtr<ID3D12Resource>          g_pDepthStencilBuffer;              // the pointer to the depth stencil buffer
ComPtr<ID3D12Resource>          g_pConstantUploadBuffer;            // the pointer to the depth stencil buffer

创建3D模型数据

我们将引入光照,并根据物体表面法线进行局部光照计算。我们首先需要修改我们的顶点数据结构,使其包括法线和切线数据。同时我们要进行贴图,因此还需要位置保存贴图坐标(uv)

一个面包圈模型实际上是由一个绕Z轴旋转的向量和一个绕Y轴旋转的向量组合而成的。因此我们用一个双重循环来生成它。

Torus – Wikipedia

struct SimpleMeshVertex
{
    XMFLOAT3    m_position;
    XMFLOAT3    m_normal;
    XMFLOAT4    m_tangent;
    XMFLOAT2    m_uv;
};

SimpleMesh torus;
void BuildTorusMesh(
                float outerRadius, float innerRadius, 
                uint16_t outerQuads, uint16_t innerQuads, 
                float outerRepeats, float innerRepeats,
                SimpleMesh* pDestMesh) 
{
    const uint32_t outerVertices = outerQuads + 1;
    const uint32_t innerVertices = innerQuads + 1;
    const uint32_t vertices = outerVertices * innerVertices;
    const uint32_t numInnerQuadsFullStripes = 1;
    const uint32_t innerQuadsLastStripe = 0;
    const uint32_t triangles = 2 * outerQuads * innerQuads; // 2 triangles per quad

    pDestMesh->m_vertexCount            = vertices;
    pDestMesh->m_vertexStride           = sizeof(SimpleMeshVertex);
    pDestMesh->m_vertexAttributeCount   = kVertexElemCount;
    pDestMesh->m_vertexBufferSize       = pDestMesh->m_vertexCount * pDestMesh->m_vertexStride;

    pDestMesh->m_indexCount             = triangles * 3;            // 3 vertices per triangle
    pDestMesh->m_indexType              = IndexSize::kIndexSize16;  // whenever possible, use smaller index 
                                                                    // to save memory and enhance cache performance.
    pDestMesh->m_primitiveType          = PrimitiveType::kPrimitiveTypeTriList;
    pDestMesh->m_indexBufferSize        = pDestMesh->m_indexCount * sizeof(uint16_t);

    // build vertices 
    pDestMesh->m_vertexBuffer = new uint8_t[pDestMesh->m_vertexBufferSize];

    SimpleMeshVertex* outV = static_cast<SimpleMeshVertex*>(pDestMesh->m_vertexBuffer);
    const XMFLOAT2 textureScale = XMFLOAT2(outerRepeats / (outerVertices - 1.0f), innerRepeats / (innerVertices - 1.0f));
    for (uint32_t o = 0; o < outerVertices; ++o)
    {
        const float outerTheta = o * 2 * XM_PI / (outerVertices - 1);
        const XMMATRIX outerToWorld = XMMatrixTranslation(outerRadius, 0, 0) * XMMatrixRotationZ(outerTheta);

        for (uint32_t i = 0; i < innerVertices; ++i)
        {
            const float innerTheta = i * 2 * XM_PI / (innerVertices - 1);
            const XMMATRIX innerToOuter = XMMatrixTranslation(innerRadius, 0, 0) * XMMatrixRotationY(innerTheta);
            const XMMATRIX localToWorld = innerToOuter * outerToWorld;
            XMVECTOR v = XMVectorSet(0.0f, 0.0f, 0.0f, 1.0f);
            v = XMVector4Transform(v, localToWorld);
            XMStoreFloat3(&outV->m_position, v);
            v = XMVectorSet(1.0f, 0.0f, 0.0f, 0.0f);
            v = XMVector4Transform(v, localToWorld);
            XMStoreFloat3(&outV->m_normal, v);
            v = XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f);
            v = XMVector4Transform(v, localToWorld);
            XMStoreFloat4(&outV->m_tangent, v);
            outV->m_uv.x = o * textureScale.x;
            outV->m_uv.y = i * textureScale.y;
            ++outV;
        }
    }

    // build indices
    pDestMesh->m_indexBuffer = new uint8_t[pDestMesh->m_indexBufferSize];

    uint16_t* outI = static_cast<uint16_t*>(pDestMesh->m_indexBuffer);
    uint16_t const numInnerQuadsStripes = numInnerQuadsFullStripes + (innerQuadsLastStripe > 0 ? 1 : 0);
    for (uint16_t iStripe = 0; iStripe < numInnerQuadsStripes; ++iStripe)
    {
        uint16_t const innerVertex0 = iStripe * innerQuads;

        for (uint16_t o = 0; o < outerQuads; ++o)
        {
            for (uint16_t i = 0; i < innerQuads; ++i)
            {
                const uint16_t index[4] = {
                    static_cast<uint16_t>((o + 0) * innerVertices + innerVertex0 + (i + 0)),
                    static_cast<uint16_t>((o + 0) * innerVertices + innerVertex0 + (i + 1)),
                    static_cast<uint16_t>((o + 1) * innerVertices + innerVertex0 + (i + 0)),
                    static_cast<uint16_t>((o + 1) * innerVertices + innerVertex0 + (i + 1)),
                };
                outI[0] = index[0];
                outI[1] = index[2];
                outI[2] = index[1];
                outI[3] = index[1];
                outI[4] = index[2];
                outI[5] = index[3];
                outI += 6;
            }
        }
    }
}

接下来,我们需要通知GPU我们全新的顶点数据结构。这是靠更新相关的DESCRIPTOR来实现的。

// create the input layout object
    D3D12_INPUT_ELEMENT_DESC ied[] =
    {
        {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0},
        {"NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0},
        {"TANGENT", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 24, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0},
        {"TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 40, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0},
    };

我们还需要在我们申请的堆当中分出一块缓冲区,用来将顶点索引数据传给GPU。在DX12当中,这些缓冲区的创建过程都是极为类似的。

// create index buffer
    {
       ThrowIfFailed(g_pDev->CreateCommittedResource(
           &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
           D3D12_HEAP_FLAG_NONE,
           &CD3DX12_RESOURCE_DESC::Buffer(torus.m_indexBufferSize),
           D3D12_RESOURCE_STATE_COPY_DEST,
           nullptr,
           IID_PPV_ARGS(&g_pIndexBuffer)));
    
       ThrowIfFailed(g_pDev->CreateCommittedResource(
           &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
           D3D12_HEAP_FLAG_NONE,
           &CD3DX12_RESOURCE_DESC::Buffer(torus.m_indexBufferSize),
           D3D12_RESOURCE_STATE_GENERIC_READ,
           nullptr,
           IID_PPV_ARGS(&pIndexBufferUploadHeap)));
    
       // Copy data to the intermediate upload heap and then schedule a copy 
       // from the upload heap to the vertex buffer.
       D3D12_SUBRESOURCE_DATA indexData = {};
       indexData.pData      = torus.m_indexBuffer;
       indexData.RowPitch   = torus.m_indexType;
       indexData.SlicePitch = indexData.RowPitch;
    
       UpdateSubresources<1>(g_pCommandList.Get(), g_pIndexBuffer.Get(), pIndexBufferUploadHeap.Get(), 0, 0, 1, &indexData);
       g_pCommandList->ResourceBarrier(1, 
                       &CD3DX12_RESOURCE_BARRIER::Transition(g_pIndexBuffer.Get(),
                               D3D12_RESOURCE_STATE_COPY_DEST,
                               D3D12_RESOURCE_STATE_INDEX_BUFFER));
    
       // initialize the vertex buffer view
       g_IndexBufferView.BufferLocation = g_pIndexBuffer->GetGPUVirtualAddress();
       g_IndexBufferView.Format         = DXGI_FORMAT_R16_UINT;
       g_IndexBufferView.SizeInBytes    = torus.m_indexBufferSize;
    }

最后,我们需要在录制绘图指令的时候,告诉GPU我们的顶点索引数据缓存区的地址。

g_pCommandList->IASetIndexBuffer(&g_IndexBufferView);

创建常量

为了让我们的模型动起来,我们需要创建常量,也就是MVP矩阵,以及光照数据。我们首先定义常量的结构。

struct SimpleConstants
{
    XMFLOAT4X4  m_modelView;
    XMFLOAT4X4  m_modelViewProjection;
    XMFLOAT4    m_lightPosition;
    XMFLOAT4    m_lightColor;
    XMFLOAT4    m_ambientColor;
    XMFLOAT4    m_lightAttenuation;
};

uint8_t*    g_pCbvDataBegin = nullptr;
SimpleConstants g_ConstantBufferData;

然后我们定义一些全局变量,用来记住计算MVP所需要的几个重要的变换矩阵。并且写一个初始化函数,对其进行初始化。

XMMATRIX g_mWorldToViewMatrix;
XMMATRIX g_mViewToWorldMatrix;
XMMATRIX g_mLightToWorldMatrix;
XMMATRIX g_mProjectionMatrix;
XMMATRIX g_mViewProjectionMatrix;

void InitConstants() {
    g_mViewToWorldMatrix = XMMatrixIdentity();
    const XMVECTOR lightPositionX = XMVectorSet(-1.5f, 4.0f, 9.0f, 1.0f);
    const XMVECTOR lightTargetX   = XMVectorSet( 0.0f, 0.0f, 0.0f, 1.0f);
    const XMVECTOR lightUpX       = XMVectorSet( 0.0f, 1.0f, 0.0f, 0.0f);
    g_mLightToWorldMatrix = XMMatrixInverse(nullptr, XMMatrixLookAtRH(lightPositionX, lightTargetX, lightUpX));

    const float g_depthNear = 1.0f;
    const float g_depthFar  = 100.0f;
    const float aspect      = static_cast<float>(nScreenWidth)/static_cast<float>(nScreenHeight);
    g_mProjectionMatrix  = XMMatrixPerspectiveOffCenterRH(-aspect, aspect, -1, 1, g_depthNear, g_depthFar);
    const XMVECTOR eyePos         = XMVectorSet( 0.0f, 0.0f, 2.5f, 1.0f);
    const XMVECTOR lookAtPos      = XMVectorSet( 0.0f, 0.0f, 0.0f, 1.0f);
    const XMVECTOR upVec          = XMVectorSet( 0.0f, 1.0f, 0.0f, 0.0f);
    g_mWorldToViewMatrix = XMMatrixLookAtRH(eyePos, lookAtPos, upVec);
    g_mViewToWorldMatrix = XMMatrixInverse(nullptr, g_mWorldToViewMatrix);

    g_mViewProjectionMatrix = g_mWorldToViewMatrix * g_mProjectionMatrix;
}

我们必须逐帧更新这些常量,也就是改变模型的位置,从而产生动画效果。

// this is the function used to update the constants
void Update()
{
    const float rotationSpeed = XM_PI * 2.0 / 120;
    static float rotationAngle = 0.0f;
    
    rotationAngle += rotationSpeed;
    if (rotationAngle >= XM_PI * 2.0) rotationAngle -= XM_PI * 2.0;
    const XMMATRIX m = XMMatrixRotationRollPitchYaw(rotationAngle, rotationAngle, 0.0f);
    XMStoreFloat4x4(&g_ConstantBufferData.m_modelView, XMMatrixTranspose(m * g_mWorldToViewMatrix));
    XMStoreFloat4x4(&g_ConstantBufferData.m_modelViewProjection, XMMatrixTranspose(m * g_mViewProjectionMatrix));
    XMVECTOR v = XMVectorSet(0.0f, 0.0f, 0.0f, 1.0f);
    v = XMVector4Transform(v, g_mLightToWorldMatrix);
    v = XMVector4Transform(v, g_mWorldToViewMatrix);
    XMStoreFloat4(&g_ConstantBufferData.m_lightPosition, v);
    g_ConstantBufferData.m_lightColor       = XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f);
    g_ConstantBufferData.m_ambientColor     = XMFLOAT4(0.0f, 0.0f, 0.7f, 1.0f);
    g_ConstantBufferData.m_lightAttenuation = XMFLOAT4(1.0f, 0.0f, 0.0f, 0.0f);
    
    memcpy(g_pCbvDataBegin, &g_ConstantBufferData, sizeof(g_ConstantBufferData));
}

同样的,我们需要创建一块CPU和GPU都能看到的缓冲区,用来向GPU传递这个常量。

	// Create the constant buffer.
	{
        size_t sizeConstantBuffer = (sizeof(SimpleConstants) + 255) & ~255; // CB size is required to be 256-byte aligned.
		ThrowIfFailed(g_pDev->CreateCommittedResource(
			&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
			D3D12_HEAP_FLAG_NONE,
			&CD3DX12_RESOURCE_DESC::Buffer(sizeConstantBuffer),
			D3D12_RESOURCE_STATE_GENERIC_READ,
			nullptr,
			IID_PPV_ARGS(&g_pConstantUploadBuffer)));

        for (uint32_t i = 0; i < nFrameCount; i++)
        {
            CD3DX12_CPU_DESCRIPTOR_HANDLE cbvHandle(g_pCbvSrvHeap->GetCPUDescriptorHandleForHeapStart(), i + 1, g_nCbvSrvDescriptorSize);
		    // Describe and create a constant buffer view.
		    D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc = {};
		    cbvDesc.BufferLocation = g_pConstantUploadBuffer->GetGPUVirtualAddress();
		    cbvDesc.SizeInBytes = sizeConstantBuffer;
		    g_pDev->CreateConstantBufferView(&cbvDesc, cbvHandle);
        }

		// Map and initialize the constant buffer. We don't unmap this until the
		// app closes. Keeping things mapped for the lifetime of the resource is okay.
		CD3DX12_RANGE readRange(0, 0);		// We do not intend to read from this resource on the CPU.
		ThrowIfFailed(g_pConstantUploadBuffer->Map(0, &readRange, reinterpret_cast<void**>(&g_pCbvDataBegin)));
	}
// 1 SRV + how many CBVs we have
    uint32_t nFrameResourceDescriptorOffset = 1 + g_nFrameIndex;
    CD3DX12_GPU_DESCRIPTOR_HANDLE cbvSrvHandle(g_pCbvSrvHeap->GetGPUDescriptorHandleForHeapStart(), nFrameResourceDescriptorOffset, g_nCbvSrvDescriptorSize);

    g_pCommandList->SetGraphicsRootDescriptorTable(2, cbvSrvHandle);

创建贴图

接下来让我们创建贴图。我们这里创建一个512×512的国际象棋棋盘格的基本贴图。

const uint32_t nTextureWidth = 512;
const uint32_t nTextureHeight = 512;
const uint32_t nTexturePixelSize = 4;       // R8G8B8A8

// Generate a simple black and white checkerboard texture.
uint8_t* GenerateTextureData()
{
    const uint32_t nRowPitch = nTextureWidth * nTexturePixelSize;
    const uint32_t nCellPitch = nRowPitch >> 3;		// The width of a cell in the checkboard texture.
    const uint32_t nCellHeight = nTextureWidth >> 3;	// The height of a cell in the checkerboard texture.
    const uint32_t nTextureSize = nRowPitch * nTextureHeight;
	uint8_t* pData = new uint8_t[nTextureSize];

	for (uint32_t n = 0; n < nTextureSize; n += nTexturePixelSize)
	{
		uint32_t x = n % nRowPitch;
		uint32_t y = n / nRowPitch;
		uint32_t i = x / nCellPitch;
		uint32_t j = y / nCellHeight;

		if (i % 2 == j % 2)
		{
			pData[n] = 0x00;		// R
			pData[n + 1] = 0x00;	// G
			pData[n + 2] = 0x00;	// B
			pData[n + 3] = 0xff;	// A
		}
		else
		{
			pData[n] = 0xff;		// R
			pData[n + 1] = 0xff;	// G
			pData[n + 2] = 0xff;	// B
			pData[n + 3] = 0xff;	// A
		}
	}

	return pData;
}

然后依然是在堆上创建缓冲区,用来传递贴图给GPU。

// Generate the texture
    uint8_t* pTextureData = GenerateTextureData();

    // Create the texture and sampler.
    {
        // Describe and create a Texture2D.
        D3D12_RESOURCE_DESC textureDesc = {};
        textureDesc.MipLevels = 1;
        textureDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
        textureDesc.Width = nTextureWidth;
        textureDesc.Height = nTextureHeight;
        textureDesc.Flags = D3D12_RESOURCE_FLAG_NONE;
        textureDesc.DepthOrArraySize = 1;
        textureDesc.SampleDesc.Count = 1;
        textureDesc.SampleDesc.Quality = 0;
        textureDesc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D;

        ThrowIfFailed(g_pDev->CreateCommittedResource(
            &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
            D3D12_HEAP_FLAG_NONE,
            &textureDesc,
            D3D12_RESOURCE_STATE_COPY_DEST,
            nullptr,
            IID_PPV_ARGS(&g_pTextureBuffer)));

        const UINT subresourceCount = textureDesc.DepthOrArraySize * textureDesc.MipLevels;
        const UINT64 uploadBufferSize = GetRequiredIntermediateSize(g_pTextureBuffer.Get(), 0, subresourceCount);

        ThrowIfFailed(g_pDev->CreateCommittedResource(
            &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
            D3D12_HEAP_FLAG_NONE,
            &CD3DX12_RESOURCE_DESC::Buffer(uploadBufferSize),
            D3D12_RESOURCE_STATE_GENERIC_READ,
            nullptr,
            IID_PPV_ARGS(&pTextureUploadHeap)));

        // Copy data to the intermediate upload heap and then schedule a copy 
        // from the upload heap to the Texture2D.
        D3D12_SUBRESOURCE_DATA textureData = {};
        textureData.pData = pTextureData;
        textureData.RowPitch = nTextureWidth * nTexturePixelSize;
        textureData.SlicePitch = textureData.RowPitch * nTextureHeight;

        UpdateSubresources(g_pCommandList.Get(), g_pTextureBuffer.Get(), pTextureUploadHeap.Get(), 0, 0, subresourceCount, &textureData);
        g_pCommandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(g_pTextureBuffer.Get(), D3D12_RESOURCE_STATE_COPY_DEST, D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE));

        // Describe and create a sampler.
        D3D12_SAMPLER_DESC samplerDesc = {};
        samplerDesc.Filter = D3D12_FILTER_MIN_MAG_MIP_LINEAR;
        samplerDesc.AddressU = D3D12_TEXTURE_ADDRESS_MODE_WRAP;
        samplerDesc.AddressV = D3D12_TEXTURE_ADDRESS_MODE_WRAP;
        samplerDesc.AddressW = D3D12_TEXTURE_ADDRESS_MODE_WRAP;
        samplerDesc.MinLOD = 0;
        samplerDesc.MaxLOD = D3D12_FLOAT32_MAX;
        samplerDesc.MipLODBias = 0.0f;
        samplerDesc.MaxAnisotropy = 1;
        samplerDesc.ComparisonFunc = D3D12_COMPARISON_FUNC_ALWAYS;
        g_pDev->CreateSampler(&samplerDesc, g_pSamplerHeap->GetCPUDescriptorHandleForHeapStart());

        // Describe and create a SRV for the texture.
        D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc = {};
        srvDesc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
        srvDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
        srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
        srvDesc.Texture2D.MipLevels = 1;
        CD3DX12_CPU_DESCRIPTOR_HANDLE srvHandle(g_pCbvSrvHeap->GetCPUDescriptorHandleForHeapStart());
        g_pDev->CreateShaderResourceView(g_pTextureBuffer.Get(), &srvDesc, srvHandle);
    }

更新Root Signature

Root Signature是DX12新增的概念。其实就好比我们代码头文件里面的函数申明,是向DX介绍我们资源的整体结构。因为我们增加了贴图和常量这两种类型的新资源,我们需要更新它。

  CD3DX12_DESCRIPTOR_RANGE1 ranges[3];
        ranges[0].Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 0, 0, D3D12_DESCRIPTOR_RANGE_FLAG_DATA_STATIC);
        ranges[1].Init(D3D12_DESCRIPTOR_RANGE_TYPE_SAMPLER, 1, 0);
        ranges[2].Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 6, 0, 0, D3D12_DESCRIPTOR_RANGE_FLAG_DATA_STATIC);

        CD3DX12_ROOT_PARAMETER1 rootParameters[3];
        rootParameters[0].InitAsDescriptorTable(1, &ranges[0], D3D12_SHADER_VISIBILITY_PIXEL);
        rootParameters[1].InitAsDescriptorTable(1, &ranges[1], D3D12_SHADER_VISIBILITY_PIXEL);
        rootParameters[2].InitAsDescriptorTable(1, &ranges[2], D3D12_SHADER_VISIBILITY_ALL);

为新增资源类型创建Heap

所有的图形资源缓冲区都需要在堆里面创建。每种资源对于堆的管理(包括内存读写保护,可执行标志,以及访问它的GPU模块)的要求不同,我们需要分别为他们申请堆。(贴图和常量是可以共用一个堆的)

// Describe and create a depth stencil view (DSV) descriptor heap.
    D3D12_DESCRIPTOR_HEAP_DESC dsvHeapDesc = {};
    dsvHeapDesc.NumDescriptors = 1;
    dsvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_DSV;
    dsvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
    ThrowIfFailed(g_pDev->CreateDescriptorHeap(&dsvHeapDesc, IID_PPV_ARGS(&g_pDsvHeap)));

    // Describe and create a shader resource view (SRV) and constant 
    // buffer view (CBV) descriptor heap.
    D3D12_DESCRIPTOR_HEAP_DESC cbvSrvHeapDesc = {};
    cbvSrvHeapDesc.NumDescriptors =
        nFrameCount                                     // FrameCount Cbvs.
        + 1;                                            // + 1 for the Srv(Texture).
    cbvSrvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
    cbvSrvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
    ThrowIfFailed(g_pDev->CreateDescriptorHeap(&cbvSrvHeapDesc, IID_PPV_ARGS(&g_pCbvSrvHeap)));

    // Describe and create a sampler descriptor heap.
    D3D12_DESCRIPTOR_HEAP_DESC samplerHeapDesc = {};
    samplerHeapDesc.NumDescriptors = 1;
    samplerHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_SAMPLER;
    samplerHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
    ThrowIfFailed(g_pDev->CreateDescriptorHeap(&samplerHeapDesc, IID_PPV_ARGS(&g_pSamplerHeap)));

    g_nCbvSrvDescriptorSize = g_pDev->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);

    ThrowIfFailed(g_pDev->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&g_pCommandAllocator)));

创建深度/模板缓冲区

我们需要打开深度测试来完成图形的隐藏面消隐

	// Create the depth stencil view.
	{
		D3D12_DEPTH_STENCIL_VIEW_DESC depthStencilDesc = {};
		depthStencilDesc.Format = DXGI_FORMAT_D32_FLOAT;
		depthStencilDesc.ViewDimension = D3D12_DSV_DIMENSION_TEXTURE2D;
		depthStencilDesc.Flags = D3D12_DSV_FLAG_NONE;

		D3D12_CLEAR_VALUE depthOptimizedClearValue = {};
		depthOptimizedClearValue.Format = DXGI_FORMAT_D32_FLOAT;
		depthOptimizedClearValue.DepthStencil.Depth = 1.0f;
		depthOptimizedClearValue.DepthStencil.Stencil = 0;

		ThrowIfFailed(g_pDev->CreateCommittedResource(
			&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
			D3D12_HEAP_FLAG_NONE,
			&CD3DX12_RESOURCE_DESC::Tex2D(DXGI_FORMAT_D32_FLOAT, nScreenWidth, nScreenHeight, 1, 0, 1, 0, D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL),
			D3D12_RESOURCE_STATE_DEPTH_WRITE,
			&depthOptimizedClearValue,
			IID_PPV_ARGS(&g_pDepthStencilBuffer)
			));

		g_pDev->CreateDepthStencilView(g_pDepthStencilBuffer.Get(), &depthStencilDesc, g_pDsvHeap->GetCPUDescriptorHandleForHeapStart());
	}
CD3DX12_CPU_DESCRIPTOR_HANDLE rtvHandle(g_pRtvHeap->GetCPUDescriptorHandleForHeapStart(), g_nFrameIndex, g_nRtvDescriptorSize);
    CD3DX12_CPU_DESCRIPTOR_HANDLE dsvHandle(g_pDsvHeap->GetCPUDescriptorHandleForHeapStart());
    g_pCommandList->OMSetRenderTargets(1, &rtvHandle, FALSE, &dsvHandle);
g_pCommandList->ClearDepthStencilView(g_pDsvHeap->GetCPUDescriptorHandleForHeapStart(), D3D12_CLEAR_FLAG_DEPTH, 1.0f, 0, 0, nullptr);

绘图

最后,更新我们的绘图指令,让其根据索引顺序进行绘制,而不是顶点缓冲区当中的顶点顺序。

  g_pCommandList->DrawIndexedInstanced(torus.m_indexCount, 1, 0, 0, 0);

Shader

由于我们改变了顶点数据结构,并且引进了光照,我们需要修改我们的Shader。为了方便,我们可以为VS Shader和PS Shader定义不同的入口函数(而不是缺省的main),这样就可以把两个Shader的代码放在一个文件当中。

让我们新创建一个simple.hlsl的文件,输入如下代码:

#include "cbuffer2.h"
#include "vsoutput2.hs"
#include "illum.hs"

v2p VSMain(a2v input) {
    v2p output;

	output.Position = mul(float4(input.Position.xyz, 1), m_modelViewProjection);
	float3 vN = normalize(mul(float4(input.Normal, 0), m_modelView).xyz);
	float3 vT = normalize(mul(float4(input.Tangent.xyz, 0), m_modelView).xyz);
	output.vPosInView = mul(float4(input.Position.xyz, 1), m_modelView).xyz;

	output.vNorm = vN;
	output.vTang = float4(vT, input.Tangent.w);

	output.TextureUV = input.TextureUV;

	return output;
}

SamplerState samp0 : register(s0);
Texture2D colorMap : register(t0);

float4 PSMain(v2p input) : SV_TARGET
{
	float3 lightRgb = m_lightColor.xyz;
	float4 lightAtten = m_lightAttenuation;
	float3 ambientRgb = m_ambientColor.rgb;
	float  specPow = 30;

	const float3 vN = input.vNorm;
	const float3 vT = input.vTang.xyz;
	const float3 vB = input.vTang.w * cross(vN, vT);
	float3 vL = m_lightPosition.xyz - input.vPosInView;
	const float3 vV = normalize(float3(0,0,0) - input.vPosInView);
	float d = length(vL); vL = normalize(vL);
	float attenuation = saturate(1.0f/(lightAtten.x + lightAtten.y * d + lightAtten.z * d * d) - lightAtten.w);

	float4 normalGloss = { 1.0f, 0.2f, 0.2f, 0.0f };
	normalGloss.xyz = normalGloss.xyz * 2.0f - 1.0f;
	normalGloss.y = -normalGloss.y; // normal map has green channel inverted

	float3 vBumpNorm = normalize(normalGloss.x * vT + normalGloss.y * vB + normalGloss.z * vN);
	float3 vGeomNorm = normalize(vN);

	float3 diff_col = colorMap.Sample(samp0, input.TextureUV.xy).xyz;
	float3 spec_col = 0.4 * normalGloss.w + 0.1;
	float3 vLightInts = attenuation * lightRgb * BRDF2_ts_nphong_nofr(vBumpNorm, vGeomNorm, vL, vV, diff_col, spec_col, specPow);
	vLightInts += (diff_col * ambientRgb);

	return float4(vLightInts, 1);
}

因为我们修改了入口函数,我们需要通知DX这个修改。具体是在我们代码当中动态Compile Shader的地方:

  D3DCompileFromFile(
        L"simple.hlsl",
        nullptr,
        D3D_COMPILE_STANDARD_FILE_INCLUDE,
        "VSMain",
        "vs_5_0",
        compileFlags,
        0,
        &vertexShader,
        &error);
    if (error) { OutputDebugString((LPCTSTR)error->GetBufferPointer()); error->Release(); throw std::exception(); }

    D3DCompileFromFile(
        L"simple.hlsl",
        nullptr,
        D3D_COMPILE_STANDARD_FILE_INCLUDE,
        "PSMain",
        "ps_5_0",
        compileFlags,
        0,
        &pixelShader,
        &error);
    if (error) { OutputDebugString((LPCTSTR)error->GetBufferPointer()); error->Release(); throw std::exception(); }

如果Shader的代码编译有问题,编译错误会通过OutputDebugString进行输出。这个输出在用Visual Studio调试的时候可以看到。

编译

编译命令如下:

调试版

D:wenliSourceReposGameEngineFromScratchPlatformWindows>cl /EHsc /Debug /Zi /D_DEBUG_SHADER -I./DirectXMath helloengine_d3d12.cpp user32.lib d3d12.lib d3dcompiler.lib dxgi.lib

执行版

D:wenliSourceReposGameEngineFromScratchPlatformWindows>cl /EHsc -I./DirectXMath helloengine_d3d12.cpp user32.lib d3d12.lib d3dcompiler.lib dxgi.lib

(本文完整代码在GitHub的branch article_16当中)

参考引用

  1. Torus – Wikipedia
  2. Microsoft/DirectX-Graphics-Samples

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