Tutorial · Rendering · GLSL · Shaders
📅 July 2026 ⏱ ≈ 22 min 🎯 Intermediate

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:

1. Hash — deterministic pseudo-random value from a coordinate 2. Noise — smoothly interpolate hashed values → continuous field 3. Shape — combine/remap noise octaves into a recognizable material (marble veins, wood rings, rock, clouds, cell walls...)

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);
}
Why it matters: Every noise algorithm below is "just" a hash plus an interpolation scheme. If your noise looks gridy or repeats too soon, the hash is almost always the first suspect — swap 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):

Given cell corners (0,0),(1,0),(0,1),(1,1) with hashed values a,b,c,d and local fraction f = fract(p): u = fade(f.x), v = fade(f.y) top = mix(a, b, u) bottom = mix(c, d, u) value = mix(top, bottom, v)
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]
}
Value vs. gradient: Value noise is cheaper (one hash call per corner) and fine for cloud-like softness. Gradient noise costs one hash + one dot product per corner but produces the organic, non-repeating look you associate with terrain and marble. Use value noise for coarse base layers, gradient noise for detail.

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:

FBM(p) = Σᵢ₌₀ⁿ⁻¹ amplitude·persistence^i · noise(p · lacunarity^i) Typical defaults: persistence ≈ 0.5 (each octave contributes half as much) lacunarity ≈ 2.0 (each octave doubles the frequency) octaves = 4–6 (more octaves = more fine detail, more cost)
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);
}
Reading the nesting: 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)
}
F1 vs F2: The function above returns the distance to the nearest point (F1), giving filled cells. Track the two smallest distances and return 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

Next step: combine everything here with triplanar mapping to texture arbitrary 3D meshes (terrain, rocks) without UV seams — sample the same noise field on the XY, YZ, and XZ planes and blend by surface normal.