本文翻译自 Fullscreen, Round 3 - Return of the Obra Dinn Devlog,原载于 TIGSource Forums。
背景:全屏模式下的抖动问题
感谢所有提出建议的朋友。我确实尝试了每一种可能的方案,最终得出的结论是:在保持游戏视觉风格的同时解决全屏模式下的不适感,最好的方法是稳定化抖动效果并抑制闪烁的像素点。
这是关于这个主题的第三篇完整开发日志了。每次写完之前的版本,我都会在回顾时发现新思路或找到其他值得尝试的方案。但这一次,我真的不想再折腾了。
抖动处理(Dithering)的基本原理
首先简单解释一下抖动处理的工作原理。《奥伯拉丁的回归》在内部以 8-bit 灰度渲染所有内容,然后在后处理阶段将最终输出转换为 1-bit。从 8-bit 到 1-bit 的转换过程是通过将源图像的每个像素与抖动图案(dither pattern)中对应的点进行比较来实现的。如果图像像素值大于抖动图案点的值,输出位设为 1;否则为 0。输出被缩减为 1-bit,而观察者的眼睛会将这些像素重新融合,近似还原出更多的灰度层次。

这个过程包含两个核心组件:源图像和抖动图案。游戏针对不同场景使用了两种不同的图案:
- 8x8 Bayer 矩阵 - 用于获得更平滑的灰度过渡
- 128x128 蓝噪声场(blue noise field) - 用于产生更少规律性的输出


球体使用 Bayer 图案,其他区域使用蓝噪声。
问题:静态图案遇上动态场景
基础的抖动处理对静态图像效果很好,但对于动态或动画图像来说就差强人意了。当源图像逐帧变化时,静态的抖动图案和低分辨率输出就变成了大问题。本该是实心的形状和阴影,现在看起来像是一团蠕动的像素混乱。

当看到这种低分辨率的「游泳抖动」效果时,人们的第一反应不是「哦,抖动就是这样工作的」,而是「这什么鬼扭曲抖动效果?怎么关掉它?」

试着在画面移动时聚焦某个物体,你就能切身感受到《奥伯拉丁的回归》全屏模式问题的核心。虽然有一些解决方案,但基本上都归结为「这种风格行不通,换掉吧」。我也沿着这条路走了很远,尝试了不同的风格,但最终我回来了——凭什么让这些该死的小像素来摆布我?
方案探索:稳定化抖动图案
为了让眼睛能够最好地重新融合一切,抖动效果在抖动图案点与输出像素保持 1:1 对应时效果最佳。但这种只与输出关联的方式意味着,作为场景后处理效果,抖动图案与被渲染的几何体之间没有联系。每一帧,移动的场景元素都会与不同的阈值进行比较。
我真正想要的是让抖动图案「钉」在几何体上,随着场景的其余部分一起稳定移动。
这是一个映射问题的核心。正如这篇长文所暗示的,理想的抖动图案映射(与屏幕 1:1)和理想的场景映射(与几何体 x:1)之间存在冲突,所以需要做出一些妥协。我的大部分工作都集中在将输入抖动图案映射到不同的空间,以便更好地将图案与场景几何体关联起来。
尝试一:纹理空间映射(Texel Space)
我的第一个尝试是将抖动图案映射到纹理空间。这相当于在场景渲染期间对物体纹理进行抖动,而不是在 8-bit 输出的后处理阶段。我没想到这会效果很好,但想看看完美场景匹配的映射是什么样子的。

结果如预期:不同物体的映射方式不同,所以它们的图案比例不匹配。这可以通过统一化来解决。但真正的问题是锯齿。任何从一个空间到另一个空间的重采样都会导致锯齿,而抖动图案不能像传统纹理那样轻松地进行 mipmap 或过滤处理。

这并非完全失败——图案确实很好地钉在了几何体上。但锯齿产生了自己的游泳效果,统一或缩放映射都无济于事。纹理元素会随着与相机距离的变化而改变大小,所以总会有一些抖动图案像素在重采样到屏幕时产生严重的锯齿。
尝试二:运动变形(Motion Warping)
如果我想让抖动图案追踪其下方的移动几何体,为什么不用场景中每个渲染像素的位置变化来变形图案呢?这有点像运动模糊,每个像素追踪它从上一帧开始的移动。在这种情况下,我更新抖动纹理以保持其图案随场景移动。如果某个场景像素在上一帧中不存在,就在那里重新加载抖动图案。这个技术因为游戏的静态特性而变得简单——我只需要担心相机的移动,而不是单个物体。

这个尝试有点粗糙,但有几点很清楚:首先,它某种程度上是有效的。其次,抖动图案需要一个邻域——不能是单个像素。如果像这个方法那样单独考虑每个像素,就会在图案中产生明显的断裂和不连续性。

这些不连续性是由于我选择的像素深度和阈值差异造成的。我曾设想过一个基于追踪区域、平均其深度并移动该区域所有抖动图案点的复杂修复方案。区域边界处的不连续性可以通过急剧的照明变化或线框线条来隐藏。这将通过游戏现有的线框生成彩色区域设置来实现。但当我坐下来实现这一切时,深度项从我提出的第一个方程中消失了,给了我一个简单得多的替代方案:
尝试三:带偏移的屏幕映射
在组合变形抖动的方程时,一个非常简单的变换浮现出来:
DitherOffset = ScreenSize * CameraRotation / CameraFov

基本上,这表达的是:当相机旋转一个视场角(FOV)时,我希望屏幕映射的抖动图案正好移动一个屏幕的距离。这保持了与屏幕的 1:1 映射,同时也考虑了场景几何体在视图中的简化变换。这实际上只匹配屏幕中心的移动,但感谢这个操蛋的世界,它几乎足够好了。

注意椅子上的抖动像素看起来大部分随几何体移动。球体也是如此。但与视图更垂直的平面匹配得不太好——地板仍然是一团糟。
虽然不完美,但简单地偏移屏幕映射的抖动保持了整体图案和场景移动足够接近,眼睛可以更好地一起追踪它们。我对这个结果相当满意。但在清理代码、提交一切、写开发日志的时候,完美钉住抖动的想法一直萦绕着我:
尝试四:世界空间 - 立方体贴图(Cube Mapping)
到目前为止的实验表明,抖动图案与场景几何体之间的任何关联都必须忽略场景的深度信息。实际上这意味着抖动可以在相机旋转期间钉在几何体上,但不能在相机平移期间。对于《奥伯拉丁的回归》来说,这并不是什么坏事,考虑到游戏的慢节奏和玩家的观察角色。你通常是在四处走动、停下来、看东西。走路时,屏幕上有太多东西在变化,游泳的抖动不那么明显。
考虑到这一点,我的下一个尝试是通过将抖动图案预渲染到以相机为中心的立方体的各个面上,来间接地将图案映射到几何体。立方体随相机平移但保持世界方向。混合了一点屏幕映射,一点场景映射。


当直接看向立方体的面时效果很好,而指向角落时则不太理想。但抖动图案在相机旋转时完美固定在 3D 空间中。即使粗糙,结果也很有希望。

作为后处理,这比纹理空间映射更通用,这是好事。现在问题归结为特定的立方体贴图方式。理想的映射是立方体上的一个纹理元素在任何相机旋转下都能精确解析为屏幕上的一个像素。用立方体这是不可能的……
尝试五:世界空间 - 球形映射(Sphere Mapping)
……但我用球形非常接近了这个目标。

找到这个特定的球形映射花了一些时间。没有办法完美地将方形纹理平铺到球体上。本来可以重新定义抖动矩阵,使用六边形网格或其他可以在球体上平铺的东西。也许可以,我没试。相反,我只是对方形平铺进行了 hack,直到这个精心调整的原始抖动图案「环形」映射给出了好的结果。

比立方体好,但仍有大量锯齿。球形映射的点大小与屏幕像素大小非常接近——刚好差一点点就会导致摩尔纹(moire patterns)。我能感觉到接近了,而对于这种锯齿,一个非常简单的修复是超采样:以更高分辨率应用抖动阈值处理,然后下采样。


这是我得到的最好结果。有一些妥协:
- 抖动图案点在屏幕边缘变得更大、效果更差
- 对于大多数相机旋转,图案不是上下左右对齐的
- 由于最终的框下采样,输出不再是 1-bit
但好处相当可观:
- 抖动在所有相机旋转时完美钉住。这在游戏中感觉有点诡异。
- 游泳抖动的不适感完全消失,即使在全屏模式下
- 游戏的像素化风格得以保留
可以通过简单的 50% 阈值将输出重新缩减为 1-bit 来消除妥协 #3。结果仍然比不超采样好(下面的三重比较是经过阈值处理的)。


总结
花了 100 小时在一件「没有人会因为它的缺席而注意到」的事情上,感觉有点奇怪。绝对不会有人想:「哇,这抖动真他妈稳定。这里发生了什么魔法。」但我不想给人们制造他们不知道自己应该有的问题,所以这是值得修复的。
带偏移的屏幕空间映射在 1x 时效果最好,球形映射在 2x 时效果最好。所有场景渲染现在都是 800x450(从 640x360 提升),这在不牺牲低分辨率风格的情况下提高了可读性。最终游戏将有两种显示模式:
- DIGITAL(数字模式):边框盒式、屏幕空间偏移抖动、1-bit 输出
- ANALOG(模拟模式):全屏、球形映射抖动、软化输出
个人感想
这篇文章展示了游戏开发中一个很少被讨论的问题:视觉效果的微小细节如何影响用户体验。Lucas Pope 花了大量时间优化一个大多数玩家根本不会意识到的技术细节,但正是这种对细节的执着追求,让《奥伯拉丁的回归》成为了一款视觉体验如此独特的游戏。
从技术角度看,这篇文章很好地展示了解决复杂渲染问题的思路:
- 理解问题的本质 - 不是简单地「换掉风格」,而是深入理解抖动处理在动态场景下的行为
- 系统性探索 - 从纹理空间到运动变形,从屏幕偏移到立方体贴图,再到球形映射,每种方案都有其优缺点
- 知道何时妥协 - 最终方案并非完美,但在各种权衡下达到了最佳平衡
对于做图形渲染或游戏开发的同学,这篇文章提供了宝贵的实践经验:有时候解决一个看似小众的技术问题,需要深入理解渲染管线的各个环节,以及创造性地组合不同的技术方案。