Created
February 16, 2026 21:29
-
-
Save kbouw/567832e9a7de53c44d62b2bb6035ee8e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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