在设计引擎材质Shader部分的时候遇到了非常多的问题,也思考了一下,进行了一些总结随便写写,无AI。

给用户提供Shader有两种模式,一种是生成式,一种是自由式。

生成式与自由式

生成式的概念有点像填空,引擎只开放部分可编程环节,在这种模式下优化和特性是最好做的,用户可以比较容易编写出不关心具体实现的代码。就像UE中用户可以通过材质蓝图来修改,通过返回WorldPositionOffset修改顶点位置,然后通过一系列BaseColor、Opacity等ShadingModel使用的参数来控制着色。用户不需要关心纹理是如何采样的,cbuffer布局是怎样的。可以很容易的做出根据不同平台进行优化。但并不仅限于可视化,也可以生成部分HLSL代码,只允许用户填空。

自由式则是像Unity,HLSL的代码近乎原生,绝大多数都是引擎层面来处理。用户需要自己定义参数、cbuffer、以及纹理绑定。引擎通过反射来获取Shader的信息,然后用户可以 通过引擎制定的某些规则来获得额外的性能加速。

CBuffer

举第一个例子是材质CBuffer:通常一个材质会覆盖多个Pass(颜色、阴影、其他)多个渲染器(模型、蒙皮、后处理、粒子)多种变体(宏开关、静态开关)等多种Shader实例,但这些Shader通常需要的材质参数都是一致的,在一套参数下,生成式可以直接根据用户准备的参数来统一为所有变体版本生成一致的cbuffer布局,然后材质就可以只储存和修改一份Buffer。而在Unity这种自由式Shader的开发模式下,引擎没办法保证所有Pass所有变体的cbuffer布局一致,只能提出一种规范,如果符合SRPBatcher的规范那么就可以使用同一个Buffer。

因为更喜欢灵活一些的Shader系统,设计会偏向自由式一些,但是优化层面真的很麻烦,我并不想做成按照规范做就获得优化,不按规范就回退,而是硬性要求每个Shader的所有pass和变体的cbuffer PerMaterial布局一致,但这点不容易做到。我大概想了这么几种思路:

首编标准:每个shader第一个编译的shader实例的cbuffer布局作为标准,后续该shader下的其他pass变体实例如果不符合标准则无法通过编译,如果第一个编译通过的shader实例的布局是错误的也没关系,因为修改后会重新编译一个shader内的所有shader实例,最终要求用户把所有pass和变体的cbuffer布局改为一致 。

无变体标准:强制要求每个shader编译个无变体的shader实例,使用这个shader实例的cbuffer布局作为标准,其他不一致的编译失败。有想过这么干,但是又觉得一些情况下材质可能只会用到某个具体的变体版本(比如蒙皮版或者instancing版),而且层看过一些游戏的优化甚至会剔除无变体版本只用instancing版本,如果无变体版本强制编译只是用来获取布局的话,会多一些冗余。

前置布局:在shaderconfig中写好cbuffer布局,在shader还没编译时引擎就知道了这个shader下的实例的cbuffer布局应该是什么,如果编译后的布局和声明的不一致,那就当做编译失败。

 

纹理绑定与采样

生成式的纹理绑定与采样可以比较好的根据不同平台采用不同方案。

比如Bindless,Bindless是桌面平台非常好用的一个特性,可以极大减少纹理绑定,并且可以很好的合批或GPUDriven来加速性能。但Bindless的采样方式和普通纹理的采样方式并不一样,对于生成式来说,提供给用户的只是一个包装好的采样函数或者节点,用户声明了一个ColorTex的纹理,引擎生成一个SampleColorTex(uv)的函数或节点就足够了,用户只要提供uv就可以调用获取某个纹理的值,并不关心参数如何绑定的。而在自由式是这一点会相对困难,可能需要多种形式的兼容代码,或者制定某种规范,规范不符的无法通过编译。