noise generator
Four kinds of procedural noise, from pure static to layered Perlin — same underlying hash function, different amounts of structure.
random gradients at grid points, blended by dot product — smoother and less blocky than value noise.
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 — which Ken Perlin developed in 1983 for the movie Tron, then refined in a 2002 paper to remove some directional artifacts (the smoother fade curve used here is from that later version) — 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's exactly zero at every grid corner and blends continuously between them, which avoids the faint blocky grid pattern value noise can show. fBm (fractal Brownian motion) layers several octaves of Perlin noise on top of each other — each one at double the frequency and half the amplitude of the last — which is the standard trick behind most procedural clouds, terrain, and marble textures.
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;
max-width: 60ch;
}
`}</style>
</div>
);
}