GameTech/URP 渲染实战

案例 4.10Bayer 有序抖动进阶

目标:只用两种(或少数几种)颜色表现连续的渐变,理解有序抖动(Ordered Dithering)的原理与 Bayer 矩阵的构造。抖动在实时渲染里用途很广:复古像素风格的后处理、抖动透明(第 16 章)、LOD 交叉淡化(URP 的 LOD_FADE_CROSSFADE)、给体积光的步进加随机起点以消除条带(第 28 章)、以及在 8 位输出前消除色带。

把灰度量化成黑白最简单的方法是阈值:大于 0.5 的变白,否则变黑。结果是大块的纯黑纯白,渐变信息全丢了(图 4-18 左一)。抖动的思路是让每个像素用不同的阈值:在一小块区域里,阈值均匀分布在 0~1 之间,于是灰度为 0.25 的区域恰好有约四分之一的像素超过阈值、被点亮,远看平均亮度就是 0.25。

0821012414631119157135Bayer 4×4:阈值 = (M + 0.5) / 16输入 0.25亮 4/16输入 0.5亮 8/16输入 0.75亮 12/16输入灰度 > 阈值 的像素点亮(2 色量化)
图 4-17左:4×4 Bayer 矩阵,数字是阈值的排序,颜色越深阈值越大;右:输入为 0.25、0.5、0.75 的平坦区域被点亮的像素,分别是 4、8、12 个,分布均匀

阈值怎么排列决定了抖动的质感。Bayer 矩阵的特点是相邻阈值尽可能不同,点亮的像素在空间上尽量分散,所以图案是均匀的网点而不是成团的斑块。它可以递归构造:

M2=[0231],M2n=[4Mn4Mn+24Mn+34Mn+1],threshold=M+0.5n2

常见做法是把 4×4 或 8×8 矩阵写成常量数组查表。我们换一个角度:观察递推式可以发现,像素坐标的最低位决定了它在最小 2×2 块里的位置,而这个位置贡献的是最高的权重(4 的最高次幂)。所以只要逐位取出 x、y,按 2×2 的规则 2·(x⊕y) + y 算出每一级的值,再按“低位高权”拼起来即可,任意尺寸都是同一段代码:

Assets/Cookbook/Ch04/ProceduralLib.hlsl(节选:bayer)
// ---------------------------------------------------------------- 有序抖动: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 矩阵第一行第二个数一致。

Assets/Cookbook/Ch04/C04_10_BayerDither.shader(节选:dither)
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;
uvec2 pixel = uvec2(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 个像素。

图 4-18“原图”是左右灰阶渐变 + 一个假光照的球。左一:固定阈值 0.5,两色;左二:Bayer 4×4,两色;右二:Bayer 8×8,两色,可表现的灰阶更多(64 级),网点更细腻;右一:Bayer 4×4,四级颜色,球的明暗已经相当平滑
Bayer 之外Bayer 抖动的规则网格在大面积平坦区域会显出明显的“十字纹”。其他常见的选择有:交错梯度噪声(Interleaved Gradient Noise,core 包 Random.hlsl 里的 InterleavedGradientNoise,计算便宜,配合 TAA 时逐帧变化效果很好)、蓝噪声贴图(Blue Noise,视觉上最“干净”,需要一张预生成的贴图)。三者可以按“开销—质量”自由替换,接口都是“每个像素给一个 [0,1) 的阈值”。