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

接上一篇,我们来设计编写解析各种资源所需要的文件格式解析器。

游戏总的来说属于交互式多媒体,游戏引擎的runtime实际上也可以被看做一个多媒体播放器。通常情况下,游戏当中所包含的媒体文件可以大致划分为如下几个类型∶

  1. 静态图片(Image)。在一个2D的游戏当中,静态图片是构成游戏画面的主要媒体内容。在3D游戏当中,静态图片则往往被作为贴图使用。历史上静态图片发展出了很多种格式(参考引用1),大部分格式的出现是为了实现图片的设备(或者应用)无关性和存储(或者网络传输)尺寸的问题,相对的,在图片的加载速度方面作出了一定的牺牲。因此,在大多数当代的游戏引擎当中,大都采用平台专有的格式对图片进行存储,而不是采用(参考引用1)当中的这些通用格式。然而,由于图片素材大多来自于美术的DCC制作工具,因此游戏引擎runtime,或者是游戏引擎的资源导入工具需要支持至少一种通用格式;
  2. 动态图像,也称为视频。这类素材的典型应用是游戏的过场动画,也有作为贴图进行应用的时候。比如在游戏场景当中包括一个播放节目的电子广告屏或者电视,那么其贴图就很可能是一个视频。当用作贴图时,在游戏引擎的runtime当中,由于其自身是以一定的fps刷新画面的,所以视频是被视为一系列按描绘顺序排列的静止图片,在每一帧当中绘制过程与静止图片并没有什么差别;而当作为过场动画进行播放的时候,因为还需要同步处理音频数据,往往是将渲染缓冲区直接临时托管给视频播放器进行画面的输出;
  3. 音频文件。最为基本的是LPCM,也就是wave文件。这个文件当中包括了声音信号经过A/D采样转换之后的样本数据。其它的音频文件格式基本上就是对于这个样本的不同压缩算法。音频的播放一般比较独立,在一个专门的模块当中进行处理。在硬件层面也往往是一个独立的模块。当今大多数设备都能在休眠模式或者极低功耗的情况下播放音乐,就是因为音频处理有单独的硬件完成。也因为比较独立,这部分主要的挑战是对于播放时间点的掌握,如何与画面以及用户输入保持精准的同步。这里面需要考虑到其它处理带来的额外压力所造成的延时。比如我们在玩很多游戏的时候,当游戏处于加载画面的时候往往会出现声音不连续的情况,这就是因为大量的资源加载挤占了硬盘的读取队列,或者是因为相关线程被同步文件I/O暂时卡死了所导致的。
  4. 3D场景文件。这里所说的3D场景文件包括我们在文章二十四当中提到的场景地图以及场景物体和场景物体的组织结构,还包括挂载在场景物体上的各种组件。这部分相对上面来说是最没有得到标准化的部分。市面上大多数商业游戏引擎都使用了自己专有的格式,而DCC工具在这一部分也是使用着五花八门的格式。因此这部分需要我们编写相当程度的代码去进行各种格式的转换和导入。

文章二十五当中我们实现了文件的基本I/O。在将文件读取进内存之后,我们需要根据其格式规范对其进行解析,把其中我们感兴趣的数据提取出来并在内存上以一种方便我们引擎使用的方式进行展开。在这里我们会用到我们之前编写的内存管理模块,也会用到我们之前编写的数学库。

首先作为演示,让我们来写一个BMP文件的解析器。BMP文件的格式规范请参考(参考引用2)。

首先,我们需要在Framework/Common下面新建一个Image.hpp文件,在其中定义适用于我们的引擎的静止图片在内存上的结构:

#pragma once
#include "geommath.hpp"

namespace My {

    typedef struct _Image {
        uint32_t Width;
        uint32_t Height;
        R8G8B8A8Unorm* data;
        uint32_t bitcount;
        uint32_t pitch;
        size_t  data_size;
    } Image;

}

注意我们包含了geommath.hpp这个之前我们写的数学库,并使用其中的R8G8B8A8Unorm类型来保存静止图片当中的像素颜色数据。R8G8B8A8Unorm这个类型在文章二十三当中并没有出现。它是我在写这篇文章的时候新加入到geommath.hpp当中的,定义如下:

 typedef Vector4Type<uint8_t> R8G8B8A8Unorm;

可以看到它就是一个数据类型为uint8_t的Vector4Type(4维向量)。unorm的意思是Unsigned Normalized Integer(参考引用3),而R8G8B8A8表示数据包括RGBA四个颜色通道,每个通道是8个bit。它代表了一种在内存当中存储颜色的格式。

结构体当中的Width指贴图的宽度,而Height指贴图的高度。他们的单位都是像素。bitcount指一个像素在内存上占的尺寸(bit数),而pitch是指图形的一行在内存上的尺寸(byte数)。这两个值与位图本身的质量、在内存上的压缩格式以及内存对齐方式有关。

data_size则是data所指向的数据区域的尺寸。注意这个尺寸应该是(pitch * Height)而不是(Width * Height * bitcount/8),原因就是内存区域有对齐的问题,贴图每行的数据尺寸如果不满足内存对齐的要求在行尾会有padding。这是为了满足GPU寻址方面的要求。

好了,一个基本的Image结构我们定义好了,接下来我们定义ImageParser这个接口,来抽象化不同图片格式的解析过程:

#pragma once
#include "Interface.hpp"
#include "Image.hpp"
#include "Buffer.hpp"

namespace My {
    interface ImageParser
    {
    public:
        virtual Image Parse(const Buffer& buf) = 0;
    };
}

这个接口很简单,传一个Buffer进去,得到一个Image。唯一需要解释的是,我们的返回值是一个Image类型。也就是说,对于Image的内存分配是在这个接口内完成的。这似乎与我们之前所说的谁分配内存谁释放的原则不符。

首先,当然,我们可以把这个接口定义为

virtual int Parse(const Buffer& buf, Image* img) = 0;

这样就要求在实际调用Parse之前,先分配好Image对象。但是问题是,在我们实际解析文件内容之前,我们也不知道到底需要为Image的data成员分配多少内存。

在一些第三方库或者Win32 API当中,我们有时候会见到这么一种设计:

int size = Parse(buf, nullptr);
Image Img;
Img.data = new uint8_t[size];
Parse(buf, &Img);

就是首先带入nullptr,来告诉函数我们需要知道需要多少内存。函数通过返回值返回所需要的内存大小之后,创建好用于保存数据的对象,再次调用这个函数进行数据填充的操作。这个方法符合谁分配谁释放的原则,但是很不自然,效率也很低。

之所以出现这种设计,或者说要求遵守谁分配谁释放的原则,是因为截止到C++11之前,C++当中没有显式指定移动语义的方法。除非使用指针进行传递,当我们采用按值返回的时候,对象的拷贝构造函数会被调用。也就是说,如果我们将Parse定义为:

   virtual Image Parse(const Buffer& buf) = 0;

并且如下进行调用

Image img = Parse(buf);

那么从C++语义上,会发生一次对象的生成(等号左边变量,也就是左值分配内存),拷贝(从等号右边拷贝到左边),以及一次对象的析构(内存的释放)。但是从C++11开始,通过定义移动语义的构造函数,可以显式的规避这种拷贝。如下,带一个&的是左值拷贝构造函数和赋值重载,带两个&的是右值拷贝构造函数和赋值重载。在右值的版本当中,我们直接接管相关的buffer而不进行赋值。

     Buffer(const Buffer& rhs) {
            m_pData = reinterpret_cast<uint8_t*>(g_pMemoryManager->Allocate(rhs.m_szSize, rhs.m_szAlignment));
            memcpy(m_pData, rhs.m_pData, rhs.m_szSize);
            m_szSize =  rhs.m_szSize;
            m_szAlignment = rhs.m_szAlignment;
        }

        Buffer(Buffer&& rhs) {
            m_pData = rhs.m_pData;
            m_szSize = rhs.m_szSize;
            m_szAlignment = rhs.m_szAlignment;
            rhs.m_pData = nullptr;
            rhs.m_szSize = 0;
            rhs.m_szAlignment = 4;
        }

        Buffer& operator = (const Buffer& rhs) {
            if (m_szSize >= rhs.m_szSize && m_szAlignment == rhs.m_szAlignment) {
                memcpy(m_pData, rhs.m_pData, rhs.m_szSize);
            }
            else {
                if (m_pData) g_pMemoryManager->Free(m_pData, m_szSize);
                m_pData = reinterpret_cast<uint8_t*>(g_pMemoryManager->Allocate(rhs.m_szSize, rhs.m_szAlignment));
                memcpy(m_pData, rhs.m_pData, rhs.m_szSize);
                m_szSize =  rhs.m_szSize;
                m_szAlignment = rhs.m_szAlignment;
            }
            return *this;
        }

        Buffer& operator = (Buffer&& rhs) {
            if (m_pData) g_pMemoryManager->Free(m_pData, m_szSize);
            m_pData = rhs.m_pData;
            m_szSize = rhs.m_szSize;
            m_szAlignment = rhs.m_szAlignment;
            rhs.m_pData = nullptr;
            rhs.m_szSize = 0;
            rhs.m_szAlignment = 4;
            return *this;
        }

注意我上面强调了“C++语义上”。因为实际上即使我们不显式地进行这样的指定,当代的大部分C++编译器在对于按值返回的时候会默认地进行类似的优化。但是编译器自动进行的操作有的时候是不那么容易理解或者不见得是我们想要的结果,所以我们通过上面的方法进行显式的指定。

好了,接下来我们来写BMP文件的解析器。我们在Framework/之下创建一个新目录,名为Codec,然后从ImageParser派生出BmpParser类,来实现对于BMP文件的解析:

#pragma once
#include <iostream>
#include "ImageParser.hpp"

namespace My {
#pragma pack(push, 1)
    typedef struct _BITMAP_FILEHEADER {
        uint16_t Signature;
        uint32_t Size;
        uint32_t Reserved;
        uint32_t BitsOffset;
    } BITMAP_FILEHEADER;

#define BITMAP_FILEHEADER_SIZE 14

    typedef struct _BITMAP_HEADER {
        uint32_t HeaderSize;
        int32_t Width;
        int32_t Height;
        uint16_t Planes;
        uint16_t BitCount;
        uint32_t Compression;
        uint32_t SizeImage;
        int32_t PelsPerMeterX;
        int32_t PelsPerMeterY;
        uint32_t ClrUsed;
        uint32_t ClrImportant;
    } BITMAP_HEADER;
#pragma pack(pop)

    class BmpParser : implements ImageParser
    {
    public:
        virtual Image Parse(const Buffer& buf)
        {
            Image img;
            BITMAP_FILEHEADER* pFileHeader = reinterpret_cast<BITMAP_FILEHEADER*>(buf.m_pData);
            BITMAP_HEADER* pBmpHeader = reinterpret_cast<BITMAP_HEADER*>(buf.m_pData + BITMAP_FILEHEADER_SIZE);
            if (pFileHeader->Signature == 0x4D42 /* 'B''M' */) {
                std::cout << "Asset is Windows BMP file" << std::endl;
                std::cout << "BMP Header" << std::endl;
                std::cout << "----------------------------" << std::endl;
                std::cout << "File Size: " << pFileHeader->Size << std::endl;
                std::cout << "Data Offset: " << pFileHeader->BitsOffset << std::endl;
                std::cout << "Image Width: " << pBmpHeader->Width << std::endl;
                std::cout << "Image Height: " << pBmpHeader->Height << std::endl;
                std::cout << "Image Planes: " << pBmpHeader->Planes << std::endl;
                std::cout << "Image BitCount: " << pBmpHeader->BitCount << std::endl;
                std::cout << "Image Compression: " << pBmpHeader->Compression << std::endl;
                std::cout << "Image Size: " << pBmpHeader->SizeImage << std::endl;

                img.Width = pBmpHeader->Width;
                img.Height = pBmpHeader->Height;
                img.bitcount = 32;
                img.pitch = ((img.Width * img.bitcount >> 3) + 3) & ~3;
                img.data_size = img.pitch * img.Height;
                img.data = reinterpret_cast<R8G8B8A8Unorm*>(g_pMemoryManager->Allocate(img.data_size));
                if (img.bitcount < 24) {
                    std::cout << "Sorry, only true color BMP is supported at now." << std::endl;
                } else {
                    uint8_t* pSourceData = buf.m_pData + pFileHeader->BitsOffset;
                    for (int32_t y = img.Height - 1; y >= 0; y--) {
                        for (uint32_t x = 0; x < img.Width; x++) {
                            (img.data + img.Width * (img.Height - y - 1) + x)->bgra = *reinterpret_cast<R8G8B8A8Unorm*>(pSourceData + img.pitch * y + x * (im
g.bitcount >> 3));
                        }
                    }
                }
            }

            return img;
        }
    };
}

代码很简单,但是很容易出问题。因为涉及到很多关于内存顺序和对齐方面的考虑,这是在一些高级语言或者脚本语言编程的时候不太会接触到的。

我们首先是根据BMP规范(参考引用2)定义了两个结构体,与BMP文件头保持相同的结构。这里需要注意的就是下面这3个预编译命令:

#pragma pack(push, 1)
#define BITMAP_FILEHEADER_SIZE 14
#pragma pack(pop)

因为在缺省状态下,C/C++的结构体当中的成员是按照4字节对齐(PC。其它硬件平台可能不同)的。注意我们第一个结构体当中的Signature是uint16_t类型,也就是2个字节。如果我们不指定pack为1,那么下一个Size成员会从第5个字节开始,而不是第3个字节开始。那么我们读取出来的BMP头部数据就会不对。

同样的,我们定义了 BITMAP_FILEHEADER_SIZE 14,而不是使用sizeof(BITMAP_FILEHEADER),这也是因为结构体会有一个对齐的问题。在PC上,sizeof(BITMAP_FILEHEADER)很可能是16,而不是14。

其次,请注意下面这行代码:

      if (pFileHeader->Signature == 0x4D42 /* 'B''M' */) {

虽然注释里面写的是’B”M’,但是实际我们比较的值是0x4D42。0x4D是‘M’,而0x42是’B’,正好倒过来。这是因为我们将Signature定义为uint16_t,而x86是little endian,当按照字读入的时候,两个字节会发生交换。

而如果是PS3,那么因为PS3是big endian,那么就应该是0x424D了。因为目前我们要支持的平台不包括big endian的机器,所以这个地方就是按照little endian来写的。

第三个需要注意的地方是下面这里:

                 for (int32_t y = img.Height - 1; y >= 0; y--) {
                        for (uint32_t x = 0; x < img.Width; x++) {
                            (img.data + img.Width * (img.Height - y - 1) + x)->bgra = *reinterpret_cast<R8G8B8A8Unorm*>(pSourceData + img.pitch * y + x * (im
g.bitcount >> 3));
                        }
                    }

这里特别需要注意如何计算像素在两边的位置。BMP文件可能是24bit或者32bit的,需要注意这个差别。(BMP文件历史上还支持24bit以下的,也就是带调色板的格式,这个游戏当中很少用到,为了代码的简洁性我们没有进行支持)

另外上面这段代码包括乘法运算,是比较低效的。事实上这段代码完全可以避免乘法运算,并且可以用ispc并列化执行。这部分就放在后面进行吧。

好了,这样一个简单的BMP解析器就写好了。为了测试代码,我们需要写一个简单的测试应用来显示读入的贴图。我们趁这个机会将在文章十的DirectX 2D的代码整合进来。首先依然是在RHI目录下新建D2d目录,参照之前D3dRHI的方式将D2d的代码作为D2dRHI整合进来,然后在PlatformWindows下新建test目录,在其中创建TextureLoadTest.cpp进行贴图加载的测试。篇幅原因,代码就不在这里贴了,感兴趣的请参考Github article_26

测试应用执行的结果如下,加载的贴图放在项目根目录下的Asset目录当中。我们的AssetLoader会自动查找这个目录。

参考引用

  1. Image file formats
  2. BMP file format – Wikipedia
  3. Normalized Integer
  4. https://msdn.microsoft.com/en-us/library/windows/desktop/dd407212(v=vs.85).aspx


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

发表评论

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

WordPress.com 徽标

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

Facebook photo

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

Connecting to %s

%d 博主赞过: