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

原文参见 Reverse engineering the rendering of The Witcher 3: Index。作者目测就是波兰人。这个系列总共15篇,作者发布跨度接近两年。其实介绍的算法并不复杂,前几篇甚至感觉很基础。但是方法非常新奇。经典的逆向工程文章大多是截几个图,描述一下做法便了事,这篇直接逆向工程shader的汇编代码,非常开眼。作者甚至自己写了个工具可以直接将HLSL代码转成汇编代码。不愧是波兰蠢驴。

本文是为上篇,翻译原作的第1-8篇,其余留待下篇。

第一部分, Tonemapping

大多数当代3A游戏中,肯定有一个渲染阶段是 Tonemapping 。快速回忆一下,现实世界中有很大的亮度范围。但是我们的计算机屏幕只能显示有限的范围,比如每像素8bit,只有0-255. 这就是有了 Tonemapping 的原因。因为它允许我们把很宽的亮度范围转换到有限的。这个过程通常有两个输入,颜色超过1的浮点数HDR图像,和场景平均亮度(后者有多种方式计算,比如通过人眼适应来模拟人眼的行为,但这里不重要了)

下一步是包括获取一个曝光值,计算曝光颜色,并通过Tonemapping曲线处理。这里是事情开始变得麻烦的地方,出现了一个新概念,比如“白点”(White Point),中性灰(Middle Gray). 有几个流行的曲线和MJP的文章 “A Closer Look at Tone Mapping” 研究了这件事。

诚实地说,我自己在代码里实现Tonemaping时遇到了一些困难。不过好在 在线的例子很有用。说回重点,有些考虑了HDR亮度,白点和中性灰。有一些没有。我想要一个“经过考验”的实现。

最近我开始研究巫师3的渲染,这个游戏有些很棒的渲染技巧。同时它的剧情,音乐,游戏性,所有东西都很棒。

当然在我开始之前,这篇是一个研究巫师3渲染的短系列之一。他绝对不是想要全面,就像Adrian Courrege的GTA5图形研究系列,至少是现在。我们从逆向工程Tonemapping开始吧。

我们会用RenderDoc的一帧截屏开始。这时Novigrad城一个任务的截图,所有效果最大。

经过一些研究后,我找到的Tonemapping那帧。像我描述过的那样,它有一个HDR颜色帧缓冲(贴图0,全分辨率),一个平均亮度的场景(贴图1,1×1浮点,用compute shader计算的

我们来看看编译过的pixel shader

需要注意的是,首先,载入的亮度不是用到的。它被美术设定的最大最小值限定。这很简单,为了避免场景的过曝或欠曝。这听起来很显然,但我从来没这么做过。第二,任何熟悉Tonemaping曲线的人都会认出这个11.2,它是John Hable在 神秘海域2Tonemapping曲线中定义的白点。

A-F的参数是从CBuffer载入的。好了,还有三个参数。cb3_v16.xyz。我们研究一下他它是啥。

一些建议,我认为x是白度(white scale)或者中性灰,因为它被乘了11.2。然后它被用于计算曝光调整。y,我叫他u2数值乘数,我们很快会来研究它z,指数系数,用来计算log/mul/exp。

另外cb3_v4.yz,可允许的最大最小亮度cb3_v7.xyz,神海2曲线的A-C值cb3_v8.xyz,神海2曲线的D-F值

现在是最难的部分,写HLSL shader会给我们编译程序及,这很有技巧,而且代码越长越南,幸运的是之前我写了个工具可以很快吧hlsl转成程序码。女士们先生们,有请D3DShaderDisassembler!

这是最后的代码:

一个我的工具的截图证明它

我相信这合理的实现了巫师3的Tonemapping,至少从机器码的角度。我已经在我的框架中实现了它,工作的相当好。

我说“相当”,因为我也不知道为啥ToneMapU2Func的除数要和0取最大,除以0不是undefined嘛

我们基本可以结束了,但我在这里发现了Tonemapping的另一个变种,美丽的日落,最低的图形设置 。

我们检查一下,shader的汇编代码

看上去很吓人,但还不坏。快速的分析注意到调用了两次神海2的方程。翻译成HLSL

所以我们有两套参数,计算两套tonemap处理的颜色,最后混合起来,很聪明。

第二部分,人眼适应(Eye Adaptation)

欢迎来到这个迷你系列的第二篇,这里我揭秘了巫师3的渲染。这次很简单了。

第一部分里,我介绍了tonemapping怎么做的。当我解释理论基础时,简单提到了人眼适应。这次我们来讲讲这个怎么处理的。

但是,什么是人眼适应?为什么我们需要它?Wikipedia知道。我们想象自己在一个暗室里,或者洞穴。当你出去时,外面是亮的。这里亮度的来源是太阳。
在暗处我们的瞳孔放大,使得更多光线进入视网膜。当变亮时,瞳孔变小,我们也会眨眼,有因为“疼”。这个变化不是理科的,眼睛需要适应这个亮度的变化。这也就是我们在实时渲染时为什么要用人眼使用。

一个缺乏人眼适应的很好的例子是DX官方SDK的 HDRToneMappingCS11,平均亮度的突变时不让人愉悦和比不自然的。

我们开始吧。为了统一,还是分析这一帧

通常人眼适应在Tonemapping之前,巫师3也不例外。

我们看看pixel shader的状态有两个输入,R32_Float类型,1×1像素贴图0是前帧的平均亮度贴图1是这帧的平均亮度。

我们看看代码

仅仅七行。我们来解释一下

  1. 获取当帧平均亮度
  2. 获取前帧平均亮度
  3. 一个测试,当帧比前帧变量还是变暗了
  4. 计算两帧亮度差值
  5. 计算变化速度,这里根据变量还是变暗有不同的数值。这很聪明,可以想象变亮变暗时变化速度不一致。但每帧这两个数值基本一致,大概0.11到0.35 最后计算适应亮度适应亮度 = 速度 * 差值 + 前帧亮度
  6. 结束

最后HLSL代码

生成的汇编是一样的,只不过这里我建议返回float而不是float4,不必浪费带宽。

所以人眼适应就是这么做的,很简单吧?

第三部分,色差特效(Chormatic Aberration)

欢迎来到第三集,这里我们揭秘巫师3的渲染技巧。
今天我们研究下色差特效。

色差效果是廉价镜头的一个效果,当镜头对不同波长的折射率不一致时,就会产生这种变形。不是所有人都喜欢它,但这里它很轻微,因此不影响游戏性。然而你也可以关了它。

看出差别了吗?我也没有。看看另一个场景

这个好多了,效果很微妙。然而我很好奇它怎么实现的。

实现

首先找到pixel shader的那个drawcall实际上色差效果是庞大的后处理的一小部分,它包括色差效果,暗角和gamma校正,在一个pixel shader中。

我们看看代码吧

以及CBuffer的数值

好了我们来理解下。
cb3_v17.xy时色差效果的核心,他计算一个像素坐标到色差中心的向量和长度,然后计算一些数值,测试,分支。

当色差效果实现时,我们用cbuffer的数值计算偏移,然后扭曲RG通道。
通常,靠近屏幕边角效果更明显。第10行很有意思,它让像素靠近,当我们增强色差时。

我很乐于分享我的实现,当然对于变量名不要介意,并且注意这个效果是在gamma矫正之前。

这里我增加了”fChromaticAberrationIntensity”来增强偏移的大小。因此,效果的强度是它控制。巫师3中它是1。

下面是强度40时

第四部分,暗角

暗角是游戏中用的最广泛的后处理之一,它在摄影中也很常见。微弱的暗角可以产生很好的效果。有几种暗角,比如UE使用自然的

让我们回到巫师3,这里有一个实时的对比,它来自NVIDIA的性能分析
请注意左上的天空比其它部分更暗,我之后会提及。

实现细节

首先,最初版本的巫师3和DLC血与酒中暗角的实现有点区别。前者使用pixel shader预计算的反向梯度。而后者用了一个预计算的256*256贴图

我会用血与酒的shader像很多其他游戏,巫师3的暗角是最后后处理计算的,我们看看汇编

有意思,看上去暗角用了gamma(46行)和线性(51行)计算。48行采样了暗角的贴图

cb3[9].xyz跟暗角无关,每一帧它都是1,1,1 它可能跟淡入淡出效果有关。

暗角主要有三个参数透明度(cb3[6].w),影响强度。0是没有,1是最强。据我观察巫师3里面它是1,血与酒中在0.15左右颜色(cb3[7].xyz),巫师会改变颜色,不必是黑色,通常它是(3/255,4/255,5/255)权重。

这很有意思,我常常见到平的暗角,像这样:

但用了权重有了有意思的效果

权重接近1,抓一帧血与酒的cbuffer,这时为甚么明亮的像素并没有太受影响。
蒙版计算会差值图像颜色和暗角颜色。
代码,这里是我实现的

附加:计算梯度巫师3用了反向梯度而不是采样预计算的贴图,我们看看汇编

幸运的是这很简单

基本上来说是计算到中心的距离,然后做些魔法(乘,saturate….就有了一个多项式

第五部分,醉酒效果

晚上

https://youtu.be/Gy_B2V0QaqA

夜里

https://youtu.be/CXu3e7LWkjE

一开始我们看到了两次旋转的图像,在我们不清醒时候很常见。距离中心越远,旋转越强。我放了第二个视频,可以清楚看到星星的旋转。

第二部分,可能第一眼看不太出来,还有一点放大缩小。

很明显这个是后处理,然而它在管线中的位置不一定清楚。原来它就在tonemapping之后,motionblur之前。

我们看看汇编代码

用了两个CBuffer,我们看看数值

有几个有意思的。

  • cb0_v0.x, 逝去的时间
  • cb0_v1.xyzw,视口纹素尺寸
  • cb3_v0.x,像素的旋转,一直是1
  • cb3_v0.y, 醉酒效果的强度。开启后它会从0长到1
  • cv3_v1.xy,像素偏移。这是sin/cos一对。因此可以使用sincos(time)
  • cb3_v2.xy,效果的中心,一般是(0.5,0.5)

我们这里像更关注理解它如何工作,而不是重写汇编。 我们从前几行开始

第0行是放大系数,很快你就会知道为何。之后是旋转偏移,就是把sincos乘0.05

第2-4行,计算uv到中心的向量,然后是平方距离和距离

缩放贴图坐标

继续看

因为我们pack的方式,我们只需要分析一对浮点数。

开始,r0.yz是旋转偏移,r1.z是到中心的距离,r1.xy是到中心的向量,r0.x是缩放系数。

我们假定缩放系数是1,可以这么写

类似r3.xy

很好。现在我们有了贴图UV,旋转偏移,但是缩放系数呢?我们看看第0行基本上就是 zoomFactor = 1.0 – 0.1 * drunkAmount

最大醉度时候是0.9

更直观的,这仅仅是归一化的坐标和距离混合结果,为了放大图片。最好的理解方式是自己写写,这里有一个shadertoy可以试试看 here

坐标偏移

产生了一些梯度。我们叫它偏移强度蒙版。实际上它有两个。一个是r0.w,另一个是五倍强的r0.x(15行)。后一个是纹素尺寸的倍数,

采样-旋转

接下来是一系列采样,实际上有两个8次采样。

我们往uv上加偏移,偏移基于像素周边单位圆,呈上前面的便宜强度。离像素越远,半径越大。采样八次,这在星星上很显眼。 这些值是pointsAroundPixel

采样 -缩放部分

第二部分是是缩放,我们看看汇编

这里有三次贴图采样,三个不同的坐标,我们分析下坐标怎么计算的但首先,这部分的输入是

计算了像素偏移(8*纹素),后来加到基础UV。强度在0.98到1.02之间震荡,从而有缩放的效果,就像旋转中的缩放系数
我们从第一对开始,r1.xy(61行)

看看第二对 r3.xy(62行)

看看第三对 r0.xy (63,64行)

三个贴图采样加在一起,结果存在r1寄存器。值得注意的是pixel shader用的是clamp的边缘采样方式

全加起来

我们现在有了r2寄存器的旋转结果和r1寄存器的三次缩放效果。看看汇编的最后

另外一个输入,r0.w是强度蒙版,cb3[0].y是醉酒的强度
我们看看怎么工作的
我最开始写了个暴力的

当然从来没人这么写,我用纸和笔写下了方程

第六部分,锐化

巫师3中锐化有两个预设:低和高。我会之后讨论它们的区别

如果你想看交互的对比,请去这里

像你看到的那样,效果在草和植被上很明显

这篇中我们会研究刚开始游戏的这帧,我选择它因为它有地形和天空
它的输入需要颜色缓冲(LDR的,在tonemapping之后)和深度缓冲

我们看看pixel shader的汇编

50行还行

锐化量计算

第一步读取了深度buffer。注意巫师3用了反向深度(1-近,0-远),另外如你所知硬件深度是非线性的,参考这篇文章

第3-6行计算了硬件深度到近-远值,方法很有意思。看着cbuffer的数值

当最近裁剪为0.2,最远为5000,我相信你可以这样计算cb12_v21.xy

cb12_v21.y = 1/nearcb12_v21.x = -(1.0 / near) + (1.0 / near) * (near / far)

这部分在巫师3的代码里很常见,所以我 猜这 有个函数
当我们有了视锥深度,第7行使用scale/bias创建一个混合系数

cb3_v1.xy分别是在近和远距离锐化的强度。我们叫它sharpenNear和sharpenFar好了。这是锐化高和低预设的唯一差别

现在是时候看看系数了,第8,9行仅仅lerp了这两个系数。它是干什么的?有了他们我就可以在Geralt近处和远处不一样的强度了

虽然看上去不是很明显,但我们根据距离差值出近处(2.177151)和远处(1.913)不同的强度。计算玩我们加了1。它是干嘛的?后面会有更详细的解释

在锐化过程中我们不想影响天空,用一个深度测试很容易做到。因为天空盒的深度是1.

我们乘上天空的影响

这个乘法在13行

采样像素中心

这里是SV_Position重要的一个地方:半像素偏移 左上角的像素不是0,0而是0.5,0.5

这里我们想采样像素中心,所以看看14-16行

之后我们就用uvCenter采样贴图。别担心,效果是常见的一致,即SV_Position.xy/ViewportSize.xy

锐化还是不锐化取决于fSharpenAmount

锐化

我们看看核心算法,基本上就是

  • 在像素的四个角落采样四遍输入颜色buffer
  • 取平均
  • 计算中心和中心平均值的差别
  • 找到差值的绝对值
  • 通过scale和bias调整绝对值
  • 用它决定效果强弱
  • 计算中心颜色和平均颜色的亮度
  • 用中心颜色除以亮度
  • 用上面的量计算新的亮度
  • 新的亮度乘上中心颜色

看上去不少,并且对我不太好理解,因为我没做过锐化
我们从最简单开始,我们这样做的四次贴图采样

所有采样用的bilinear (D3D11_FILTER_MIN_MAG_LINEAR_MIP_POINT).

HLSL里到底怎么做的?

现在我们有了四个采样了

这根据中心和边缘的最大绝对值计算出了边缘

看看效果

最后的代码

我很高兴这段代码和汇编一致

总结起来,巫师3的锐化写的很好,主要改变强度的方式是改变近/远的强度,它不是常量,而是根据游戏会改变的,我这里放了一些典型值

Skellige:


sharpenNearsharpenFarsharpenDistanceScalesharpenDistanceBiassharpenLumScalesharpenLumBias
low0.40.20.025-0.25-13.33331.33333
high21.80.025-0.25-13.33331.33333

Kaer Morten


sharpenNearsharpenFarsharpenDistanceScalesharpenDistanceBiassharpenLumScalesharpenLumBias
low0.577510.313030.06665-0.33256-12
high2.177511.913030.06665-0.33256-12

第七部分之一,平均亮度

几乎所有当代的游戏都会计算每帧的平均明度,之后用来计算人眼适应和tonemapping。简单的方法比如计算mipmap用最后一级通常可以用,但有些局限。更复杂的方法是用compute shader,比如并行消减 parallel reduction.

我们看看巫师3的方法。我们之前已经研究了人眼适应,现在唯一缺的就是平均明度

巫师3中计算平均明度有两个pass,我决定为了清晰起见不在一起讲它们,今天集中讲第一篇,明度分布(直方图)

找到它们不麻烦,他们是连续的dispatch,就在人眼适应之前

我们看看输入,有两张。一张1/4的HDR颜色缓冲,一张全屏深度

注意一个小技巧,这张缓冲是很大的一部分。重用缓冲是个好事。

为什么降采样颜色缓冲?我猜是性能考虑。

这个pass的输出是一个structuredbuffer,256个byte4,因为shader没有debug信息,我们假定就是uint吧

重要:第一步是调用 ClearUnorderedAccessViewUint将structured buffer归零

我们看看汇编,注意这是这个系列里第一次看compute shader

我们看看cbuffer

我们已经知道了第一个输入是降采样的HDR,对于1080p,它是480*270。我们看看dispatch是(270,1,1),有270个线程组(threadgroup)。简单来说就是一行一个threadgroup

现在我们知道了上下文,我们来研究shader干了什么。每个线程组在x方向有64个线程,(dcl_thread_group 64 1 1)还有一个共享数组256个元素,每个有4byte(dcl_tgsm_structured g0,4,256)

注意shader里用了  SV_GroupThreadID(vThreadIDInGroup.x)[0-63]和 SV_GroupID (vThreadGroupID.x)[0-269]

1)我们用一个循环将共享内存置0,因为我们有256个元素,每组64个线程,所以一个简单的循环

2)之后,我们设了barrier同步每个线程  GroupMemoryBarrierWithGroupSync(sync_g_t)保证归0都进行了

3)之后我们有一个大概这样的循环

每次加64

下一步计算纹素位置

Y方向我们有SV_GroupID.x,270个线程组。X方向,大概就是这里了

因为每组64个线程,所以一次过64个像素,比如

  • 线程组(0,0,0)会处理像素(0,0),(64,0),(128,0)…..(448,0)
  • 线程组(1,0,0)是(1,0),(65,0),(129,0)….(449,0)
  • 线程组(63,0,0)是(63,0),(127,0)…(447,0)

这样处理了所有像素

我们也计算了亮度(21行)

好了现在我们已经计算了每一个像素的亮度,下一步是载入对应的深度值但我们深度图是全屏的,这怎么处理?

结果好像很简单,仅仅是将颜色的坐标乘上个常数(cb0_v2.z),我们4x降采样了颜色缓冲,所以这里是4

到目前为止很不错,我们来看看24-25行

好吧我们有一个浮点数相等 ??结果进了r2.x,然后做了个,逐位和操作(bitwise AND) ?在浮点数上?这是什么鬼?

逐位等+逐位和操作

可以是说这是对我最困难的部分,我甚至想过疯狂的asint/asfloat组合其他的方法呢?我们做一个浮点-浮点相等比较

结果呢

看上去不太对,为啥是and,0x3f80000理论上1.0f,如果我们把1.0换成别的呢?

结果是

这次对了,但如果你把0变成其他的,这里会是movc

我们回到compute shader,下一步是检查深度是否等于cb_v2.w,它是0.简单来说,就是检查像素是不是在最远平面上(天空),如果是的话,就把值设置成0.5(我看了几帧)

这样系数用来插值颜色亮度和天空亮度(cb0_v2.x,在0左右),我猜这是控制天空在计算平均亮度时候的重要性,通常用来减弱天空的重要性。很聪明

最后我们计算它在对数分布明度区间的ID,并对对应ID加了1

下一步,又是设置了barrier保证一行的像素都处理完了,我们把共享内存的值加给structuredbuffer

每个线程组有64个线程填充完共享内存后,每个线程会往输入缓存加4个数值

至于输出缓冲,我们考虑一下,缓冲的和应该是所有像素(480*270=129 600),因此我们知道了有多少个像素有特定的明度

如果你对compute shader不是很熟悉,这可能不是很直观,这篇看几遍就好了。用纸和笔写下来试图理解背后的原理

第七部分之二,平均亮度

在我们再次开始和compute shader的斗争之前,我们快速回顾一下之前干了什么,我们处理了一张4x降采样的HDR颜色缓冲,之后做了一遍亮度直方图(256个uint的structuredbuffer),计算了每个像素的对数亮度,把它放到256个元素中,每个像素加1,这样最后这256个元素的和应该是像素的数量

比如这个全屏缓冲,降采样后480*270,256个元素的和是480*270=129 600

这个简单介绍后,我们看看最后的计算,这里只有一个线程组(Dispatch(1,1,1))

我们看看汇编

一个cbuffer

快速看看汇编,有两个UAV,一个是前面输入的缓冲,另一个是1×1的R32_Float贴图。我们每个线程组还有64个线程,以及一个256个元素的4byte的线程组共享内存

我们首先用输入缓冲的数值填充共享内存,我们有64个线程,跟以前差不多为保证数据都射完了,我们设了个barrier

所以计算都在一个线程内,其它只是用来将输入缓冲放进共享内存

计算线程的id是0,为什么?理论上我们可以用任何线程,但与0比较避免了附加的证书-证书比较(ieq  指令)

算法基于特性范围的像素第11行我们乘了 宽度*高度,获得了所有像素数量,并乘了两个0-1的数,规定了起始和终止的范围。

后面有clamp保证0<=起始<=终点<=总像素数-1

如你看到,后面还有两个循环,问题是它们结尾都有奇怪的条件移动。我重建他们很困难,另外注意21行的-1,为什么?我们稍后揭晓

第一个循环的目的是忽略掉起始像素,给我们起始像素+1的id所在的元素。

比如,起始像素是30000,在第一个元素里有37000个像素(比如晚上),我们会从第30001号像素开始分析,这是会立刻跳出循环并把忽略掉的像素置0

第21行神秘的-1和循环的布尔条件相关
有了第lumaValue个元素像素的数量和lumaValue自己,我们可以进入第二个循环,它是用来计算平均亮度的

这是我们把明度编码进0-255范围解码很简单,回退这个计算就行

快速总结

为了解码亮度,我们回退这个操作

然后我们计算贡献时,乘以特定亮度像素的数量,然后加和直到结束的像素之后除以分析到的总的像素

完整的代码在这里