Skip to main content

Noise in one fetch: a 3D texture instead of eight hashes

Part 4. Precalc, hardware filtering, and a 2.3× speedup

Demoscene
Computer graphics
Performance
A classic demoscene trick: random numbers are generated at startup into a 64³ 3D texture, and the GPU does the interpolation. Zero bytes on disk, 512 KiB of video memory, and 7.48 → 3.21 ms per frame.
Author

Mikhail

Published

3 November 2026

NoteWhere we are

In part 2 the noise was computed “honestly”, with hashes on the ALU. The picture was right but slow. Today we speed it up without changing a single artistic constant, using the trick the demoscene has relied on since the 1990s: compute ahead of time whatever can be computed ahead of time.

What one noise sample costs

Let us count the work per pixel. Every march step calls density, and inside it:

Table 1: Worst-case cost of ALU noise.
What noise calls Hashes
Domain warp (wn) 1 8
fbm, 4 octaves 4 32
Per step 5 40
Per pixel, up to 64 steps up to 320 up to 2,560
Per 1280×720 frame up to 2.4 billion

Each hash is a fract and a few multiplications, plus a trilinear blend of eight results. Early exit and faster stepping through empty space cut the real figure several times, but the order of magnitude is clear. On an M1, the same frame of the “core” scene at 640×360 takes 7.48 ms. At 1280×720 there are four times as many pixels, about 30 ms, or roughly 33 frames per second instead of 60.

The idea: let the hardware interpolate

Look at value noise once more: random numbers at lattice nodes and smooth interpolation between them. The second half is exactly what the GPU does on every texture read with trilinear filtering: it takes the 8 neighboring texels and blends them by the fractional part of the coordinate. In hardware, and for free.

So the noise can live in a 3D texture of random numbers and be read in a single fetch:

flowchart TB
  subgraph "At startup, CPU"
    A["xorshift32"] --> B["64³ = 262,144 random u16"]
    B --> C["3D MTLTexture, R16Unorm<br/>512 KiB of video memory"]
  end
  subgraph "Every pixel, GPU"
    D["noise(p)"] --> E["one fetch<br/>sampler: linear + repeat"]
    E --> F["fbm: 4 fetches instead of 32 hashes"]
  end
  C --> E

On disk this texture takes not a single byte: 20 lines of code generate it at launch. This is precalc, one of the main techniques of 64k intros. Farbrausch generated the textures of their intros this way [1].

Generating it on the CPU

Marsaglia’s xorshift32 supplies the random numbers: three shifts and three XORs per number [2]:

x \leftarrow x \oplus (x \ll 13), \quad x \leftarrow x \oplus (x \gg 17), \quad x \leftarrow x \oplus (x \ll 5)

The generator is deterministic, so every launch produces the same nebula:

src/main.rs (abridged)
const NOISE_N: usize = 64;

fn noise_texture(device: &ProtocolObject<dyn MTLDevice>)
    -> Retained<ProtocolObject<dyn MTLTexture>>
{
    let desc = MTLTextureDescriptor::new();
    desc.setTextureType(MTLTextureType::Type3D);
1    desc.setPixelFormat(MTLPixelFormat::R16Unorm);
    unsafe { desc.setWidth(NOISE_N); desc.setHeight(NOISE_N); desc.setDepth(NOISE_N); }
    let tex = device.newTextureWithDescriptor(&desc).expect("noise texture");

    let mut data = vec![0u16; NOISE_N * NOISE_N * NOISE_N];
2    let mut x: u32 = 0x9E37_79B9;
    for v in &mut data {
        x ^= x << 13;
        x ^= x >> 17;
        x ^= x << 5;
3        *v = (x >> 16) as u16;
    }
    // replaceRegion(…, bytesPerRow: NOISE_N * 2, bytesPerImage: NOISE_N * NOISE_N * 2)
    tex
}
1
16 bits per value. Why not 8 is explained below.
2
The seed is the golden-ratio constant traditional in hashing. Any non-zero value works.
3
Take the upper 16 of 32 bits: the low bits of simple generators are usually less well mixed.

Sampling in the shader

There is a subtlety. Hardware filtering is linear: the interpolated function has a crease at each lattice cell boundary, and in clouds it shows up as little “diamonds”. The ALU version smoothed the fractional part with smoothstep. The same trick works with a texture: we only need to adjust the sampling coordinate so that the linear weight itself comes out smoothed [3]:

\mathbf{p}' = \frac{\lfloor\mathbf{x}\rfloor + s(\{\mathbf{x}\}) + 0.5}{64}, \qquad s(f) = f^2(3 - 2f)

Here \{\mathbf{x}\} is the fractional part. The 0.5 is there because a texel’s center lies in its middle. The hardware reads two neighboring texels with weight s(f) instead of f, and the interpolation becomes smooth.

float noise(float3 x) {
    float3 i = floor(x), f = fract(x);
    f = f * f * (3.0 - 2.0 * f);
    return mix(mix(mix(hash(i + float3(0,0,0)), hash(i + float3(1,0,0)), f.x),
                   mix(hash(i + float3(0,1,0)), hash(i + float3(1,1,0)), f.x), f.y),
               mix(mix(hash(i + float3(0,0,1)), hash(i + float3(1,0,1)), f.x),
                   mix(hash(i + float3(0,1,1)), hash(i + float3(1,1,1)), f.x), f.y), f.z);
}
star_fabric.metal
// One hardware trilinear fetch; the smoothstep remap of the in-cell
// coordinate gives C1-smooth interpolation instead of linear.
constexpr sampler kNoise(filter::linear, address::repeat);

float noise(texture3d<float> nz, float3 x) {
    float3 i = floor(x);
    float3 f = fract(x);
    f = f * f * (3.0 - 2.0 * f);
    return nz.sample(kNoise, (i + f + 0.5) / 64.0).r;
}

The texture has to reach the shader: a texture3d<float> nz [[texture(0)]] parameter on fs and an argument for noise, fbm, and density. On the CPU side, draw gains one line:

enc.setFragmentTexture_atIndex(Some(&iv.noise), 0);

address::repeat makes the texture tile. At an fbm frequency of 0.33, the first octave repeats about every 190 world units, nearly the camera’s whole path. The other octaves are rotated by the matrix and scaled by 2.03, so their periods do not line up and the repetition goes unnoticed.

Measurement

The same frame of the “core” scene, second 80, 640×360, best of five runs:

Table 2: One frame, one machine (M1).
Noise version GPU time Speedup
ALU, 8 hashes per sample 7.48 ms 1×
3D texture, 1 fetch 3.21 ms 2.3×

Two frames side by side: on the left, light haze with warm clouds on the right; on the right, light haze with teal and violet clouds.

The same frame: texture noise on the left, ALU noise on the right. The images differ because the random numbers themselves differ (the hash lattice versus the xorshift lattice), but the character of the clouds is the same.

For the final shader at 1280×720, the worst frame takes 12.4 ms. That fits the 16.6 ms budget, so a base M1 runs it at 60 frames per second.

Why 16 bits and not 8

An R8 texture is half the size (256 KiB instead of 512), and the first version used one. But look at what happens to the noise further down the pipeline:

  1. Four octaves are summed: the first octave’s quantization step of 1/256 ≈ 0.0039 carries a weight of 0.5.
  2. Density is computed as (n − 0.5) · 4 · dens, so the step is amplified fourfold.
  3. Then smoothstep and the absorption exponential turn density into brightness, and on the steep parts of the curve small steps become visible.

After step 2, one step of 8-bit noise is about 0.8% of density, which can become visible on the smooth gradients of the gas. 16 bits remove the question entirely, at a cost of 256 KiB of video memory nobody will miss.

WarningNo proof

As part 3 describes, the bands that made us switch to R16 turned out to be an artifact of the downscaled preview. So the reasoning above is an engineering margin, not a measured problem. There is also a second source of steps that R16 does not fix: with hardware filtering, the interpolation weights are computed at limited precision. In a soft nebula it is not visible.

Precalc more broadly

Table 3: Precalc in its various forms.
What is computed ahead What it costs Example
A sine table Memory Old demos on 8-bit machines
Textures from a graph of operations Startup time Texture generators in 64k intros
Noise in a 3D texture 512 KiB of VRAM This article
The whole soundtrack ~1.4 s of startup, 53 MB of RAM Part 5

The general principle: the file is small because it stores a recipe, not data. The price is paid in startup time and memory, and a modern machine has both to spare.

References

[1]
[2]
Marsaglia G. Xorshift RNGs. Journal of Statistical Software. 2003. DOI: 10.18637/jss.v008.i14
[3]
Quilez I. Improved texture interpolation. 2009. https://iquilezles.org/articles/texture/
Back to top