Blue Noise

The acron.sampling module makes the sampling patterns a real-time renderer jitters with. Its main class ranks spatiotemporal blue noise: a stack of tileable threshold masks, one per frame, whose values are blue noise over the screen in every slice and over time at every pixel (Wolfe, Morrical, Akenine-Moller and Ramamoorthi, “Spatiotemporal Blue Noise Masks”, EGSR 2022).

Why it matters: a pass that takes one random number per pixel per frame – a shadow filter’s rotation, a ray march’s start, a dithered fade – leaves grain that a spatial blur or a temporal accumulation (TAA) has to remove. White noise and per-pixel hashes put much of that grain at low frequencies, which neither removes; one 2D blue-noise mask animated by a golden-ratio offset is blue on the screen but not over time. Spatiotemporal blue noise keeps both, so the same filter budget leaves visibly less noise.

SpatiotemporalBlueNoise

Ranks depth slices of size x size texels with a void-and- cluster algorithm (Ulichney 1993) whose energy is the paper’s separable one: a Gaussian over the toroidal distance within a slice (sigma_space) plus a Gaussian over the toroidal distance between the slices of the same pixel (sigma_time). The tightest cluster and the largest void are kept in tournament trees, so the ranking costs O(N log N): 64 x 64 x 32 takes about half a second. halton(index, base) is the radical inverse, the usual source of TAA jitter offsets.

The ranks are deterministic: one seed gives the same masks on every machine with IEEE doubles (integer random numbers, ties broken by the lower index, energies summed in a fixed order with floating-point contraction off, and the Gaussian kernels from an exponential of the module’s own instead of the C library’s, which may round differently between platforms), so a game can generate them at startup instead of shipping a binary, or cache them on disk. A stack holds at most 2^26 texels (8192 x 8192 x 1, 1024 x 1024 x 64); a larger one logs an error and generate() does nothing.

Generating masks

from acron.sampling import SpatiotemporalBlueNoise

noise = SpatiotemporalBlueNoise(64, 32, seed=1)
noise.generate()                      # releases the GIL
data = noise.get_bytes()              # 64 * 64 * 32 bytes, x fastest
v = noise.get_value(10, 20, 3)        # (rank + 0.5) / count

get_bytes is the RAM image of a single-channel 2D texture array (Texture.setup_2d_texture_array(size, size, depth, T_unsigned_byte, F_red)): level k stands for the interval [k, k + 1) / 256, and every level appears equally often when the texel count is a multiple of 256. get_rank gives the exact rank (0 to count - 1); coordinates wrap.

A shader reads slice frame % depth at pixel % size with texelFetch. Two independent values per pixel come from two masks with different seeds (two channels of one texture); they are blue each, not as points in the square – the paper’s vector-valued variant is not implemented.

The Python framework’s frame pipeline uses this module for its jitter (acron.pipeline.bluenoise, shaderlib/stbn.glsl); see The Frame Pipeline.

Parameters and cost

The kernels reach three standard deviations, never more than half the torus. sigma_space and sigma_time of 1.9 (the paper’s) suit 16 to 64 slices; smaller values sharpen the spectrum’s cutoff and leave more low- frequency energy. The time grows with the texel count (about 0.5 s for 131 072 texels, 0.9 s for 262 144 on a current laptop).

A slice is slightly less blue than a purely spatial mask of the same size: the temporal term competes with the spatial one. At a 10% threshold, a 32 x 32 slice has one or two pairs of adjacent texels, where white noise of the same density has about ten.