前言
上篇 中铺垫了很多关于基底(Substrate
)材质系统,主要是关于“材料”这一部分的抽象提炼——每个部分被称为一个slab,而相互的组织方式是通过运算符。
有了单个Substrate,下一步就是考虑其上的一些组织方式、数据传输及计算——让我们从Substrate Tree开始。
本文还是以翻译原文PPT页及解说稿为主,打星号的部分则是我个人的补充。由于篇幅原因拆成了上下两篇,这是其中的下篇。
1 基底树——Substrate Tree
首先让我们假设你想渲染图中的黄色粗糙金属,它被有着蓝色高光的清漆包裹。
在使用Substrate的情况下,我们会用slab B表达粗糙金属,用覆盖在其上的slab A表达清漆层,通过一个垂直层叠(vertical layering)运算来组合。
但我们不能直接分别计算两个slab再对光照贡献进行累加。这会得到错误的结果,因为我们需要先计算slab A在slab B上层产生的效果,例如材质透射或fresnel效果等。(*即光线并不是全部到达了slab B)
为了正确计算这种效果,我们使用一个树状结构来作为材质的拓扑表达——运算符作为节点(nodes),slab作为叶子(leaves)。
We are going to process/walk/evaluate this tree in order to be able to output closures. Closures represent a bag of parameters that can be sent to the renderer for lighting evaluation, accounting for all the different operators effects on the visual result.
我们将逐步处理、遍历、计算这颗树以便输出结果的闭包(closures)。这里的闭包代表了一组参数,用以传递给光照计算渲染的阶段,基于所有不同的运算符需要的效果来计算最终的视觉结果。
在图中的例子里,我们可以看到Slab A在材质拓扑的顶层,因此光照可以直接计算;而slab B在层级的底部,因此我们需要考虑Slab A中通过的光线,以便能计算slab B的光照。
一旦经过求值计算(evaluated),一个Slab会输出一个闭包。每个闭包能以任意顺序或并行进行计算得出——每个闭包计算会输出一个亮度颜色(luminance color),然后所有闭包结果进行组合运算就得到最后的图像。
*这部分主要是说闭包是每个slab能自己算出的参数或属性单元(基于材料特性可以算一些例如颜色、法线、分布函数PDF等),闭包参数计算是顺序无关的,只在对树求值的阶段需要关注顺序。
让我们再看一个更复杂的例子。
图中我们有碳纤维(carbon fiber),水平混合了粗糙金属。你可以看到两个slab间的过渡区域——这就是两种slab通过一个水平混合(horizontal mixing)运算符链接的原因。
在这之上,你可以看到一个通过垂直层叠运算连接的清漆层。
在这颗树中我们需要计算大量的闭包,以便对slab之间的各种运算进行求值。例如:
- ToViewThroughput(可以翻译成:视觉方向的吞吐量),它受水平混合权重、上层吞吐量或覆盖率权重的影响。
- TopTransmittance(可以翻译成:上层传播系数,上篇讲过它是一个0到1之间的值),它代表了一个给定的slab的传播系数,我们可以在光照计算中使用它——通过一个简单的公式将实际的光线方向进行重映射(remapping)就能实现。
- 我们也输出粗糙度和厚度用于粗糙折射效果(后续展开细节)。
那么我们如何处理和遍历这颗树?为了保持展示页的相对简单,我们仅展示一下如何通过闭包计算光的覆盖率(Coverage)和传播系数(Transmittance)。
首先,我们计算每个slab的覆盖率和传播系数。可以看到图中底部的slab都是不透明的,因此它们的覆盖率是1,传播系数是0。
The colored coat slab has coverage of 1 and a transmittance mapping resulting from the mean free path as seen when viewing the material in isolation along the normal of the surface.
清漆层也有着1的覆盖率,而其传播系数的一组映射,是由沿着表面的不同法线方向单独“观察”这一层的mean free path(上篇中的一个概念)得出的。
*这里作者没有明确说这组参数的含义,个人猜想是最大、中位数、最小值。
其次,我们将处理每个运算符——深度优先,以便从slab中汇集信息。
因此,水平运算符收集了(其叶子节点的)覆盖率、传播系数的值。在图中的情况,两个不透明的slab共同组成了一个不透明的表面。
之后,处理根节点的垂直运算符——它也从叶子节点中汇集了覆盖率、传播系数。
在不透明表面覆盖清漆层,整体得到的仍是不透明表面。(*最终传播系数是0)
根节点的覆盖率、传播系数的值很重要:它代表了材质的整体覆盖率和整体传播系数,用以计算光吞吐量——如果用户选择了alpha blend混合和半透明渲染方式,则材质会按规则和场景颜色进行混合。(*即到了根节点才考虑计算和场景的半透明混合,之前都只计算闭包值)
另外,需要注意顶层的覆盖率和传播系数是被单独存储的,以便后续的一些计算用使用。
第三步,我们从每个slat开始向根节点遍历,以便计算其它的一些值,例如之前提到的ToViewThrouput或是TopTransmittance。
我们可以看到,在清漆层向顶层执行垂直运算时,这两个系数都是没有变化的。(*个人觉得这两个参数的取名有一定误导性,最终还是要结合后面的步骤如何计算来看)
碳纤维slab首先遍历到了水平混合运算。因而,它的ToViewThrouput会被混合权重影响。
之后碳纤维层遍历到了垂直运算节点,因此它的ToViewThrouput和TopTransmittance被节点中存储的TopCoverage和TopTransmittance影响了。(*可以看图中,都相乘了)
继续对每个slab都执行类似的操作。
处理substrate树时我们其实还计算了更多数据:
- Energy preservative(可以翻译成:能量驻留),代表slab间的能量传递信息。
- 我们使用粗糙度追踪(roughness tracking)来体现粗糙的顶层对其底层的高光效果的锐利程度的影响。(*即粗糙的顶层会使光散射,使本来清晰的高光变模糊)
- 我们也考虑了顶层的粗糙度对于光线折射的影响——因而粗糙表面下的底层也会显得模糊(基于顶层的粗糙度和厚度)。其中使用的Point Spread Function是一个使用高斯模糊特殊计算的波瓣(lobe *之前多次介绍过,是光分布的一种描述),其中硬编码了空气到水体的折射路径——我们基于折射高光波瓣(refracted specular lobe)来计算底层表面的模糊程度。(*这部分的效果可以看图)
Some more details: when we compile a material topology, we do it for the worst case topology. This is needed because we need to flatten the tree parsing code. Compilers could not handle dynamic tree processing using for loop unfortunately (weird behaviors and crashes at compilation time would happen).
再介绍一些细节:当编译一个材质的拓扑数据时,我们的编译器计算了最差的拓扑情况。这是因为我们需要将树的解析代码尽量展平(flatten),因为(不幸的是)编译器无法处理动态树操作中的循环(会导致发生奇怪的问题和崩溃)。
我们也收集了例如切线(tangent)等slab之间基准的共享参数,只在GBuffer中存储一份数据拷贝。
你可以从图中蓝色部分看出,我们也追踪了每个slab启用的特性(features),以便得知最差情况的复杂度和每像素的GBuffer字节需求。这也可以通过项目设置中的最大每像素GBuffer字节(maximum GBuffer bytes per pixel)来进行简化,后续会解释到。
2 规模控制——Scalability
*这一节主要介绍通过工程设置来控制闭包规模,最终影响画质的流程。Scalability在考虑规模变大时也会翻译成可扩展性,但这里更多是说控制规模。
首先,我们需要克服闭包组合过多的问题(原文是closure combinatorial explosion)。
我们要演示的这个例子展示了标号3的slab与其上两层不同覆盖率的层有着复杂的拓扑结构(如图)。如果想要以可信的方式表达出材质的拓扑结构,我们需要考虑不同的光路径。(*图中黄色箭头)
例如,光可以通过S0,之后通过S2到达S3;或者它可以通过S0,然后直接到达S3。以此类推,能得到6种不同的路径及(从S3观测的)吞吐率和感官粗糙度。
这会直接导致需要计算6种不同的闭包(有着不同的ToViewThroughput、TopTransmittance和高光粗糙度)。这种计算过于昂贵。
为了简化这种情况,我们得出了一种能够在统计上表达slab组合(基于之间的运算)的方式。在本例种,slab3将被一个单独的虚拟slab覆盖,综合了其它slab的参数。这样就会仅得到一个闭包并计算。
*这里用的trick感觉也近似掉了不少东西。
在我们的统计表达中,一个slab,或者说多个slab的合计,可以表达为以下参数:传播系数、覆盖率、粗糙度和折射波瓣。(transmittance, coverage, roughness and a refraction lobe)
It assumes there is no correlation between the coverage of the matter of each Slab.
这个方式假设不同slab的材料的覆盖率之间没有相关性。(*例如上层材料必须覆盖在下层材料之上)
例如,slab的覆盖率运算是怎样的?它仅仅减少slab的覆盖率,而不影响材料的其它几项参数。
我们也为水平混合和垂直层叠运算提供了更复杂的方案,具体可以参照bonus页。
*bonus页中有5页介绍了这部分内容,简单来说覆盖率主要通过基于权重的加和乘来计算,而传播系数的计算还要考虑各自覆盖率的影响。篇幅原因就不列出bonus页了,有兴趣可以去看看原文。
现在我们控制了闭包的数量,下一步就是基于不同平台和画质的设置。例如,我们可以动态适配:
- 着色质量
- 启用的特性(enabled features,粗糙追踪或粗糙折射等)
- 通过设置每像素的缓冲区字节上限,可以控制GBuffer中的数据精度,甚至能调整例如移动端前向渲染使用的数据精度。
所以对于图中的包含水平混合和垂直层叠的例子,我们如何简化闭包的输出?
需要记住的是,一个slab是一个材料的基础表达方式。因此在理解这一构成的基础上我们可以构思合并不同slab的方式。
Thus our solution to the simplification problem: progressive tree simplification using parameter blend of slabs descriptions based on some empirical rules. If that is not enough, we can even simplify by disabling special features.
对于这一简化问题,我们的方案如下:使用激进的树简化方案,采用一些经验规则来做slab的参数混合。如果还不够,我们也能直接关闭特定的feature。
需要着重注意的是,这对于通过MDL或MaterialX标准提出的一些BSDF是不可行的——例如特定的BSDF和DiffuseBSDF无法通过这个方式合并。
这里是一个简单的例子:
- Slab A包含了diffuse, F0, F90和normal这几个参数(F0、F90上篇介绍过)
- Slab B包含了不同的diffuse,F0, F90和normal参数
- 一个相当直接的想法就是通过一定的插值方式来对参数做混合
这里的默认材质包含了所有的lobe和3个闭包,存储在72字节。
我们可以简化这一材质,通过对最深的子树做水平混合操作。此时,我们只需要2个slab,2个闭包存储在48字节里。
最终,我们可以通过混合垂直运算将它简化成一个材质——这时就只有1个slab,一个闭包存储在12字节里。
3 存储与计算——Storage & Evaluation
*Evaluation这个词虽然翻译成计算,但其实完整的信息应该包括计算求值,而且这里特指的主要是光照计算求值。
这里的设计理念(原文用了philosophy一词,直译是哲学)是,你应该只为有效使用的数据付出内存和光照计算的开销。
我们可以逐像素适配它的内存分配规模——基于对应渲染内容的复杂度。如果材质的大部分都比较简单,则开销就比较小。这种特性又能允许某些场合出现很复杂的材质。
如你所知,虚幻引擎有多种不同的光照路径(管线):
- 延迟渲染,其中闭包数据存储在gbuffer,之后在光照pass读取
- 前向渲染,光照计算在基础pass中进行
- Lumen光线追踪,材质属性存储在一个光线追踪的数据交换区(payload,直译是荷载)
- 以及类似的,传统光线追踪
让我们看看材质的生命周期中发生了什么:
- 材质被编译。编译器确定出权重始终为0的slab,它们会被静态编译分析出并剔除,之后对生成的代码进行平整。
- 当基础pass的材质shader被执行时。闭包被计算并写入gbuffer——如果一个闭包的权重是0,它将被跳过并从待写入的列表移除。
- 之后,闭包就被准备好用于光照pass的计算。
让我们看看更多gbuffer使用上的细节:
- 材质闭包在gbuffer的存储采用激进的压缩策略,以尽量减少字节数。
- gbuffer中的第一位表达了每个像素的复杂度,然后屏幕被基于着色复杂度分成不同类型的tile块(简单slab,slab+features,多个slab等等)
- 我们的每个光照pass都是tile化的(支持并行),因而shader代码可以被优化,以便在读取和处理闭包时能高效进行。这些都可以减少数据注册的压力,提高数据容量并减少GPU的渲染开销。
*材质复杂度在UE中直接就有一个调试工具,输出在屏幕上就是图中绿底的图。
这里简单演示了一下闭包的动态剔除(前两中页介绍的内容)。
图中可以看到2个slab水平混合。在tile分类的debug视图(右侧)中,你可以看到我们只为复杂的tile付出开销——这部分材质每像素输出两个闭包;而混合权重为0的闭包则被计算pass直接移除掉了。
那么我们是如何在GBuffer或材质buffer中逐像素存储数据的呢?
- 数据头(header)代表了材质的复杂度,例如分类、闭包数量、切线基(tangent bases)的数量等。
- 之后基于启用的特性不同,我们有一组闭包列表——每个闭包对应不同特性,例如SSS或Fuzz。
- 在之后我们有切线基的列表(所有闭包计算共用的)。
- 最后但是同样重要的是,我们存储了其它数据例如TopLayer数据(法线、粗糙度等)和SSS数据以便传递给我们的后处理部分——例如SSR、SSAO、DFAO或SSS,以避免届时需要读取全部材质数据。
基于不同的复杂度情况我们有着不同的数据打包(压缩)方式。
例如,对于图中的简单材质——仅依赖diffuse、specular和roughness,只需要12字节数据。
这里是一个单独材质——一个slab包含一些feature。在这个例子中,用到了朦胧(haziness)特性,字节数上升到20字节每像素。
这里是一个复杂材质,包含很多slab。
你或许认出了我们上一个GDC的DEMO中展示的蛋白石(Opal )材质。它由2个用到了raymarch的高度场的slab组成,以及一层高光的透明覆盖层。
这里使用了闭包的通用表达格式,每像素需要76个字节。
这里是一个特殊的材质,包含了例如Glint和SpecularLUT等进阶的视觉要素,它能够用来精确表现有着闪烁的车漆或进阶的珍珠形式的外观。(*图中表明了,闭包部分92个字节,总共100)
对于每个闭包,它们的参数被激进地使用离散化(quantization)的方式压缩。并且我们使用了dithering以避免出现带状伪影(banding artefact)。所有这些闭包数据最终都以UINT流的形式写入我们的GBuffer。
我们的GBuffer是一个UINT作为数据格式的2D纹理数组——被我们称为材质buffer。
在基础pass中,最初的3个层被映射为Render Target输出,以便在部分硬件上利用其混合输出的缓冲区机制。剩余的层被映射为一个单独的UAV,超出之前Render Target输出范围所有的UINT数据被写入其中。(*UAV是一个着色的中间概念,是Unordered Access view的缩写)
在低端平台上,我们的目标是避免昂贵的UAV写入,因此所有旧材质都有3UINT的数据上限。
而关于buffer的的分配策略:我们允许它增长,基于屏幕上渲染哪种材质;但我们从不收缩它的容量,以避免内存重分配导致的内存碎片和显示故障。
一旦闭包被压缩存储到GBuffer中,后续就可以读取并用于计算光照了。
我们简单地循环所有闭包,并加载它们,之后基于启用的feature来计算光照。
如前所述,tile复杂度的分类能帮助我们优化这些pass中的GPU开销。
对于硬件光线追踪,我们需要把材质数据发送给射线生成Shader(RGS——Ray Generation Shader)。这一步在光线追踪的payload结构中完成,并且这一结构需要保持尽量小且高效——我们将其限制在了64字节。
然而,我们不能传入整个材质的substrate tree的数据到RGS中,因为这无法匹配多个slab的表达形式——类似的,多闭包也在很多情况下无法适配。
对于Lumen,所有的lobe和反射细节相对没有那么重要——对于全局光照来说。我们对一个substrate tree执行了一个全面的简化——简化成一个单独的slab,只输出一个闭包。这样它就能符合payload的需求,并在lumen的RGS中计算了。
而对于传统的光线追踪,我们需要关注所有的反射细节和光的互相影响。我们简单地随机选择一个闭包,并基于直接光照的albedo在hit shader中进行处理。这个闭包的PDF会基于所有闭包的直接光照的albedo进行重新分配权重,之后光线追踪器就可以对这个闭包的lobe进行选择和采样了。
这是一个选择,牺牲了丰富的变化来换取更好的性能。通过些微更多的采样数,结合我们的降噪器,就能得出图中展示的例子。
*光追这一步看着虽然差不多,但实际上已经不是多个slab组合这么回事了。所以之前那么多slab特性主要还是针对直接光照的,在简介光照上精度其实损失了很多。
*这里给出了不同复杂度材质在PS5 1080P时的耗时,提供了传统材质和Substrate材质的对比。
*最后是一些内存占用对比。
*不同材质复杂度的数据字节占用。
4 总结
*总结的部分不长,好处就不重复了,上篇中都提到过。这里主要看看目前的局限。
首先,我们需要一个深度prepass(即预先绘制深度,而不是深度+着色的方式进行)。这是由于某些材质和闭包可能需要在UAV中写入数据。
其次,我们需要一个单独的贴花加速pass(decal accumulation pass),通常被称为DBuffer。这是因为我们新设计的GBuffer不再能混合了,一些贴花需要在闭包生成之后的pass再被合成进去。
也有一些shader编译方面的挑战: 在base pass可能遇到更多编译和数据打包的情况,因此shader会变得更大并且需要编译更久。虽然凡事都是有代价的(Nothing comes for free 更好的效果就要付出更多时间成本),但我们会持续改进这一点。
我们也希望减轻额外buffers写入相关的额外开销——主要是与后处理pass通信的TopLayer和SSS数据缓冲的部分。
最后但是同样重要的是,我们也希望基于更多的用户体验来进行调整改进。因为slab参数化方式自身相比原有的材质参数化方式有着一定的学习曲线,最终可能我们会提供一定的方式来提供简化的参数化方式,例如映射成类似传统shading model的形式。
*作者似乎很喜欢用Last but not least这个词组。总的来说,新系统的开销会略高于目前的材质系统,并有着更长的着色器编译时间,目前方案的可接受程度踩在工业化能接受的临界线上。
结语
原文中还针对其中2项问题及工具化的细节有10页左右的的Bonus,这里就不展开了,有兴趣可以去详细看看具体技术细节。
目前作为一个可选插件来说,Substrate材质在虚幻5中还是被标记为“实验性”阶段,距离实际可稳定用于产品还有一定距离。
不过回看之前的一些实验阶段的成功案例,至少在虚幻的路线图中成功在工业化领域落地的比例比较多。除了一整套基于实景扫描及网格自动处理的高清资源管线外,典型的还比如逐渐广泛用于动作游戏的Motion Matching(动作自动匹配混合)系统。在《黑神话:悟空》的研发中期就曾经在一次分享会上提到这个技术,介绍了当时的一些局限及应用细节——距离当时2年之后,现在随便一个开发者都可以对着UE的范例工程配置出相当丰富的实时混合动作了,而且这项技术最终也在黑猴游戏中得到了不错的产品级验证。
正如在Motion Matching之前踩在硬件CPU性能的平均线上一样,Substrate材质系统目前也基本踩在GPU性能的平均线以上,但是我估计很快就会有以Substrate材质作为视觉特点的产品出现了,毕竟这能够大幅缩短图形和材质Feature的开发时间。
如果说之前只是看渲染相关的方面,近期我确实逐步了解了虚幻5这个引擎的方方面面。以我目前的了解来看,之后的3D游戏不管是任何开发规模,至少在画面精细程度和动作流畅度上都会有着相当高的起点——高质量只是一个方面,最重要的是工具链非常的高效。
最后是资料链接:
Authoring Materials That Matters - Substrate in Unreal Engine 5 的PPTX
作者提供的参考资料谱系