当我们在ue4中制作了一个美术材质之后,引擎本身会为我们做很多事情,它会把结点翻译为hlsl,生成多个shader变体,并在多个mesh pass中去选择性的调用所需的shader,其中一个重要的过程就是获取shader绑定的数据。
本文将主要讨论ue4是如何处理来自材质的不同的输入,它们将以怎样的形式传递给shader,以怎样的频率更新,并在调用层做了怎样的优化处理。
我们在材质中能控制输入的地方有两处,一个是材质直接输入,另一个是材质参数集合:
① 材质直接输入。
我们可以在母材质中开放给shader的参数,如美术纹理和参数。静态的参数我们使用静态材质,动态可运行时修改的参数我们使用动态材质。
这种输入的特点是每个材质独享一份参数输入,因此它在底层设计为每个Material实例独立的Uniform Buffer,在ue4中使用FUniformExpressionCache数据结构来描述。
② 材质参数集合(MPC, Material Parameter Collection)
我们还可以创建MPC资产,可以添加vector或scalar参数。
这种输入的特点是参数可以在多个材质共享,比较适合一些全场景的效果控制,但一个材质支持输入的MPC数量是比较有限的。
它在底层设计为场景中全局的Uniform Buffer集合,同时每个shader引用到的MPC索引由FUniformExpressionCache记录,可方便我们快速查找。
材质会生成多个shader变体,其中可能包括prepass, shadowpass, basepass的vs和ps,根据顶点类型的不同可能还包括了不同的顶点工厂(Vertex Factory),比如staticmesh, instance或skeletal。这些shader是在代码中定义的,每个特定的shader还可以指定一些输入。
③ 顶点工厂的输入
主要包括的是顶点属性(Vertex Attribute)的输入,包括位置、法线、顶点色、实例位置等。我们可以使用Vertex Buffer或Buffer传输这些数据。
④ 顶点或像素着色器的输入
主要包括的是更加底层模块的一些输入,比如光照/阴影/大气等的输入。
这部分的输入格式是由代码指定的,和材质中主要通过Uniform Buffer来绑定不一样,这里输入的格式更加灵活,一些共用的数据可能会放在Uniform Buffer中,而一些常量(Loose Data)可能直接绑定到shader中,我们还可以绑定一些资源类型的数据(SRV),比如Texture2D, Buffer, Structure Buffer等。
⑤ 全局输入
在多个shader中共享的数据或者一些必需的数据会设计在全局的Uniform Buffer中。
比如在basepass和defer pass中都可能会用到的各种LightUniformBuffer,存储灯光方向等信息;几乎在所有pass都会用到的ViewUniformBuffer,存储相机位置等信息。
还设计了一些高频数据作为独立的Unform Buffer,比如逐物件的PrimitivieUniformBuffer等;
ue4在开始计算场景可见性(SceneVisibility)前,会先尽早地完成一些数据的上传,让GPU先开始忙碌起来,这样的话可以不阻碍后续的一些渲染工作,包括:
① 上传Primitive Uniform Buffer(静态物件初始化调用,动态物件多帧上传)
② 上传GPU Skin(蒙皮物件动画信息)
③ 上传Material Uniform Buffer(静态材质初始化调用,动态材质多帧上传)
④ 上传Material Parameter Collection(更新时上传)
⑤ 上传一些代码中定义的Uniform Buffer(如View等)
还有一部分数据比如LightmapUniformBuffer一般都是静态的,所以不会频繁更新,我们也较难捕获到这方面的数据。
单个ub数据上传的时间并不算太长,大概是us的量级。但如果场景中使用了大量的动态物件和动态材质,整体的上传时间还是比较可观的。
当我们向shader传入特定输入的时候,意味着shader中应该有对应的变量。材质中的变量是由ue4自动生成的,而代码中则是程序指定的变量。
在整个绘制工作流中,我们会首先完成shader的编译,并且去收集这些shader中存在的绑定信息。当我们在C++中收集绑定输入的实际值时,会先去校验shader中对应的绑定点和slot id。
绑定类型
ue4的Mesh Draw管线中,我们使用Shader Binding来完成这一点,它是一种延迟的设计,因为它会先去收集所有可用的绑定实参,提交前再调用实际的RHI层的绑定。
它支持的类型包括Uniform Buffer,Sampler,SRV(texture, buffer),Loose Data,顶点的输入则由Input Stream负责,不包含在Shader Binding负责的范畴中。所有的绑定信息我们可以认为是一个Input Layout,它可编码为缓冲区。
其中,Uniform Buffer, Sampler, SRV记录的是实际分配的引用,是一种分离式的设计;而Loose Data是我们直接绑定在shader上的参数,存储了实际的数据,可以理解为一个内联的常量数据。
API映射
我们在API中完成一次drawcall,通常会设置首先去各种状态量和绑定量,包括:
● SetPipelineState
● SetVertexBuffer/ SetIndexBuffer
● SetShaderBinding
对于CPU端来说,消耗主要体现在数据的准备和调用上;实际指令执行的过程中,GPU也会产生状态切换的消耗。
在API底层,如在Vulkan中,Shader Binding的调用会被映射为vkCmdBindDescriptorSets;dx12则相对复杂,它可能会映射到SetGraphicsRootConstantBufferView或SetGraphicsRootDescriptorTable等。
Vulkan的设计可能会产生更少的调用,而DX12的设计会更加适合输入排列的复用,但大多数的游戏引擎并不会优化到这么细致。
在输入调用上,Shader Binding之所以要设计为延迟调用,是为了尽可能缓存一些绑定命令,减少CPU端渲染指令调用的次数。缓存的可复用性依赖于绘制对象的排序,我们应该尽可能把共享相同状态的对象合并到一起。
在缓存机制上,我们可以去缓存的内容包括PipelineState,它包含的最重要内容就是Shader,如果Shader Code完全一致仅仅是输入不同我们是可以缓存的。其次是一些输入,比如Uniform Buffer, SRV等,这些数据的缓存会对一些图形API产生收益。
实际实现中,Shader Binding会去维护一个缓存的状态,只有在绑定发生变化的时候,才去实际调用RHI层的设置接口,当我们把具有相同状态的对象排列在一起时,尤其是使用相同Shader的物体,缓存优化会得到较好的收益。