RhinoFreak 发表于 2022-9-30 13:32

浅谈CommandBuffer

1. 概述

    有近一个月未更新文章了,思考了一些关于RHI编码层的一些相关问题(先提前说一下,本文不会再介绍一些关于RHI的一些基础知识了),因此迟迟未能更新。现在就简单的谈一谈关于CommandBuffer的一些个人理解的知识吧,其中有些会因为认知水准有限,可能介绍的不全面或者不对,如有老师指正,感激不尽。笔者这里就抛砖引玉了。
2.CommandBuffer实现方式

    介绍一个东西,得先进行一个定义。笔者对CommandBuffer的定义是: 进行渲染指令的录制,其成员函数进行编码渲染指令,被编码线程或主线程调用。
2.1 Vulkan&Metal的CommandBuffer

    下面我们先来看一下Vulkan的关于CommandBuffer的介绍,如下图



Vulkan

    CommandBuffer由CommandBufferPool分配而来,CommandBuffer录制渲染状态设置以及渲染资源创建的渲染指令,并提交到CommandQueue,由CommandQueue提交给GPU。并且Vulkan将不在有渲染线程以及渲染上下文的概念,可以由多个线程持有各自的CommandBuffer进行渲染指令的录制并提交给CommandQueue。这里需要注意的是同一个线程持有一个CommandBufferPool分配多个CommandBuffer是不需要进行同步处理的,反之需要开发者自己处理。并且vulkan提供了多种同步机制,semaphore(信号)用于同步Queue(VKQueue是可以有多个的);Fence(栅栏)用于同步GPU和CPU;Event(事件)和Barrier(屏障)用于同步Command Buffer(在vulkan官方文档上介绍,驱动层是以pipeline为单位进行切换,可以在当前帧编码渲染指令,但是效率较低,建议提前进行编码指令,那么换句话说,vulkan建议差帧渲染以提高效率)。
    简单来说,Vulkan极大的释放了CPU侧渲染指令录制的压力,将之分摊到了多个CPU线程之中,这在重度游戏及重度的多媒体应用来说,是一个非常大的性能提升。但是如果渲染任务并不是很重,这倒是不是非常明显。但是Vulkan带来的并不单单是这一点,而是综合与全面,它整合了GL中多种弊端,举例来说,subpass概念来代替FrameBufferFetch与PixelLocalStorage来降低移动端TBR架构下的带宽问题等,感兴趣的可以自信阅读API进行理解,这里就不在赘述了。



Metal

    Metal也是苹果推出的新一代渲染API,替代了OpenGLES,并且在ios侧,其OpenGLES的接口底层已经完全被Metal重新实现了一遍。关于Metal的历史不在赘述,现在来简单介绍Metal的CommandBuffer架构。
    Metal的CommandBuffer是与Vulkan的CommandBufferPool是能力对齐的,都是分配真正的CommandBuffer来使用,而Metal的CommandBufferEncoder是与Vulkan的CommandBuffer对齐的,那么其大致流程也是一样的,不在需要渲染上下文以及渲染线程概念了。并且其比Vulkan贴心的是,其CommandBuffer分为了Render&Compute&Blit三种类型,这像极了UE中对于CommandList的封装概念(此处点赞)。并且其也提供了同步机制,Fence用于同步CommandBuffer,Event用于同步不同的Queue(也可同步CPU与GPU),在这里虽然和Vulkan的名字不太一样,但是功能定位层面,都是提供了。这是由于自身的架构以及设计理念之上的差异,但是解决的实际问题确实是一样的,这对于引擎开发人员是一个非常重要的知识,一定不能局限于名字上,而是要理解它要处理的问题是什么上。
    虽然笔者介绍了新的API中CommandBuffer的相关知识,但是毕竟比较浅显,一些更加系统跟深入的分析并没有介绍,感兴趣的可以自己阅读API或其他文档进行学习。
    总结: 从上面简单的介绍来看,我们发现一个非常重要的信息,那就是充分利用现代硬件多核心的优势,尽最大的努力把最高的算力释放给开发人员。因此,对于引擎来讲,需要将现在API的核心架构概念搞清楚,以便于在引擎底层RHI优化或者重构提供一些指导性意见。CommandBuffer的概念将多线程录制渲染指令变为现实,CommandBufferPool将内存导致的碎片化性能问题处理掉,CommandQueue处理掉渲染指令的提交,同步的策略使得开发人员可以控制GPU或CPU中执行的顺序。据笔者了解,现代伟大的商业亦或是开源游戏引擎,都在引进或者优化RHI层的概念,虽然名字不尽相同,但是概念能力是一致的。
2.2 unity的CommandBuffer

    unity2018版本之前,是将渲染指令使用enum枚举,然后主线程将对应渲染指令所需数据进行序列化然后与指令enum进行映射,并将指令记录在一个环形buffer中(单读单写可以做到无锁),渲染线程反序列化数据并按环形buffer中的enum顺序将渲染指令提交给GPU。这种方式中,主线程与渲染线程各持一份数据,数据是安全的,另外渲染线程不必完全落后主线程一帧,即延迟是可以不足一帧的,但是这种方案针对于一个主线程,一个渲染线程,其是比较舒服的,但是如果想多个线程录制命令那?以笔者目前的认知,unity2018之前使用的作业模式,每个作业(可称为工作线程)生成中间渲染指令,相当于enum的包裹体,然后再渲染线程进行同步合并为一个大的渲染指令队列,这在一定意义可以做到将计算着色器及耗费算力的东西放到一个作业中进行处理。但是按照笔者的理解,这是不优雅的,当然unity在后面也用GraphicsJob替代了这种模式。
    在unity2018版本之后,unity提出了新的GraphicsJob,这是一种新的架构,主要针对于Metal&Vulkan等新一代渲染API,这种模式去掉了渲染线程,由工作线程直接将渲染指令进行编码提交给GPU侧,这可以说是完全释放了新一代图形API的特性了。但是别急着高兴,虽然Metal&Vulkan&Dx12在API层级上支持了多个线程同时并发向GPU提交渲染指令,但是Unity还未进行全平台适配,据笔者所指,目前这种方式只在PC端使用Vulkan可以。但是笔者相信未来肯定是全平台进行适配的,毕竟这是未来的大势,解放算力。
    整体来说,unity中的CommandBuffer更像是一个标志位的记录与读取(数据序列化到环形buffer中并与标志位映射到:数据从环形buffer反序列化并读取对应的enum)。这种框架整体比较干净整洁便于理解,如果添加多个CommandBuffer也是比较容易进行拓展的,但是笔者个人理解,如果一个Rendertarget序列化还算简单,如果是一个Camera那(unity中的Camera持有RenderPath等)?因此可能需要针对于RHI层要有一层对应metainfo封装出来,以减轻高度抽象对象的序列化与反序列的复杂度问题。
2.3 Bgfx的CommandBuffer

    关于bgfx的多线程渲染,笔者曾耗时一个月整理的几万字的文档以来介绍<感兴趣可阅读:SkySnow:<引擎架构>-Bgfx多线程渲染>。对于Bgfx,个人认为,开源渲染器中优秀的代表之一,但是不足之处也是蛮多的,但是对于一些非重度游戏及重度多媒体来说,其提供的完整系统的渲染能力是完全能支撑起来一个中度以下的游戏及多媒体应用。
    上文笔者定义的CommandBuffer概念,其实是Bgfx中CommandBuffer与Encoder的结合体,CommandBuffer是负责渲染资源创建的录制并与数据映射,Encoder是一次Drawcall所需的渲染状态设置的录制。Bgfx使用这两个概念将一帧内所有的渲染指令录制完毕,然后渲染线程降渲染指令提交到GPU。这从一定意义上来讲,可以说是跟现代API进行对齐了,但是其比较大的缺陷是,Bgfx的CommandBuffer录制渲染资源创建都是带锁的,并且CommandBuffer之间无时序同步的设计,Encoder无时序及同步的设计,这些都是需要使用者自己拓展使用的。
    整体来说,Bgfx完整系统的渲染能力以及比较合理的CommandBuffer设计,足以支撑中度及以下的应用了。
2.4 UE的CommandBuffer

    关于UE4亦或是UE5,如同Unity一样,都是非常优秀的商业级游戏引擎。在UE中其CommandBuffer是称为CommandList的,名字并不重要,重要的是其思想。
    UE与与Unity不同的是UE存在RHI线程。简单来说,RenderThread会使用CommandBuffer进行渲染指令创建,主线程(GameThread)也会使用CommandBuffer进行渲染设置命令录制(注意是渲染设置非渲染资源创建),然后RenderThread负责将渲染指令提交给RHI线程,RHI线程负责将渲染指令真正提交给GPU。如下图


主线程可以编码渲染指令,渲染线程也可以编码渲染指令(注意渲染指令分为渲染资源创建与渲染设置,RenderThread是渲染资源指令创建,GameThread是渲染状态设置指令创建),最后由RHI线程统一提交给GPU。在这里说一个细节,因为UE是有RenderThread线程的,其渲染指令分为立即模式与延迟模式
1. 立即模式:CommandListImmediate(名字不重要),这种主要是针对GPU资源的创建,将在RenderThread进行编码渲染指令并立即返回GPU资源的句柄(线程安全的智能指针)
2. 延迟模式:这种是在主线程或者是渲染线程调用,针对于一次Drawcall的Pipeline状态的设置,由链表串连由结构体包裹的渲染指令,并不会返回GPU资源的句柄
    UE的资源创建是在RenderThread线程中,并且其抽象出了RHI线程来提交资源创建指令到GPU中,故不需要延迟创建资源的机制。并且其抽象出来CommandListBase的概念,更容易进行拓展,比如说ComputeCommandList概念等(与Metal的ComputeCommandEncoder对齐)
笔者曾按照UE的思路,借助智能指针实现了一个延迟创建资源的案例,有点类似于Filament,但是又不像Filament一样,搞一大堆宏与模板(笔者是比较讨厌宏编码,因此就只能手动写一些代码了,所幸的是这种方式虽然代码多了,但是更容易看懂了),如下代码:
备注:Filament是一个模板编程的渲染器,如果对编译出来的包大小没要求可以使用;另外如果是非重度游戏开发使用,也不用多线程渲染,可以用BSF引擎(轻量易理解非模板编程)。
class CommandBuffer;
class BufferT;
//这里代表独占指针,RAII技术
//感兴趣的可以查看笔者文章:https://zhuanlan.zhihu.com/p/496435807
//写出一个简单的共享指针及独占指针还是很简单的
template<typename T>
class Handle
{
public:
        Handle()noexcept
          : _Data(nullptr)
        {
        }
        explicit Handle(T* data)noexcept
          : _Data(std::move(data))
        {
        }
      ~Handle() noexcept
        {
                if (_Data)
                {
                        delete _Data;
                        _Data = nullptr;
                }
        }
      Handle(const Handle<T>& raw_ptr) noexcept = delete;
      Handle& operator = (const Handle<T>& raw_ptr) noexcept = delete;
      T* operator->() const noexcept
        {
                return this->_Data;
        }
        T& operator*() const noexcept
        {
                return *this->_Data;
        }
public:
        //这里可以是指针,也可以是共享指针
        //如果是共享指针,驱动层就需要考虑风险指针的回收机制
        //如果是指针,那么对应的可以将Destroy函数也作为命令,回收机制放到RenderGraph中
        T* _Data;
};
//此处为GPUResource的封装抽象,这里是示例
class BufferT
{
public:
        BufferT()
        {
          SN_LOG("BufferT Construct.");
        }

        void Log()
        {
                SN_LOG("BufferT Log Function.");
        }
};
using CreateHandle = Handle<BufferT>;
//该函数对应驱动层的具体接口,这里是示例
void SetBufferD(BufferT* buffer)
{
        SN_LOG("Call SetBufferD.");
}
//该函数对应驱动层的具体接口,这里是示例
BufferT* CreateBufferD()
{
        SN_LOG("Call CreateBufferD.");
        return new BufferT();
}
       

class GRICommandBase
{
private:
        using CMDExeAndDes = void(*)(CommandBuffer& commandBuffer, GRICommandBase* cmd);
public:
        GRICommandBase(CMDExeAndDes cmdFun)
                : _CallBackFun(cmdFun)
                , _Next(nullptr)
        {
        }

        void ExecuteCmd(CommandBuffer& commandBuffer)
        {
                _CallBackFun(commandBuffer,this);
        }
public:
        GRICommandBase* _Next;
private:
        CMDExeAndDes _CallBackFun;
};
template<typename Cmd>
class GRICommand : public GRICommandBase
{
public:
        GRICommand()
                : GRICommandBase(CMDExeAndDes)
        {
        }
        static void CMDExeAndDes(CommandBuffer& commandBuffer, GRICommandBase* cmd)
        {
                Cmd* t_cmd = (Cmd*)cmd;
                t_cmd->Execute(commandBuffer);
                t_cmd->~Cmd();
        }
};

struct SetBuffer : public GRICommand<SetBuffer>
{
        SetBuffer(CreateHandle handle)
                : _Handle(handle)
        {
        }
        void Execute(CommandBuffer& commandBuffer)
        {
                SetBufferD(_Handle._Data);
        }
public:
        CreateHandle _Handle;
};

struct CreateBuffer :public GRICommand<CreateBuffer>
{
public:
        CreateBuffer(CreateHandle handle)
                : _Handle(handle)
        {
        }

        void Execute(CommandBuffer& commandBuffer)
        {
                _Handle._Data = CreateBufferD();
        }
public:
        CreateHandle _Handle;
};

class CommandBuffer
{
public:
        CommandBuffer()
                : _Head(new GRICommandBase(nullptr))
        {
        }

        ~CommandBuffer()
        {
                if (_Head)
                {
                        delete _Head;
                        _Head = nullptr;
                }
        }
        void EncoderSetBuffer(CreateHandle ch)
        {
                SetBuffer* sb = new SetBuffer(ch);
                _Curr->_Next = sb;
                _Curr = _Curr->_Next;
        }
        CreateHandle EncoderCreateBuffer()
        {
                Handle<BufferT> handle;
                if (handle._Data == nullptr)
                {
                        SN_LOG("Data is Null.");
                }
                CreateBuffer* cb = new CreateBuffer(handle);
                _Curr->_Next = cb;
                _Curr = _Curr->_Next;
                return handle;
        }

        void RenderCmd()
        {
                GRICommandBase* cmdTemp;
                cmdTemp = _Head->_Next;
                while (cmdTemp)
                {
                        cmdTemp->ExecuteCmd(*this);
                        cmdTemp = cmdTemp->_Next;
                }
        }
private:
        GRICommandBase* _Head;
        GRICommandBase* _Curr{ _Head };
};

int main()
{
    {
      //主线程或者编码线程编码指令
        CommandBuffer* cb = new CommandBuffer();
        //编码命令
        CreateHandle handle = cb->EncoderCreateBuffer();
        cb->EncoderSetBuffer(handle);

        //渲染线程将渲染指令提交给GPU
        cb->RenderCmd();
        handle._Data->Log();
    }
}
这里就不展开说代码的具体细节,但是此Demo证明了可以完全使用延迟思路。毕竟现如今有名气的游戏引擎都是延迟一帧的渲染架构。笔者也曾验证并体验过延迟一帧后的画面感受,其实人眼是分辨不太出来的(有人像之时有猛烈晃动可稍有体感)。
3.结束语

    简单的谈了一下关于CommandBuffer的一些知识,主要是为了让SkySnowEngine中的CommandBuffer设计的更加合理一些,毕竟是自己写的,没有架构或者业务上面的妥协退让,因此想要博采众家之长,写出一个不是那么差劲的代码出来。因此对于SkySnowEngine就有了一个简单的思路,如下

[*]CommandBuffer中使用链表对Command进行记录,分配Command使用内存池以解决内存相关问题
[*]CommandBufferPool分配CommandBuffer,将CommandBufferPool的概念与Vulkan对齐
[*]返回的渲染资源采用Handle,只是借鉴Bgfx的思路,Handle使用智能指针思路
[*]CommandQueue负责提交渲染指令到GPU,负责CommandBuffer的时序问题
[*]RenderThread是可选,主要负责对CommandQueue提交指令到GPU的处理,GL保留,vulkan及Metal将去掉,引入JobSystem搭配新api的同步思路进行设计
[*]整体是否采用差帧思路(暂未思考清楚)
这只是对CommandBuffer的一些比较浅显的理解了,如有有识之士,欢迎私聊。
如果对您有些许帮助,请推荐给自己的朋友,感谢关注、点赞和收藏。
4. 参考文献


[*]https://www.chromium.org/developers/design-documents/gpu-command-buffer/
[*]Optimizing Graphics in Unity - Unity Learn
[*]Multithreading for game engines
[*]https://www.khronos.org/assets/uploads/developers/library/2019-gdc/Vulkan-Bringing-Fortnite-to-Mobile-Samsung-GDC-Mar19.pdf
[*]https://www.slideshare.net/IntelSoftware/scalability-for-all-unreal-engine-4-with-intel
[*]https://www.intel.com/content/dam/develop/external/us/en/documents/understanding-directx-multithreaded-rendering-performance-by-experiments.pdf
[*]https://www.diva-portal.org/smash/get/diva2:1597882/FULLTEXT01.pdf
[*]https://developer.nvidia.com/sites/default/files/akamai/gameworks/blog/munich/mschott_vulkan_multi_threading.pdf
页: [1]
查看完整版本: 浅谈CommandBuffer