Procedural Generation
Noise and terrain on the GPU
Why Procedural
Procedural generation creates content algorithmically rather than by hand. A few parameters and some math produce infinite terrain, endless textures, or unique shapes on demand. No artist needs to sculpt each mountain; no storage holds every possible variation. The algorithm is the content.
The GPU excels at procedural work. Generating a noise texture means computing the same function at every pixel—embarrassingly parallel. Extracting geometry from a density field processes voxels independently. The data flows through compute passes, each transforming the output of the previous, until a final rendered image appears.
Noise Functions
At the heart of most procedural generation lies noise: deterministic pseudo-randomness with spatial coherence. Unlike pure random values (which produce static), noise functions return similar values for nearby inputs. This creates organic-looking patterns—hills that rise and fall smoothly, clouds that blend into each other.
Interactive: Noise Types
Perlin noise uses gradient vectors at grid points, producing smoother results. The classic choice for terrain.
Three noise algorithms dominate procedural generation. Value noise interpolates random values placed on a grid—simple but prone to visible grid artifacts. Perlin noise places random gradient vectors at grid points and interpolates based on dot products with the offset, producing smoother results. Simplex noise uses a triangular (simplex) grid, eliminating directional artifacts and computing faster in higher dimensions.
On the GPU, noise is typically computed per-pixel in a shader:
fn perlin2d(p: vec2<f32>) -> f32 {
let i = floor(p);
let f = fract(p);
// Smooth interpolation curve
let u = f * f * (3.0 - 2.0 * f);
// Hash grid corners to gradient directions
let g00 = gradient(hash(i + vec2(0.0, 0.0)));
let g10 = gradient(hash(i + vec2(1.0, 0.0)));
let g01 = gradient(hash(i + vec2(0.0, 1.0)));
let g11 = gradient(hash(i + vec2(1.0, 1.0)));
// Dot products with offset vectors
let d00 = dot(g00, f - vec2(0.0, 0.0));
let d10 = dot(g10, f - vec2(1.0, 0.0));
let d01 = dot(g01, f - vec2(0.0, 1.0));
let d11 = dot(g11, f - vec2(1.0, 1.0));
// Bilinear interpolation
return mix(mix(d00, d10, u.x), mix(d01, d11, u.x), u.y);
}The hash function converts grid coordinates to pseudo-random values. Different hash implementations trade off between quality and speed. A simple approach uses trigonometric functions; more robust implementations use bit manipulation.
Fractal Noise
Single-frequency noise looks too smooth for natural phenomena. Real terrain has both broad mountains and fine rocks; real clouds have large formations and wispy edges. Fractal noise (also called fractional Brownian motion or fBm) layers multiple frequencies of noise to create richer detail.
Each layer is called an octave. The first octave has low frequency and high amplitude—the broad shapes. Each subsequent octave doubles the frequency (controlled by lacunarity) and halves the amplitude (controlled by persistence). Summing these layers produces noise with detail at multiple scales.
Interactive: Octave Layering
Each octave adds detail at a higher frequency with lower amplitude. Persistence controls amplitude falloff, lacunarity controls frequency increase.
The formula is straightforward:
fn fbm(p: vec2<f32>, octaves: i32) -> f32 {
var value = 0.0;
var amplitude = 1.0;
var frequency = 1.0;
var max_value = 0.0;
for (var i = 0; i < octaves; i++) {
value += perlin2d(p * frequency) * amplitude;
max_value += amplitude;
amplitude *= persistence; // e.g., 0.5
frequency *= lacunarity; // e.g., 2.0
}
return value / max_value; // Normalize to [0, 1]
}More octaves add finer detail at the cost of more computation. Four to eight octaves suffice for most applications. Beyond that, the added detail becomes imperceptible.
Terrain Generation
Terrain is the classic application of procedural noise. A heightmap—a 2D array where each value represents elevation—can be generated entirely from fractal noise. The noise value at each coordinate becomes the height at that point.
Interactive: Terrain Generator
Noise values become elevation. Color mapping creates terrain types: water, sand, grass, rock, snow.
Raw noise produces reasonable hills and valleys, but real terrain benefits from modifications. Ridged noise (taking the absolute value and inverting) creates mountain ridges. Terracing (quantizing heights) produces plateaus. Erosion simulation carves river channels and smooths slopes.
Color mapping transforms height values into terrain types. Below a threshold is water. Just above is sand or beach. Middle heights are grass or forest. High elevations become rock and snow. The thresholds and color gradients define the terrain's character.
fn terrain_color(height: f32, sea_level: f32) -> vec3<f32> {
if (height < sea_level) {
return vec3(0.1, 0.3, 0.6); // Deep water
} else if (height < sea_level + 0.05) {
return vec3(0.8, 0.75, 0.55); // Sand
} else if (height < sea_level + 0.3) {
return vec3(0.3, 0.55, 0.25); // Grass
} else if (height < sea_level + 0.5) {
return vec3(0.4, 0.35, 0.3); // Rock
} else {
return vec3(0.95, 0.95, 0.95); // Snow
}
}The GPU generates the heightmap in a compute pass, then either samples it in a fragment shader for 2D rendering or uses it to displace vertices for 3D terrain meshes.
Marching Cubes
For 3D procedural geometry—caves, asteroids, organic shapes—we need to extract surfaces from volumetric data. Marching cubes is the standard algorithm. Given a 3D grid of density values, it produces a triangle mesh representing the surface where density crosses a threshold.
Interactive: Isosurface Extraction
Marching Cubes (or Marching Squares in 2D) extracts surfaces from volumetric data.
Each grid cell examines its corner values. Based on which corners are inside (above threshold) vs outside, a lookup table determines how to draw edges that approximate the isosurface.
The algorithm examines each cubic cell in the grid. Each of the eight corners is either inside (above threshold) or outside (below). This creates 256 possible configurations, reduced to 15 unique cases by symmetry. A lookup table maps each configuration to the triangles that approximate the surface passing through that cell.
On the GPU, marching cubes runs as a compute shader. Each thread processes one cell, reading the eight corner values, determining the configuration, and emitting triangles to a vertex buffer. Atomic counters track how many vertices have been written.
@compute @workgroup_size(4, 4, 4)
fn march(@builtin(global_invocation_id) id: vec3<u32>) {
let cell = id;
// Sample 8 corners
var corners: array<f32, 8>;
corners[0] = sample_density(cell + vec3(0, 0, 0));
corners[1] = sample_density(cell + vec3(1, 0, 0));
// ... remaining corners ...
// Determine configuration
var config = 0u;
for (var i = 0u; i < 8u; i++) {
if (corners[i] > threshold) {
config |= (1u << i);
}
}
// Look up triangles for this configuration
let num_tris = triangle_count[config];
for (var t = 0u; t < num_tris; t++) {
emit_triangle(cell, config, t, corners);
}
}The result is a mesh that conforms to the density field. By defining density procedurally (noise for terrain, distance fields for shapes), marching cubes converts implicit surfaces into explicit geometry that the render pipeline can draw.
Compute-Generated Meshes
Beyond marching cubes, compute shaders can generate any procedural geometry. Grass blades placed according to density maps. Tree branches grown by L-system rules. Particle trails solidified into ribbons. The pattern is consistent: compute shaders write to vertex and index buffers, which the render pipeline then draws.
Interactive: Procedural Pipeline
The entire pipeline can run on the GPU: noise generation, geometry extraction, and rendering.
The pipeline often involves multiple stages. A noise pass generates the underlying field. A geometry pass extracts or places geometry. A normal pass computes surface normals for lighting. Finally, the render pass draws the result.
Each stage reads from the previous stage's output. Ping-pong buffers handle cases where a stage both reads and writes similar data. The entire pipeline stays on the GPU—no round trips to the CPU, no upload latency.
// Stage 1: Generate density field
@compute @workgroup_size(8, 8, 8)
fn generate_density(...) {
let density = fbm3d(world_pos, 6);
textureStore(density_volume, id, vec4(density));
}
// Stage 2: Extract geometry
@compute @workgroup_size(4, 4, 4)
fn extract_mesh(...) {
// Marching cubes on density_volume
// Writes to vertex_buffer, index_buffer
}
// Stage 3: Compute normals
@compute @workgroup_size(256)
fn compute_normals(...) {
// Read vertex positions, compute face/vertex normals
// Write to normal_buffer
}
// Stage 4: Render (vertex + fragment shaders)
// Draws using vertex_buffer, index_buffer, normal_bufferThe flexibility is enormous. Any function that maps coordinates to density can become geometry. Any rule that places points can become a particle system or mesh. The GPU provides the parallel horsepower; the algorithms define the content.
Key Takeaways
- Procedural generation creates content algorithmically, enabling infinite variation from compact code
- Noise functions (value, Perlin, simplex) provide coherent pseudo-randomness for organic patterns
- Fractal noise layers octaves of different frequencies to create multi-scale detail
- Terrain generation maps noise to elevation, with color ramps defining biomes
- Marching cubes extracts triangle meshes from volumetric density fields
- Compute shaders generate meshes by writing directly to vertex and index buffers
- Multi-stage pipelines chain compute passes: noise → geometry → normals → render