对于引擎,我们开始有多个渲染通道,因此是时候创建一个实际的材质系统了。材质系统在渲染引擎中以架构复杂而闻名,因为它们需要在引擎的许多不同部分之间平衡许多方面(灵活性、易用性、批处理)。
在引擎中,使用的系统相当简单,但目前可以完成其工作。这不是最佳解决方案,但它是在这种用例中有效的解决方案。
材质系统可以在 material_system.h/cpp
文件对中找到,但它也依赖于描述符系统和着色器反射系统。
它基于主教程章节中显示的原始材质系统的相同核心逻辑。材质将包含管线和着色器,以及用于纹理的槽位 2 的描述符集。
着色器效果
struct ShaderEffect {
VkPipelineLayout builtLayout;
std::array<VkDescriptorSetLayout, 4> setLayouts;
struct ShaderStage {
ShaderModule* shaderModule;
VkShaderStageFlagBits stage;
};
std::vector<ShaderStage> stages;
//others omitted
}
在 vk_shaders.h
中实现,着色器效果是一个结构体,它将一组着色器分组在一起,这些着色器将组成一个管线,并负责处理其描述符集布局和管线布局。在某种程度上,着色器效果保存了构建管线所需的所有着色器相关状态。
要创建着色器效果,我们填充其着色器阶段,然后让它构建管线布局并获取所需的反射数据,然后我们可以将其用于其他功能。
给定材质将具有多个着色器效果,这取决于它映射到的管线。用于仅深度阴影渲染的着色器效果与用于前向通道的着色器效果不同。纹理材质的效果与无纹理材质的效果也不同。
着色器Pass本质上是着色器效果的构建版本,它存储了构建的管线
struct ShaderPass {
ShaderEffect* effect{ nullptr };
VkPipeline pipeline{ VK_NULL_HANDLE };
VkPipelineLayout layout{ VK_NULL_HANDLE };
};
//default effects
ShaderEffect* texturedLit = build_effect(engine, "tri_mesh_ssbo_instanced.vert.spv" ,"textured_lit.frag.spv" );
ShaderEffect* defaultLit = build_effect(engine, "tri_mesh_ssbo_instanced.vert.spv" , "default_lit.frag.spv" );
ShaderEffect* opaqueShadowcast = build_effect(engine, "tri_mesh_ssbo_instanced_shadowcast.vert.spv","");
//passes
ShaderPass* texturedLitPass = build_shader(engine->_renderPass,forwardBuilder, texturedLit);
ShaderPass* defaultLitPass = build_shader(engine->_renderPass, forwardBuilder, defaultLit);
ShaderPass* opaqueShadowcastPass = build_shader(engine->_shadowPass,shadowBuilder, opaqueShadowcast);
效果模板
struct EffectTemplate {
PerPassData<ShaderPass*> passShaders;
ShaderParameters* defaultParameters;
assets::TransparencyMode transparency;
};
为了收集多个通道所需的管线,我们将它们收集在一个效果模板中。这有点像主材质。其他材质是从它创建的。例如,一个效果模板是 LitTexturedOpaque 模板,它是接收光照并具有纹理的材质的材质模板,它也可以在阴影通道上渲染。
在材质系统中,我们首先创建一些模板,然后我们将使用它们作为各个材质的基础。
{
EffectTemplate defaultTextured;
//no transparent pass
defaultTextured.passShaders[MeshpassType::Transparency] = nullptr;
//default opaque shadowpass
defaultTextured.passShaders[MeshpassType::DirectionalShadow] = opaqueShadowcastPass;
//textured lit for main view
defaultTextured.passShaders[MeshpassType::Forward] = texturedLitPass;
defaultTextured.defaultParameters = nullptr;
defaultTextured.transparency = assets::TransparencyMode::Opaque;
templateCache["texturedPBR_opaque"] = defaultTextured;
}
对此可能的改进是,效果模板可以从文件配置中创建,这样可以更容易地配置不同类型上使用的着色器。
效果模板还包含一个 ShaderParameters 结构体。这是为了让材质可以拥有一个数据 uniform 缓冲区,以便从着色器中索引,用于材质颜色等。效果模板中的 ShaderParameters 结构体是默认结构体。
材质
struct Material {
EffectTemplate* original;
PerPassData<VkDescriptorSet> passSets;
std::vector<SampledTexture> textures;
ShaderParameters* parameters;
};
最后,我们有材质本身。材质将保存指向其效果模板父级的指针,并且还将保存渲染所需的描述符集。
材质保存它使用的纹理的向量,因为这将用于构建此处使用的描述符集。
材质资源
加载场景时,我们还需要加载其中不同对象的材质。这是额外章节中解释的资源系统的扩展,并且是 GLTF 加载代码的一部分。
材质资源是一个小型资源,其中包含嵌入的 json,其中包含材质参数和构建材质时要使用的基础效果(映射到效果模板)
struct MaterialInfo {
std::string baseEffect;
std::unordered_map<std::string, std::string> textures; //name -> path
std::unordered_map<std::string, std::string> customProperties;
TransparencyMode transparency;
};
材质信息将在加载预制件时加载,并且材质将被初始化。
缓存系统
要创建材质,您需要填写 MaterialInfo 结构体,并按名称向材质系统请求材质。
struct MaterialData {
std::vector<SampledTexture> textures;
ShaderParameters* parameters;
std::string baseTemplate;
};
{
vkutil::MaterialData texturedInfo;
texturedInfo.baseTemplate = "texturedPBR_opaque";
texturedInfo.parameters = nullptr;
vkutil::SampledTexture whiteTex;
whiteTex.sampler = smoothSampler;
whiteTex.view = _loadedTextures["white"].imageView;
texturedInfo.textures.push_back(whiteTex);
vkutil::Material* newmat = _materialSystem->build_material("textured", texturedInfo);
}
在大多数 GLTF 和 FBX 文件中,非常常见的是您会看到相同的材质使用不同的名称。当加载多个预制件时,这种情况更为常见,其中某些材质很可能相同。为了改进这一点,材质系统被大量缓存。build_material
函数是一个谎言,它会首先尝试查找是否存在与您想要创建的材质相同的材质。它只会创建材质并正确构建纹理描述符(如果它是唯一的组合)。这样,材质会不断合并,这使得从间接绘制批处理的角度来看,使用起来更好。
vkutil::Material* vkutil::MaterialSystem::build_material(const std::string& materialName, const MaterialData& info)
{
Material* mat;
//search material in the cache first in case it's already built
auto it = materialCache.find(info);
if (it != materialCache.end())
{
//material found, just return it
mat = (*it).second;
materials[materialName] = mat;
}
else {
//need to build the material
Material *newMat = new Material();
newMat->original = &templateCache[ info.baseTemplate];
newMat->parameters = info.parameters;
//not handled yet
newMat->passSets[MeshpassType::DirectionalShadow] = VK_NULL_HANDLE;
newMat->textures = info.textures;
//build descriptor set
auto& db = vkutil::DescriptorBuilder::begin(engine->_descriptorLayoutCache, engine->_descriptorAllocator);
for (int i = 0; i < info.textures.size(); i++)
{
VkDescriptorImageInfo imageBufferInfo;
imageBufferInfo.sampler = info.textures[i].sampler;
imageBufferInfo.imageView = info.textures[i].view;
imageBufferInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
db.bind_image(i, &imageBufferInfo, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, VK_SHADER_STAGE_FRAGMENT_BIT);
}
db.build(newMat->passSets[MeshpassType::Forward]);
LOG_INFO("Built New Material {}", materialName);
//add material to cache
materialCache[info] = (newMat);
mat = newMat;
materials[materialName] = mat;
}
return mat;
}
管线已经被缓存,因为管线是使用效果模板创建的,并且唯一效果模板的数量非常少。
渲染
材质系统与网格通道的工作方式紧密相关,但重要的是,当您使用给定的网格通道注册可渲染对象时,材质会被“解包”。如果您有一个网格并将其注册到深度通道,则深度通道将首先检查材质是否具有深度效果,如果具有,它将直接存储最终的管线和描述符集信息。
if (object->bDrawShadowPass)
{
if (object->material->original->passShaders[MeshpassType::DirectionalShadow])
{
//add object to shadow pass
_shadowPass.unbatchedObjects.push_back(handle);
}
}
渲染唯一需要的是管线 ID 和描述符集 ID,因此 vk_scene.cpp
中的网格渲染器将“解包”材质的层,最终仅存储所需的内容。通过这样做,共享相同管线 ID 的材质在系统对网格进行排序时会“合并”在一起,并且会更好地进行批处理。
由于阴影通道始终使用相同的默认不透明阴影效果,并且它没有纹理(空描述符集),因此引擎中的阴影通道将始终在单个绘制调用中渲染。即使场景由多个不同的材质组成,当需要渲染时,系统也会看到管线始终相同,因此只会有一个绘制调用。