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
Noise in one fetch: a 3D texture instead of eight hashes
Part 4. Precalc, hardware filtering, and a 2.3× speedup
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:
| 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:
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:
| Noise version | GPU time | Speedup |
|---|---|---|
| ALU, 8 hashes per sample | 7.48 ms | 1× |
| 3D texture, 1 fetch | 3.21 ms | 2.3× |

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:
- Four octaves are summed: the first octave’s quantization step of 1/256 ≈ 0.0039 carries a weight of 0.5.
- Density is computed as
(n − 0.5) · 4 · dens, so the step is amplified fourfold. - Then
smoothstepand 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.
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
| 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.
Series: Demoscene 2026
Part 4 of 7 · suggested reading order
Previous: Six iterations to a picture: debugging a shader without a screen
Next: A synthesizer in 600 lines: a soundtrack without a single sample