译文链接:[Graphics Study] DOOM (2016) | 翻译自 Lele Fent
原文链接:DOOM (2016) - Graphics Studywww.adriancourreges.com/blog/2016/09/09/doom-2016-graphics-study/
一帧是如何渲染的
需要分析的帧图像如下:
DOOM 没有使用 Direct3D,而是使用 OpenGL 和 Vulkan。以下分析是在 GTX 980 上开启 Ultra 画质使用 Vulkan 运行游戏截帧的结果。有一些分析是猜测的结果,有些是根据 Tiago Sousa 和 Jean Geffroy 在 Siggraph 2016 上的演讲。
Mega-Texture 的更新
首先是mega-texture的更新,mega-texture 在id Tech 5里被提出,当时被用于游戏RAGE中,然后现在也用在了 DOOM 的渲染里。简单来说,这项技术的核心思想就是在 GPU 内存中分配若干超大纹理(在 DOOM 里大小为 16k × 8k),每张超大纹理包含了 128 × 128 个 tiles 。
这些 tiles 被用于表示当前渲染的场景中需要使用到的纹理及其对应的 mipmap。当一个 pixel shader 读取一张 “virtual texture”时,它实际上最终会从上述超大纹理中找到某些 tiles 来进行渲染。
随着玩家视角的移动,这些纹理集合也会进行更新。当当前场景模型需要引用新的 virtual textures 时,就需要把新的 tiles stream 进来,把不需要的 tiles stream 出去。在每一帧刚开始的时候, DOOM 会通过调用 vkCmdCopyBufferToImage 来更新若干 tiles,从而把真正的纹理数据加载到GPU内存中。
更多关于 mega-textures 的内容可以参考:
Shadow Map Atlas
对于每个需要投射阴影的光源,会为它生成一张 depth map,并存储到一张 8k × 8k 的 texture atlas 里。为了优化,并不是所有 depth map 都需要实时更新。DOOM 大量重用了上一帧的结果,每一帧只为那些需要更新 depth maps 更新深度值。下图分别显示了前一帧和当前帧的 8k × 8k 的 depth buffer,可以看出只有更少一部分发生了变化:
如果光源是静态的,并且只影响静态物体,那么显然完全不需要更新它的 depth map。如果有动态物体经过该光源的影响范围,那么就需要重新为它生成 depth map 了。DOOM 有一套特定的阴影优化方案,例如将 depth map 中的静态部分 cache 起来,然后只计算动态物体的 depth 并将其和静态部分合并起来得到最终结果。Depth map 的大小也会根据光源距离摄像机的远近发生变化,其在 atlas 中的位置也不是固定的。
Depth Pre-Pass
然后,需要为所有不透明物体渲染 depth pre-pass。渲染时会按照一定顺序渲染,先渲染玩家武器,然后是静态物体,最后是动态物体。下图分别显示了 20%、40%、60%、80%、100% 的 depth map 渲染进度:
Depth pre-pass 还会负责输出 velocity 信息。当动态物体被渲染到 depth map 中时,还会计算它们逐像素的 velocity 并写入到 velocity map 中。Velocity map 只需要两个通道来表示动态物体在水平和垂直方向上的移动速度,例如下图中的武器移动范围很小,因此它的 velocity 几乎是全黑的。黄色部分是 velocity map 的默认颜色,这些区域只需要通过它的 depth 信息和 camera velocity 计算速度值即可:
Occlusion Queries
为了减少提交给 GPU 的几何体数目,我们应该尽可能把那些不在视野范围内的物体剔除掉。DOOM 的绝大部分剔除工作是使用 Umbra middleware 中间件来实现的,但它仍然做了一些 GPU culling 来进一步剔除。大致方法是,在世界空间下把一些网格分组用一个包围盒包围起来,然后把该包围盒交给 GPU 去渲染,如果经过光栅化后它所有的像素都没有通过深度测试(使用之前 depth prepass 得到的深度图),那么该包围盒中的所有物体都不需要渲染。下图中红色表示被遮挡的部分,绿色表示通过测试可视的部分:
Occlusion query 的结果通常不能立刻返回,但我们又不想因此阻塞GPU pipeline。通常来说,我们会把读取结果这一操作延迟到后续若干帧,这意味着我们的算法应当更加保守一点,来防止物体出现popping。
Clustered Forward Rendering of Opaque Objects
这一阶段会渲染所有的不透明物体和 decals。光照结果会存储到一张 HDR buffer 中。下图是 Lighting 25%、50%、75%、100% 的光照结果:
由于之前有完整的 depth pre pass,我们现在可以把 depth test 设置为 EQUAL 来避免不必要的 overdraw。Decals 也是在这个阶段被渲染的,它们被存储到一张 texture atlas 里。
Clustered forward rendering 最早由 Emil Person’s 和 Ola Olsson’s 提出。首先,需要把视椎体划分为若干 tiles,DOOM 创建了 16 × 8 个 tiles,至此即为 tiled forward rendering。Clustered rendering 更进一步,继续在三维空间上划分,它沿着 Z 方向划分空间,这样得到的每个 block 被称为一个 cluster,也可以称为 frustum shaped voxels 或者 froxels 。 DOOM 里视椎体被划分为 16 × 8 × 24 个 clusters,其中 Z 方向上的划分是指数分布的。
通常 cluster renderer 会包含如下步骤:
- 首先 CPU 会为每个 cluster 计算影响其光照的若干 lists,包括 lights,decals 和 cubemaps 等。(译注:不一定是在 CPU 算的,可参见 RTR4)。这些数据被储存在若干 GPU buffers 里,然后可以在 shader 里访问它们。在 DOOM 里每个 cluster 最多可以有 256 个 lights,256 个 decals 和 256 个 cubemaps。
- 然后 GPU 会渲染每个像素。根据该像素的屏幕坐标和深度,可以判断出它属于哪个 cluster,从而可以得到该 cluster 会受哪些 lights 和 decals 的影响,计算它们对光照的贡献。
下面显示了 pixel shader 是如何提取出相应的 lights 和 decals lists 的,简单来说是使用了 indirect offset 去访问各种 lists。首先有 cluster list,其中记录了若干信息,包括:
- item offset(32 bits)
- light count(8 bits)
- decal count(8 bits)
- probe count(8 bits)
我们根据当前像素的屏幕位置和深度拿到它对应在 cluster list 中的 cluster 数据,如下:
根据 cluster 中的数据得知它的 item offset 为 1,并且受 3 个 lights、2 个 decals 和 1 个 probes 影响。接着,我们按 offset 1 访问 item list,并依次读取 3 个 items 数据,其中 light index(12 bits)即为影响该 cluster 的 3 个 lights 索引:
按索引访问 light list 即可得到真正的光源数据:
同理,从同样的位置连续读取 2 个 item 数据即可得到 2 个 decals 索引:
按索引访问 decal list 即可得到真正的 decal 数据:
同理还有 probe list,由于这个 pass 不需要计算环境反射因此这里没有显示,后面会讲到。
Clustered-forward rendering 近年来得到了更多的关注,它比传统的 forward rendering 可以更快地处理大量光源,同时又不需要像 deferred rendering 那样需要频繁访问 GBuffers,对带宽的压力更小。
值得注意的是,这个 pass 除了会写入一张 lighting buffer 外,还会使用 MRT 进行一次 2 thin G-Buffers 的写入,包括一张 Normal map(R16G16 float format)和一张 Specular map(R8G8B8A8,alpha 通道存储了 smoothness):
DOOM 实际上混合使用了 forward 和 deferred rendering,这些额外的 G-Buffers 会用于计算额外的光照效果例如反射。
另一个没有提到的事是,此时还会为 mega-texture 系统生成一个 160 × 120 的 feedback buffer,它用于告诉 streaming system 需要 stream 进来哪些纹理以及它们的对应的 mipmap 等级。
GPU Particles
使用了一个 compute shader 来更新粒子系统,包括位置、速度和lifetime 等。Compute shader 会读取粒子的当前状态,以及normal buffer 和 depth buffer(用于碰撞检测),然后执行一次粒子模拟,再把新的状态结果存储到 buffers 中。
Screen Space Ambient Occlusion
SSAO 被用于加暗缝隙等位置,同时还用于计算 specular occlusion 来避免物体在遮挡区域出现过亮的 specular lighting。DOOM 在半分辨率下计算 SSAO,通过访问 depth buffer、normal buffer 和 specular buffer 计算而得。初始的计算结果会有很多噪点。
Screen Space Reflections(SSR)
DOOM 使用一个 pixel shader 来计算 SSR。
它的输入包括:depth map(计算像素的世界空间位置),normal map(计算反射向量),specular map(计算反射程度),前一帧的图像(pre-tonemapping)。SSR 计算起来并不复杂,但由于它完全是在屏幕空间计算的,因此当我们朝下看时,SSR 会逐渐失效。但如果和其他反射来源结合得好的话,除非我们盯着看,否则通常不会察觉到这些瑕疵。
Static Cubemap Reflections
静态环境反射是由IBL计算得到的。我们在不同位置预生成若干 128 ×128 大小的 environment probes 。正如之前提到的,也会为每个cluster 生成对应的 probe list。
所有的 cubemaps 会存储到一个数组里。然后使用一个 pixel shader 来读取 depth、normal、specular buffers,根据上述提到的 cluster lists 生成一张静态的 reflection map:
Blending Maps Together
这一步会使用一个 compute shader 来混合所有上述生成的 maps。它会读取 depth 和 specular map,然后将之前的光照结果和如下 maps 进行混合:
- SSAO 信息
- 当前像素有效的 SSR 信息(如果有的话)
- 当 SSR 无效时,使用 static reflection map
- 计算某些雾效
-
Blend + Fog: Before
-
Blend + Fog: After
-
Blend + Fog: After
Particle Lighting
场景里的 particles 会逐 sprite 计算光照。DOOM 会在低分辨率下计算每个 sprite 的光照结果,然后把它们存储到一张 4k 的 atlas 里。在 atlas 中对应 tiles 的分辨率会根据它们距离摄像机的远近、设置等发生变化。这张 atlas 会为具有相同分辨率的 sprites 分配特定的区域,下图展示了所有 64 × 64 大小的 sprites 的光照:
随后,会在全分辨率下渲染粒子,然后把上述低分辨率的光照结果与其混合。这样一来,可以把粒子的光照计算解耦出来,使其不受游戏屏幕分辨率的影响。
Downscale and Blur
会对 screen buffer 生成若干 downscaled 和 blurred 的mipmaps,会被用于后续玻璃折射等效果的渲染:
Transparent Objects
所有的半透明物体会接着渲染到屏幕上:
-
Transparent Objects: Before
-
Transparent Objects: After
DOOM 里的毛玻璃效果很好,这里会使用 decals 来只影响玻璃的某些区域来加强或减弱折射模糊效果。Pixel shader 里会计算当前的折射模糊系数,然后从上述 blurred levels 里选取最接近该模糊系数的两张 maps,在它们直接做线性插值来近似得到最后的折射模糊效果。
Distortion Map
温度很高的区域会产生热扭曲效果。场景中的 distortions 会先渲染到一张低分辨率的 distortion map 里。它的 RG 通道表明在水平和垂直方向上的扭曲程度,B 通道表明模糊程度。真正的扭曲效果会在屏幕后处理阶段使用这张 distortion map 计算而得。
User Interface
UI 部分会按照 premultiplied alpha mode 渲染到另一张不同的 LDR RT 里。渲染到另一张 RT 而不是直接在 final frame 上渲染的好处是,可以对所有 UI 部分直接应用一些后处理效果,比如 color aberration 。渲染过程并没有使用任何 batching 技术,就是简单地一个个绘制 UI 元素,大概花费了 120 个draw calls。最后,UI Buffer 会和场景的渲染结果混合到一起,得到最后的渲染结果。
Temporal Anti-Aliasing and Motion-Blur
接着,会使用 velocity map 和前一帧的渲染结果来计算 TAA 和 motion blur 效果。TAA 可以很好地处理 specular antialiasing,比 FXAA 等效果好很多。
-
TAA and Motion Blur: Before
-
TAA and Motion Blur: After
Scene Luminance
这一步会计算场景的 average luminance ,它随后会被用于计算 tonemapping 。具体计算方法是,将 HDR 格式的 lighting buffer 不停降采样直到得到一张 2 × 2 的纹理,每一次迭代时都会从上一级纹理计算 4 个相邻像素的平均值作为这一次迭代的结果。
Bloom
首先使用一个 bright pass filter 来提取高亮区域,它的结果会逐步 downscaled 和 blurred 到若干 layers 中,blur 时会使用separate Gaussian filter。最后这些 layers 会合成到一张四分之一分辨率大小的 HDR RT 中。
Final Post-Processing
这一步会在一个单独的 pixel shader 里计算如下所有效果:
- 通过 diatortion map 计算热扭曲效果
- 叠加 bloom texture
- 计算诸如 vignetting、dirt、lens flares 等效果
- 从 2 × 2 的 luminance texture 采样得到场景的 average luminance,结合额外的曝光系数计算 tonemapping 和 color grading
-
Tonemapping: Before
-
Tonemapping: After
DOOM 使用了 filmic tonemapping operator (同样被用于 Uncharted 2 tonemapper 和GTA V - Graphics Study),公式如下:
(x(Ax+BC)+DE) / (x(Ax+B)+DF) - (E/F)
UI and Film Grain
最后,UI 被混合到当前帧图像中,同时对其应用了轻微的film-grain效果:
-
UI - Film Grain: Before
-
UI - Film Grain: After
至此,当前帧图像绘制完毕。DOOM 在高性能表现下能够得到如此高质量的渲染效果,得益于它大量使用了前一帧的计算数据。总体来说,这一帧一共使用了 1331 个 draw calls,132 张贴图和 50 张 render targets。
Bonus Notes
Close-Up on Glass
拿另一帧图像具体讲一下玻璃是怎么渲染的:
- 预处理若干用于折射模糊效果的 mip levels
- 按从后往前的顺序渲染半透明物体,应用 decals、lighting、probe reflection 等,根据逐像素的折射系数采样 mip levels 得到玻璃效果
-
Glass: Before
-
Glass: After
Depth of Field
-
DoF: Before
-
DoF: After
不是所有的游戏都正确计算了 DoF 效果。最简单的方法通常会根据像素的深度值对其应用一个高斯模糊。这种方法非常简单高效但存在一些问题:
- 高斯模糊不能正确地产生bokeh,我们需要使用一个 flat kernel 来让高亮区域产生正确的圆形或六边形等 bokeh 形状
- 在一个 pass 里计算 DoF 经常会导致 bleeding artifacts
DOOM 使用了一个相对比较正确的方法来计算 DoF ,它会单独计算 near 和 far 两张 textures:
- Near field 会被模糊得很厉害
- Far field 同样会被模糊,但是不会读取任何 in focus 和 near field 的像素,从而避免前景物体被错误地 bleed 到后景中
-
Far Field
-
Far Field - Blur #1
-
Far Field - Blur #1 & #2
DOOM 会在半分辨率下使用一个 64 taps 的 disk filter 来得到 bokeh 效果,disk 的采样半径会随着当前像素的 CoC 值变化。接着,它会使用一个 16 taps 的 blur filter 对上述结果再次进行处理,这个 filter 不会使用任何模糊系数,而会直接使用相邻 taps 的最大值(相当于 max filter)。这一步骤可以进一步 widen 第一次的模糊结果, 同时还可以解决上一步在采样时可能出现的 gaps(如果 CoC 很大导致采样半径太大的话会有瑕疵)。这是受McIntosh’s work工作的启发。
最后会把 near 和 far fields 叠加混合到原图像中得到最后的 DoF 效果。接着会再计算 motion blur 效果。
More Reading
- The devil is in the details: idTech 666 (Siggraph 2016) by Tiago Sousa and Jean Geffroy
- Tech Interview: Doom by Digital Foundry
- id Software Tech Interview by DSOGaming
- QuakeCon 2016: Doom Uncapped – Part1 and Part 2
- Doom: The definitive interview by VentureBeat