[Translate]Reverse Engineering the rendering of The Witcher 3 III | [译]巫师3渲染逆向工程 3

原文参见 Reverse engineering the rendering of The Witcher 3: Index

这是第二篇,翻译原文13-15节。由于文章太长,而且废话较多,这里先做个简单的摘要吧

摘要

13介绍巫师嗅觉的效果实现,其中,

13.a-介绍如何通过stencil 操作获得蒙版

13.b-介绍了如何做描边

13.c-把所有结合起来,还加了暗角,拖影,鱼眼等效果

14-介绍了云的渲染,主要用层积云作为案例。云是用贴图做的光照计算

15-介绍了雾的计算,这里雾不是完全的体积雾,但是做了raymarching计算,受光照和高度影响

13.a “巫师嗅觉”

目前为止,本系列中解释的几乎所有效果/技术都与巫师3无关。你几乎可以在每一款现代电子游戏中找到tonemapping, 暗角或计算平均亮度之类的东西。即使是醉酒效应也相当普遍。

所以我决定仔细研究一下“巫师嗅觉”的渲染机制。因为杰拉特是个巫师,他的感官比普通人敏感得多。因此,他比其他人看得多,听得多,这对他解决调查问题有很大帮助。巫师感官机制允许玩家可视化这些痕迹。

下面是这种效果的演示:

https://youtu.be/794z0wbcI6U

如你所见,有两种类型的物体:Geralt可以与之交互的(黄色轮廓)和与调查相关的痕迹(红色轮廓)。一旦Geralt调查红色痕迹,它可以变成黄色。请注意,整个屏幕变得更灰色,并应用鱼眼效果

效果相当复杂,所以我决定把它分成三篇博文。

在第一个我将描述如何选择对象,在第二个描述如何生成描边,第三个里结合在一起。

选择对象

正如我所提到的,有两种类型的对象,所以我们要区分它们。在巫师3,这是通过使用模板缓冲(stencil buffer)。渲染gbuffer时,要标记为“痕迹”(红色)的模型将使用Stencil=8进行渲染。用黄色标记为“感兴趣”的模型将使用Stencil=4进行渲染。

例如,以下两个贴图显示了“巫师嗅觉”和相应Stencil Buffer的示例:

Stencil Buffer-简短回顾

Stencil Buffer通常用于通过为某些类别的网格指定相同的ID,来标识所绘制的网格。

思想是在模板测试通过后,使用Always函数和replace运算符,在其他情况下使用keep运算符。

下面是如何用d3d11实现它:

使用API的StencilRef将stencil写入缓冲

渲染的强度

在实现上,用了一个r11g11b10_float全屏纹理,在r通道中保存感兴趣的对象,g通道中保存痕迹。

为什么我们需要它呢?杰拉特的感官半径是有限的,当玩家离得足够近的时候,特定的物体才会有描边

看看表现

首先用黑色清空这个强度贴图
然后两个全屏drawcall:第一个用于“痕迹”,第二个用于感兴趣的对象:

第一个drawcall用于跟踪-绿色通道:

第二个是感兴趣的东西-红色通道:

好吧,但是我们如何区分哪些像素会被考虑呢?我们必须使用stencil buffer!
在每次调用期间,都会执行stencil测试,仅接受之前用“8”或“4”标记的像素。

在这种情况下如何进行测试?关于stencil测试的基本知识,这里有一篇很好的博客

一般模板测试公式如下:

stencilref是通过api调用传递的值,
StencilReadMask是一个用于读取stencil的掩码(请注意,它同时存在于左侧和右侧)。
op是用于比较的运算符,它是通过api设置的,
StencilValue是当前处理像素中stencil buffer区的值。

我们使用二进制AND来计算操作数
了解了基本知识,让我们看看在这些drawcall使用的设置:

痕迹的stencil状态

感兴趣对象的stencil状态

如我们所见,readmask是唯一的区别。我们试试看将这些值替换为stencil测试公式:

很聪明。如你所见,在这种情况下,我们不比较模板值,而是检查是否设置了stencil buffer的特定位。stencil buffer的每个像素都是uint8,所以我们有[0-255]。

附带说明:所有drawIndexed(36)调用都与将足迹渲染为轨迹相关,因此强度贴图在该特定帧中的最终为:

但在stencil 测试之前有一个pixel shader。28738和28748都使用相同的像素着色器:

这个pixel shader只写入一个渲染目标,因此第24-27行是多余的。
这里发生的第一件事是取样深度(point clamp sampler),第1行用于重建世界坐标,通过与特殊矩阵相乘,之后通过透视除法(第2-6行)。

有了杰拉特的位置(CB3[7].xyz-请注意这不是摄像机位置!),计算从杰拉特到这个特定点的距离(第7-9行)。

对于该着色器很重要的输入是:
-CB3[0].rgb-输出的颜色。这可以是float3(0,1,0)(踪迹)或float3(1,0,0)(感兴趣的对象)。
-CB3[6].Y-距离比例因子。这直接影响最终输出的半径和强度。

后面我们有一些tricky的公式来计算强度,根据从杰拉特到物体的距离。我猜所有的系数都是通过实验选定的。
最终输出是颜色*强度。

HLSL应该是这样的:

13.b “巫师嗅觉”之二

在第一篇文章中,我展示了“强度图”是如何生成的。
我们有一个全分辨率的r11g11b10_float纹理

有了这个,我们可以进入下一个阶段-我称之为“描边图”。

这是一个有点奇怪的512×512 r16g16_float的纹理。重要的是,它是以ping-pong缓冲的方式实现的。就是说,输入前一帧的轮廓图(连同强度图)以在当前帧中生成新的轮廓图。

你可以用很多方法实现乒乓缓冲,但我个人喜欢如下(伪代码):

这种方法,输入总是[m_outlineIndex]而输出总是[!m_outlineIndex],这一般在应用后处理特效方面具有良好的灵活性。

让我们看看pixel shader:

如您所见,轮廓图的输出被划分为四个相等的正方形,这是我们需要看的第一件事:

我们首先计算floor(TextureUV*2.0),得出:

要确定单个正方形,使用一个小函数:

注意,当输入为float2(0.0,0.0)时,此函数返回1.0
所以

mask每个部分都等于1或0,并负责贴图内四个方块之一

一旦我们得到mask,让我们进一步第15行采样强度贴图。请注意,强度纹理是r11g11b10_float,而我们采样所有rgba。在这种情况下,.a隐式设置为1.0f。

用于此操作的贴图坐标可以计算为frac(textureuv*2.0)。因此,此操作的结果如下所示:

你觉得相似吗?

下一步很巧妙,做了一个点积

这样,在左上角的正方形中,我们只有红色通道(因此,只有感兴趣的对象),在右上角只有绿色通道(只有痕迹),在右下角有所有东西(因为强度的.w分量隐式设置为1.0)。结果如下:

有了这个主过滤器,我们就可以确定物体的轮廓了,这并不像人们想象的那么难。算法与锐化的算法非常相似-计算最大绝对差!

接下来,我们在当前处理的一个纹素附近采样四个texel(重要的是:本例中的texel大小是1.0/256.0!)并且计算红色通道和绿色通道的最大绝对差:

现在-如果我们把filter和maxAbsDifference相乘

如此简单有效。

一旦我们有了轮廓,我们就从上一帧中提取轮廓图。
然后,为了产生“重影”效果,我们使用当前过程和轮廓图中的值计算一些参数。

向我们的老朋友-整数噪音-问好。这里也有个动画参数(cb3[0].zw),在cbuffer中,并且随时间变化。

然后,我们以与之前强度图相同的方式对轮廓图进行采样(此时纹素的大小为1.0/512.0),并计算.x分量的平均值:

然后,计算该特定像素中的平均值和值之间的差,并用整数噪声进行扰动:

下一步是用噪声干扰“旧”轮廓图的值-这是主要给输出贴图提供块状外观的地方。

后面还有一些计算,在最后计算了“阻尼”

13.c “巫师嗅觉” 之三

在第一部分中,生成了全屏的(效果)强度图,它包含可视效果与距离的关系。在第二部分中,详细地研究了描边贴图如何决定描边和拖影效果。

这是最后一站,我们要把一切结合起来。最后是一个全屏后处理,输入有:颜色缓冲、描边图和强度图。

之前:

之后:

这个视频展示了这个效果

如你所见,除了将轮廓应用于杰拉特能看到/听到的物体之外,鱼眼效果也用于整个屏幕,整个屏幕(特别是角落)变得灰暗,感觉像真正的怪物猎人在行动。

完整的pixel shader汇编代码

先看看输入

fisheyeAmount是主要的变量,我猜当杰拉特开始使用嗅觉时,它会从0渐变到1。其他的数值大多是常量,但我才如果用户关掉鱼眼会有些不同

shader中第一件事情是计算暗角

In HLSL

uv先被映射到[-1,1],取决ui之,然后发生了一个挤压,蒙版最后这样

现在,我有意省略几行代码,并仔细研究负责“缩放”效果的代码。

首先uv坐标乘二减一

坐标变成

计算点积,变成一个蒙版

与之前提到的uv坐标相乘

重要提示:在左上角值为负,它们被表示为黑色的原因是(这里显示用的)r11g1b10_float的精度有限。那里没有符号位,所以我们不能存储负值。

稍后计算一个衰减因子

做一个clamp和乘法,这样计算了uv偏移

使用colorUV采样颜色缓冲,就得到了边角扭曲的样子

描边

下一步是采样描边图图以查找轮廓。这很容易,首先uv坐标可以对“感兴趣的物体”描边进行采样,然后对“痕迹”进行采样:

值得注意的是,我们只对描边图中的.x通道进行采样,以及只采样了四方格的上半部分

运动

为了产生拖影的运动,使用了与醉酒效果相似的技巧。引入一个单位圆,我们对感兴趣的物体和痕迹的描边图以及颜色缓冲进行了8次采样。

注意,我们刚才用8.0除以找到的描边。

由于我们在纹理坐标空间[0-1]^2中,半径为1的圆围绕特定像素旋转会产生不可接受的瑕疵

所以,我们先来看看半径是如何计算的。要做到这一点,我们必须回到刚才跳过的15-21行,计算半径的部分。一个小问题是,它的计算分散在着色器中。所以,一部分在(15-21)和第二部分在(41-42):

如你所见,我们只考虑纹素[0-0.03]附近的区域,加和乘以20,最终结果是

第41行之后

然后在第42行乘以0.03,这是整个屏幕的圆半径。如你所见,屏幕边缘附近的半径越来越小。

有鉴于此,我们可以看看负责拖尾运动的汇编代码:

停一下,在第40行,我们有个时间因子- elapsedTime * 0.1。在第43行,我们在循环中采样了颜色缓冲

r0.x(第41-42行)是圆的半径,r4.x(第44行)是感兴趣物体的轮廓,r4.y(第45行)-痕迹的轮廓(之前除以8)和r4.z(第46行)-循环计数器

如我们所料,循环有8次。我们首先用i*pi_4计算弧度角,得到2*pi-全周期。角度随时间而变化。

使用sincos我们确定采样点(单位圆)坐标并使用乘法调整半径(第54行)。
之后,我们围绕一个像素旋转,并对轮廓和颜色进行采样。循环之后,我们将得到轮廓和颜色的平均值(由于除以8)。

颜色采样非常相似,但对于颜色UV,我们将偏移量乘以“单位”圆。

强度

循环之后,我们采样强度贴图并调整最终强度:

HLSL:

暗角与最终合成

然后我们有两个插值。用我早前描述的 “环形采样”的颜色图与灰色图合成这样除角落是灰色以外,还用0.6的系数降低了最终图像的饱和度:

然后用鱼眼量和颜色图结合

第二种使用鱼眼量将颜色缓冲与上述颜色组合。这意味着边角灰色,而且屏幕变暗了

HLSL:

现在我们可以看看描边
颜色(红色和黄色)来自cbuffer

最后是把描边混合起来,不仅仅是相加,首先计算点积

看起来是这样的:

这用来插值原本颜色与巫师直觉的颜色

14. 层卷云

说到户外,天空是决定游戏世界是否可信的因素之一。天空在大部分时间里占据了整个屏幕的40-50%。天空不仅仅是一个渐变色,我们还有星星,太阳,月亮,最后还有云。

虽然当前的趋势显然是使用raymarching渲染体积云,但巫师3中的云完全是基于贴图的。我已经看了一段时间,但明显,它比我最初预期的要复杂。如果你一直在关注这个系列,你就会知道“血与酒”和本体之间有区别。你猜怎么着-血与酒的云层也有一些变化。

巫师3里有几层云。根据目前的天气情况,我们只能看到卷云、高积云,也许还有一些来自层云家族(例如在暴风雨中)。或者,一无所有。

某些层输入贴图和shader有所不同。这影响了pixel shader的复杂性和长度。
尽管有这么多的多样性,但我们可以在巫师3的云渲染中观察到一些常见的模式。首先,他们都是前向管线中的,这是绝对正确的选择。它们都使用颜色混合(见下文)。这样就更容易控制特定层如何覆盖天空。

更有趣的是,有些层用相同的设置渲染两次。

经过评估,我选择了我能找到的最短的shader-为了(1)有最大的概率完整逆向工程它,(2)能够理解它的所有方面。

我会仔细看看巫师3:血与酒中的层卷云。
下面是一个示例:
渲染前

第一次渲染过程后

在第二次渲染过程之后

这里卷云被渲染了两次,增加了它的强度。

几何体与Vertex Shader

云模型看上去类似于典型的天空半球:

所有顶点都在[0-1]中,因此为了使模型围绕(0,0,0)点居中,在投影矩阵变换之前使用“缩放+偏移”。对于云,网格主要沿XY平面拉伸以超过视锥大小,结果如下:

它还计算了TBN。此外,还有逐顶点雾计算(颜色和强度)。

Pixel Shader

汇编如下

输入有两张四方连续纹理。其中一个包含法线贴图和云形状(A通道)。第二种是形状扰动的噪声。

法线贴图
云形状
噪音纹理

带云参数的主要cbuffer是cb4。其值为

除此之外,还有其他cbuffer使用的值。别担心,我们也会讲到。

反向阳光方向

在shader中发生的第一件事是计算日光的归一化、Z反转方向:

正如我前面提到的,Z是向上轴,而CB0[9]是阳光方向。这个矢量进入太阳

采样云贴图

下一步是计算uv来采样“云”贴图,解压法向量并对其进行归一化。

为了使云层运动,我们需要以秒为单位的经过时间(cb[0].x),乘以速度因子,它影响云层在天空中运动速度(cb4[5].xy)

在我之前所说的云层的模型上uv被拉伸了,我们还需要影响贴图缩放系数(cb4[4].xy),影响云的大小

最终公式为:

在对所有4个通道进行采样后,我们得到了法线贴图(rgb通道)和云形状(a通道)。

法线贴图

正常方式计算法线贴图

高光强度(1)

下一步是计算ndotl,这会影响特定像素的高亮显示。
考虑以下汇编:

下面可视化了这帧的NdL

它用来插值最小强度和最大强度:
这样,部分云层暴露在阳光下会更明亮。

高光强度(2)

还有一个因素影响云的强度。
人和太阳垂直截面上的云更亮(米氏散射,译者注)
因此基于xy平面计算梯度,用它插值最小/最大值

最后,我们将两个强度相乘,并做了2.2次幂。

云的颜色

计算云的颜色首先从cbuffer中的两个值开始,是太阳附近的云和天空对面的云的颜色。他们被highlightedSkySection插值
然后,结果乘以 finalIntensity。
最后,将结果与雾混合(为了提高性能,在vertex shader中进行了计算)。

确保卷云在地平线上更明显

在这帧上看不太到它,但事实上,云层在地平线附近比在杰拉特头上更明显。
你可能注意到我们在计算第二强度时计算了worldtocamera的长度:

汇编中下次出现

cb[7].x和cb[8].x的值分别为2000.0和7000.0。

这使用一个名为linstep的函数。

它有三个参数:最小值/最大值(范围)和V值(值)。

所以它的工作方式是,如果v在[min max]范围内,它返回一个介于[0.0-1.0]之间的线性插值。另一方面,如果v超出边界,linstep返回0.0或1.0。

一个简单的例子:

所以它与HLSL中的smoothstep非常相似,只是在这种情况下执行的是线性插值而不是hermite插值。

linstep在hlsl中不存在,但它非常有用。真的值得放在你的工具箱里。

回到巫师3:
一旦我们推导出这个系数了,它表明了天空离杰拉特有多远,我们就用它来减弱云层的强度:

cloudShape是第一个贴图的A通道,closeCloudsHidingFactor是cbuffer的一个值,它控制杰拉特头上的云的可见程度。在我测试的每一帧中,它都是0.0,这等于没有云。随着距离衰减越来越接近1.0(从相机到天穹顶的距离增加),云越来越明显。

采样噪声贴图

对于噪声贴图,采样坐标的计算与对于云纹理的计算相同,有uv缩放和速度倍增。

结合在一起

一旦我们有了一个噪声值,我们就必须把它和cloudShape结合起来。
我在理解“param2.w”(始终为1.0)和noisemult(设置为5.0,来自cbuffer)时遇到了一些问题。
无论如何,这里最重要的是影响云的可见度的最终值generalCloudsVisibility 。
最后输出颜色是cloudsColor 乘以噪声,A通道也是如此

15. 雾

雾可以通过多种方式实现。然而,简单距离雾的时代已经过去了,可编程渲染管线给我们打开了新的大门,可以有物理上正确和视觉上合理的解决方案。

当前雾渲染的趋势是使用compute shader 

尽管上述介绍已经出现在2014年,巫师3也在2015/2016年上线,但杰拉特冒险中的雾是完全基于屏幕的,是典型的后处理。

这里是针对雾的pixel shader 汇编-值得注意的是,整个游戏(2015年和两个DLC)都是相同的:

下面是一个有雾的日落场景示例:

我们看看输入:
贴图上我们有深度缓冲、AO和HDR颜色缓冲

结果是

深度缓冲用来重建世界位置

AO可以使阴影变暗

shader从确定像素是否不在天空开始。如果像素位于天空(深度==1.0)shader返回黑色。如果一个像素在场景中(深度<1.0),我们使用深度缓冲(第7-11行)重建世界位置,并通过雾的计算进行处理。

雾在延迟着色处理后不久。可以看到一些前向渲染元素还缺少在这个场景中。

关于巫师3中的雾,首先要知道的是它由两部分组成:“雾色fog color”和“空气色aerial color”。

每个部分有3种颜色:前、中、后。因此,我们有cbuffer数据有如“fogcolorfront”、“fogcolormidle”、“aerialcolorback”等。参见输入:

在计算最终颜色之前,我们需要计算一些向量和点积。Shader可以访问像素的世界位置、相机位置和雾/光方向这允许我们计算视向量和雾方向之间的点积。

点积的绝对值的平方用来计算混合系数,再将结果与一些和距离有关的参数相乘:

视向量和光照方向点积负责在“前”和“后”颜色之间进行选择。

这里是最终梯度的可视化(_dd)。

空气/雾影响系数的计算要复杂得多。它有更多的参数,不仅仅是rgb颜色。它还包括场景密度。我们使用raymarching来确定雾的强度和比例因子:

有了视向量,我们可以把它除以16进行raymarching。如下,在计算中仅考虑.z分量(高度)(curr_pos_z_step)。

雾的强度显然取决于高度(.z分量),在最后雾的强度做了个指数计算
“final_exp_fog”和“final_exp_aerial”来自cbuffer,它们允许控制雾和空气颜色如何随着高度的升高影响。

雾的重载(Override)

我发现的shader不包括这段汇编

根据我的理解,这看起来像是对雾颜色和影响的两次重载
在大多数情况下,只有一个重载(cb12_v192.x是0.0),但在这种特殊情况下-它的值是~0.22,所以我们执行第二个重载。

这是我们最后一个没有雾重载(第一个图像)、单重载(第二个图像)和双重载(第三个图像,最终结果)的场景:

调整AO

我发现的shader也根本没有使用AO。让我们再来看看AO贴图