网格(Mesh)用作定义虚拟物体的形状,材质(Material)则是描述这些物体表面的外观,需要借助纹理和材质参数,参数包括光照参数、UV偏移、基础颜色等等。在渲染系统渲染某个网格体时,将对应材质参数传递给着色器,即可完成不同表面的渲染。

如下图,这些网格体利用了不同的材质参数完成渲染。

说明:本文出现的代码,只是为了标明主要流程和设计,大部分只是伪代码,具体实现可查看工程源码。

工程源码:https://gitee.com/xingchen0085/adiosy_engine

视频:XINGCHEN0085的个人空间-哔哩哔哩

渲染循环

以下是该渲染器的主循环伪代码:

伪代码简要描述:

  • RenderPass为渲染流程,内部可以包含多个渲染子流程,也是使用RenderPass定义后续需要用的颜色、深度等附件。可以根据RenderPass创建多个RenderTarget。一个Application中可以定义N个RenderPass,所以第一层循环从RenderPass开始。
  • RenderTarget为渲染目标,对应的就是不同的FrameBuffer。满足同时预览多个相机的画面的需求,比如对虚拟物体在不同视图下编辑(透视图、正视图、左视图等)。RenderTarget是基于RenderPass创建的,所以第二层循环是RenderTarget,绑定某个RenderTarget,相当于绑定某一套FrameBuffer。
  • Material System为材质系统,真正的渲染代码在这里完成。材质系统绑定到了每个RenderTarget上,也就是每个RenderTarget可以自定义处理某些材质系统。所以第三层循环是材质系统。
  • Material为材质信息,每个材质系统要渲染绑定了某材质组件的Entity,也就是每个材质系统下会有很多Material。所以第四层循环就是绑定某个材质。
  • Mesh为网格体,也就是在这里,正式的提交Draw Call。

以上就是该工程的循环主循环。对于材质系统相关流程,设计主要考虑以下几点:

1/ 不同的RenderTarget绑定不同的材质系统,比如有的RendertTarget只处理非光照材质的Mesh渲染;有的RenderTarget只处理Phong式光照材质的渲染;有的RenderTarget则处理非光照材质、Phong式光照材质、PBR材质等渲染。RenderTarget支持添加或删除某些材质系统。

2/ 材质系统会跟Shader、Pipeline、DescriptorSet图形API对象产生关联,为了渲染和场景树解耦,所以把材质系统作为一种特殊的ECS系统,场景树种的Entity只需要添加某个Material Component即可,相当于标识这个Entity下的Mesh渲染都是使用这个Material System来完成。

3/ 为了性能考虑,减少绑定Descriptor的次数,所以在循环种先循环所有材质,再循环所有绑定该材质下的网格体提交渲染。

材质参数 Material

材质内容包含两个重要信息:材质参数和纹理列表。渲染时,使用Uniform Buffer/Stoage Buffer等将材质参数打包发送给Shader;通过Tetxure/Sampler传递给着色器进行采样。如下图:

材质参数可能是:基础颜色(base color)、漫反射颜色(diffuse)、镜面反射颜色(specular),或者是PBR材质的一些参数等。

不过针纹理列表的话,这里有延申设计了两个概念:

1/ 每个材质内用到的纹理,一般只会读取一次,并将其放入纹理池中以供复用。

2/ 渲染时可以指定一些参数进行采样,额外添加一个TextureView的对象,内部包含纹理指针、采样方式(Sampler,比如过滤、UV寻址方式、Lod等)和UV变换(UV唯一、缩放、旋转)数据。

材质基类定义了基础的数据,然后可以由此派生出其他材质类,比如无光照材质(Unlit)、Phong式光照材质、PBR材质等。

材质基类的基础定义:

 struct TextureView{
    AdTexture *texture = nullptr;						// 纹理
    AdSampler *sampler = nullptr;						// 采样方式
    bool bEnable = true;								// 是否启用
    glm::vec2 uvTranslation{ 0.f, 0.f };				// UV位移
    float uvRotation{ 0.f };							// UV旋转
    glm::vec2 uvScale { 1.f, 1.f };						// UV缩放

    bool IsValid() const {
        return bEnable && texture != nullptr && sampler != nullptr;
    }
};

class AdMaterial{
public:
...
...
private:
    int32_t mIndex = -1;
    std::unordered_map<uint32_t, TextureView> mTextures;
    friend class AdMaterialFactory;
};

材质工厂

为了方便创建不同材质,材质的维护统一交给材质工厂来作,不提供外界实例化材质的。重点需要实现的函数就是创建材质。

 class AdMaterialFactory{
public:
    AdMaterialFactory(const AdMaterialFactory&) = delete;
    AdMaterialFactory &operator=(const AdMaterialFactory&) = delete;

    static AdMaterialFactory* GetInstance(){
        return &s_MaterialFactory;
    }

    ~AdMaterialFactory() {
        mMaterials.clear();
    }

	...
    ...

    template<typename T>
    T* CreateMaterial(){
        auto mat = std::make_shared<T>();
        uint32_t typeId = entt::type_id<T>().hash();

        uint32_t index = 0;
        if(mMaterials.find(typeId) == mMaterials.end()){
            mMaterials.insert({ typeId, { mat }});
        } else {
            index = mMaterials[typeId].size();
            mMaterials[typeId].push_back(mat);
        }
        mat->mIndex = index;
        return mat.get();
    }
private:
    AdMaterialFactory() = default;

    static AdMaterialFactory s_MaterialFactory;

    // 所有材质
    std::unordered_map<uint32_t, std::vector<std::shared_ptr<AdMaterial>>> mMaterials;
};

材质的创建:

auto matFactory = ade::AdMaterialFactory::GetInstance();
ade::AdUnlitMaterial *unlitMat = matFactory->CreateMaterial<ade::AdUnlitMaterial>();
ade::AdPhongMaterial *phongMat = matFactory->CreateMaterial<ade::AdPhongMaterial>();
ade::AdPBRMaterial *pbrMat = matFactory->CreateMaterial<ade::AdPBRMaterial>();

以上,就是材质数据的维护。材质数据包有了之后,怎么将网格体跟材质扯上关系呢?这就需要材质组件来完成绑定了。

材质组件 Material Component

材质组件由两个重要作用:

1/ 标识某个Entity使用某个材质系统。

2/ 绑定网格体和材质参数。

材质组件的定义:

 template<typename T>
class AdMaterialComponent : public AdComponent{
public:
    void AddMesh(AdMesh *mesh, T *material = nullptr){
        if(!mesh){
            return;
        }
        uint32_t meshIndex = mMeshList.size();
        mMeshList.push_back(mesh);

        if(mMeshMaterials.find(material) != mMeshMaterials.end()){
            mMeshMaterials[material].push_back(meshIndex);
        } else {
            mMeshMaterials.insert({ material, { meshIndex } });
        }
    }

    uint32_t GetMaterialCount() const {
        return mMeshMaterials.size();
    }

    const std::unordered_map<T*, std::vector<uint32_t>> &GetMeshMaterials() const {
        return mMeshMaterials;
    }

    AdMesh *GetMesh(uint32_t index) const {
        if(index < mMeshList.size()){
            return mMeshList[index];
        }
        return nullptr;
    }
private:
    std::vector<AdMesh*> mMeshList;									// 网格体列表
    std::unordered_map<T*, std::vector<uint32_t>> mMeshMaterials;	// 材质对应的网格体列表
};


// 具体的材质组件
enum UnlitMaterialTexture{
    UNLIT_MAT_BASE_COLOR_0,
    UNLIT_MAT_BASE_COLOR_1
};
struct UnlitMaterialUbo{
    alignas(16) glm::vec3 baseColor0{ 0, 0, 0 };
    alignas(16) glm::vec3 baseColor1{ 0, 0, 0 };
    alignas(4)  float mixValue{ 0.5f };
    alignas(16) TextureParam textureParam0;
    alignas(16) TextureParam textureParam1;
};

class AdUnlitMaterial : public AdMaterial{
public:
    const UnlitMaterialUbo& GetParams() const { return mParams; }
    void SetParams(const UnlitMaterialUbo &params){
        mParams = params;
        bShouldFlushParams = true;
    }
private:
    UnlitMaterialUbo mParams{};
};

class AdUnlitMaterialComponent : public AdMaterialComponent<AdUnlitMaterial>{

};

alignas 是为了手动内存对齐。该工程中,给Shader传输的数据包全部采用 std140 形式打包。

材质组件的第一个作用如何体现呢?

可以通过下述代码,给Enity添加一个材质组件。

ade::AdEntity *entity = scene->CreateEntity("Cube");
auto &materialComp = entity->AddComponent<ade::AdUnlitMaterialComponent>();
materialComp.AddMesh(mesh1, material0);
materialComp.AddMesh(mesh2, material0);
materialComp.AddMesh(mesh3, material2);

基于此,添加一个MaterialSystem,来专门处理拥有这种MaterialComponent的Entity渲染。

材质系统 Material System

材质系统的重要流程如下:

1/ 初始化渲染管线;包括DescriptorSetLayout、DescriptorPool、Uniform Buffer和PipelineLayout等对象。一个材质系统中可以使用多个Shader、多套渲染管线。

2/ 渲染主循环调用 OnRender() 函数后,根据ECS架构查询出拥有对应材质组件的Entity列表,然后循环所有材质、所有网格执行Draw提交。

3/ 资源销毁。

class AdUnlitMaterialSystem : public AdMaterialSystem{
public:
    void OnInit(AdVKRenderPass *renderPass) override;
    void OnRender(VkCommandBuffer cmdBuffer, AdRenderTarget *renderTarget) override;
    void OnDestroy() override;
    ...
    ...
}

材质系统初始化/销毁

void AdUnlitMaterialSystem::OnInit(AdVKRenderPass *renderPass) {
	// ---------------------------------------------------------------------------
    // 1. Pipeline Layout
    ShaderLayout shaderLayout = {
        .descriptorSetLayouts = { ... },
        .pushConstants = { ... }
    };
    mPipelineLayout = std::make_shared<AdVKPipelineLayout>(device, shaders, shaderLayout);

    // 2. Pipeline
    mPipeline = std::make_shared<AdVKPipeline>(device, renderPass, mPipelineLayout.get());
    mPipeline->SetVertexInputState(...);
    mPipeline->SetInputAssemblyState(...)->EnableDepthTest();
    mPipeline->SetDynamicState(...);
    mPipeline->Create();

    // ---------------------------------------------------------------------------
	mDescriptorPool = std::make_shared<ade::AdVKDescriptorPool>(device, 1, poolSizes);
    mFrameUboDescSet = mDescriptorPool->AllocateDescriptorSet(...)
    // ---------------------------------------------------------------------------
    ...
    ...
}

void AdUnlitMaterialSystem::OnDestroy() {
    mDescriptorPool.reset();
    mPipelineLayout.reset();
    mPipeline.reset();
    ...
    ...
}

材质系统渲染

void AdUnlitMaterialSystem::OnRender(VkCommandBuffer cmdBuffer, AdRenderTarget *renderTarget) {
    // 1. Context params
    AdAppContext *appContext = AdApplication::GetAppContext();
    AdScene *scene = appContext->scene;
    if(!scene){
        return;
    }

	// ---------------------------------------------------------------------------
    // 2. !!! 这里是重点,这个使用entt::view函数,筛选出材质组件
    entt::registry &reg = scene->GetEcsRegistry();
    auto view = reg.view<AdTransformComponent, AdUnlitMaterialComponent>();
    if(view.begin() == view.end()){
        return;
    }
    // ---------------------------------------------------------------------------

    // 3. bind pipeline
    mPipeline->Bind(cmdBuffer);
    vkCmdSetViewport(cmdBuffer, ...);
    vkCmdSetScissor(cmdBuffer, ...);

    // 4. Update frame descriptor set
    UpdateFrameUboDescSet(renderTarget->GetCamera(), ...);

    // 5. Render
    view.each([this](const AdTransformComponent &transComp, const AdUnlitMaterialComponent &materialComp){
        for (const auto &entry: materialComp.GetMeshMaterials()){
            AdUnlitMaterial *material = entry.first;
            int32_t materialIndex = material->GetIndex();
            VkDescriptorSet paramDescSet = mMaterialDescSets[materialIndex];
            VkDescriptorSet resourceDescSet = mMaterialResourceDescSets[materialIndex];
            ...

    		// ---------------------------------------------------------------------------
            // Update material descriptor set
            if(shouldForceUpdateMaterial || material->ShouldFlushParams()){
                UpdateMaterialParamsDescSet(paramDescSet, material);
                material->FinishFlushParams();
            }

            if(shouldForceUpdateMaterial || material->ShouldFlushResource()){
                UpdateMaterialResourceDescSet(resourceDescSet, material);
                material->FinishFlushResource();
            }
            ...
            // ---------------------------------------------------------------------------

            // Bind Descriptor set and push constant
            VkDescriptorSet descriptorSets[] = { mFrameUboDescSet, paramDescSet, resourceDescSet ... };
            vkCmdBindDescriptorSets(descriptorSets);
            
            vkCmdPushConstants(...);

            // Draw Meshes...
            for (const auto &meshId: entry.second){
                materialComp.GetMesh(meshId)->Draw(cmdBuffer);
            }
        }
    });
}

设计规则

上文把该工程材质系统设计用到的内容都列了以下,但其实内部还有很多小的细节,又或者有的是困惑了非常之久的问题。所以我将部分再详细描述以下。

1. DescriptorSet规范

上文提到,关于渲染的大部分内容其实是在Material System中实现的。和Shader打交道的就是Material System,我们可以通过Uniform Buffer和Texture等方式将数据传递给Shader。但有一个问题是,VkDescriptorSet是一个透明对象,它和CommandBuffer类似,也是需要从Pool中申请处理,然后执行”录制“,再提交给GPU。只不过这里的”录制“就是将材质参数和纹理绑定到DescriptorSet,调用的是vkUpdateDescriptorSets()这个API。

但是,当后期渲染场景变大,材质数量变多之后,如果每次都执行一遍vkUpdateDescriptorSets(),会导致性能问题。所以控制vkUpdateDescriptorSets()的调用次数就很重要了。

那怎么做呢?

一般的方案是:传递给着色器的数据,根据不同的更新频率分类,将其分为若干个DescriptorSet,单独更新。切记不要将不同更新频率的数据放在同一个DescriptorSet中,这样会白白浪费性能。

举个例子:我们可以分为一下几种DescriptorSet。

注:每个Pipeline的DescriptorSet数量也是有限制的,可以通过查询VkPhysicalDeviceProperties::limits::maxBoundDescriptorSets 得到。

Descriptor set 数据类型 更新频率
DS0 全局/每帧的数据;比如环境纹理、雾或当前时间、鼠标位置、当前选中物体、风速等数据 只更新一次或每帧更新一次
DS1 每个材质的参数 每个材质更新一次
DS2 每个材质的纹理数据,纹理数据一般更新频率会比材质参数低,所以跟材质参数分开 每个材质更新一次
DS3 每个Entity的参数(最好使用PushConstant) 每个物体更新一次
DS4 每个Mesh的参数(最好使用PushConstant) 每个Mesh更新一次

DS0 更新频率稳定,基本上不会有太大性能问题。

DS1/DS2 每个材质参数和纹理数据,则需要在材质中维护两个变量,标识这个材质是否发生变更了,如果发生变更了,更新对应的DescriptorSet后,再将其标识更新为false即可。如下代码:

class AdMaterial{
public:
    ...
    ...
    // 提供给 Material System 查询
    bool ShouldFlushParams() const { return bShouldFlushParams; }
    // 提供给 Material System 查询
    bool ShouldFlushResource() const { return bShouldFlushResource; }
    // 提供给 Material System 设置
    void FinishFlushParams() { bShouldFlushParams = false; }
    // 提供给 Material System 设置
    void FinishFlushResource() { bShouldFlushResource = false; }
	
    void AdMaterial::SetTextureView(uint32_t id, AdTexture *texture, AdSampler *sampler) {
        if(HasTexture(id)){
            mTextures[id].texture = texture;
            mTextures[id].sampler = sampler;
        } else {
            mTextures[id] = { texture, sampler };
        }
        bShouldFlushResource = true;		// texture替换时更新标识   !!! (1)
    }
protected:
    bool bShouldFlushParams = false;		// params是否发生变更,material内部设置
    bool bShouldFlushResource = false;		// texture是否发生替换,material内部设置
	...
	...
};

class AdUnlitMaterial : public AdMaterial{
public:
    const UnlitMaterialUbo& GetParams() const { return mParams; }
    void SetParams(const UnlitMaterialUbo &params){
        mParams = params;
        bShouldFlushParams = true;			//params发生变更时更新标识   !!! (2)	
    }
private:
    UnlitMaterialUbo mParams{};
};

void AdUnlitMaterialSystem::OnRender(VkCommandBuffer cmdBuffer, AdRenderTarget *renderTarget) {
    ...
    ...
    ...
    view.each([](...){
		...
        int32_t materialIndex = material->GetIndex();
        VkDescriptorSet paramDescSet = mMaterialDescSets[materialIndex];
        VkDescriptorSet resourceDescSet = mMaterialResourceDescSets[materialIndex];
        if(shouldForceUpdateMaterial || material->ShouldFlushParams()){
            UpdateMaterialParamsDescSet(paramDescSet, material);      //更新params descset   !!! (3)	
            material->FinishFlushParams();
        }

        if(shouldForceUpdateMaterial || material->ShouldFlushResource()){
            UpdateMaterialResourceDescSet(resourceDescSet, material);//更新resource descset   !!! (4)	
            material->FinishFlushResource();
        }
        ...
        ...
    }
}

DS3 每个Enity的数据,比如Enity的状态,模型矩阵等信息,这类数据每个Enitity都需要更新。最好使用Push_Constant常量推送方式,只有在大小超过了Push_Constant范围后再考虑使用DescriptorSet。

DS4 每个Mesh的数据,比如Mesh额外信息,这类数据每个Mesh都需要更新。最好使用Push_Constant常量推送方式,只有在大小超过了Push_Constant范围后再考虑使用DescriptorSet。

举个例子:我设计了一个UnlitMaterial的材质,它是一个无光照材质。片元着色器中接收两个base_color的值,这两个base_color通过mix()函数混合;另外base_color也可以是两张纹理提供颜色数据。所以我的着色器编写如下:

#version 450

layout(location=0)      in vec3 a_Pos;
layout(location=1)      in vec2 a_Texcoord;
layout(location=2)      in vec3 a_Normal;

out gl_PerVertex{
    vec4 gl_Position;
};

layout(set=0, binding=0, std140) uniform FrameUbo{
    mat4  projMat;
    mat4  viewMat;
    ivec2 resolution;
    uint  frameId;
    float time;
} frameUbo;

layout(push_constant) uniform PushConstants{
    mat4 modelMat;
    mat3 normalMat;
} PC;

out layout(location=1) vec2 v_Texcoord;

void main(){
    gl_Position = frameUbo.projMat * frameUbo.viewMat * PC.modelMat * vec4(a_Pos.x, a_Pos.y, a_Pos.z, 1.f);
    v_Texcoord = a_Texcoord;
}
#version 450

layout(location=1) in vec2 v_Texcoord;

struct TextureParam{
    bool  enable;
    float uvRotation;
    vec4  uvTransform;   // x,y --> scale, z,w --> translation
};

vec2 getTextureUV(TextureParam param, vec2 inUV){
    vec2 retUV = inUV * param.uvTransform.xy;           // scale
    retUV = vec2(                                       // rotation
        retUV.x * sin(param.uvRotation) + retUV.y * cos(param.uvRotation),
        retUV.y * sin(param.uvRotation) + retUV.x * cos(param.uvRotation)
    );
    inUV = retUV + param.uvTransform.zw;                // translation
    return inUV;
}

layout(set=0, binding=0, std140) uniform FrameUbo{
    mat4  projMat;
    mat4  viewMat;
    ivec2 resolution;
    uint  frameId;
    float time;
} frameUbo;

layout(set=1, binding=0, std140) uniform MaterialUbo{
    vec3 baseColor0;
    vec3 baseColor1;
    float mixValue;
    TextureParam textureParam0;
    TextureParam textureParam1;
} materialUbo;

layout(set=2, binding=0) uniform sampler2D texture0;
layout(set=2, binding=1) uniform sampler2D texture1;

layout(location=0) out vec4 fragColor;

void main(){
    vec3 color0 = materialUbo.baseColor0;
    vec3 color1 = materialUbo.baseColor1;

    if(materialUbo.textureParam0.enable){
        TextureParam param = materialUbo.textureParam0;
        param.uvTransform.w = -frameUbo.time;
        color0 = texture(texture0, getTextureUV(param, v_Texcoord)).rgb;
    }

    if(materialUbo.textureParam1.enable){
        color1 = texture(texture1, getTextureUV(materialUbo.textureParam1, v_Texcoord)).rgb;
    }

    fragColor = vec4(mix(color0, color1, materialUbo.mixValue), 1.0);
}

2. DescriptorSet和Material参数隔离

问题:更新Material数据需要DescriptorSet和UniformBuffer等Vulkan对象,但问题是,申请DescriptorSet需要DescriptorPool和DescriptorSetLayout参数,并且DescriptorSetLayout和Shader定义挂钩,也跟Pipeline有关系。所以如果把DescriptorSet和UniformBuffer加到Material中,会导致Material对象和图形API关联过大,无法解耦。

先说一下目前的解决方案:

1/ Material中只维护参数和纹理信息,不维护DescriptorSet信息;

2/ 在Material System中维护N个DescriptorSet,并且DescriptorPool和DescriptorSetLayout等参数都在Material System中维护,Material作为一种”数据包“给到Material System使用;

3/ Material的不提供外部直接创建,而是由Material Factory统一维护,并且在Material中维护一个Material index字段(int32_t类型),并通过该字段和Material System中的Descriptor Set关联,index是两套数据的下标,时间复杂度为O1。

优劣势分析。

优势:

  • Material 和 DescriptorSet解耦。
  • 不需要将Material System中的Pipeline、DescriptorPool和DescriptorSetLayout等参数对外提供,内部处理即可。
  • Material System中可以自由指定DescriptorSet,分配和释放由Material System内部处理。

劣势:

  • 过度依赖Material Index字段,且该字段没有实际意义,仅为了程序识别。
  • 需要两边数据(Material和DescriptorSet)协调配合,处理不好的话出问题概率较大。

实现:

1/ Material中维护一个Index字段。

class AdMaterial{
public:
   ...
   ...
private:
    int32_t mIndex = -1;
    std::unordered_map<uint32_t, TextureView> mTextures;
    friend class AdMaterialFactory;
};

2/ AdMaterialFactory在创建Material时,赋值Index字段。并且支持查询某种类型Material的总数量。

class AdMaterialFactory{
public:
    ...
    ...

    template<typename T>					// 返回某种类型Material的数量
    size_t GetMaterialSize(){
        uint32_t typeId = entt::type_id<T>().hash();
        if(mMaterials.find(typeId) == mMaterials.end()){
            return 0;
        }
        return mMaterials[typeId].size();
    }

    template<typename T>
    T* CreateMaterial(){
        auto mat = std::make_shared<T>();
        uint32_t typeId = entt::type_id<T>().hash();

        uint32_t index = 0;
        if(mMaterials.find(typeId) == mMaterials.end()){
            mMaterials.insert({ typeId, { mat }});
        } else {
            index = mMaterials[typeId].size();
            mMaterials[typeId].push_back(mat);
        }
        mat->mIndex = index;					// material index
        return mat.get();
    }
private:
    AdMaterialFactory() = default;

    static AdMaterialFactory s_MaterialFactory;

    std::unordered_map<uint32_t, std::vector<std::shared_ptr<AdMaterial>>> mMaterials;
};

3/ 在Material System中,根据material Index取出对应的Descriptor set使用。

// 维护Material descriptor pool 和 descriptor sets
void AdUnlitMaterialSystem::ReCreateMaterialDescPool(uint32_t materialCount) {
    mMaterialDescriptorPool = std::make_shared<ade::AdVKDescriptorPool>(...);

    mMaterialDescSets = mMaterialDescriptorPool->AllocateDescriptorSet(...);
    mMaterialResourceDescSets = mMaterialDescriptorPool->AllocateDescriptorSet(...);
}

// 渲染
void AdUnlitMaterialSystem::OnRender(VkCommandBuffer cmdBuffer, AdRenderTarget *renderTarget) {
    ...
    ...
    view.each([](...){
		...
        int32_t materialIndex = material->GetIndex();
        VkDescriptorSet paramDescSet = mMaterialDescSets[materialIndex];
        VkDescriptorSet resourceDescSet = mMaterialResourceDescSets[materialIndex];
        ...
        ...
    }
}

3. DescriptorPool根据Material的数量动态增长

其实这个问题是和【2.DescriptorSet和Material参数隔离】有关系的,我上面列出来的代码有个函数名为ReCreateMaterialDescPool(),为什么需要重新创建DescriptorSetPool呢?这是因为有这个矛盾点:系统内部的材质总数量是未知的,可能只有10个,也可能有10000个。但DescriptorSetPool是需要指定每种DescriptorSet的数量和总数量(maxSets字段)的。

也就意味着,如果DescriptorSetPool可申请的DescriptorSet数量过少,会报错VK_ERROR_OUT_OF_POOL_MEMORY,但如果给设置的数量过大,也会浪费内存。

解决这个问题,目前我了解到的有三种解决方案:

1/ 创建DescriptorSetPool时,指定VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT 标识。当指定该标识后,Descriptor的释放需要手动进行,所以可以做到动态的申请和是否DescriptorSet。但这就没用到DescriptorPool原生释放上的一些优化,针对Material,也不应该每一帧都重新申请再释放DescriptorSet,这样意味着每一帧都需要vkUpdateDescriptorSets()。所以弃用该方案。

2/ 手动指定vkResetDescriptorPool(),重置Pool,但也需要每一帧都需要vkUpdateDescriptorSets()。所以弃用该方案。

3/ 申请一定量的DescriptorSet后,将其缓存,然后复用这些DescriptorSet。最终Destory DescriptorPool时自动释放这些DescriptorSet。这个方案可行,但这个”一定量“不好把控。

我目前考虑使用的方案是:采用方案三,然后默认初始化16个DescriptorSet,当发现Material数量超过16时,以2倍的速率扩容,扩容时重新创建一次DescriptorPool和申请DescriptorSet。该方案使用这样的扩容机制:16 -> 32 -> 48 -> 96 -> 128 -> 256 -> 512 -> 1024 -> 2048…

实现:

1/ 定义默认DescriptorSet数量和最大数量。

#define NUM_MATERIAL_BATCH              16
#define NUM_MATERIAL_BATCH_MAX          2048

2/ 扩容机制实现。

void AdUnlitMaterialSystem::ReCreateMaterialDescPool(uint32_t materialCount) {
    uint32_t newMaterialCount = mLastMaterialCount;
    if(mLastMaterialCount == 0){						// init
        newMaterialCount = NUM_MATERIAL_BATCH;			
    }

    while (newMaterialCount < materialCount){
        newMaterialCount *= 2;							// (1)计算扩容数量
    }

    if(newMaterialCount > NUM_MATERIAL_BATCH_MAX){		// 最大数量限制
        LOG_E("Material max count is : {0}, but request : {1}", NUM_MATERIAL_BATCH_MAX, newMaterialCount);
        return;
    }
    LOG_W("{0}: {1} -> {2} S.", __FUNCTION__, mLastMaterialCount, newMaterialCount);
    AdRenderContext *renderCxt = AdApplication::GetAppContext()->renderCxt;
    AdVKDevice *device = renderCxt->GetDevice();

    mMaterialDescSets.clear();							// (2)销毁之前申请的DescriptorSet
    mMaterialResourceDescSets.clear();					// 因为DescriptorPool被销毁后,这些DescSet也不能使用了
    if(mMaterialDescriptorPool){
        mMaterialDescriptorPool.reset();				// 销毁旧的DescriptorPool
    }

    mMaterialDescriptorPool = std::make_shared<ade::AdVKDescriptorPool>(device, newMaterialCount * 2, ...);

    mMaterialDescSets = mMaterialDescriptorPool->AllocateDescriptorSet(..., newMaterialCount);
    mMaterialResourceDescSets = mMaterialDescriptorPool->AllocateDescriptorSet(..., newMaterialCount);
    ...
    ...
    uint32_t diffCount = newMaterialCount - mLastMaterialCount;
    for(int i = 0; i < diffCount; i++){
        mMaterialBuffers.push_back(std::make_shared<AdVKBuffer>(...));	// (3)添加新的Uniform Buffer
    }
    mLastMaterialCount = newMaterialCount;
}

3/ 扩容后,因为旧的DescriptorSet被销毁,所以需要重新更新Material数据到DescriptorSet中。

// 判断是否扩容
bool shouldForceUpdateMaterial = false;
uint32_t materialCount = AdMaterialFactory::GetInstance()->GetMaterialSize<AdUnlitMaterial>();
if(materialCount > mLastMaterialCount){
    ReCreateMaterialDescPool(materialCount);
    shouldForceUpdateMaterial = true;			// 扩容时更新Material数据到DescriptorSet
}

view.each([this, &shouldForceUpdateMaterial](const AdTransformComponent &transComp, const AdUnlitMaterialComponent &materialComp){
    for (const auto &entry: materialComp.GetMeshMaterials()){
        AdUnlitMaterial *material = entry.first;
        int32_t materialIndex = material->GetIndex();
        VkDescriptorSet paramDescSet = mMaterialDescSets[materialIndex];
        VkDescriptorSet resourceDescSet = mMaterialResourceDescSets[materialIndex];
        ...

        // ---------------------------------------------------------------------------
        // Update material descriptor set
        if(shouldForceUpdateMaterial || material->ShouldFlushParams()){
            UpdateMaterialParamsDescSet(paramDescSet, material);
            material->FinishFlushParams();
        }

        if(shouldForceUpdateMaterial || material->ShouldFlushResource()){
            UpdateMaterialResourceDescSet(resourceDescSet, material);
            material->FinishFlushResource();
        }
        ...
        ...
    }
});

总结

关于材质系统的设计,受之前OpenGL写过一个版本的影响,还是将其兼容到ECS中去了,发现的确很方便,这是一大幸事。但是,因为OpenGL和Vulkan的差别很大,特别是DescriptorSet这部分,绞尽脑汁设计了特别久,比如下图是我当时考虑的所有设计方案,对比各方案优缺点,然后尝试能不能处理掉缺点。实在不能解决就弃用。

不过,上述方案都被我自己推翻了。最终就是舍弃掉一部分之前的想法,折中设计出当前这个方案。所以该方案在后续还会一点点的去优化。

所以,整个过程还是收获良多的,收获到一些架构设计的经验,当一个架构能做到方便理解、方便维护、方便拓展是非常有成就感的一件事。另外也体会到了Vulkan一些设计的优势之处,对Vulkan的了解又更深入了些。