Skip to content

Instantly share code, notes, and snippets.

@kbouw
Created February 16, 2026 21:29
Show Gist options
  • Select an option

  • Save kbouw/567832e9a7de53c44d62b2bb6035ee8e to your computer and use it in GitHub Desktop.

Select an option

Save kbouw/567832e9a7de53c44d62b2bb6035ee8e to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Perlin Noise: Structure from Randomness</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { height: 100%; }
body { background: #111216; }
@keyframes blink { 0%,50% { opacity: 1 } 51%,100% { opacity: 0 } }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script>
var h = React.createElement;
var useState = React.useState;
var useEffect = React.useEffect;
var useRef = React.useRef;
var useCallback = React.useCallback;
var useMemo = React.useMemo;
// ─── Design Tokens ────────────────────────────────────────────
var COLORS = {
bg: "#111216",
surface: "rgba(255,255,255,0.015)",
surfaceHover: "rgba(255,255,255,0.04)",
border: "rgba(255,255,255,0.04)",
text: "#e8e6e3",
textMuted: "#6a6e76",
textDim: "#4a4e56",
accent: "#4ade80",
accentGlow: "rgba(74,222,128,0.2)",
blue: "#8cb4ff",
blueGlow: "rgba(140,180,255,0.15)",
yellow: "#f0c674",
yellowGlow: "rgba(240,198,116,0.15)",
red: "#f07474",
redGlow: "rgba(240,116,116,0.1)",
purple: "#a78bfa",
connector: "#3a3e46",
};
var FONTS = {
body: "'IBM Plex Sans', -apple-system, sans-serif",
mono: "'JetBrains Mono', monospace",
};
// ─── Perlin Noise Implementation ──────────────────────────────
// Classic 2D Perlin noise with permutation table
var PERM = (function() {
var p = [];
for (var i = 0; i < 256; i++) p[i] = i;
// Fisher-Yates with fixed seed for determinism
var seed = 42;
function rng() { seed = (seed * 16807 + 0) % 2147483647; return (seed - 1) / 2147483646; }
for (var i = 255; i > 0; i--) {
var j = Math.floor(rng() * (i + 1));
var tmp = p[i]; p[i] = p[j]; p[j] = tmp;
}
var perm = new Array(512);
for (var i = 0; i < 512; i++) perm[i] = p[i & 255];
return perm;
})();
var GRAD2 = [[1,1],[-1,1],[1,-1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]];
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + t * (b - a); }
function dot2(g, x, y) { return g[0] * x + g[1] * y; }
function perlin2(x, y) {
var xi = Math.floor(x) & 255;
var yi = Math.floor(y) & 255;
var xf = x - Math.floor(x);
var yf = y - Math.floor(y);
var u = fade(xf);
var v = fade(yf);
var aa = PERM[PERM[xi] + yi] & 7;
var ab = PERM[PERM[xi] + yi + 1] & 7;
var ba = PERM[PERM[xi + 1] + yi] & 7;
var bb = PERM[PERM[xi + 1] + yi + 1] & 7;
var x1 = lerp(dot2(GRAD2[aa], xf, yf), dot2(GRAD2[ba], xf - 1, yf), u);
var x2 = lerp(dot2(GRAD2[ab], xf, yf - 1), dot2(GRAD2[bb], xf - 1, yf - 1), u);
return lerp(x1, x2, v);
}
function fbm(x, y, octaves, persistence, lacunarity) {
var total = 0;
var amplitude = 1;
var frequency = 1;
var maxVal = 0;
for (var i = 0; i < octaves; i++) {
total += perlin2(x * frequency, y * frequency) * amplitude;
maxVal += amplitude;
amplitude *= persistence;
frequency *= lacunarity;
}
return total / maxVal;
}
// ─── Shared Components ────────────────────────────────────────
function InsightCard(props) {
return h("div", {
style: Object.assign({
background: "rgba(255,255,255,0.02)",
border: "1px solid rgba(255,255,255,0.04)",
borderRadius: 8,
padding: "12px 16px",
fontFamily: FONTS.mono,
fontSize: 12,
color: COLORS.textMuted,
lineHeight: 1.6,
}, props.style || {})
}, props.children);
}
function SliderControl(props) {
return h("div", { style: { marginBottom: props.noMargin ? 0 : 12 } },
h("div", { style: { display: "flex", justifyContent: "space-between", marginBottom: 4 } },
h("span", { style: { fontSize: 11, color: COLORS.textMuted, fontFamily: FONTS.mono } }, props.label),
h("span", { style: { fontSize: 12, color: COLORS.text, fontFamily: FONTS.mono } }, props.displayValue || props.value)
),
h("input", {
type: "range",
min: props.min, max: props.max, step: props.step || 1,
value: props.value,
onChange: function(e) { props.onChange(parseFloat(e.target.value)); },
style: {
width: "100%", height: 6, appearance: "none", WebkitAppearance: "none",
background: "rgba(255,255,255,0.06)", borderRadius: 3, outline: "none",
cursor: "pointer", accentColor: props.color || COLORS.accent,
}
})
);
}
// ─── Canvas Hook ──────────────────────────────────────────────
function useCanvas(draw, deps, width, height) {
var canvasRef = useRef(null);
useEffect(function() {
var canvas = canvasRef.current;
if (!canvas) return;
canvas.width = width || canvas.offsetWidth;
canvas.height = height || canvas.offsetHeight;
var ctx = canvas.getContext("2d");
draw(ctx, canvas.width, canvas.height);
}, deps);
return canvasRef;
}
// ─── Panel 1: Random vs Coherent ──────────────────────────────
function RandomVsCoherent() {
var ref = useState("random"), mode = ref[0], setMode = ref[1];
var ref2 = useState(4.0), scale = ref2[0], setScale = ref2[1];
var ref3 = useState(0), seed = ref3[0], setSeed = ref3[1];
var canvasRef = useCanvas(function(ctx, w, h) {
var imgData = ctx.createImageData(w, h);
var data = imgData.data;
// use a seeded random for white noise
var s = seed * 9301 + 49297;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var idx = (y * w + x) * 4;
var val;
if (mode === "random") {
s = (s * 16807 + 0) % 2147483647;
val = ((s - 1) / 2147483646) * 255;
} else {
var n = perlin2((x / w) * scale + seed * 17.3, (y / h) * scale + seed * 31.7);
val = (n * 0.5 + 0.5) * 255;
}
data[idx] = val;
data[idx + 1] = val;
data[idx + 2] = val;
data[idx + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
}, [mode, scale, seed], 240, 240);
return h("div", null,
h("div", { style: { marginBottom: 20 } },
h("h3", { style: { fontFamily: FONTS.body, fontSize: 18, fontWeight: 400, color: COLORS.text, margin: "0 0 6px" } }, "Random isn't natural"),
h("p", { style: { fontFamily: FONTS.body, fontSize: 14, color: COLORS.textMuted, margin: 0, lineHeight: 1.5 } },
"Math.random() produces static. Perlin noise produces structure. Toggle between them to see the difference."
)
),
// Mode toggle
h("div", { style: { display: "flex", gap: 8, marginBottom: 16, justifyContent: "center" } },
h("button", {
onClick: function() { setMode("random"); },
style: {
padding: "9px 18px", fontSize: 12, borderRadius: 6, cursor: "pointer",
fontFamily: FONTS.body,
background: mode === "random" ? "rgba(240,116,116,0.12)" : "rgba(255,255,255,0.02)",
border: "1px solid " + (mode === "random" ? "rgba(240,116,116,0.25)" : "rgba(255,255,255,0.04)"),
color: mode === "random" ? COLORS.red : COLORS.textDim,
}
}, "Math.random()"),
h("button", {
onClick: function() { setMode("perlin"); },
style: {
padding: "9px 18px", fontSize: 12, borderRadius: 6, cursor: "pointer",
fontFamily: FONTS.body,
background: mode === "perlin" ? "rgba(74,222,128,0.12)" : "rgba(255,255,255,0.02)",
border: "1px solid " + (mode === "perlin" ? "rgba(74,222,128,0.25)" : "rgba(255,255,255,0.04)"),
color: mode === "perlin" ? COLORS.accent : COLORS.textDim,
}
}, "Perlin noise")
),
// Canvas
h("div", { style: { display: "flex", justifyContent: "center", marginBottom: 16 } },
h("div", { style: {
position: "relative", width: 240, height: 240,
borderRadius: 8, overflow: "hidden",
border: "1px solid " + (mode === "perlin" ? "rgba(74,222,128,0.15)" : "rgba(240,116,116,0.15)"),
}},
h("canvas", { ref: canvasRef, style: { width: 240, height: 240, display: "block" } }),
h("div", { style: {
position: "absolute", bottom: 8, left: 8, fontFamily: FONTS.mono, fontSize: 10,
padding: "3px 8px", borderRadius: 4,
background: "rgba(0,0,0,0.6)",
color: mode === "perlin" ? COLORS.accent : COLORS.red,
}}, mode === "perlin" ? "coherent noise" : "white noise")
)
),
// Controls
mode === "perlin" && h("div", { style: { marginBottom: 16 } },
h(SliderControl, {
label: "scale", value: scale, min: 1, max: 16, step: 0.5,
displayValue: scale.toFixed(1), onChange: setScale, color: COLORS.accent,
})
),
h("div", { style: { textAlign: "center", marginBottom: 16 } },
h("button", {
onClick: function() { setSeed(seed + 1); },
style: {
padding: "7px 16px", fontSize: 11, borderRadius: 6, cursor: "pointer",
fontFamily: FONTS.mono,
background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.06)",
color: COLORS.textMuted,
}
}, "↻ new seed")
),
h(InsightCard, null,
mode === "random"
? h("span", null,
"Every pixel is independent. No correlation between neighbors. This is what ",
h("span", { style: { color: COLORS.red } }, "Math.random()"),
" gives you: useful for coin flips, useless for terrain."
)
: h("span", null,
"Adjacent pixels are ", h("span", { style: { color: COLORS.accent, fontWeight: 600 } }, "correlated"),
". Values change smoothly across space. This is what makes mountains have slopes and clouds have edges. Adjust the scale to zoom in and out of the noise field."
)
)
);
}
// ─── Panel 2: How It Works ────────────────────────────────────
function HowItWorks() {
var ref = useState(0), step = ref[0], setStep = ref[1];
var ref2 = useState(false), isPlaying = ref2[0], setIsPlaying = ref2[1];
var timerRef = useRef(null);
var STEPS = [
{
title: "Lay down a grid of random gradients",
detail: "At each integer coordinate, assign a random unit vector (a direction). These gradients are the only random part of the whole algorithm.",
color: COLORS.blue,
},
{
title: "Pick a point to evaluate",
detail: "You want the noise value at some point P. Find which grid cell P falls inside: the four corners each have a gradient vector.",
color: COLORS.yellow,
},
{
title: "Compute offset vectors",
detail: "For each corner, compute the vector from that corner to P. These offset vectors tell us where P sits relative to each gradient.",
color: COLORS.purple,
},
{
title: "Dot product: gradient · offset",
detail: "At each corner, dot the gradient with the offset. This produces a scalar: positive when P is \"in the direction\" of the gradient, negative otherwise.",
color: COLORS.accent,
},
{
title: "Smooth interpolation",
detail: "Blend the four dot products using a smoothstep curve: 6t⁵ − 15t⁴ + 10t³. The zero derivatives at boundaries guarantee no seams between cells.",
color: COLORS.text,
},
];
var canvasRef = useCanvas(function(ctx, w, h) {
ctx.fillStyle = "rgba(17,18,22,1)";
ctx.fillRect(0, 0, w, h);
var gridSize = 3;
var cellW = w / gridSize;
var cellH = h / gridSize;
var px = 1.6, py = 1.3; // our sample point in grid space
// Draw grid
ctx.strokeStyle = "rgba(255,255,255,0.06)";
ctx.lineWidth = 1;
for (var i = 0; i <= gridSize; i++) {
ctx.beginPath(); ctx.moveTo(i * cellW, 0); ctx.lineTo(i * cellW, h); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, i * cellH); ctx.lineTo(w, i * cellH); ctx.stroke();
}
// Fixed gradient directions for each grid point
var gradients = [
[[0.7,0.7],[-0.9,0.4],[0.3,-0.95],[0.8,0.6]],
[[-0.5,0.87],[0.95,-0.3],[-0.7,-0.7],[0.4,0.9]],
[[0.6,-0.8],[-0.3,0.95],[0.85,0.53],[-0.6,-0.8]],
[[0.9,0.44],[-0.8,0.6],[0.5,-0.87],[0.7,0.7]],
];
// Step 0+: Draw gradient arrows at grid points
if (step >= 0) {
ctx.lineWidth = 2;
for (var gy = 0; gy <= gridSize; gy++) {
for (var gx = 0; gx <= gridSize; gx++) {
var cx = gx * cellW;
var cy = gy * cellH;
var g = gradients[gy] ? gradients[gy][gx] : null;
if (!g) continue;
var arrowLen = 22;
// Highlight corners of P's cell
var isCorner = step >= 1 && gx >= 1 && gx <= 2 && gy >= 1 && gy <= 2;
ctx.strokeStyle = isCorner ? COLORS.blue + "cc" : "rgba(140,180,255,0.25)";
ctx.fillStyle = isCorner ? COLORS.blue : "rgba(140,180,255,0.25)";
// Dot at grid point
ctx.beginPath(); ctx.arc(cx, cy, isCorner ? 4 : 3, 0, Math.PI * 2); ctx.fill();
// Arrow
var ex = cx + g[0] * arrowLen;
var ey = cy + g[1] * arrowLen;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(ex, ey); ctx.stroke();
// Arrowhead
var angle = Math.atan2(g[1], g[0]);
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - 6 * Math.cos(angle - 0.4), ey - 6 * Math.sin(angle - 0.4));
ctx.lineTo(ex - 6 * Math.cos(angle + 0.4), ey - 6 * Math.sin(angle + 0.4));
ctx.closePath(); ctx.fill();
}
}
}
// Step 1+: Draw sample point P
if (step >= 1) {
var ppx = px * cellW;
var ppy = py * cellH;
// Highlight the cell
ctx.fillStyle = "rgba(240,198,116,0.06)";
ctx.fillRect(1 * cellW, 1 * cellH, cellW, cellH);
ctx.fillStyle = COLORS.yellow;
ctx.beginPath(); ctx.arc(ppx, ppy, 6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#111216";
ctx.font = "bold 10px " + FONTS.mono;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText("P", ppx, ppy);
}
// Step 2+: Draw offset vectors
if (step >= 2) {
var ppx = px * cellW;
var ppy = py * cellH;
var corners = [[1,1],[2,1],[1,2],[2,2]];
var offsetColors = [COLORS.purple, COLORS.purple, COLORS.purple, COLORS.purple];
corners.forEach(function(c, i) {
var cx = c[0] * cellW;
var cy = c[1] * cellH;
ctx.strokeStyle = COLORS.purple + "80";
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(ppx, ppy); ctx.stroke();
ctx.setLineDash([]);
});
}
// Step 3+: Show dot product values
if (step >= 3) {
var ppx = px * cellW;
var ppy = py * cellH;
var corners = [[1,1],[2,1],[1,2],[2,2]];
corners.forEach(function(c) {
var cx = c[0] * cellW;
var cy = c[1] * cellH;
var g = gradients[c[1]][c[0]];
var ox = (px - c[0]);
var oy = (py - c[1]);
var dp = (g[0] * ox + g[1] * oy);
var dpColor = dp >= 0 ? COLORS.accent : COLORS.red;
ctx.fillStyle = "rgba(0,0,0,0.7)";
var labelX = cx + (cx < ppx ? -30 : 10);
var labelY = cy + (cy < ppy ? -14 : 12);
ctx.fillRect(labelX - 2, labelY - 10, 40, 16);
ctx.fillStyle = dpColor;
ctx.font = "11px " + FONTS.mono;
ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillText((dp >= 0 ? "+" : "") + dp.toFixed(2), labelX, labelY);
});
}
// Step 4: Show interpolation curve preview
if (step >= 4) {
// Small smoothstep curve in corner
var curveX = w - 90;
var curveY = h - 55;
var curveW = 70;
var curveH = 35;
ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.fillRect(curveX - 8, curveY - 8, curveW + 16, curveH + 20);
ctx.strokeStyle = "rgba(255,255,255,0.1)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(curveX, curveY + curveH);
ctx.lineTo(curveX + curveW, curveY + curveH);
ctx.stroke();
ctx.strokeStyle = COLORS.text + "80";
ctx.lineWidth = 2;
ctx.beginPath();
for (var i = 0; i <= curveW; i++) {
var t = i / curveW;
var s = t * t * t * (t * (t * 6 - 15) + 10);
var cy = curveY + curveH - s * curveH;
if (i === 0) ctx.moveTo(curveX + i, cy);
else ctx.lineTo(curveX + i, cy);
}
ctx.stroke();
ctx.fillStyle = COLORS.textDim;
ctx.font = "9px " + FONTS.mono;
ctx.textAlign = "center";
ctx.fillText("smoothstep", curveX + curveW / 2, curveY + curveH + 12);
}
}, [step], 400, 400);
var play = useCallback(function() {
setStep(-1);
setIsPlaying(true);
var s = 0;
function advance() {
if (s > 4) { setIsPlaying(false); return; }
setStep(s);
s++;
timerRef.current = setTimeout(advance, 1400);
}
timerRef.current = setTimeout(advance, 400);
}, []);
useEffect(function() {
play();
return function() { clearTimeout(timerRef.current); };
}, []);
return h("div", null,
h("div", { style: { marginBottom: 20 } },
h("h3", { style: { fontFamily: FONTS.body, fontSize: 18, fontWeight: 400, color: COLORS.text, margin: "0 0 6px" } }, "Five steps to smooth noise"),
h("p", { style: { fontFamily: FONTS.body, fontSize: 14, color: COLORS.textMuted, margin: 0, lineHeight: 1.5 } },
"Gradients, dot products, interpolation. Click each step or hit replay."
)
),
// Canvas
h("div", { style: { display: "flex", justifyContent: "center", marginBottom: 16 } },
h("canvas", { ref: canvasRef, style: {
width: 400, height: 400, borderRadius: 8,
border: "1px solid rgba(255,255,255,0.04)",
}})
),
// Step indicators
h("div", { style: { display: "flex", flexDirection: "column", gap: 4, marginBottom: 12 } },
STEPS.map(function(s, i) {
var isActive = i <= step;
var isCurrent = i === step;
return h("div", {
key: i,
onClick: function() {
clearTimeout(timerRef.current);
setIsPlaying(false);
setStep(i);
},
style: {
display: "flex", alignItems: "flex-start", gap: 10, cursor: "pointer",
padding: "8px 12px", borderRadius: 6,
background: isCurrent ? s.color + "0a" : "transparent",
border: "1px solid " + (isCurrent ? s.color + "30" : "transparent"),
opacity: isActive ? 1 : 0.25,
transition: "all 0.3s ease-out",
}
},
h("span", { style: {
fontFamily: FONTS.mono, fontSize: 10, color: isActive ? s.color : COLORS.textDim,
marginTop: 2, width: 16, flexShrink: 0,
}}, i + 1),
h("div", null,
h("div", { style: { fontFamily: FONTS.body, fontSize: 13, color: isActive ? COLORS.text : COLORS.textDim, fontWeight: 500, marginBottom: 2 } }, s.title),
isCurrent && h("div", { style: { fontFamily: FONTS.body, fontSize: 12, color: COLORS.textMuted, lineHeight: 1.5 } }, s.detail)
)
);
})
),
// Replay
h("div", { style: { textAlign: "center" } },
h("button", {
onClick: play,
style: {
fontFamily: FONTS.mono, fontSize: 11, padding: "6px 14px", borderRadius: 6,
background: isPlaying ? COLORS.accent + "15" : "rgba(255,255,255,0.03)",
border: "1px solid " + (isPlaying ? COLORS.accent + "40" : "rgba(255,255,255,0.04)"),
color: isPlaying ? COLORS.accent : COLORS.textMuted,
cursor: "pointer", transition: "all 0.2s",
}
}, isPlaying ? "playing..." : "▶ replay")
)
);
}
// ─── Panel 3: Octaves (fBm) ──────────────────────────────────
function OctaveStrip(props) {
var canvasRef = useRef(null);
useEffect(function() {
var canvas = canvasRef.current;
if (!canvas) return;
canvas.width = 56; canvas.height = 56;
var ctx = canvas.getContext("2d");
var imgData = ctx.createImageData(56, 56);
var data = imgData.data;
var freq = Math.pow(props.lacunarity, props.oct);
var amp = Math.pow(props.persistence, props.oct);
for (var y = 0; y < 56; y++) {
for (var x = 0; x < 56; x++) {
var idx = (y * 56 + x) * 4;
var n = perlin2((x / 56) * props.baseScale * freq, (y / 56) * props.baseScale * freq) * amp;
var val = (n * 0.5 + 0.5) * 255;
data[idx] = val; data[idx + 1] = val; data[idx + 2] = val; data[idx + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
}, [props.oct, props.persistence, props.lacunarity, props.baseScale]);
return h("div", { style: { display: "flex", flexDirection: "column", alignItems: "center", gap: 4 } },
h("canvas", { ref: canvasRef, style: { width: 56, height: 56, borderRadius: 4, border: "1px solid rgba(255,255,255,0.06)", display: "block" } }),
h("div", { style: { fontFamily: FONTS.mono, fontSize: 9, color: COLORS.textDim, textAlign: "center" } },
"×" + Math.pow(props.persistence, props.oct).toFixed(2)
)
);
}
function OctavesPanel() {
var ref1 = useState(1), octaves = ref1[0], setOctaves = ref1[1];
var ref2 = useState(0.5), persistence = ref2[0], setPersistence = ref2[1];
var ref3 = useState(2.0), lacunarity = ref3[0], setLacunarity = ref3[1];
var ref4 = useState(4.0), baseScale = ref4[0], setBaseScale = ref4[1];
// Main fBm canvas
var mainRef = useCanvas(function(ctx, w, h) {
var imgData = ctx.createImageData(w, h);
var data = imgData.data;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var idx = (y * w + x) * 4;
var n = fbm((x / w) * baseScale, (y / h) * baseScale, octaves, persistence, lacunarity);
var val = (n * 0.5 + 0.5) * 255;
data[idx] = val; data[idx + 1] = val; data[idx + 2] = val; data[idx + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
}, [octaves, persistence, lacunarity, baseScale], 280, 280);
// Individual octave strips
var octaveStrips = [];
var stripCount = Math.min(octaves, 6);
for (var o = 0; o < stripCount; o++) {
octaveStrips.push(
h(OctaveStrip, { key: o, oct: o, persistence: persistence, lacunarity: lacunarity, baseScale: baseScale })
);
}
return h("div", null,
h("div", { style: { marginBottom: 20 } },
h("h3", { style: { fontFamily: FONTS.body, fontSize: 18, fontWeight: 400, color: COLORS.text, margin: "0 0 6px" } }, "Layering detail with octaves"),
h("p", { style: { fontFamily: FONTS.body, fontSize: 14, color: COLORS.textMuted, margin: 0, lineHeight: 1.5 } },
"One layer of noise is too smooth. Stack octaves at increasing frequency and decreasing amplitude to build natural complexity."
)
),
// Main canvas
h("div", { style: { display: "flex", justifyContent: "center", marginBottom: 16 } },
h("div", { style: { position: "relative" } },
h("canvas", { ref: mainRef, style: {
width: 280, height: 280, borderRadius: 8,
border: "1px solid rgba(74,222,128,0.15)",
}}),
h("div", { style: {
position: "absolute", top: 8, left: 8, fontFamily: FONTS.mono, fontSize: 10,
padding: "3px 8px", borderRadius: 4,
background: "rgba(0,0,0,0.6)", color: COLORS.accent,
}}, octaves + " octave" + (octaves > 1 ? "s" : ""))
)
),
// Octave strips
octaveStrips.length > 0 && h("div", { style: {
display: "flex", gap: 8, justifyContent: "center", marginBottom: 16, flexWrap: "wrap",
padding: "12px", background: "rgba(255,255,255,0.015)", borderRadius: 8,
border: "1px solid rgba(255,255,255,0.04)",
}},
octaveStrips.reduce(function(acc, strip, i) {
if (i > 0) acc.push(
h("div", { key: "plus-" + i, style: {
fontFamily: FONTS.mono, fontSize: 14, color: COLORS.textDim, alignSelf: "center",
}}, "+")
);
acc.push(strip);
return acc;
}, []),
h("div", { style: { fontFamily: FONTS.mono, fontSize: 14, color: COLORS.textMuted, alignSelf: "center", marginLeft: 4 } }, "= result")
),
// Controls
h("div", { style: { marginBottom: 8 } },
h(SliderControl, {
label: "octaves", value: octaves, min: 1, max: 8, step: 1,
displayValue: octaves, onChange: setOctaves, color: COLORS.accent,
}),
h(SliderControl, {
label: "persistence", value: persistence, min: 0.1, max: 0.9, step: 0.05,
displayValue: persistence.toFixed(2), onChange: setPersistence, color: COLORS.yellow,
}),
h(SliderControl, {
label: "lacunarity", value: lacunarity, min: 1.5, max: 4.0, step: 0.1,
displayValue: lacunarity.toFixed(1), onChange: setLacunarity, color: COLORS.blue,
}),
h(SliderControl, {
label: "base scale", value: baseScale, min: 1, max: 12, step: 0.5,
displayValue: baseScale.toFixed(1), onChange: setBaseScale, color: COLORS.purple,
noMargin: true,
})
),
h(InsightCard, null,
h("span", { style: { color: COLORS.accent, fontWeight: 600 } }, "Persistence"),
" controls how quickly amplitude drops per octave (lower = smoother). ",
h("span", { style: { color: COLORS.blue, fontWeight: 600 } }, "Lacunarity"),
" controls how quickly frequency increases (higher = more fine detail). Most terrain generators use persistence ≈ 0.5 and lacunarity ≈ 2.0."
)
);
}
// ─── Panel 4: Applications ───────────────────────────────────
function ApplicationsPanel() {
var ref = useState("terrain"), app = ref[0], setApp = ref[1];
var ref2 = useState(0), time = ref2[0], setTime = ref2[1];
var animRef = useRef(null);
// Animate time for movement demos
useEffect(function() {
var running = true;
function tick() {
if (!running) return;
setTime(function(t) { return t + 0.016; });
animRef.current = requestAnimationFrame(tick);
}
if (app === "animation" || app === "clouds") {
animRef.current = requestAnimationFrame(tick);
}
return function() { running = false; if (animRef.current) cancelAnimationFrame(animRef.current); };
}, [app]);
var canvasRef = useCanvas(function(ctx, w, h) {
ctx.fillStyle = COLORS.bg;
ctx.fillRect(0, 0, w, h);
if (app === "terrain") {
// Heightmap interpreted as terrain cross-section
var heightScale = 0.7;
ctx.beginPath();
ctx.moveTo(0, h);
for (var x = 0; x <= w; x++) {
var n = fbm(x / w * 6, 0.5, 6, 0.5, 2.0);
var elevation = h * (1 - (n * 0.5 + 0.5) * heightScale) - 20;
if (x === 0) ctx.moveTo(x, elevation);
else ctx.lineTo(x, elevation);
}
ctx.lineTo(w, h); ctx.lineTo(0, h); ctx.closePath();
// Gradient fill
var grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, "rgba(74,222,128,0.5)");
grad.addColorStop(0.5, "rgba(74,222,128,0.2)");
grad.addColorStop(1, "rgba(74,222,128,0.05)");
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = COLORS.accent;
ctx.lineWidth = 2;
ctx.beginPath();
for (var x = 0; x <= w; x++) {
var n = fbm(x / w * 6, 0.5, 6, 0.5, 2.0);
var elevation = h * (1 - (n * 0.5 + 0.5) * heightScale) - 20;
if (x === 0) ctx.moveTo(x, elevation);
else ctx.lineTo(x, elevation);
}
ctx.stroke();
// Labels
ctx.fillStyle = COLORS.textDim;
ctx.font = "10px " + FONTS.mono;
ctx.textAlign = "left";
ctx.fillText("fBm(x, 0.5) → elevation", 8, h - 8);
} else if (app === "clouds") {
// Animated cloud density
var imgData = ctx.createImageData(w, h);
var data = imgData.data;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var idx = (y * w + x) * 4;
var n = fbm((x / w) * 5 + time * 0.3, (y / h) * 5, 5, 0.55, 2.0);
// Remap to cloud-like: threshold and brighten
var density = Math.max(0, n * 1.5);
var val = density * 255;
data[idx] = val * 0.85;
data[idx + 1] = val * 0.9;
data[idx + 2] = val;
data[idx + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
ctx.fillStyle = COLORS.textDim;
ctx.font = "10px " + FONTS.mono;
ctx.textAlign = "left";
ctx.fillText("fBm(x + t, y) → density", 8, h - 8);
} else if (app === "animation") {
// Noise-driven particle motion
ctx.fillStyle = "rgba(17,18,22,0.15)";
ctx.fillRect(0, 0, w, h);
var count = 12;
for (var i = 0; i < count; i++) {
var baseX = (i + 0.5) / count * w;
var baseY = h / 2;
var nx = perlin2(i * 3.7 + 0.5, time * 0.8) * 40;
var ny = perlin2(i * 3.7 + 100.5, time * 0.8) * 60;
var px = baseX + nx;
var py = baseY + ny;
var size = 4 + perlin2(i * 3.7 + 200.5, time * 0.5) * 3;
ctx.fillStyle = COLORS.blue + "60";
ctx.beginPath(); ctx.arc(px, py, size + 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = COLORS.blue;
ctx.beginPath(); ctx.arc(px, py, size, 0, Math.PI * 2); ctx.fill();
// Trail
for (var t = 1; t <= 5; t++) {
var tnx = perlin2(i * 3.7 + 0.5, (time - t * 0.04) * 0.8) * 40;
var tny = perlin2(i * 3.7 + 100.5, (time - t * 0.04) * 0.8) * 60;
ctx.fillStyle = COLORS.blue + (Math.max(0, 30 - t * 6)).toString(16).padStart(2, "0");
ctx.beginPath(); ctx.arc(baseX + tnx, baseY + tny, size * (1 - t * 0.15), 0, Math.PI * 2); ctx.fill();
}
}
ctx.fillStyle = COLORS.textDim;
ctx.font = "10px " + FONTS.mono;
ctx.textAlign = "left";
ctx.fillText("perlin(id, t) → position", 8, h - 8);
} else if (app === "texture") {
// Wood-like rings
var imgData = ctx.createImageData(w, h);
var data = imgData.data;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var idx = (y * w + x) * 4;
var nx = (x / w) * 4;
var ny = (y / h) * 4;
var n = perlin2(nx, ny) * 10;
var ring = n - Math.floor(n); // fractional part creates rings
var val = ring * 0.7 + 0.15;
// Wood-ish color
data[idx] = val * 180 + 40;
data[idx + 1] = val * 120 + 30;
data[idx + 2] = val * 60 + 15;
data[idx + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
ctx.fillStyle = COLORS.textDim;
ctx.font = "10px " + FONTS.mono;
ctx.textAlign = "left";
ctx.fillText("fract(perlin(x,y) × 10) → wood grain", 8, h - 8);
}
}, [app, Math.floor(time * 15)], 320, 200);
var apps = [
{ id: "terrain", label: "Terrain", desc: "fBm noise as heightmap elevation", color: COLORS.accent },
{ id: "clouds", label: "Clouds", desc: "Scrolling noise as volumetric density", color: COLORS.blue },
{ id: "animation", label: "Motion", desc: "Noise over time for organic movement", color: COLORS.blue },
{ id: "texture", label: "Texture", desc: "Noise distortion for procedural materials", color: COLORS.yellow },
];
return h("div", null,
h("div", { style: { marginBottom: 20 } },
h("h3", { style: { fontFamily: FONTS.body, fontSize: 18, fontWeight: 400, color: COLORS.text, margin: "0 0 6px" } }, "Noise in the wild"),
h("p", { style: { fontFamily: FONTS.body, fontSize: 14, color: COLORS.textMuted, margin: 0, lineHeight: 1.5 } },
"Same algorithm, different inputs. Select an application to see Perlin noise at work."
)
),
// App selector
h("div", { style: { display: "flex", gap: 6, marginBottom: 16, flexWrap: "wrap" } },
apps.map(function(a) {
var isActive = app === a.id;
return h("button", {
key: a.id,
onClick: function() { setApp(a.id); setTime(0); },
style: {
flex: 1, minWidth: 70, padding: "8px 10px", borderRadius: 6, cursor: "pointer",
fontFamily: FONTS.body, fontSize: 12, textAlign: "left",
background: isActive ? a.color + "12" : "rgba(255,255,255,0.02)",
border: "1px solid " + (isActive ? a.color + "40" : "rgba(255,255,255,0.04)"),
color: isActive ? a.color : COLORS.textDim,
transition: "all 0.2s",
}
}, a.label);
})
),
// Canvas
h("div", { style: { display: "flex", justifyContent: "center", marginBottom: 16 } },
h("canvas", { ref: canvasRef, style: {
width: 320, height: 200, borderRadius: 8,
border: "1px solid rgba(255,255,255,0.06)",
}})
),
// Description
h("div", { style: {
padding: "12px 16px", marginBottom: 12,
background: apps.find(function(a) { return a.id === app; }).color + "06",
borderRadius: 6,
border: "1px solid " + apps.find(function(a) { return a.id === app; }).color + "15",
borderLeft: "3px solid " + apps.find(function(a) { return a.id === app; }).color + "40",
}},
h("p", { style: { fontFamily: FONTS.body, fontSize: 13, color: COLORS.textMuted, lineHeight: 1.6, margin: 0 } },
app === "terrain" ? "Feed fBm noise into a heightmap and you get mountains. The first octave gives you continental-scale landmasses, the last gives you individual rocks. This is how Minecraft, Terraria, and No Man's Sky generate their worlds."
: app === "clouds" ? "Sample noise with a slowly increasing x-offset each frame and the clouds scroll. Threshold the output to get clear/cloudy boundaries. Add a second noise layer for turbulence and you have a volumetric sky."
: app === "animation" ? "Instead of sampling noise across space, sample it across time. Each particle gets a unique noise channel. The result is organic, non-repeating motion without keyframes, perfect for idle animations, camera shake, or ambient particle effects."
: "Multiply noise output by a constant and take the fractional part. The sharp boundaries create ring-like patterns. With the right color mapping, this produces convincing wood grain, marble veins, or circuit-board traces."
)
),
h(InsightCard, null,
"The same ", h("span", { style: { color: COLORS.accent, fontWeight: 600 } }, "perlin2(x, y)"),
" call powers all four demos. The difference is just what you feed in (space, time, or both) and how you interpret the output (elevation, density, position, color)."
)
);
}
// ─── Main Component ──────────────────────────────────────────
var PANELS = [
{ id: "random", label: "The Problem", Component: RandomVsCoherent },
{ id: "algorithm", label: "The Algorithm", Component: HowItWorks },
{ id: "octaves", label: "Octaves", Component: OctavesPanel },
{ id: "applications", label: "In the Wild", Component: ApplicationsPanel },
];
function PerlinVisual() {
var ref = useState(0), activePanel = ref[0], setActivePanel = ref[1];
var tabs = PANELS.map(function(p, i) {
return h("button", {
key: p.id,
onClick: function() { setActivePanel(i); },
style: {
flex: 1, fontFamily: FONTS.mono, fontSize: 12,
fontWeight: activePanel === i ? 500 : 400,
color: activePanel === i ? COLORS.text : COLORS.textDim,
background: activePanel === i ? "rgba(255,255,255,0.05)" : "transparent",
border: "none", borderRadius: 7, padding: "10px 8px",
cursor: "pointer", transition: "all 0.2s ease-out", letterSpacing: "0.01em",
}
},
h("span", { style: { display: "inline-block", width: 16, fontFamily: FONTS.mono, fontSize: 10, opacity: 0.4, marginRight: 4 } }, i + 1),
p.label
);
});
var ActiveComponent = PANELS[activePanel].Component;
return h("div", { style: {
background: COLORS.bg, color: COLORS.text, fontFamily: FONTS.body,
minHeight: "100vh", display: "flex", flexDirection: "column", alignItems: "center",
padding: "40px 16px",
}},
// Header
h("div", { style: { maxWidth: 640, width: "100%", marginBottom: 32 } },
h("div", { style: { fontFamily: FONTS.mono, fontSize: 11, color: COLORS.accent, textTransform: "uppercase", letterSpacing: "0.12em", marginBottom: 8 } }, "Interactive Explainer"),
h("h1", { style: { fontFamily: FONTS.body, fontSize: 28, fontWeight: 300, color: COLORS.text, margin: "0 0 8px", lineHeight: 1.3, letterSpacing: "-0.01em" } }, "Perlin Noise"),
h("p", { style: { fontFamily: FONTS.body, fontSize: 15, color: COLORS.textMuted, margin: 0, lineHeight: 1.5 } }, "How a grid of random vectors produces mountains, clouds, and motion.")
),
// Tab bar
h("div", { style: { maxWidth: 640, width: "100%", display: "flex", gap: 4, marginBottom: 24, background: "rgba(255,255,255,0.015)", borderRadius: 10, padding: 4, border: "1px solid rgba(255,255,255,0.04)" } }, tabs),
// Active panel
h("div", { style: { maxWidth: 640, width: "100%", background: "rgba(255,255,255,0.015)", border: "1px solid rgba(255,255,255,0.04)", borderRadius: 12, padding: "28px 24px", minHeight: 420 } },
h(ActiveComponent, { key: activePanel })
),
// Nav arrows
h("div", { style: { maxWidth: 640, width: "100%", display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 16 } },
h("button", {
onClick: function() { setActivePanel(Math.max(0, activePanel - 1)); },
disabled: activePanel === 0,
style: {
fontFamily: FONTS.mono, fontSize: 12,
color: activePanel === 0 ? COLORS.textDim : COLORS.textMuted,
background: "none",
border: "1px solid " + (activePanel === 0 ? "transparent" : "rgba(255,255,255,0.04)"),
borderRadius: 6, padding: "8px 16px",
cursor: activePanel === 0 ? "default" : "pointer", transition: "all 0.2s",
}
}, "← prev"),
h("span", { style: { fontFamily: FONTS.mono, fontSize: 11, color: COLORS.textDim } }, (activePanel + 1) + " / " + PANELS.length),
h("button", {
onClick: function() { setActivePanel(Math.min(PANELS.length - 1, activePanel + 1)); },
disabled: activePanel === PANELS.length - 1,
style: {
fontFamily: FONTS.mono, fontSize: 12,
color: activePanel === PANELS.length - 1 ? COLORS.textDim : COLORS.textMuted,
background: "none",
border: "1px solid " + (activePanel === PANELS.length - 1 ? "transparent" : "rgba(255,255,255,0.04)"),
borderRadius: 6, padding: "8px 16px",
cursor: activePanel === PANELS.length - 1 ? "default" : "pointer", transition: "all 0.2s",
}
}, "next →")
)
);
}
ReactDOM.render(h(PerlinVisual), document.getElementById("root"));
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment