谈谈跨平台图形API的抽象

本来按3月份的计划,是先把王者荣耀基本模式抄完 ,并以此为基础来抽象出一套基于Lua的通用客户端框架,然后根据需求再慢慢优化。

但是,3月底GAMES系列又出了一个新课GAMES104,《现代游戏引擎:从入门到实践》。

这门课一下子燃爆了我的兴趣,于是我决定暂停客户端框架的开发计划。学完GAMES104之后再回来继续开发客户端框架。

经过这几年的观察。我发现由于算力的缘故,很多高级的技术总是选应用于端游,然后再过很多年。才被用于手游开发(有时甚至还需要各种Trick才能跑得起来)。所以,要想学习和体验最新的引擎技术,最好还是通过端游引擎。

我打算趁着这次GAMES104的课程,写一个自己的引擎。

这个引擎应该使用最新的技术和最新的硬件特性。

这个引擎的业务逻辑语言为Lua。从表现力上讲,Lua要比C和C++强不少,虽然性能会慢一点,但是因为是实验性质的引擎,开发快反而会更重要。

这个引擎应该是跨平台的。虽然我的主要目标是端游,但是我也希望像在手机算力允许的情况下,可以在手机上玩耍。

我花了一周时间把vulkan教程上的例子抄了一遍(画一一个三角形,我竟然抄了3天半 ^_^!)。

然后就开始根据GAMES104的视频课程实现引擎了。

虽然第一版引擎以Vulkan图形API为基础,但是我还是希望能先抽象的个差不多的RHI(Render Hardware Interface), 为未来支持Direct3D和Metal打下基础。

这对我来讲很难,因为我没有任何Direct3D和Metal的基础,连Vulkan也只有一个星期的经验。

我还是想试一下。


一个最容易想到的方案是,为所有图形API设计相同的接口和相同的导出结构,然后使用宏来切换平台,这也正是RHI的表面含义.

伪码如下:

//-----rhi/texture.h------
namespace rhi {
    gpu_handle texture_create();
    texture_destroy(gpu_handle handle);
}
//-----rhi/texture.cpp-------
#ifdef RHI_VULKAN
#include "vulkan/texture.cpp"
#elseif RHI_DIRECT3D
#include "direct3d/texture.cpp"
#elseif RHI_METAL
#include "metal/texture.cpp"
#endif
//-----render/texture2d.h
namespace render {
    class texture2d {
    public:
        texture2d() {
            gpu_texture = texture_create();
        }
        ~texture2d() {
            texture_destroy(gpu_texture);
        }
    private:
        gpu_handle gpu_texture;
        int width;
        int height;
    }
}

但是这么做会有一些令人纠结的问题。

以texture2d为例,在Vulkan层面去使用texture时,部分情况是需要用到width和height,write_enable,filter_mode等属性,这时需要如何去获取这些属性。

这时有三种方案:

  • 第一种方案:在调用rhi::texture_create()时把所有需要用到的参数都传递过去,然后Vulkan层在内部保存供后面使用。这样做有两个坏处:数据冗余严重、需要额外的代码来将texture2d和gpu_texture之间的属性进行同步。

  • 第二种方案:在调用rhi::texture_create()时,直接把texture2d的this指针传递进去,Vulkan层在内部将gpu_texture和this进行绑定。Vulkan层在内部操作gpu_texture时可以通过这种绑定关系,查询到texture2d的指针,并读取相关设置信息。这么做同样也有坏处,首先是会产生循环引用,在render层textuer_2d引用了gpu_texture, 在vulkan层gpu_texture又引用了texture2d,然后是,因为rhi::texture_create的参数有了类型,那么就需要为每一种texture(textur2d, texture3d, cubemap)等添加一个texture_create/texture_destroy接口。

  • 第三种方案:在第二种方案的基础上,可以通过去掉gpu_handle的存在,来切断循环引用。

伪码如下:

//-----rhi/texture.h------
namespace rhi {
    bool texture_create(texture2d *tex);
    void texture_destroy(texture2d *tex);
}
//-----rhi/texture.cpp-------
#ifdef RHI_VULKAN
#include "vulkan/texture.cpp"
#elseif RHI_DIRECT3D
#include "direct3d/texture.cpp"
#elseif RHI_METAL
#include "metal/texture.cpp"
#endif
//-----render/texture2d.h
namespace render {
    class texture2d {
    public:
        texture2d() {
            rhi::texture_create(this);
        }
        ~texture2d() {
            rhi::texture_destroy(this);
        }
    private:
        int width;
        int height;
    }
}

当调用rhi::texture_create时,Vulkan层会创建一个texture的GPU资源,并这份GPU资源和texture2d指针进行绑定,但是这种绑定并不导出到外部接口使用。

后续操作某个GPU资源时,直接使用texture2d指针即可。

至于绑定方式,可以有多种多样,最简单直接方式就是使用unordered_map(显然性能并不会太高)。

第三种方案和第二种方案有一个通病,就是一个texture2d资源同时需要至少两个对象来表示,render层的texture2d和vulkan层的gpu_texture2d, 这会造成内存碎片问题。


花了2周时间反复重构了多次,都不太满意。

在重构过程中,我想到了一个全新的思路。

伪码如下:

//-----render/texture2d.h
namespace render {
    class texture2d {
    pubilc:
    static texture2d *create(int width, int height);
    static void destroy(texture2d *tex);
    protected:
        texture2d() {}
        ~texture2d() {}
    protected:
        gpu_handle gpu_texture;
        int width;
        int height;
    }
}

//-----vulkan/vk_texture2d.h
namespace vulkan {
    class vk_texture2d : public render::texture2d {
    public:
        vk_texture2d(int width, int height) : texture2d() {
            //todo some gpu create
        }
        ~vk_texture2d() {
            ~texture2d()
            //release some gpu resource
        }
    private:
        //some GPU-related resource
    }
}

//-----vulkan/vk_factory.h
namespace render {
    texture2d *texture2d::create(int width, int height) 
    {
        return new vk_texture2d(width, height);
    }
    void texture2d::destroy(texture2d *tex)
    {
        delete (vk_texture2d *)tex;
    }
}

在这次抽象中,我彻底去掉了RHI相关的所有中间层。而且几乎解决了上述方案中所有的缺点:内存碎片不存在了,循环引用没有了,GPU和CPU端数据的粘合逻辑消失了。

当然,这套抽象也有他自己的缺点:

但是,相比他能解决的问题,我觉得这两个问题都不算是大问题。

业务逻辑是使用Lua来做,所以本来也不会用到new来创建渲染对象。

少使用乃至不使用继承更是我一惯的坚持原则。

最后, 完整代码附上