重新抽象图形API

这次抽象,我几乎全盘否定了之前的抽象

本来,RHI的抽象已经基本完成了,可以开心的写基础的光照阴影这些功能了。

但是,在QQ群里无意间看到大佬们聊起来bindless, 然后去查了查资料,发现bindless性能又好,抽象又好做,于是果断入bindless的坑。

在bindless抽象中,会把所有用到的材质参数,纹理全部放加载到显存,然后使用一个ID索引来引用相关的资源。

随着重构的进行,我发现之前的设计思路有很多问题,这都是因为我对GPU资源管理的不熟悉所致。

当我们向GPU上传纹理时或做更新资源时,由于GPU离CPU较远,所以Vulkan总是倾向于我们调用批量接口。

对!就像Nvidia说的那样,Batch! Batch! Batch!,但不仅仅局限于DrawCall, 转换内存布局,上传资源都需要Batch。

那么,此前做的所有抽象基本完全无用了。

在之前的抽象中,我都是基于同步的思路来做的,比如我new一个图片就需要等待他上传成功才能返回,不然结构体中的一些数据没有办法进行初始化。

经过仔细思考后,我认为此前提到的方案1其实是更好的选择。

数据冗余的问题也不难解决,只要我们把图形API层需要用到的数据下沉到图形API层的内部代码中,然后在RHI层的结构中做一个代理函数,通过gpu_handle来获取相关属性并返回即可。在实际应用中,由于width,height,format等属性属于低频访问,无论怎么封装,其实都不能成为性能瓶颈。

这样,新RHI层的数据结构只需要持有相应gpu_handle即可,这个结构有释放资源的责任。

比较微妙的是,由于bindless的存在,我们shader中的参数,如纹理,材质参数也都是通过id来索引的。

这样我们便直接把图形API层的资源管理透明掉了,RHI的数据结构可以直接和shader进行匹配,而不用关心图形API层是如何管理资源的。

透明掉图形API层之后,我们便拥有了更大的实现自由,比如在new rhi::texture2d时,图形API层只需要把上传到GPU时所需要的数据保存下来,然后分配一个gpu_handle, 函数就可以立即返回了。

在业务逻辑调用DrawCall之前,把需要用到的资源上传到GPU即可。

在实践中,由于DrawCall也是通过写入CommandBuffer中最终提交才异步执行的,因此我们只需要保证在CommandBuffer中,上传资源相关的GPU命令先于相关的DrawCall命令之前执行就可以了。

在Batch的指导思想下,其实使用gpu_handle的封装方式,能获得更好的Cache Friendly。因为所有图形API层的数据结构总是在渲染时,被批量访问。

而且,由于gpu_handle并不是指针,如果有需要还可以实现一些池的优化技术,如内存整理等(随着分配释放的进行,池中可能会有很多空位长时间用不到,这时出于内存和Cache Friendly的考虑,需要整理一个池中的元素的分布)。

最后,这次重构代码在此

ps. 由于一些原因,可能未来很长一段时间内,这个引擎的Thread都需要换出了,先在这里保存一下上下文,以便后面可以继续换入 😀

给Lua实现了一个数学库

我的玩具引擎计划以Lua语言为第一业务语言。

引擎可以加载出一个场景之后,我就需要一个相机控制器,来接收用户输入来移动和旋转相机,以实现场景漫游。

我打算使用Lua来编写这一逻辑。在计算相机的Transform时,需要进行一定的数学运算。这就需要一个Lua版的数学库

怎么给Lua写一个简洁高效的数学库,这并不是最近才开始思考的问题。

早些年在使用tolua框架时,就发现在Lua中进行数学计算时会产生大量的临时对象,极大的加重GC的负担。

虽然这两年时不时就会想起这个问题,也一直没有解决方案。

这次,在没有了Unity的包袱之后, 希望能找到一条全新的思路。

为什么在C#中写数学运算就不会产生GC呢。根本原因是,在C#中(vector3,quaternion,matrix)等对象都是struct类型,即值类型。这些对象都是在栈上分配,函数返回即销毁。就算当值返回,也是直接值拷贝出去的。

在Lua中,严格意义的值类型只有boolean,number两种类型。虽然string表现的像个值类型,但是临时string对象一样会产生内存垃圾。

所以我们一般实现vector3时,会使用Table或userdata来保存xyz。这是因为Lua中的值类型不足以装下xyz这么多数据。

一个很直觉的思路,我们能不能扩展Lua中的值类型,使他最多能包含xyzw四个字段。

答案是能,但是代价很大。内存的代价,性能的代价,以及维护的代价。

我仔细回忆了这几年有限的客户端经历,我发现数学运算都是扎堆的。

换句话说,我们的数学运算一般都是几个有限的输入和几处有限的输出。但是中间计算过程很复杂,只要解决了这些中间过程产生的临时变量,那也算基本符合预期了。

沿着这个思路,即然Lua中只有numbert和boolean是值类型,那我有没有可能用number来代表一个vector3或quaternion呢?

答案是肯定的。我们只需要用C实现一片额外的空间,然后用索引指向这个vector3或quaternion的值就大功告成了。

基于以上思路,我实现了一个数学栈。这个栈的范围只能在一个函数内使用。

如果你想将计算结果返回到另一个函数使用,你只能将栈中的值取出,然后显式返回给其他函数。

如果其他函数需要再次进行数学计算,就需要重新开辟一个数学栈空间。

大致用法如下:

local mathx = require "engine.math"
local camera_up = {x = 0, y = 0, z = 0}
local camera_forward = {x = 0, y = 0, z = 0}
local camera_right = {x = 0, y = 0, z = 0}

local stk<close> = mathx.begin()
print("rotation", component.get_quaternion(self))
local rot = stk:quaternion(component.get_quaternion(self))
local up = stk:mul(rot, stk:vector3f_up())
local forward = stk:mul(rot, stk:vector3f_forward())
local right = stk:mul(rot, stk:vector3f_right())
stk:save(up, camera_up)
stk:save(right, camera_right)
stk:save(forward, camera_forward)

首先使用math.begin()来创建一个数学栈,接着我们就可以在栈上进行各种数学计算。

当数学计算结束时,我们可以使用stk:save来取出数学栈中的xyzw的值。

stk:save有两种使用方式,当我们传入一个table时,stk会直接将xyzw的值置入table内。我们还可以不传入参数,这时stk:save就会根据值的类型返回xyz或xyzw的值。

这里使用了Lua的toclose特性, 当栈使用完之后,__close函数会自动将栈对象放入Cache中。

下次调用math.begin时,直接从Cache中分配,这样可以做到0内存分配。

在实现完这个库之后,我特意与xlua做了一个性能对比。

local stk<close> = math.begin()
local v1 = stk:vector3f(xx_v3_1)
local v2 = stk:vector3f(xx_v3_2)
v2 = stk:vector3f_cross(v1, v2)
v2 = stk:vector3f_cross(v1, v2)
v2 = stk:vector3f_cross(v1, v2)
v2 = stk:vector3f_cross(v1, v2)
stk:save(v2, xx_v3_2)

与同样逻辑的xlua写法对比,性能要高出300%左右。并且随着计算过程的增加,性能优势会越来越明显。

除此之外,在进行数学计算之前,我们往往需要获取到transform中的position,rotation,scale等属性。

这些属性要么是vector3, 要么是quaternion。如果我们用table或userdata来返回依然会加重GC负担。

仔细数一下其实这些数据类型最大也只有4个变量。因此,我让函数component.get_position直接返回xyz三个值,而component.get_rotation直接返回xyzw四个值。

至于是否需要存到table里,这个交由业务逻辑来控制。

谈谈跨平台图形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来创建渲染对象。

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

最后, 完整代码附上