- 01.Starting Point
- 02.First attempt: naive box blur + weak unsharp
- 03.Why it looked wrong
- 04.Fix: Gaussian approximation + proper algorithm
- 05.Color adaptation for dark theme
- 06.Hexbin backdrop concepts
- 07.1. Topology (`backdrop-hexbin-topology.tsx`)
- 08.2. Signal Propagation (`backdrop-hexbin-signal.tsx`)
- 09.3. Heatmap Pulse (`backdrop-hexbin-pulse.tsx`)
- 10.Performance: frame rate kill
- 11.React Flow pointer event issue
- 12.Lazy section loading
- 13.Where We Landed
- 14.Takeaways
Reaction-Diffusion + Hexbin Backdrop Concepts
Lab has ~26 specimens (packets, grids, backdrops) on a React Flow infinite canvas. User found an OpenProcessing sketch by Zaron Chen (MIT licensed) doing reaction-diffusion via blur + unsharp masking
Commit: c4e3f3c (includes all work below plus other lab specimens)
Starting Point
Lab has ~26 specimens (packets, grids, backdrops) on a React Flow infinite canvas. User found an OpenProcessing sketch by Zaron Chen (MIT licensed) doing reaction-diffusion via blur + unsharp masking — wanted it as a new grid specimen.
The reference code uses p5.js with WebGL shaders (Shox.blur(3) Gaussian + unsharp(texelSize*7, 64) strength). The key insight: you don't need Gray-Scott equations — repeatedly blurring then sharpening a noise field self-organizes into organic patterns.
First attempt: naive box blur + weak unsharp
Created src/components/lab/grid-reaction.tsx at 150×150 internal resolution with:
- Single-pass box blur, radius 2
- Unsharp strength 5.0
imageRendering: 'pixelated'- Two blur→unsharp passes per frame
Result: static QR-code-like binary pattern. Not the organic lava-lamp look at all.
Why it looked wrong
Three problems identified by comparing to reference:
- Box blur produces sharp edges — reference uses Gaussian (smooth falloff creates rounded organic shapes)
- Same blur radius for both passes — reference uses narrow blur for diffusion (kernel 3) but WIDER blur for unsharp comparison (
texelSize*7= 7 pixels) - Strength too low — reference uses 64, I used 5
Also: reference initializes from a smooth noise texture (NoiseMono_2.png), not random pixel noise. Random pixels → fine-grained static. Smooth noise → large-scale blobs that the algorithm can refine.
Fix: Gaussian approximation + proper algorithm
Rewrote to match reference's actual algorithm:
// Per frame:
// 1. Gaussian blur (3-pass box blur ≈ Gaussian) — narrow radius (diffusion)
gaussianBlur(buf, blurA, BLUR_RADIUS); // radius 1
// 2. Blur the result AGAIN with wider radius (for unsharp comparison)
gaussianBlur(blurA, blurB, UNSHARP_RADIUS); // radius 3
// 3. Unsharp: amplify the difference between narrow and wide blur
for (let i = 0; i < RES * RES; i++) {
const sharp = blurA[i]!;
const wide = blurB[i]!;
buf[i] = clamp(sharp + UNSHARP_STRENGTH * (sharp - wide)); // strength 48
}The 3-pass box blur (boxBlur → boxBlur → boxBlur) closely approximates Gaussian. Used sliding-window accumulator for O(n) per row instead of O(n*k).
Initialization switched to 2-octave simplex noise at low frequency (NOISE_SCALE = 4.0):
const v = noise.get(nx, ny) * 0.7 + noise.get(nx * 2.5, ny * 2.5) * 0.3;
buf[y * RES + x] = v * 0.5 + 0.5;Resolution bumped to 200×200, imageRendering: 'auto' for smooth upscaling.
Color adaptation for dark theme
Other lab specimens use rgba(255, 255, 255, 0.25) on #0a0a0a backgrounds. Applied same treatment:
const val = 1 - buf[i]!; // Invert (structures dark, gaps glow)
const highlight = val * PATTERN_ALPHA * 255; // PATTERN_ALPHA = 0.25
d[idx] = Math.floor(BG_R + highlight); // BG = rgb(10,10,10)Border at 0.6 intensity (not 1.0) to keep edges from collapsing without being visually harsh.
Hexbin backdrop concepts
User wanted backdrop concepts based on the existing grid-hexbin.tsx (SVG hex grid with center-biased random intensity). Created three Canvas-based animated backdrops:
1. Topology (backdrop-hexbin-topology.tsx)
Hex grid intensity driven by drifting 2-layer simplex noise. Two noise fields moving in different directions create a shifting topographic heatmap.
2. Signal Propagation (backdrop-hexbin-signal.tsx)
4 emitter points spawn concentric ring-waves. Each wave lights hexes as it passes (intensity = ringFalloff * ageFade). Multiple emitters create interference.
3. Heatmap Pulse (backdrop-hexbin-pulse.tsx)
Center-biased base intensity with per-cell breathing (sin(time + phase)). Random bursts spawn ring + inner-glow patterns that expand and fade.
All three share the same hex grid builder and drawHex() function.
Performance: frame rate kill
All three running simultaneously killed FPS. Two fixes applied:
- Increased hex radius from 14 to 22 (~60% fewer cells)
- Frame throttle at 50ms (~20fps):
const animate = (now: number) => {
if (now - lastFrame < FRAME_INTERVAL) {
rafRef.current = requestAnimationFrame(animate);
return;
}
lastFrame = now;
// ... render
};Still too expensive with all three running. Added play/pause — components start paused, no rAF loop runs until user clicks play:
const [playing, setPlaying] = useState(false);
useEffect(() => {
if (!playing) return;
// ... setup + animate loop
return () => cancelAnimationFrame(rafRef.current);
}, [width, height, playing]);React Flow pointer event issue
Play button inside React Flow nodes couldn't be clicked. The nopan nodrag classes (React Flow's built-in escape hatches) weren't enough because the framework sets pointer-events: none on non-interactive nodes.
Fix: force pointer events back on the button specifically:
className="nopan nodrag nowheel absolute bottom-2 left-2 z-10 pointer-events-auto ..."
pointer-events-auto overrides any inherited pointer-events: none from React Flow's node wrapper. z-10 ensures button is above the canvas element.
Lazy section loading
All specimens rendering simultaneously was still too heavy. Restructured the lab page so only the active section's nodes are in the React Flow canvas:
const [activeSection, setActiveSection] = useState<SectionId>('packets');
const nodes = useMemo(() => buildNodes(activeSection), [activeSection]);
// key={activeSection} forces React Flow to remount (clean fitView)
<ReactFlow key={activeSection} nodes={nodes} ... />Section nav became a tab switcher instead of fitBounds navigator. Unmounted sections = unmounted components = cancelled rAF loops = zero CPU.
Where We Landed
grid-reaction.tsx— reaction-diffusion via blur+unsharp feedback, dark theme, ~200×200backdrop-hexbin-topology.tsx— noise-driven hex heatmap, play/pause, ~20fps throttlebackdrop-hexbin-signal.tsx— ring-wave interference on hex grid, play/pausebackdrop-hexbin-pulse.tsx— breathing + burst activity on hex grid, play/pause- Lab page loads one section at a time (tab-switched, not all-at-once)
Takeaways
- Reaction-diffusion without PDEs: blur + unsharp mask in a feedback loop. The key is using different blur radii (narrow for diffusion, wide for unsharp comparison) and high strength (40-60+). Gaussian smoothness (not box blur) is critical for organic shapes.
- Smooth noise initialization matters: random pixels → fine static. Low-frequency simplex → large blobs that the algorithm refines into organic coral patterns.
- React Flow pointer events:
nopan nodraghandles gesture prevention butpointer-events-autois needed to override the framework'spointer-events: noneon non-interactive nodes. - Canvas specimens performance: frame throttling + play/pause + lazy section mounting. All three are needed when you have 20+ animated specimens.