通用材质系统介绍

材质系统是一个实时渲染引擎非常重要的部分,它使得开发者能够非常便捷地设计出具有真实感的场景和角色。一个好的材质系统可以提高引擎的易用性,并可以方便的扩展渲染效果,来提升渲染质量和效率。

材质系统需求

图形引擎通常需要支持不同的渲染效果,一个优秀的材质系统通常要支持多Pass渲染管线和自定义Shader模板,由于渲染效果的复杂多样性会导致Shader数量大幅增加,这样会造成Shader文件冗余,因此材质系统要提供一套Shader复用的机制。同时,市面上各硬件厂商对图形API的支持程度不同,受限于硬件水平的差异,材质系统也要兼容中低高端硬件。综上所述,通用材质系统需要满足以下需求:
多Technique:材质中包含多个实现方案,这样在进行高中低端机适配或实现不同材质效果时,我们可以方便进行材质更替。
多Pass:对于复杂绘制效果,单次绘制无法实现,常常包含多个Pass的渲染。
自定义Shader:减少Shader数量,提供Shader复用机制。
模板 + 实例:材质是一个模板,通过对某一个材质进行实例化,指定不同的数据和贴图,就可以让物体表现出不同的显示效果。

材质数据

材质描述了场景中物体与光照进行交互的过程,本质上它是指能够描述一个物体显示外观的一系列数据,它包括几个方面:

  • 着色模型(Shading Model):着色器的组合,决定了材质的参数与光照参数如何被处理,从而合成最终的颜色。比如最基本的着色模式为:Surface Color =  Diffuse + Specular + Emissive + Ambient。
  • 渲染状态:比如剔除模式(正面、背面、不剔除),混合模式(开启,关闭),混合因子,深度测试,模板测试等。
  • 混合模式(Blend Mode):决定了几何对象渲染完成后如何与场景中的其他物体进行叠加,混合模式会影响对象的绘制顺序,混合模式的渲染次序从先到后是:不透明(Opaque) > 蒙版(Masked) > 半透明(Translucent) > 叠加(Additive) > 相乘(Modulate)。
  • 参数: Shader中使用到的Uniform参数,比如纹理贴图,采样器,颜色因子,相机参数,光源参数,Pass间的混合参数等。

材质模板

材质文件就是将上述的材质数据进行合理的组织,方便应用开发者使用,通常材质文件被划分为三个重要的模块:

  • Defines:宏列表,定义Shader宏有什么值。
  • Properties:定义Shader中参数的值。
  • Technique:Pass列表,定义渲染用的状态和Shader文件。

总结下来,一个材质模板文件应该是类似这样的一个结构:

Material “ForwardPbr” {
Properties {
	Color(“Color”,Color)=(1,1,1,1)
	SpecularColor(“SpecularColor”,Color)=(1,1,1,1)
	Gloss(“Gloss”,Range(8.0,256))=20
}
     Technique {
         Pass {
Blend One One
CullMode None
SkinningEnable true
Shader “ForwardPbr.vert”
Shader “ForwardPbr.frag”
}
         Pass{}
     }
     Technique {
          Pass{}
          Pass{}
     }
}

CGKit的材质系统

图形引擎中提到的材质贴图和物体颜色,高光计算,ALPHA混合、纹理过滤、裁剪模式等,在Vulkan中大多数由渲染管线控制。

Vulkan图形渲染管线介绍

Vulkan中的图形管线决定了顶点数据如何被程序加工与处理,以及几何对象的渲染顺序,提交到设备的状态控制值,着色器模型,是图形引擎的核心模块,Vulkan的图形管线包括以下几个部分(其中Vulkan通过Pipeline State Object进行状态管理):

在图形引擎或游戏引擎中,我们通常用材质文件来描述上述PSO状态数据,Shader数据和贴图数据,通过各种变换操作,最终将网格使用到的顶点数据转化为屏幕空间像素。材质决定了应用最后的展示效果和图像质量。

CGKit材质系统介绍

CGKit的材质系统同样也要满足通用材质系统的需求,支持多Technique、多Pass和自定义Shader。

CGKit材质系统架构图和类

CGKit的材质系统主要由以下类组成,我们简短介绍下它们的功能:
Material:包含了创建一个材质需要的所有资源,包括属性定义,Shader文件定义,纹理贴图,渲染状态设置,由多个Technique组成。
PipelineState:Vulkan的PSO的封装,包括了管线中的所有状态。
ShaderProgram:表示渲染一个模型用到的所有Shader,负责把glsl编译成SPIRV,并反射出所有的ShaderResource。
ShaderResource:通过Shader反射系统获取的Shader资源,可以获取到资源的名字,位置等信息。
DescriptorSetLayout:定义了Shader中的资源与DescriptorSet的映射。
PipelineLayout:管理一组描述符集合布局。
DescriptorSet:管理一组Shader资源。
RenderPipeline:用于渲染的Vulkan管线。
MaterialInstance:根据Material文件创建材质实例,会根据Material文件创建DescriptorSetLayout,DescriptorSet和RenderPipeline。
它对应的架构图如下所示:

CGKit材质模板

CGKit使用json文件格式定义材质模板,材质文件描述伪代码如下:

“Material” : {
“basePath” : “material/ ForwardPbr.cgmat”,
“Properties” : [{
“name” : “Color”,
“type” : “vec4”,
“value” : “1,1,1,1”
},
{
“name” : “albedo”,
“type” : “texture”,
“value” : “models/chip/chip_albedo.png”
},
{
“name” : “normal”,
“type” : “texture”,
“value” : “models/chip/chip_normal.png”
}]
     “Techniques” : [{
     	“Pass”:[{
"rasterizationState": {
"cullMode": " NONE"
},
"depthStencilState": {
   	"depthTestEnable": true,
     "depthWriteEnable": true
},
“SkinningEnable” : true,
"shader": [{
"type": "SHADER_STAGE_TYPE_FRAGMENT"
          "uri": "shaders/basic_pbr.frag",
     }],
}]
    }]
}

CGKit自定义Shader

材质系统中最重要的一块就是Shader文件的配置,实现Shader的自定义需要完成以下功能:

  • Shader编译;
  • Shader代码复用;
  • Shader拼接;
  • Shader反射;
  • Shader参数更新;

Shader编译

Shader只是一段可执行的汇编代码,我们无论是使用GLSL、HLSL、CG,或者使用Unity的Unity Shader,最终提交给GPU时,都需要将这些高层实现语言编译成二进制的汇编语言。
CGKit的图形API是Vulkan,而Vulkan使用的是SPIRV格式的Shader,我们通过KhronosGroup提供的Glslang可以将GLSL、HLSL编写的Shader代码编译成SPIRV中间代码。CGKit使用Glslang将GLSL转换称为SPIRV:

External/`uname -s`/bin/GlslangValidator -H -o Asset/Shaders/Vulkan/pbr_ps.spv Asset/Shaders/Vulkan/pbr.frag

Shader代码复用

不同的渲染效果需要不同的Shader实现,每个Shader完全独立输入的方式会造成Shader文件大量的冗余,CGKit提供了一套Shader代码复用的机制,通过将Shader进行模块划分并增加预处理宏来减少Shader数量。
鉴于Shader存在大量通用的数据结构及函数,通过对Shader进行合理模块划分,可以达到Shader代码复用的功能。比如我们对不同的材质效果进行整理,找出它们数据结构之间的共性,抽取通用部分放在独立的glsl文件里,然后将剩余独有的部分保留在各自的文件里。
通常我们会将一些常量数据,如灯光,MVP矩阵,相机参数,材质贴图(如阴影图,PBR材质模型贴图)放在cbuffer.glsl文件中。同样的会将一些通用算法,如求交函数,伪随机函数,插值函数,光照阴影计算,PBR中的各种GGX计算函数放在一个functions.glsl文件中。
为了复用Shader的数据结构和算法,CGKit在Shader中定义了预处理宏,通过在材质文件中开启或关闭这些宏来动态启用或关闭Shader代码,达到了减少Shader文件数量的目的。例如我们可以动态开启和关闭一些渲染效果,如光照,阴影,雾效等等。
因为要动态开启和关闭宏,CGKit通过Glslang对Shader实时编译,为避免实时编译增加Shader的加载时间,CGKit同时也提供了Shader缓存机制。

Shader拼接

CGKit使用GLSL Shader,由于GLSL语言不支持#include预编译命令,我们需要用命令行工具把不同模块的Shader文件重新组合起来,形成一个完整的GLSL Shader:

cat Asset/Shaders/cbuffer.glsl Asset/Shaders/functions.glsl pbr_ps.glsl > Asset/Shaders/Vulkan/pbr.frag

Shader反射

对于Shader里面的符号变量,如uniform buffer,texture sampler,push constant,specialization constant,CGKit需要与这些符号变量进行交互,通过材质系统设置或更新它们的值,因此,我们需要通过一套反射机制获取到对应变量的name,set,bind,location等信息。
SPIRV-Cross提供了一套Shader的反射机制,CGKit首先通过Glslang将指定的GLSL格式的Shader代码编译成SPIRV,再通过SPIRV Reflection将SPIRV code里面的符号变量全部反射出来。

Shader参数更新
Shader中的数据流主要包括两部分:

  • vertex、index buffer等mesh提供的数据:这部分属于Shader固定输入,在创建管线的时候指定好顶点格式声明,在渲染的时候绑定相应的顶点,索引buffer即可。
  • uniform buffer,texture sampler:这部分输入需要CGKit通过Descriptor Set进行设置和更新。通过SPIRV-Cross的Shader反射,我们可以获取到对应资源的名字,位置信息。因为我们是通过材质文件来更新这些Shader资源的,所以我们在材质文件里面指定了这些参数,通过严格按名字匹配来更新Shader资源。因此我们建议用户尽量统一Shader里面的参数名字,并定义在公共头文件中。

CGKit材质创建

CGKit根据材质模板生成材质实例,生成材质实例的过程其实是自动化创建Vulkan纹理贴图,描述符集合布局,管线布局,描述符集合,渲染管线的过程。
CGKit加载材质的时候根据Shader反射填充好描述符集合,在更新Shader的uniform buffer,texture sampler时,会相应地更新DescriptorSet,在提交绘制命令时,只需要绑定不同的DescriptorSet就能切换不同的资源。

创建DescriptorSetLayout

创建描述符集合布局分两步:

1. 通过Shader反射机制获取ShaderResource:材质文件里面定义了一个渲染对象需要用到的所有Shader,我们通过Shader的反射机制将Shader文件里面的符号变量资源反射出来,作为一个Shader资源存放在ShaderProgram类,Shader资源包含了资源的名字以及所属的描述符集合的索引和绑定槽,类似下面的结构体:

struct ShaderResource {
    String name = “”;
    ShaderStageFlag stageFlag = SHADER_STAGE_VERT;
    ShaderResourceType type; // 资源类型
    u32 set = 0;
    u32 binding = 0;   // binding
    u32 arraySize = 0;   // 对应VkDescriptorSetLayoutBinding的descriptorCount
    u32 offset = 0;  // for push constants
    u32 size = 0;   // for push constants
    u32 constantID = 0;   // for specialization constants
    u32 location = 0;
    u32 inputAttachmentIndex = 0;
    u32 vecSize = 0;
    u32 columns = 0;
};

其中Shader中资源的类型如下:

enum ShaderResourceType {
    SHADER_RESOURCE_TYPE_INPUT,
    SHADER_RESOURCE_TYPE_OUTPUT,
    SHADER_RESOURCE_TYPE_BUFFER_UNIFORM,
    SHADER_RESOURCE_TYPE_BUFFER_STORAGE,
    SHADER_RESOURCE_TYPE_INPUTATTACHMENT,
    SHADER_RESOURCE_TYPE_IMAGE,
    SHADER_RESOURCE_TYPE_IMAGE_SAMPLERR,
    SHADER_RESOURCE_TYPE_IMAGE_STORAGE,
    SHADER_RESOURCE_TYPE_SAMPLER,
    SHADER_RESOURCE_TYPE_PUSH_CONSTANT,   // for pipeline layout creating
    SHADER_RESOURCE_TYPE_SPECIALIZATION,  // for Shader stage creating
    SHADER_RESOURCE_TYPE_All
};

2. 根据ShaderResource创建描述符集合布局:通过Shader反射后ShaderProgram类最终拥有不同的描述符集编号,及其对应的ShaderResource。我们根据ShaderResource生成DescriptorSetLayoutBinding,当然,要去掉四种没有绑定槽的资源(Input,Output,PushConstant,SpecializationConstant)。然后根据DescriptorSetLayoutBinding信息生成DescriptorSetLayout。在DescriptorSetLayout类中,我们可以根据资源的名字得到它的绑定槽,以及对应的描述符布局绑定信息。

创建DescriptorSet

创建描述符集合分两步。
1. 创建DescriptorPool:规定好每个描述符池能够分配的最大描述符集合个数,假定为16,从DescriptorSetLayout中获取所有的Bindings,统计描述符的数量,用这个数与最大描述符集合个数相乘,得到描述符池的大小,依据这个大小创建描述符池。描述符池会容许创建16个描述符集合,如果描述符集合的数量超过了16,则重新分配一个描述符池。

2.根据DescriptorSetLayout和DescriptorPool生成描述符集合:同类型的描述符集合会对应多个描述符池。

创建PipelineLayout

根据DescriptorSetLayout和Shader中的push constant资源创建管线布局。

创建RenderPipeline 

从PipelineState中获取管线的状态信息和Shader信息,从mesh中拿到顶点布局信息,创建管线。

CGKit材质应用

材质资源一旦被创建,就可以添加到各种渲染组件中进行渲染。如果要修改材质表现效果,我们只需要在运行时动态修改材质参数,包括渲染状态,纹理参数,Shader参数,Shader文件,就可以达到目的,不需要关注材质系统底层做了什么事情。

CGKit材质系统优化

材质排序

我们都知道,像Vulkan这样的图形接口每设置一次GPU状态的时候,都会有一定的开销。为保证渲染流畅,我们要尽量减少状态切换来降低开销。
在CGKit中,通过对几何对象的材质进行分组排序,将相似的材质排在一起可以减少渲染流程中的状态切换,从而达到提高渲染效率的目的,分组的顺序如下:

  • 先按混合模式分组,顺序为:不透明 > 蒙版 > 半透明 > 叠加 > 相乘;
  • 混合模式分组后,每组中的对象再按着色模型分组;
  • 着色模型分组后,每组对象再按纹理分组;
  • 纹理分组后,再按其他参数分组。

即分组的优先级为:混合模式 > 着色模型 > 纹理对象 > 其他参数。

调整资源更新频率

Shader资源在渲染时需要不断更新,而且每个资源的更新频率会不一样。应用需要指定每个资源的更新频率,按照资源的更新频率可以把Shader资源分为三种类型:

  • Static:只要绑定了就不会改变的资源,例如相机属性(包括相机位置,视图矩阵,投影矩阵),光照属性(光源类型,光源位置,光源方向,光源颜色,光照强度,光源衰减因子),屏幕宽高,阴影Shadowmap等全局常量。
  • Mutable:相当于材质的更新频率,例如漫反射贴图、法线贴图,自发光贴图,切换一个材质就会更新一次。
  • Dynamic:随时都可能更新的资源,如模型的世界矩阵。

预先创建管线

Vulkan中的图形渲染管线几乎不可改变,如果需要更改Shader,混合,光栅化等状态的设置,则必须重新创建管线。因此我们可以预先创建好所有的管线,这样管线的操作都是提前知道的,则可以通过驱动程序更好地优化它。

缓存机制

随着场景复杂度的增加,材质文件数量会变多,与材质创建相关的资源会大量重复,我们可以将这些资源缓存起来,避免资源的重复创建并加快资源的加载和创建。与材质创建相关的资源主要有Texture,Shader,DescriptorSetLayout,PipilineLayout,DescriptorSet,enderPipiline,我们可以将这些资源都缓存起来,加载资源的时候,先从缓存里面查找,找不到,再从磁盘中加载和创建。

>>访问华为图形计算服务官网,了解更多相关内容
>>获取华为图形计算服务开发指导文档
>>华为HMS Core官方论坛
>>华为图形计算服务开源仓库地址:GitHubGitee

点击右上角头像右方的关注,第一时间了解华为移动服务最新技术~

06-03 10:27