[ue4] 着色器绑定(Shader Binding)

        当我们在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的物体,缓存优化会得到较好的收益。

你可能感兴趣的:(ue4渲染,ue4)