Procedural Textures: Theory to Shader
Every marble countertop, weathered plank, and alien terrain you've seen in a WebGL demo was probably never painted by hand. It came out of a handful of noise functions evaluated per-pixel on the GPU. This tutorial builds that machinery from first principles — value noise, gradient (Perlin-style) noise, fractal Brownian motion, domain warping, and Worley cells — and turns each one into a working GLSL fragment shader.
1. Why Procedural Textures
A 4K hand-painted marble texture costs disk space, download time,
and eventually starts to tile visibly when the camera gets close. A
procedural texture instead evaluates a mathematical
function f(x, y, z) → color at every fragment. It costs
zero storage, has infinite resolution, tiles seamlessly if you
design it that way, and can be animated for free by adding a time
term. The tradeoff is that every pixel costs GPU cycles — so the
functions below are chosen for being cheap, deterministic, and
composable.
The core idea across almost all procedural texturing is the same three-stage pipeline:
2. Hashing: Pseudo-Random Numbers on the GPU
GLSL has no random() function, so every noise
implementation starts with a hash: a function that
takes an input coordinate and returns a number in [0, 1)
that looks random but is fully deterministic (same input → same
output, every frame, every GPU). The classic one-liner uses a large
irrational dot product fed through fract(sin(x)):
// Classic sine hash — fast, but has visible banding on some GPUs
float hash21(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
// Integer-hash based alternative — more uniform, no sin() precision issues
vec2 hash22(vec2 p) {
vec3 p3 = fract(p.xyx * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.xx + p3.yz) * p3.zy);
}
hash21 for hash22-derived
variants if you see periodic artifacts.
3. Value Noise
Value noise hashes the four corners of the grid cell containing the
sample point, then smoothly interpolates between them using
bilinear interpolation with a smoothing curve (Ken Perlin's
quintic fade, 6t⁵ − 15t⁴ + 10t³, avoids
the visible creases you get with plain linear or cubic
smoothstep):
float fade(float t) {
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}
float valueNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
vec2 u = vec2(fade(f.x), fade(f.y));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
4. Gradient (Perlin-style) Noise
Value noise interpolates values at corners; gradient noise interpolates dot products with pseudo-random gradient vectors at each corner. This produces a visually richer, less "blobby" pattern with more consistent local contrast — the technique Ken Perlin introduced in 1985 and refined in 2002 (Simplex noise). The core difference:
vec2 gradient(vec2 i) {
float angle = hash21(i) * 6.2831853; // map hash → [0, 2π)
return vec2(cos(angle), sin(angle));
}
float gradientNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = vec2(fade(f.x), fade(f.y));
// Dot product of gradient at each corner with the vector to the sample point
float a = dot(gradient(i), f - vec2(0.0, 0.0));
float b = dot(gradient(i + vec2(1.0,0.0)), f - vec2(1.0, 0.0));
float c = dot(gradient(i + vec2(0.0,1.0)), f - vec2(0.0, 1.0));
float d = dot(gradient(i + vec2(1.0,1.0)), f - vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 0.5 + 0.5; // remap [-1,1] → [0,1]
}
5. Fractal Brownian Motion (FBM)
A single noise call looks too uniform — real materials have detail at multiple scales. FBM sums several "octaves" of the same noise function at increasing frequency and decreasing amplitude, mimicking the 1/f power spectrum of natural phenomena:
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < 6; i++) {
value += amplitude * gradientNoise(p * frequency);
frequency *= 2.0; // lacunarity
amplitude *= 0.5; // persistence
}
return value;
}
6. Domain Warping
Domain warping (a technique popularized by Inigo
Quilez) feeds noise into the input coordinates of more noise before
evaluating the final field. Instead of sampling
fbm(p), you sample
fbm(p + fbm(p)), or nest it two levels deep. This
bends the noise's iso-contours into swirling, marble-vein-like
shapes instead of the isotropic "blobs" plain FBM produces:
float warpedFbm(vec2 p) {
vec2 q = vec2(
fbm(p + vec2(0.0, 0.0)),
fbm(p + vec2(5.2, 1.3))
);
vec2 r = vec2(
fbm(p + 4.0 * q + vec2(1.7, 9.2)),
fbm(p + 4.0 * q + vec2(8.3, 2.8))
);
return fbm(p + 4.0 * r);
}
q is a 2D
displacement field built from noise; r is a second,
larger displacement built by sampling noise at p
offset by q; the final return warps p
again by r. Each level multiplies the visual
complexity roughly geometrically while only tripling the cost.
7. Worley / Cellular Noise
Also called Voronoi noise, Worley noise scatters one random point per grid cell and, for each fragment, returns the distance to the nearest scattered point (checking the current cell plus its 8 neighbours, since the nearest point can sit just across a cell boundary). This produces cell-like patterns perfect for stone, reptile skin, or crystalline surfaces:
float worleyNoise(vec2 p) {
vec2 cell = floor(p);
float minDist = 1.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 neighbor = vec2(float(x), float(y));
vec2 point = hash22(cell + neighbor); // random point inside that cell
vec2 diff = neighbor + point - fract(p);
float dist = length(diff);
minDist = min(minDist, dist);
}
}
return minDist; // 0 at cell centers, ~0.7 at cell borders (F1 metric)
}
F2 − F1 instead to
get thin cracked lines along cell boundaries — perfect for dried
mud, cracked stone, or leaf veins.
8. Building Marble and Wood Shaders
With hash → noise → FBM → warp in hand, materials become a matter of remapping a scalar noise field through a function and a color ramp.
Marble
Classic marble (Perlin's original 1985 demo) perturbs a sine wave with turbulence, then colors the result:
vec3 marble(vec2 p) {
float n = fbm(p * 3.0);
float stripes = sin((p.x + n * 4.0) * 6.2831853) * 0.5 + 0.5;
vec3 veinColor = vec3(0.15, 0.15, 0.17);
vec3 baseColor = vec3(0.92, 0.90, 0.88);
return mix(veinColor, baseColor, smoothstep(0.15, 0.85, stripes));
}
Wood
Wood rings come from the fractional part of the radial distance from a "trunk center", perturbed by low-amplitude noise so the rings aren't perfectly circular:
vec3 wood(vec2 p) {
vec2 centered = p - vec2(0.5);
float radius = length(centered) + fbm(p * 4.0) * 0.1;
float rings = fract(radius * 10.0);
vec3 darkGrain = vec3(0.30, 0.17, 0.09);
vec3 lightGrain = vec3(0.55, 0.35, 0.20);
return mix(darkGrain, lightGrain, smoothstep(0.0, 0.6, rings));
}
Both functions plug directly into a Three.js
ShaderMaterial fragment shader as
gl_FragColor = vec4(marble(vUv), 1.0) — no textures,
no loading time, infinite zoom without pixelation.
9. Performance and Tiling Notes
- Octave count is your main dial. Each FBM octave costs one full noise evaluation; 6 octaves on a 1080p fullscreen quad is roughly 12M noise calls per frame. Drop to 3–4 octaves for mobile GPUs, or bake the result once into a render target if the texture is static.
- Seamless tiling requires the noise to be periodic. Sample noise on the surface of a 4D torus (two pairs of sine/cosine coordinates) rather than raw 2D coordinates so the pattern wraps with zero seam — used for skyboxes and repeating ground textures.
-
Precompute where you can. If the texture doesn't
need to animate, render the FBM/Worley pattern to an offscreen
THREE.WebGLRenderTargetonce, then sample it as a normal texture in later frames — trades a one-time cost for near-zero per-frame cost. -
Precision matters at scale.
fract(sin(x))hashes lose quality (visible grid lines) once coordinates exceed roughly ±10,000 due tomediump/highpfloat precision limits on mobile GPUs — wrap large world coordinates back toward the origin before hashing.