深度解析塑造未来的技术文章。

抖动的艺术:用一个比特绘制精美图像

探讨抖动算法如何仅用黑白像素营造明暗层次,从 Floyd-Steinberg 到蓝噪声,并附实用实现思路。

一位船长肖像由黑白抖动点阵构成,逐渐呈现出明暗层次

Lucas Pope的Return of the Obra Dinn是史上视觉效果最惊艳的游戏之一,它只用了两种颜色:黑和白。每个像素一比特,没有灰度,没有渐变,也没有抗锯齿,每个像素要么亮要么灭。然而这款游戏却能渲染出完整的3D世界,角色清晰可辨,还有景深和氛围光效,靠的全是巧妙的抖动技术。

抖动(Dithering)是一种通过排列黑白像素图案,让人眼误以为看到中间灰度的技术。它从计算机图形学的早期就已经存在,如今又重新火了起来,在游戏开发、网页设计,以及在电子墨水屏上显示图像这类越来越常见的需求中都有用武之地。这些算法设计精巧、种类繁多,适用范围也比你想象的要广。

为什么抖动有效

你的视觉系统并不会逐个分辨像素,而是对一片区域进行整体感知。当你看到黑白像素交替排列的图案时,看到的不是一个个小点,而是灰色。一块区域里有25%的像素是白色,看起来就像25%的灰;有75%是白色,看起来就是75%的灰。区域内白色像素的密度,决定了人眼感知到的亮度。

这和报纸的网点印刷(halftone)用的是同一个原理。大小和间距各不相同的墨点,在白纸上只用纯黑墨水,就能营造出连续的色调。抖动可以看作它的数字版本,只不过用的是像素图案,而不是网点大小。

难点不在于生成灰度,那很简单。真正的挑战在于如何避免视觉瑕疵:不能出现色带(图案之间的生硬过渡),不能有方向性偏差(形成可见的线条),也不能出现摩尔纹(抖动图案与显示网格之间产生的干涉条纹)。

阈值抖动:最朴素的做法

最简单的抖动方式是:将每个像素的亮度与一个阈值(通常是50%)进行比较。比阈值亮就输出白色,比阈值暗就输出黑色。结果是一张高对比度的剪影,完全没有过渡层次。它适合文字和线稿,但用来处理照片或3D渲染画面就很糟糕了。

import numpy as np
from PIL import Image
def threshold_dither(image, threshold=128):
"""Binary threshold — no dithering at all, really."""
gray = np.array(image.convert('L'), dtype=float)
return Image.fromarray((gray > threshold).astype(np.uint8) * 255)

有序抖动:网格上的图案

有序抖动使用一个阈值矩阵(也叫Bayer矩阵),在重复的图块中为不同位置设置不同的阈值。它不再用同一个阈值去比较所有像素,而是根据每个像素所在的位置,与矩阵中对应的值进行比较。

def ordered_dither(image, matrix_size=4):
"""Ordered dithering with Bayer matrix."""
gray = np.array(image.convert('L'), dtype=float)
h, w = gray.shape
# 4×4 Bayer matrix (values 0-15, normalized to 0-255)
bayer_4x4 = np.array([
[ 0,  8,  2, 10],
[12,  4, 14,  6],
[ 3, 11,  1,  9],
[15,  7, 13,  5]
]) * (255 / 16)
# Tile the matrix across the image
threshold = np.tile(bayer_4x4, (h // 4 + 1, w // 4 + 1))[:h, :w]
return Image.fromarray((gray > threshold).astype(np.uint8) * 255)

有序抖动会产生一种辨识度很高的交叉网纹图案。在低分辨率下,规则的网格结构会很明显,使画面带有一种机械感和复古质感。在很多场景下,这其实是一种刻意的审美选择,许多游戏和艺术家都会有意使用8×8 Bayer矩阵所带来的这种独特质感。

Bayer矩阵的构造非常精巧:每一级阈值新增的像素,都尽可能远离已有的像素。这样可以减少像素聚集,使每个亮度级别下的点分布都尽量均匀。它的速度也极快,每个像素只需一次比较,不依赖相邻像素,因此非常适合实时渲染。

误差扩散:Floyd-Steinberg及其变体

误差扩散抖动采用了完全不同的思路。它不使用固定的阈值矩阵,而是按顺序逐个处理像素,将「误差」(原始像素值与量化结果(黑或白)之间的差值)分配给尚未处理的相邻像素。

def floyd_steinberg_dither(image):
"""Floyd-Steinberg error diffusion dithering."""
gray = np.array(image.convert('L'), dtype=float)
h, w = gray.shape
for y in range(h):
for x in range(w):
old_pixel = gray[y, x]
new_pixel = 255.0 if old_pixel > 128 else 0.0
gray[y, x] = new_pixel
error = old_pixel - new_pixel
# Distribute error to neighbors
# Floyd-Steinberg diffusion kernel:
#        * 7/16
#  3/16 5/16 1/16
if x + 1 < w:
gray[y, x + 1] += error * 7 / 16
if y + 1 < h:
if x - 1 >= 0:
gray[y + 1, x - 1] += error * 3 / 16
gray[y + 1, x] += error * 5 / 16
if x + 1 < w:
gray[y + 1, x + 1] += error * 1 / 16
return Image.fromarray(gray.astype(np.uint8))

Floyd-Steinberg抖动的效果比有序抖动自然得多。像素分布中没有明显的网格图案,处理平滑渐变的效果也非常出色。墨点会有机地聚集,整体观感更接近照片式的网点印刷。

它的缺点在于必须按顺序执行。每个像素都依赖于先前已处理像素的误差,因此很难并行化。这使得它比有序抖动慢,也更难在基于着色器的渲染管线中实现。对于预处理的图片,这不是问题;但对于60fps的实时3D渲染,它就成了瓶颈。

蓝噪声抖动:两全其美

蓝噪声抖动是许多应用场景中目前的最佳方案。它和有序抖动一样使用阈值矩阵(因此速度快,也能并行化),但矩阵中的值经过特殊排列,形成蓝噪声分布:点的位置看似随机,却保持最小间距,既避免了白噪声的聚集,也避免了有序抖动的网格图案。

「蓝噪声」这个术语来自信号处理领域。蓝噪声的能量主要集中在高频部分,就像蓝光一样。从空间角度看,这意味着图案没有大尺度结构(没有可见网格,也没有方向性偏差),而局部的点又分布得相当均匀(没有聚团,也没有空洞)。它的效果类似于往靶子上投飞镖,但每支飞镖与其他飞镖之间都必须保持一定的最小距离。

生成一个好的蓝噪声阈值矩阵出奇地困难,这是一个需要反复迭代优化的问题,直到频谱符合预期的特征。不过一旦矩阵生成好了,使用它的速度和Bayer抖动一样快,每个像素同样只需一次比较。

《Return of the Obra Dinn》的做法:球面映射抖动

《Return of the Obra Dinn》的抖动设计尤为巧妙,因为它解决了3D渲染中特有的一个问题:时间稳定性。如果在屏幕空间中应用标准抖动图案,移动摄像机时,图案会「游动」起来:它固定在屏幕上,而3D几何体却在它下面移动。这会产生一种闪烁效果,极其分散注意力。

Pope的解决方案是把抖动图案映射到3D空间,而不是屏幕空间。抖动图案被投影到包围摄像机的球面上,然后根据每个像素的视线方向进行采样。摄像机旋转时,抖动图案随之旋转;物体在世界空间中移动时,它们的抖动依然稳定,因为图案取决于世界坐标位置,而不是屏幕坐标。

这项技术可以用在任何使用抖动或网点效果的3D应用中。关键在于:如果你需要时间稳定性,阈值就应该由某种稳定的量(世界坐标、视线方向)决定,而不是由每一帧都在变化的屏幕坐标决定。

抖动的实际应用:不止于复古审美

抖动不只是复古游戏的审美元素,在现代开发中它也有切实的用途。

  • 电子墨水屏。电子阅读器和电子墨水标识的灰度能力有限(通常只有16级)。要在这些设备上显示照片和渐变,抖动是必不可少的。抖动算法的选择会直接影响可读性和图像质量。
  • 减小文件体积。一张1比特的抖动图像,其原始数据大小只有8位灰度图像的八分之一。对于网页上的装饰性图片,抖动PNG可以比灰度图小得多。一些网页设计师出于审美和性能的双重考虑,会刻意使用抖动图像。
  • 颜色量化。当你把全彩图像缩减到有限调色板时(如GIF、索引色PNG,或调色板受限的显示设备),抖动可以防止平滑渐变被映射到离散颜色值时产生的色带。在颜色空间中使用Floyd-Steinberg抖动,正是GIF编码器的处理方式。
  • 渲染中的渐变色带。低精度渲染目标(每通道8位)在平滑渐变中可能出现明显的色带。添加屏幕空间抖动(哪怕只是±0.5的数值扰动)就能打散色带,而且不会引入明显的噪点。现代大多数游戏引擎都会自动这样处理。
  • 热敏打印与小票打印。热敏打印机本质上是1比特设备,每个点要么加热(黑色),要么不加热。抖动可以把照片和图形转换成可打印的形式。算法的选择决定了你的小票图像是清晰可辨,还是一团模糊的色块。

在着色器中实现抖动

对于实时应用,有序抖动和蓝噪声抖动都可以高效地以片段着色器(fragment shader)的形式实现。

// GLSL fragment shader for Bayer 8x8 ordered dithering
precision mediump float;
uniform sampler2D uTexture;
varying vec2 vTexCoord;
// Bayer 8x8 matrix lookup via bit manipulation (no texture needed)
float bayer8x8(vec2 pos) {
ivec2 p = ivec2(mod(pos, 8.0));
int index = p.x + p.y * 8;
// Bit-reversal trick for Bayer matrix values
int value = 0;
int x = p.x ^ p.y;
int y = p.y;
for (int i = 0; i < 3; i++) {
value = value * 4 + (x & 1) * 2 + (y & 1);
x >>= 1;
y >>= 1;
}
return float(value) / 64.0;
}
void main() {
vec4 color = texture2D(uTexture, vTexCoord);
float luminance = dot(color.rgb, vec3(0.299, 0.587, 0.114));
float threshold = bayer8x8(gl_FragCoord.xy);
float dithered = step(threshold, luminance);
gl_FragColor = vec4(vec3(dithered), 1.0);
}

对于蓝噪声,通常需要预先计算好噪声纹理,然后在着色器中对其进行采样。纹理可以平铺,因此一张64×64或128×128的蓝噪声纹理,重复平铺后就能覆盖整个屏幕。每个像素的开销只是一次纹理采样加一次比较,在现代GPU上几乎可以忽略不计。

如何选择合适的算法

每种抖动方法都有其最适合的应用场景。

  • 阈值抖动:适用于文字、线稿和高对比度图形,这类场景细节比色调范围更重要。
  • 有序抖动(Bayer):适用于需要刻意营造复古或网点风格的实时渲染。速度快,可并行化,对GPU友好。
  • Floyd-Steinberg:适用于对质量要求最高的静态图像处理。最适合照片和平滑渐变的图像。
  • 蓝噪声:适用于希望获得自然效果、又不想要可见图案的实时渲染。是现代应用中通用性最强的选择。
  • 球面/世界空间抖动:适用于对时间稳定性要求很高的3D渲染。对于像《Return of the Obra Dinn》这样的1比特3D渲染来说必不可少。

抖动是这样一个主题:它的核心概念很简单,就是「排列点阵来模拟明暗」,但在具体实现时的选择却大有门道。每种算法都是在质量、速度和视觉风格之间的不同权衡。在电子墨水屏、低带宽网络和复古审美依然重要的今天,理解这些权衡比以往任何时候都更有实用价值。