filament是Google开源的一个跨平台实时pbr渲染引擎。注意,这是一个渲染引擎,不是一个完整的游戏引擎。
filament的材质系统文档:Filament Materials Guide,pbr算法文档:Physically Based Rendering in Filament。这些文档只是从使用层面简单介绍材质系统和使用的PBR算法等,并没有深入介绍材质的整体流程和一些关键技术细节。因此,本文打算深入介绍材质系统相关的整体流程以及材质渲染相关的关键技术细节。
一. 材质编写
这部分大概介绍下材质相关的语法。以下面的材质示例代码来说明:
1 | material { |
从上述示例代码来看,一个材质分开三块:material、vertex、fragment。其中,material是材质熟悉块,vertex是顶点代码块,fragment是片元代码块。如果是一个compute材质,那么只有一个compute代码块。
1.1 材质属性块
这部分包括所有的材质设置,比如渲染状态设置、材质的uniform参数以及各种其它设置。举例说明,blending是混合模式,是渲染状态设置;shadingModel是光照模型,类似一个材质变体设置;parameters则是uniform参数,比如float最终是存放在材质的uniform buffer内,而sampler2d是生成uniform sampler。
1.2 代码块
filament的材质是一种surface材质。surface材质是一种受限制的材质,意思是一种只开放表面属性修改的材质,这种材质书写方式入门比较简单,但是功能比较受限制。unity的默认管线也支持surface材质,具体可以参考Introduction to surface shaders in the Built-In Render Pipeline。
1.2.1 顶点代码
vertex下的入口函数是materialVertex,只能在该函数内修改inout的MaterialVertexInputs参数material来定制顶点着色器。MaterialVertexInputs是顶点的输入定义结构体,定义如下:
1 | struct MaterialVertexInputs { |
比如,color、uv0、uv1都是顶点属性。
1.2.2 片元代码
fragment也是类似的逻辑,入口函数是material,只能通过修改inout的MaterialInputs参数material来定制片元着色器。MaterialInputs结构体是pbr或者更复杂的渲染模型的属性,定义如下。
1 | struct MaterialInputs { |
因此,只需要简单的在入口函数内修改属性,就可以便捷的实现材质效果。
1.2.3 compute代码块
如果使用的是compute材质,那么代码块是compute代码块。示例如下:
1 | material { |
二. 材质编译
filament有一个专门的材质编译工具matc。该工具主要做了两件事情:一个是解析material材质属性块,根据材质属性块生成代码,需要序列化的材质属性写入材质属性数据块中;一个是编译材质代码,将编译后的所有的变体代码写入到代码数据块中。
整体流程图:
2.1 材质属性编译
材质属性,指定是material块内的定义。材质属性主要包括两类,生成代码的属性和其它属性。生成代码的属性包括parameters和constants属性,parameters和constants属性会生成代码定义,同时也会进行序列化;而其它类型的材质属性会在解析后进行序列化。
2.1.1 parameters属性
parameters块内定义的属性,type指定类型,主要是两类:数值类型和采样器类型。最终,所有的数值类型会存放到一个着材质实例的uniform buffer内。而每个采样器类型的参数,都会生成一个着材质实例的uniform sampler。
2.1.2 其它属性
这部分属性包括,材质渲染状态设置、光照模型相关等。对于编译器来说,这部分设置解析出来后再序列化就行。
2.2 材质代码编译
2.2.1 生成材质属性定义
示例代码的parameters生成的ubo定义可能如下:
- Uniform Buffer生成
1 | layout(binding = 10, std140, set = 1) uniform MaterialParams { |
- Uniform Sampler生成
示例代码的parameters生成的uniform sampler定义可能如下:
1 | layout(binding = 10, set = 2) uniform lowp sampler2D materialParams_baseColor; |
- Specialization Constants生成
示例代码的constants生成的specialization constants定义可能如下
1 | layout (constant_id = 8) const bool materialConstants_overrideAlpha = false; |
至于什么是specialization constants,请参考文档:Utilizing Specialization Constants。简而言之,这是一种将编译器的预处理阶段的宏延迟到gpu编译阶段的手段。
三. 材质加载和材质实例创建
3.1 整体介绍
这部分主要涉及三个类,材质类Material、材质示例类MaterialInstance、材质解析类MaterialParser。Material类对应的是Unity的Shader类,MaterialInstance类对应的是Unity的Material类。整体流程如下:
3.2 材质创建
3.2.1 数据解析
- MaterialParser
材质创建的第一步是解析二进制的材质数据,即材质编译阶段序列化各种数据块,包括材质属性数据块以及材质代码数据块。该类的parse函数会遍历解析出所有的数据Chunk,保存起来作为后续真正的数据解析使用。 - 兼容检测
将所有的数据块全部加载到内存中后,再做一些基本的检测,比如材质版本号匹配、shaderModel(平台)匹配等,如果通过才真正去创建材质。3.2.2 创建材质对象
材质数据加载后,并且通过兼容性检测后,会调用engine的createMaterial函数去创建材质对象。filament的所有gpu相关对象最终都是通过engine类来创建管理的。3.2.3 初始化材质属性
在FMaterial类的构造函数内,会通过调用MaterialParser的具体解析函数,将所有材质数据属性解析出来,然后做好相应的运行时状态初始化。 - 描述集Layout
材质内有两个DescriptorSetLayout,一个是材质本身的DescriptorSetLayout,一个是PerView的DescriptorSetLayout。这两个layout是用于提交材质级别和PerView级别的数据。材质本身的DescriptorSetLayout用于后续初始化材质实例的DescriptorSet;材质实例的DescriptorSet用于提交材质数据。 - SpecializationConstants
specializationConstants是一种新的动态变体技术,具体在生成材质属性定义里面有介绍。在材质解析阶段,需要解析出材质的specializationConstants设置,然后在切换材质变体时候通过传递给对应的gpu program进行切换。
filament的specializationConstants变体问题
filament将specializationConstants数据保存在材质内,会导致设置时候引起该材质所有的材质实例变化。这并不喝了,因为变体从使用上是材质实例级别的,不同的材质实例需要保存不同的变体设置,所以应该通过材质实例来保存和设置变体数据。 - pushConstants
关于什么是pushConstants ,参考文档:Push Constants。简单理解,即这是一块区分于UBO的小数据块,相比UBO有一定的性能优势,但是大小受限制。
实际上,当前版本的filament只是用pushConstants处理了引擎morphing数据,并没有开放材质级别的写法。如下代码,材质文件固定序列化上述代码的pushConstants。1
2
3
4
5
6
7utils::FixedCapacityVector<filament::MaterialPushConstant> const PUSH_CONSTANTS = {
{
"morphingBufferOffset",
filament::backend::ConstantType::INT,
filament::backend::ShaderStage::VERTEX,
},
}; - 其它属性
其它属性涉及范围比较广,比如渲染状态的设置,包括深度测试/写入、混合、模板测试等,也包括材质相关的设置,比如材质效果相关的设置等。这些属性都会序列化为数据块,然后在材质创建时候,通过MaterialParser解析出来保存在材质内。3.3 材质实例创建
创建材质实例有两种路径,但是初始化流程是一致的,都是初始化描述集和其它材质属性。 - 初始化描述集
材质实例的DescriptorSet通过材质的DescriptorSetLayout进行初始化。材质实例使用该DescriptorSet进行数据的提交和绑定,包括Sampler和Uniform Buffer。因此,需要在初始化阶段将材质Uniform Buffer的通过调用setBuffer设置给DescriptorSet。 - 初始化其它材质属性
其它材质属性,基本是通过从材质或者其它材质实例内拷贝的方式设置的。这些存储在材质实例内的材质属性,通常与渲染状态或者渲染效果相关,比如混合、深度测试/写入等。
四. 材质变体
filament支持基于宏的传统变体,即每一个变体是一个gpu program。在编译材质时候,根据不同的宏定义组合编译出不同的gpu program;在运行阶段,根据stage和变体匹配到对应的gpu program。
4.1 变体定义
4.1.1 Surface材质变体
filament有一个Variant类,里面定义了Surface材质可以使用的变体。如下摘自其变体注释代码:
1 | // DIR: Directional Lighting |
1 | static constexpr size_t POST_PROCESS_VARIANT_BITS = 1; |
根据上述定义,后处理材质支持2个变体。
4.1.3 Compute材质变体
compute材质不支持变体切换。
4.2 变体编译
- 计算所有有效变体组合
1 | // Generate all shaders and write the shader chunks. |
determineSurfaceVariants函数是跟Variant类的定义,遍历出所有有效的顶点变体和片元变体。determinePostProcessVariants则是返回2个固定的顶点和片元变体。determineComputeVariants返回默认的一个0变体。
- 根据变体组合生成宏定义
材质编译工具的代码生成类ShaderGenerator里面有一个generateSurfaceMaterialVariantDefines函数,该函数会根据变体组合variant来生成对应的宏定义。其部分代码如下:
1 | void ShaderGenerator::generateSurfaceMaterialVariantDefines(utils::io::sstream& out, |
所有的宏定义组合就对应一个变体组合的program。
4.3 变体切换
变体编译阶段是根据材质类型、Stage(顶点/片元)、变体组合来生成对应的program的。因此,变体切换阶段,也是根据这些信息查找出对应的program代码。然后,使用这个代码(比如SPIR-V中间代码)来创建gpu program。
部分关键代码如下:
1 | void FMaterial::prepareProgramSlow(Variant variant, |
getSurfaceProgramSlow最终会调用到MaterialChunk::getBinaryShader从二进制材质数据中查找出指定变体的二进制代码。makeKey函数的参数是shaderModel(平台)、variant、shaderStage,这个key就是变体查找的键值。
4.4 变体使用
filament的变体使用方式分为三步:
- 计算变体组合
这一步通常是渲染管线或者渲染Pass在计算。比如,ColorPass或者DepthPass、PickingPass等。 - prepareProgram
使用第一步计算出的变体调用材质函数的prepareProgram以准备变体。
1 | void prepareProgram(Variant variant, |
- getProgram
使用第一步计算出的变体调用材质函数的getProgram获得对应变体的program的handle。
1 | backend::Handle<backend::HwProgram> getProgram(Variant variant) const noexcept { |
然后将获得的program赋值给pipeline的program。
4.5 再谈SpecializationConstants变体
4.5.1 filament变体的问题
根据前述变体使用的步骤,在使用filament的变体时候并不方便。
1. 首先,变体设置是需要在外部计算的。
2. 其次,变体设置并没有保存在材质实例内,反而需要外部代码保存。
3.从使用角度上来说,应该在材质实例内保存其对应的variant,然后一个材质实例对应一个program才更方便理解。
4.5.2 filament的SpecializationConstants变体问题
filament的specializationConstants变体也存在类似的问题。specializationConstants变体是通过传入设置数据给gpu program对象来生效的,而且specializationConstants变体的设置还存在在材质内。
4.5.3 优化思路
因此,比较好的优化方向是将variant和specializationConstants的状态都保存在材质实例内;然后,通过材质实例来从材质中获得不同的program和设置不同的specializationConstants数据。
五. 渲染数据的提交和绑定
最后再来讲一讲渲染使用的数据提交和绑定。渲染数据一般指定是Buffer和Sampler,也包括其它一些特殊数据,比如SpecializationConstants和pushConstants。从作用范围上来区分,渲染数据一般能分为三个级别,渲染当前pass要使用的全局数据、当前使用材质的数据、当前drawcall对应的物体的数据。
DescriptorSet
首先,需要明确filament提交和绑定Buffer和Sampler数据的封装类DescriptorSet。filament通过该类提交和绑定Buffer和Sampler。因此,后续三个级别的数据提交都是通过对该类的封装进行。
5.1 渲染Pass数据提交和绑定
通常是封装一个Pass级别的数据提交类,比如ColorPassDescriptorSet、PostProcessDescriptorSet、SsrPassDescriptorSet。这些类里面有一个DescriptorSet对象用于真正的数据提交和绑定。
1 | class PostProcessDescriptorSet { |
后处理Pass会在适当的时候调用相关函数进行数据提交和绑定,一般是在Pass的最开始进行设置。
实际上,一些数据是全局的,可以一次设置后不用改变。所以,Pass级别的数据不一定完全遵守Pass级别的作用域。比如,PostProcessDescriptorSet的setFrameUniforms实际上是在renderJob一开始就调用了,如下代码:
1 | void FRenderer::renderJob(RootArenaScope& rootArenaScope, FView& view) { |
5.2 材质数据提交和绑定
所有的渲染Pass都必须使用材质,渲染物体的Pass需要切换材质,后处理Pass则是使用一个材质。材质数据的使用也是需要有时机的。材质数据实际上会覆盖Pass级别的数据的设置,如果有重复的话;不过,按照filament的定义,这两部分数据是不会互相影响的,因为使用的是不同的DescriptorSetLayout。实际上,filament定义pipeline里面已经对PerView和PerMaterial的DescriptorSetLayout做了区分。PerView的layout就是前面所说的渲染Pass级别的数据。代码如下:
1 | struct PipelineLayout { |
prepare是在FRenderer::beginFrame里面调用的,即每帧开始时候会提交所有的材质实例数据,实际上这里主要是非处理材质,后处理材质是在commitAndRenderFullScreenQuad内提交的。commit内做了两件事情,一个是更新UBO数据,一个是通过DescriptorSet进行commit。
5.2.2 材质绑定数据
绑定材质数据是通过调用材质实例的use函数。材质绑定数据要在调用drawcall函数之前,比如renderpass里面就必须在执行每个command的drawcall前绑定,后处理这种Pass则需要在最终执行渲染drawcall之前绑定数据,可以参考commitAndRenderFullScreenQuad函数代码。下面代码段是RenderPass内绑定材质数据的代码:
1 | if (UTILS_UNLIKELY(mi != info.mi)) { |
上述代码在RenderPass::Executor::execute内执行command的一段,可以看到在材质实例变化时候,会调用材质实例的use函数绑定不同的材质级别数据。
5.3 Renderable数据提交和绑定
同一个渲染Pass的Command,可以包括多个材质实例;同一个材质实例,可以渲染多个物体。因此,物体级别的数据优先级别是最高的。
5.3.1 初始化Renderble数据
1 | struct PrimitiveInfo { // 56 bytes |
如上代码,RenderPass内的Command定义内有一个图元信息结构PrimitiveInfo。该结构内有一个backend::DescriptorSetHandle成员dsh。dsh表示的就是Renderable级别的渲染数据。dsh是在RenderPass::generateCommandsImpl内通过场景数据FScene::RenderableSoa内的FScene::DESCRIPTOR_SET_HANDLE数据初始化的。
5.3.2 绑定Renderble数据
在执行每个command的drawcall之前,会调用driver.bindDescriptorSet绑定Renderable级别的数据。
1 | driver.bindDescriptorSet(info.dsh, |
5.4 其它数据
5.4.1 SpecializationConstants
这部分之前提过,filament是通过切换变体时候,将材质内保存的constants设置数据传递给gpu program,也提到过这部分在实现上有一定的不合理。
5.4.2 PushConstant
1 | if (UTILS_UNLIKELY(info.hasMorphing)) { |
pushConstant数据则是在调用drawcall之前通过driver.setPushConstant传入driver。目前,filament只支持固定的pushConstants数据。