案例 4.10Bayer 有序抖动进阶
目标:只用两种(或少数几种)颜色表现连续的渐变,理解有序抖动(Ordered Dithering)的原理与 Bayer 矩阵的构造。抖动在实时渲染里用途很广:复古像素风格的后处理、抖动透明(第 16 章)、LOD 交叉淡化(URP 的 LOD_FADE_CROSSFADE)、给体积光的步进加随机起点以消除条带(第 28 章)、以及在 8 位输出前消除色带。
把灰度量化成黑白最简单的方法是阈值:大于 0.5 的变白,否则变黑。结果是大块的纯黑纯白,渐变信息全丢了(图 4-18 左一)。抖动的思路是让每个像素用不同的阈值:在一小块区域里,阈值均匀分布在 0~1 之间,于是灰度为 0.25 的区域恰好有约四分之一的像素超过阈值、被点亮,远看平均亮度就是 0.25。
阈值怎么排列决定了抖动的质感。Bayer 矩阵的特点是相邻阈值尽可能不同,点亮的像素在空间上尽量分散,所以图案是均匀的网点而不是成团的斑块。它可以递归构造:
常见做法是把 4×4 或 8×8 矩阵写成常量数组查表。我们换一个角度:观察递推式可以发现,像素坐标的最低位决定了它在最小 2×2 块里的位置,而这个位置贡献的是最高的权重(4 的最高次幂)。所以只要逐位取出 x、y,按 2×2 的规则 2·(x⊕y) + y 算出每一级的值,再按“低位高权”拼起来即可,任意尺寸都是同一段代码:
// ---------------------------------------------------------------- 有序抖动:Bayer 矩阵 // levels = 1 → 2×2,2 → 4×4,3 → 8×8。返回 (0,1) 内均匀分布的阈值 // 递推关系 M(2n) = [4M+0, 4M+2; 4M+3, 4M+1]:越低的位权重越大 float BayerThreshold(uint2 pixel, uint levels) { uint v = 0u; [unroll] for (uint i = 0u; i < 3u; i++) { if (i >= levels) break; uint x = (pixel.x >> i) & 1u; uint y = (pixel.y >> i) & 1u; uint m2 = ((x ^ y) << 1u) | y; // 2×2 矩阵 [0 2; 3 1] v |= m2 << (2u * (levels - 1u - i)); } float n = (float)(1u << (2u * levels)); // 4^levels 个阈值 return (v + 0.5) / n; }
// ---------------------------------------------------------------- 有序抖动:Bayer 矩阵 // levels = 1 → 2×2,2 → 4×4,3 → 8×8。返回 (0,1) 内均匀分布的阈值 // 递推关系 M(2n) = [4M+0, 4M+2; 4M+3, 4M+1]:越低的位权重越大 float BayerThreshold(uvec2 pixel, uint levels) { uint v = 0u; for (uint i = 0u; i < 3u; i++) { if (i >= levels) break; uint x = (pixel.x >> i) & 1u; uint y = (pixel.y >> i) & 1u; uint m2 = ((x ^ y) << 1u) | y; // 2×2 矩阵 [0 2; 3 1] v |= m2 << (2u * (levels - 1u - i)); } float n = float(1u << (2u * levels)); // 4^levels 个阈值 return (float(v) + 0.5) / n; }
可以手算验证:坐标 (1, 0) 的最低位是 x=1、y=0,2×2 值为 2,乘以 4 得 8;更高位都是 0,结果 8,与图 4-17 中 4×4 矩阵第一行第二个数一致。
uint2 pixel = (uint2)cellPx; float threshold = 0.5; // 模式 0:所有像素同一个阈值 if (_Mode > 0.5) threshold = BayerThreshold(pixel, (uint)_Mode); // 1→2×2, 2→4×4, 3→8×8 // 量化到 _Levels 级:先放大到 [0, L-1],加上阈值后向下取整 float L = _Levels - 1.0; float q = floor(v * L + threshold) / L;
多级量化的写法是 floor(v·(L−1) + threshold) / (L−1):把值放大到 [0, L−1],加上 [0,1) 的阈值后向下取整。L=2 时退化为二值抖动;L 越大,相邻两级之间的“台阶”越小,抖动图案也越不明显。
抖动图案通常要贴在屏幕像素上(用 positionCS.xy,即 SV_POSITION 在片元里的像素坐标),这样无论物体怎么移动,网点都对齐像素、不会游走闪烁。本案例为了在书里看清网点结构,把面片划成 96×96 的“虚拟像素”,每个虚拟像素占屏幕上约 4 个像素。

Random.hlsl 里的 InterleavedGradientNoise,计算便宜,配合 TAA 时逐帧变化效果很好)、蓝噪声贴图(Blue Noise,视觉上最“干净”,需要一张预生成的贴图)。三者可以按“开销—质量”自由替换,接口都是“每个像素给一个 [0,1) 的阈值”。