← code

noise generator

Four kinds of procedural noise, from pure static to layered Perlin — same underlying hash function, different amounts of structure.

type

random gradients at grid points, blended by dot product — smoother and less blocky than value noise.

the building block

All four types share one building block: a deterministic hash function that turns integer coordinates (plus a seed) into a pseudo-random number. White noise is that hash function, called directly per cell — no relationship between neighbors at all, which is exactly why it reads as static. Value noise calls the same hash only at the corners of a grid, then smoothly interpolates between them for everything in between, which is what gives it visible structure instead of pure noise.

perlin noise

Perlin noise is the type that made procedural texturing practical. Ken Perlin developed it in 1982 while working at MAGI on Disney's Tron, out of dissatisfaction with how machine-made computer imagery looked at the time, and described it publicly in the 1985 SIGGRAPH paper An Image Synthesizer. It interpolates something more clever than raw values: each grid corner gets a random gradient (a direction), and a sample point's contribution from each corner is the dot product between that gradient and the offset toward the point. The payoff is a field that is exactly zero at every grid corner and blends continuously between them, which avoids the faint blocky grid pattern value noise can show. The smoother fade curve used here comes from Perlin's 2002 follow-up, Improving Noise, which reduced some directional artifacts in the original; he also designed a separate replacement, simplex noise, that scales better into higher dimensions.

fbm

fBm (fractional Brownian motion) is not a different noise function at all — it layers several octaves of Perlin noise on top of each other, each at double the frequency and half the amplitude of the last:

fBm(x)  =  ∑i=0k−112i  noise ⁣(2ix)\mathrm{fBm}(x) \;=\; \sum_{i=0}^{k-1} \frac{1}{2^{i}}\; \mathrm{noise}\!\left(2^{i} x\right)

Each term halves the amplitude while doubling the frequency, so octave ii contributes detail at half the scale and half the strength of the one before. Summing scaled copies of the same signal is what produces detail at every zoom level simultaneously, and it is the standard trick behind most procedurally generated clouds, terrain, and marble textures. Turning the octave count up here mostly adds fine grain; turning it down leaves the broad shapes intact.

determinism

Because the hash is deterministic, the same seed always reproduces the same field — nothing is stored, and any point can be evaluated on demand without computing its neighbors first. That property is what makes this class of noise usable for infinite worlds, and it is the same reason the sdf raymarcher can describe an entire scene as functions evaluated per pixel rather than as stored geometry.

view source
import { useEffect, useRef, useState } from 'react';

type NoiseKind = 'white' | 'value' | 'perlin' | 'fbm';

const TYPES: { id: NoiseKind; label: string; blurb: string }[] = [
  {
    id: 'white',
    label: 'white',
    blurb:
      'independent random values, no spatial correlation — like TV static.',
  },
  {
    id: 'value',
    label: 'value',
    blurb: 'random values at grid points, smoothly interpolated between them.',
  },
  {
    id: 'perlin',
    label: 'perlin',
    blurb:
      'random gradients at grid points, blended by dot product — smoother and less blocky than value noise.',
  },
  {
    id: 'fbm',
    label: 'fbm',
    blurb:
      'several octaves of Perlin noise summed at doubling frequency, halving amplitude each time — the classic technique behind procedural clouds and terrain.',
  },
];

const CELL = 5;
const BG: [number, number, number] = [14, 17, 19];
const FG: [number, number, number] = [124, 242, 156];

function hash(x: number, y: number, seed: number): number {
  let h = (x * 374761393 + y * 668265263 + seed * 2147483647) | 0;
  h = (h ^ (h >>> 13)) * 1274126177;
  h = (h ^ (h >>> 16)) >>> 0;
  return h / 4294967295;
}

function fade(t: number): number {
  return t * t * t * (t * (t * 6 - 15) + 10);
}
function lerp(a: number, b: number, t: number): number {
  return a + t * (b - a);
}

function whiteNoise(x: number, y: number, seed: number): number {
  return hash(Math.floor(x), Math.floor(y), seed);
}

function valueNoise2D(x: number, y: number, seed: number): number {
  const x0 = Math.floor(x);
  const y0 = Math.floor(y);
  const sx = fade(x - x0);
  const sy = fade(y - y0);
  const v00 = hash(x0, y0, seed);
  const v10 = hash(x0 + 1, y0, seed);
  const v01 = hash(x0, y0 + 1, seed);
  const v11 = hash(x0 + 1, y0 + 1, seed);
  return lerp(lerp(v00, v10, sx), lerp(v01, v11, sx), sy);
}

function dotGrad(
  ix: number,
  iy: number,
  x: number,
  y: number,
  seed: number,
): number {
  const angle = hash(ix, iy, seed) * Math.PI * 2;
  return Math.cos(angle) * (x - ix) + Math.sin(angle) * (y - iy);
}

function perlin2D(x: number, y: number, seed: number): number {
  const x0 = Math.floor(x);
  const y0 = Math.floor(y);
  const sx = fade(x - x0);
  const sy = fade(y - y0);
  const n00 = dotGrad(x0, y0, x, y, seed);
  const n10 = dotGrad(x0 + 1, y0, x, y, seed);
  const n01 = dotGrad(x0, y0 + 1, x, y, seed);
  const n11 = dotGrad(x0 + 1, y0 + 1, x, y, seed);
  return lerp(lerp(n00, n10, sx), lerp(n01, n11, sx), sy);
}

function fbm(x: number, y: number, seed: number, octaves: number): number {
  let total = 0;
  let amplitude = 1;
  let frequency = 1;
  let maxAmp = 0;
  for (let i = 0; i < octaves; i++) {
    total += perlin2D(x * frequency, y * frequency, seed + i * 101) * amplitude;
    maxAmp += amplitude;
    amplitude *= 0.5;
    frequency *= 2;
  }
  return total / maxAmp;
}

function colorFor(intensity: number): string {
  const c = Math.max(0, Math.min(1, intensity));
  const r = Math.round(BG[0] + (FG[0] - BG[0]) * c);
  const g = Math.round(BG[1] + (FG[1] - BG[1]) * c);
  const b = Math.round(BG[2] + (FG[2] - BG[2]) * c);
  return `rgb(${r},${g},${b})`;
}

interface Props {
  initialType?: NoiseKind;
}

export default function NoiseGenerator({ initialType = 'perlin' }: Props) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [kind, setKind] = useState<NoiseKind>(initialType);
  const [freq, setFreq] = useState(0.08);
  const [octaves, setOctaves] = useState(4);
  const [playing, setPlaying] = useState(true);

  const kindRef = useRef(kind);
  const freqRef = useRef(freq);
  const octavesRef = useRef(octaves);
  const playingRef = useRef(playing);
  const seedRef = useRef(Math.random() * 1000);
  const redrawRef = useRef<() => void>(() => {});

  useEffect(() => {
    kindRef.current = kind;
    redrawRef.current();
  }, [kind]);
  useEffect(() => {
    freqRef.current = freq;
    redrawRef.current();
  }, [freq]);
  useEffect(() => {
    octavesRef.current = octaves;
    redrawRef.current();
  }, [octaves]);
  useEffect(() => {
    playingRef.current = playing;
  }, [playing]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    let cols = 0;
    let rows = 0;
    let raf = 0;
    let frame = 0;
    let t = 0;

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      const dpr = window.devicePixelRatio || 1;
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      cols = Math.ceil(rect.width / CELL);
      rows = Math.ceil(rect.height / CELL);
    };

    const draw = () => {
      const type = kindRef.current;
      const freq = freqRef.current;
      const seed = seedRef.current;
      const oct = octavesRef.current;
      // white noise "flickers" by reseeding on a slow cadence rather than
      // panning through space -- reseeding every frame at 60fps would be an
      // uncomfortable full-field strobe, so it's throttled like the other
      // islands throttle their own step rate.
      const whiteSeed = seed + Math.floor(frame / 6);

      for (let y = 0; y < rows; y++) {
        for (let x = 0; x < cols; x++) {
          let intensity: number;
          if (type === 'white') {
            intensity = whiteNoise(x, y, whiteSeed);
          } else {
            const nx = x * freq + t;
            const ny = y * freq + t * 0.6;
            if (type === 'value') {
              intensity = valueNoise2D(nx, ny, seed);
            } else if (type === 'perlin') {
              intensity = (perlin2D(nx, ny, seed) + 0.75) / 1.5;
            } else {
              intensity = (fbm(nx, ny, seed, oct) + 0.75) / 1.5;
            }
          }
          ctx.fillStyle = colorFor(intensity);
          ctx.fillRect(x * CELL, y * CELL, CELL, CELL);
        }
      }
    };

    const tick = () => {
      frame++;
      if (playingRef.current) {
        t += 0.006;
        draw();
      }
      raf = requestAnimationFrame(tick);
    };

    resize();
    draw();
    raf = requestAnimationFrame(tick);
    redrawRef.current = draw;

    const onResize = () => {
      resize();
      draw();
    };
    window.addEventListener('resize', onResize);

    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener('resize', onResize);
    };
  }, []);

  const togglePlaying = () => setPlaying((p) => !p);
  const regenerate = () => {
    seedRef.current = Math.random() * 1000;
    redrawRef.current();
  };

  const active = TYPES.find((t) => t.id === kind)!;

  return (
    <div className="noise-generator">
      <canvas
        ref={canvasRef}
        className="ng-canvas"
        aria-label={`${kind} noise field`}
        role="img"
      />

      <div className="controls">
        <div className="group">
          <span className="group-label">type</span>
          {TYPES.map((t) => (
            <button
              key={t.id}
              type="button"
              className={kind === t.id ? 'active' : ''}
              onClick={() => setKind(t.id)}
            >
              {t.label}
            </button>
          ))}
        </div>
        <button type="button" onClick={togglePlaying}>
          {playing ? 'pause' : 'play'}
        </button>
        <button type="button" onClick={regenerate}>
          regenerate
        </button>
      </div>

      <div className="sliders">
        <label>
          scale
          <input
            type="range"
            min={0.02}
            max={0.3}
            step={0.005}
            value={freq}
            onChange={(e) => setFreq(Number(e.target.value))}
          />
        </label>
        {kind === 'fbm' && (
          <label>
            octaves ({octaves})
            <input
              type="range"
              min={1}
              max={6}
              step={1}
              value={octaves}
              onChange={(e) => setOctaves(Number(e.target.value))}
            />
          </label>
        )}
      </div>

      <p className="blurb">{active.blurb}</p>

      <style>{`
        .noise-generator {
          border: 1px solid #2a2d31;
          background: #0e1113;
        }
        .ng-canvas {
          display: block;
          width: 100%;
          aspect-ratio: 2 / 1;
          border-bottom: 1px solid #2a2d31;
        }
        .noise-generator .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          padding: 0.75rem 1rem 0.5rem;
        }
        .noise-generator .group {
          display: flex;
          align-items: center;
          gap: 0.4rem;
          flex-wrap: wrap;
        }
        .noise-generator .group-label {
          color: var(--muted);
          font-size: 0.75rem;
          text-transform: uppercase;
          letter-spacing: 0.04em;
          margin-right: 0.15rem;
        }
        .noise-generator .controls button {
          font-family: inherit;
          font-size: 0.75rem;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.25rem 0.6rem;
          cursor: pointer;
        }
        .noise-generator .controls button:hover {
          color: #e6e6e6;
        }
        .noise-generator .controls button.active {
          color: #0b0d0f;
          background: var(--accent);
          border-color: var(--accent);
        }
        .noise-generator .sliders {
          display: flex;
          flex-wrap: wrap;
          gap: 1.25rem;
          padding: 0 1rem 0.75rem;
        }
        .noise-generator .sliders label {
          display: flex;
          align-items: center;
          gap: 0.5rem;
          color: var(--muted);
          font-size: 0.75rem;
        }
        .noise-generator .blurb {
          color: var(--muted);
          font-size: 0.8rem;
          margin: 0;
          padding: 0 1rem 1rem;
        }
      `}</style>
    </div>
  );
}