Effect Design Theory Reference
Detailed reference covering mathematical patterns, rendering pipeline, animation techniques, shader porting, and audio reactivity for RGB LED effects. Consult SKILL.md for quick rules.
Mathematical Patterns
Noise Functions
Simplex Noise (Preferred)
Uses a simplicial grid (triangles in 2D) instead of squares. Benefits for LED effects:
- Fewer directional artifacts than Perlin (no grid-aligned bias)
- O(n^2) complexity vs O(2^n) for classic Perlin
- Better isotropic (direction-independent) noise
- Ideal for organic/flowing effects
For LED grids, 2-3 octaves of fractal noise is sufficient. More octaves add detail below LED resolution that gets aliased away.
Perlin Noise
Good for 1D and 2D but shows grid-aligned artifacts (horizontal/vertical bias) at low resolution. Prefer simplex for 2D effects.
Multi-Octave (Fractal) Noise
Layer noise at different frequencies and amplitudes:
value = noise(x) * 1.0 // base
+ noise(x*2) * 0.5 // detail
+ noise(x*4) * 0.25 // fine detailEach octave at 2x frequency, 0.5x amplitude. 2-3 octaves for LEDs.
Voronoi (Worley Noise)
Partition space into cells around random seed points. Each pixel colored by nearest seed distance. Produces:
- Organic cell patterns (like soap bubbles or cracked mud)
- Clean boundaries between color regions
- Works well at low LED density because cells are naturally large
Variants: Color by nearest seed, color by second-nearest, color by distance difference (cracks).
Metaballs
Implicit surface technique where each "blob" has an influence field:
field(x,y) = sum(radius_i^2 / distance(x,y, center_i)^2)When field > threshold, the pixel is "inside." Creates smooth, organic merging shapes. Naturally glow-like without post-processing.
LED advantage: Metaballs are inherently low-frequency. The smooth falloff fields survive downsampling to LED grids.
Sine Plasma
The simplest successful pattern. Layer sinusoidal functions:
value = sin(x * freq1 + time)
+ sin(y * freq2 + time * 0.7)
+ sin((x + y) * freq3 + time * 1.3)
+ sin(sqrt(x*x + y*y) * freq4 + time * 0.5)Map the summed value to a color palette. Produces smooth, flowing, psychedelic patterns that look great on LEDs.
Expanding Rings / Ripples
Concentric circles emanating from a point:
distance = sqrt((x - cx)^2 + (y - cy)^2)
value = sin(distance * frequency - time * speed)Naturally low spatial frequency. Multiple overlapping ring sources create interference patterns.
Particle Systems
Discrete bright points moving through space with trails. The standard community approach:
- Array of particles with position, velocity, color, lifetime
- Each frame: update positions, draw particles, apply trail fade
- Trail via semi-transparent black overlay (see Animation section)
Particle effects work on LEDs because the bright particles are point sources — exactly what LEDs are.
Patterns That Fail on LEDs
Bloom / Glow Post-Processing
Traditional bloom convolves bright regions with a Gaussian blur. Fails because:
- LEDs are too sparse for blur kernels to produce visible softness
- No optical blending between physically separated LEDs
- Keycap bezels isolate each LED perceptually
What works instead:
- Radial falloff functions:
1.0 / (1.0 + distance^2)per glow source - Additive blending of multiple falloff sources
- Brightness boost at source (saturated white core, colored surround)
- Temporal glow (pulse adjacent LEDs with delayed, attenuated color)
Ray Marching / Complex 3D
Detail below LED resolution is wasted compute. A 100-LED keyboard cannot represent the detail a ray marcher produces.
Film Grain / Dithering
Single-pixel noise is invisible at LED density.
Fine Fractals
Mandelbrot at high zoom has detail that aliases to mush when sampled to LED positions.
Thin Lines / Sharp Geometry
Below the Nyquist limit for LED grids. Lines vanish or alias between LEDs.
The Nyquist Rule
Maximum representable spatial frequency = 1 / (2 * LED_pitch). On a keyboard with ~18mm key pitch, the finest visible feature spans ~2 keys. Pre-filter (low-pass) the canvas before sampling to avoid aliasing.
Practical rule: Design features that span at least 3-4 LEDs. Below that, patterns break down.
Rendering Pipeline
Canvas 2D at the daemon's configured resolution
The universal format across all 210 community effects:
- Canvas 2D context (not WebGL)
- Resolution is whatever the daemon is configured for — 640x480 by default
requestAnimationFramefor the render loop- Engine samples canvas pixels at LED positions
- Effects MUST read
ctx.canvas.width/ctx.canvas.heightevery frame — never hardcode - Effects ported from the historical 320x200 SDK grid use
scaleContext(ctx.canvas, { width: 320, height: 200 })to translate design coords to live pixels
Why Canvas 2D over WebGL:
- Simpler mental model — draw calls map to visual intent
- No shader compilation — instant effect loading
- Adequate performance — even 640x480 is trivial; USB transfer is the bottleneck
- Better portability — no driver issues
Compositing Modes
| Mode | Effect | Use Case |
|---|---|---|
source-over |
Normal layering | Default |
lighter |
Additive blending | Overlapping lights, energy effects |
screen |
Soft additive (never exceeds white) | Controlled glow |
multiply |
Darken overlaps | Shadows, masking |
lighter is the key mode for LED effects — simulates how real light combines.
Temporal Patterns and Animation
Trail/Fade Technique
Each frame: overlay semi-transparent black, then draw new elements:
ctx.fillStyle = "rgba(0, 0, 0, alpha)";
ctx.fillRect(0, 0, canvas.width, canvas.height);| Alpha | Trail Length | Use |
|---|---|---|
| 0.02-0.05 | Very long | Slow comets, aurora |
| 0.05-0.10 | Long | Flowing effects |
| 0.10-0.20 | Medium | Standard (most effects) |
| 0.20-0.40 | Short | Reactive, snappy |
| 0.50-1.0 | None/minimal | Full redraw each frame |
Delta-Time Animation
Always base motion on elapsed time:
const now = performance.now();
const dt = (now - lastTime) / 1000;
lastTime = now;
position += velocity * dt;Never use frame count — requestAnimationFrame rate varies.
Easing Functions
Sinusoidal for organic motion (breathing, pulsing):
brightness = (Math.sin(time * speed) + 1) / 2;Fast attack / slow decay for reactive effects:
- Instant jump to peak on trigger
- Exponential decay:
value *= 0.95each frame - Or
value = peak * Math.exp(-decay * elapsed)
Breathing Effect
const phase = (Math.sin((time * 2 * Math.PI) / period) + 1) / 2;
const brightness = minBright + phase * (maxBright - minBright);Period of 2-4 seconds. Use HSV V for brightness control.
Audio Reactivity
Available Engine Data
- Beat detection: Boolean pulse on bass hits
- Frequency bands: Bass, mid, treble energy levels
- Overall level: RMS amplitude
- Audio density: How "full" the spectrum is
Design Principles
- Fast onset, slow decay: Jump to peak on beat, exponential fade over 300-500ms
- Map bass to brightness: Low frequencies drive overall intensity
- Map treble to detail: High frequencies modulate fine pattern elements
- Smooth the input: Apply EMA smoothing (alpha 0.1-0.3) to raw audio data
- Threshold, don't scale: Beats should trigger clear visual events, not proportional nudges
Property System
Effects expose user controls via HTML meta tags:
<meta
property="speed"
label="Speed"
type="number"
min="1"
max="10"
default="5"
/>
<meta property="color" label="Color" type="color" default="#ff0000" />
<meta
property="mode"
label="Mode"
type="combobox"
values="wave,pulse,chase"
default="wave"
/>Available types: number, boolean, color, combobox (alias dropdown), sensor, hue, area, textfield (aliases text, input), asset, and rect. There is no string type; use textfield. Anything else parses as an unknown kind and gets no control panel widget.
Callbacks: the engine builds the callback name by concatenating, so the
property name is used verbatim with no capitalization. property="speed"
calls window.onspeedChanged(), not onSpeedChanged. Name properties in the
casing you want to see in the callback.
Best practice: 3-5 meaningful controls with sensible defaults. The effect should look great at zero configuration.
Shader Porting Guide
When adapting Shadertoy/GLSL shaders to LED Canvas 2D effects:
What Translates Well
- UV-based math — normalize pixel coordinates to 0-1, same logic applies
- Distance fields — compute distance from shapes, map to color
- Noise functions — reimplement in JS (simplex noise libraries available)
- Color palettes —
palette(t) = a + b * cos(2*pi * (c*t + d))(Inigo Quilez technique) - Time-based animation —
iTimemaps toperformance.now() / 1000
What Doesn't Translate
- Per-pixel parallelism — Canvas 2D is CPU-sequential; use imageData for batch pixel writes
- Multi-pass rendering — No framebuffer ping-pong; use multiple canvas layers
- 3D ray marching — Too much detail for LED density; simplify to 2D projections
- Post-processing chains — Bloom, DOF, motion blur — skip these entirely for LEDs
Porting Checklist
- Replace
fragCoord/iResolutionwithx/width, y/height - Replace
iTimewithperformance.now() / 1000 - Remove any post-processing passes
- Reduce spatial frequency (increase scale factors)
- Convert color output to canvas RGB
- Test at effective LED resolution (not full canvas)
Hypercolor Engine Details
Color Types
Rgba // u8 sRGB — input/output format
Rgb // u8 sRGB — no alpha
LinearRgba // linear f32 — internal math
Oklab // perceptually uniform — gradients, blending
Oklch // polar perceptual: palette generation, hue cyclingSampling Pipeline
Canvas (sRGB u8)
-> zone_local_to_canvas (scale, rotate, translate, then edge behavior)
-> decode sRGB to linear u16
-> resample in linear light (nearest, bilinear, area, or gaussian)
-> FadeToBlack attenuation when the edge behavior asks for it, still linear
-> encode linear back to sRGB u8
-> ZoneColorsThe sampler applies no color styling. Every stage above is decode, resample,
attenuate, encode, so a sampled pixel reaches ZoneColors with its color
intact. There is no chroma boost and no Oklch anywhere in
core/src/spatial/; polish_sampled_color() does not exist in the tree. An
LED-safe palette is the effect's job.
Output Pipeline
BackendManager::write_frame(zone_colors, layout) routes each zone's colors to
its mapped device and runs the only color adjustment left in the engine:
- Zone brightness scaling
- Decode sRGB to linear
- LED perceptual compensation: a bounded lift for low-luma chromatic colors, which point-source LEDs under-represent (blue, cyan, magenta most of all), weighted down toward neutrals and never exceeding channel headroom
- Output brightness in linear
- Write linear-light bytes straight to LED PWM, with no sRGB re-encode
Step 5 is why an effect must never apply its own gamma: the engine's decode already is the transfer.
Linear-Light Discipline
Everything downstream of the canvas works in linear light. Bilinear and area
sampling decode before they blend, FadeToBlack attenuates in linear, the
screen-capture TemporalSmoother keeps its EMA state in linear-light units to
avoid gamma-space smearing, and <meta type="color"> defaults parse through
LinearRgba::from_hex_srgb, so a control color is linear by the time a
renderer sees it.