Power functions¶
The shared toolbox the light domain is built from: a small set of named, integer-only routines that effects compose into a look. One home per idea — an effect that needs a distance, a bar, a noise field or a smooth follower calls the same one every other effect calls, so behaviour is consistent, the cost is measured once, and a fix reaches everything at once.
Three consumers share this vocabulary, and each uses a different slice of it:
- Effects are the main consumer — they draw, so they reach for nearly all of it.
- Modifiers fold coordinates through
modifyLogicaland never draw, so they reach for almost none of it. That asymmetry is the architecture, not a gap: an effect decides what a pixel looks like, a modifier decides where a pixel comes from. - MoonLive scripts reach the same routines through the builtin table
(
core/moonlive/MoonLiveBuiltins.h), which carries plain scalar arguments — so a script sees the flat form of a function, not the C++ callback form a compiled effect can use.
Sources: draw.h (drawing), core/math16.h (16-bit math), core/noise.h (fields).
The caller lists below are generated by reading the call sites, so they record what the code does
rather than what it intends.
Many of the names here are FastLED's, deliberately:
scale8, sin8, the gradient-palette model (CRGBPalette16 / colorFromPalette), and the
beatsin8 / inoise8 / qadd8 family are the vocabulary the LED-effect world already shares, so a
contributor recognises them on sight. projectMM links no part of FastLED: the implementations are
ours, integer-only and tuned for this render loop, with FastLED credited as the prior art behind
the convention here and in each primitive's own notes (core/math8.h names Mark Kriegsman's
lib8tion directly).
Migrating an effect — two steps, in this order¶
Step 1, the port: behave identically. Bringing an effect over from WLED or MoonLight reproduces the original's visual behaviour exactly, because the original is the best available description of what the effect should look like. At this stage a difference is a bug, not a variation — pin it with a golden so any drift is visible. Don't get creative with defaults, oscillator math, color mapping, or geometry, and don't silently drop a parameter that is the mechanism (the PaintBrush straight-vs-curved-lines bug was a dropped partial-line length; Game of Life was wrong the first time by not porting the real algorithm). Study the source for the algorithm, defaults, and visual result, then write our own implementation against EffectBase and our primitives — carry the behaviour forward, don't trace or copy the structure (see Industry standards, our own code). Credit the origin as prior art in the block below.
Step 2, the tuning: change it deliberately. Once the port is faithful it becomes ours to improve. Adopting a power function often makes an effect look better as a side effect — bouncing balls that collide with each other because the physics is now the shared kernel's, a gradient that stops banding because the maths went 16-bit — and that is a real gain, not a regression. The rule is only that the change is deliberate and visible: say what moved, re-baseline the golden in the same commit, and let the product owner judge it on the panel. What is forbidden is drifting silently.
Used by nearly everything¶
Not a category so much as the floor: three things almost every effect touches whatever else it does. If you read only one row of this page, read these — an effect that uses none of them is doing something unusual.
| Power function | What it does | Effects | Modifiers |
|---|---|---|---|
colorFromPalette |
Reads a color out of the shared palette by index, so every effect follows the user's palette choice | 39 of 47 | — |
draw::pixel |
Writes one pixel, clipped to the grid | 24 of 47 — the rest reach it through a higher-level primitive | — |
BeatPhase |
A BPM phase accumulator that keeps its numerator in 64 bits and divides late, so animation never freezes on sub-millisecond frames | 13 — Dissolve, DistortionWaves, Echo, LavaLamp, Metaballs, Noise, Plasma, PolarNoise, SdfShapes, Sine, Spiral, Tunnel, Wave | — |
Frame and pixel operations¶
Whole-buffer work: writing, reading, and moving what is already there.
These act on the grid as a surface rather than on a shape. Between them they cover the four things an effect does to a frame before it draws anything: clear it, dim what was there (the trail), blur it, or shift it bodily. Most effects open with one of these and close with per-pixel writes.
| Power function | What it does | Effects | Modifiers |
|---|---|---|---|
draw::fill |
Fills every light with one color, leaving channels beyond RGB untouched | AudioSpectrum, Blurz, RubiksCube, Solid, Spectrum, Text | — |
draw::fade |
Fades every channel toward black — the trail primitive | 13, through Layer::fadeToBlackBy — Blurz, BouncingBalls, Fireworks, FixedRectangle, FreqSaws, GEQ, GEQ3D, Lissajous, NoiseMeter, PaintBrush, Random, StarField, StarSky |
— |
draw::blur |
Separable box blur across every axis with extent > 1; one call covers 1D, 2D and 3D | Blurz | — |
draw::get |
Reads one pixel back, black outside the grid | Echo, GameOfLife | — |
draw::blendPixel |
Lerps a pixel toward a color by an amount, rather than replacing it | GameOfLife, Tetrix | — |
draw::addPixel |
Adds light to a pixel, saturating instead of wrapping to black | Blurz | — |
draw::scroll |
Shifts the whole grid along an axis, optionally wrapping — the shift register | FreqMatrix | — |
draw::splat |
Draws a point at a fractional position, splitting its light across neighbouring pixels so motion is smooth on a coarse grid | (no caller yet — the particle kernel is its consumer) | — |
Geometry¶
Drawing a shape by walking the pixels it covers.
The classical rasteriser: given endpoints, a centre and a radius, or a run length, light exactly the cells the shape passes through. Integer-only and exact, with no distance computed anywhere — which makes these the cheap way to draw when the shape sits on the grid and does not need to move smoothly between pixels.
Contrast with signed distance fields below: same shapes, opposite approach, different trade-off.
| Power function | What it does | Effects | Modifiers |
|---|---|---|---|
draw::line |
A straight line between two points (3D Bresenham) | GEQ3D, PaintBrush | — |
draw::bar |
A run of cells growing from an origin along one axis, colored per cell — the audio-meter staple | AudioSpectrum, GEQ, Spectrum | — |
draw::fillCircle |
A filled disc (midpoint algorithm), colored per row | Echo | — |
draw::text |
Draws a string, returning its pixel width (glyph is reached through it, not called directly) |
DemoReel, Text | — |
draw::sprite |
Blits one frame of a small palette-indexed bitmap (index 0 = transparent key), clipped at every edge — the multi-color sibling of glyph; movement belongs to the particle pool, never to the sprite |
FlyingToasters | — |
draw::circle |
A circle outline on integer coordinates, exact and symmetric | (no caller yet) | — |
draw::rect, fillRect |
An axis-aligned rectangle, outlined or filled | (no caller yet) | — |
draw::lineAA |
An anti-aliased line (Wu 1991) that splits its light between the two cells straddling the true path | (no caller yet) | — |
Signed distance fields¶
Describing a shape as "how far away is it", then reading a picture out of that number.
Instead of drawing a circle, an SDF answers how far is this pixel from the circle's edge — negative inside, zero on it, positive outside. That one number does far more work than a rasteriser's yes/no: the sign fills the shape, its magnitude gives an anti-aliased edge for free, taking the absolute value turns it into an outline, and two distances combine into a third shape with a single min or smin.
This is what makes shapes composable and smooth-moving. It costs a distance per pixel, so it is the right tool when a shape moves sub-pixel or merges with another, and the wrong one for a static bar.
| Power function | What it does | Effects | Modifiers |
|---|---|---|---|
sdCircle |
True signed distance to a circle's edge: negative inside, zero on the rim, positive outside | SdfShapes | — |
sdBox |
Signed distance to an axis-aligned box | SdfShapes | — |
smin |
Smooth minimum of two distances — the operator that makes shapes flow together instead of merely overlapping | SdfShapes | — |
coverage |
Turns a distance into 0..255 coverage, which is anti-aliasing for free | SdfShapes | — |
sdCircleSq |
The squared form: same sign contract without the square root, for a plain fill | (no caller yet) | — |
sdSegment |
Signed distance to a thick line segment (a capsule) | (no caller yet) | — |
Fields¶
Smooth pseudo-random values across space: everything organic.
Noise is the source of anything that should look natural rather than drawn — clouds, fire, smoke, water, marbling, drifting colour. The defining property is that nearby points get similar values (unlike a raw hash), so the result flows instead of flickering.
One sample is a soft blur; the character comes from composing them. Summing octaves adds structure at every scale, folding the field creases it into flame, and displacing the sample coordinate by another field is what produces the flowing, liquid look.
| Power function | What it does | Effects | Modifiers |
|---|---|---|---|
inoise8 |
Perlin gradient noise in 1D, 2D or 3D: a smooth, deterministic pseudo-random field | Noise, NoiseMeter, Wave | noise |
fbm8 |
Sums noise octaves at doubling frequency and halving amplitude, turning a blur into cloud and terrain structure. Re-widened per octave count, so the field keeps its full range however many are summed | PolarNoise, Tunnel, Aurora, Nebula | fbm |
warp8 |
Samples noise at a coordinate that noise itself displaced — the flowing, marbled look | PolarNoise, Aurora | warp |
turbulence8 |
Sums the folded absolute value of noise, whose creases read as billowing smoke and flame | (no caller yet) | — |
blobCentres + blobField |
Orbits N sources on sine paths and sums their inverse-square falloff — the metaball field behind anything fluid or molten | LavaLamp, Metaballs | — |
curl16 |
The perpendicular gradient of a noise potential: a flow field that is divergence-free by construction, so what it carries neither piles up nor drains away | Nebula, Trails | — |
Fluid |
A stable-fluid solver (Stam 1999) in Q16.16: the medium works out its own motion rather than reading it from a function, one independent medium per depth slice | Fluid | — |
Transport¶
A field says where things go; these carry light along one, frame after frame. The state they move is the effect's own plane rather than the layer buffer, held at 16 bits because a value multiplied by slightly less than one many times a second has nowhere to go at 8.
Each takes a depth, so a cube is the same call as a panel and a panel pays nothing for it (d = 1
reduces to the 2D loop exactly). What that buys on a cube differs by kernel: the noise fields sample
a genuine third axis, so slices differ rather than one plane repeating, while the transport kernels
carry light WITHIN each slice and not yet between them. Moving light through a volume needs a
trilinear sampler and a third velocity component, which is open work.
| Power function | What it does | Effects | Modifiers |
|---|---|---|---|
draw::advect, advect16 |
Moves a whole plane along a velocity rule by sampling BACKWARD, so every destination is written exactly once and nothing tears or duplicates | Trails, Nebula, Fluid | — |
draw::decay, decay16 |
Fades a plane by a HALF-LIFE in milliseconds, so a tail's length is stated in seconds and holds at any framerate | Trails, Nebula, Fluid | — |
draw::quantize |
Narrows 16 bits to 8, carrying the error into the next frame (temporal) or offsetting by a Bayer cell (ordered), so a slow fade stays smooth rather than stepping | through blit16 |
— |
draw::blit16 |
A 16-bit plane onto the canvas, dithered: the one narrowing step every wide-plane effect shares | Trails, Nebula, Fluid, MoonLive scripts | — |
draw::upscale16 |
Bilinearly stretches a small plane over a large one, so a smooth field can be computed at a fraction of the resolution (measured 3.0x at half, 6.6x at quarter) | Nebula | — |
halfLifeKeep |
What fraction of a value survives a given elapsed time at a given half-life: the framerate-independent decay both decay forms are built on |
through decay |
— |
Polar and geometry math¶
Addressing the grid by angle and radius instead of by x and y.
Swapping coordinate systems is the cheapest way to change what an effect looks like. Anything radial — rings, spirals, rotation, kaleidoscopes, tunnels, radial wipes, a spectrum bent around a circle — is an ordinary pattern read through polar coordinates rather than a special algorithm.
The 16-bit forms matter here: the 8-bit versions step visibly on a large fixture and their distance is an octagon rather than a circle.
| Power function | What it does | Effects | Modifiers |
|---|---|---|---|
PolarLut |
Every pixel's angle and radius, built once per geometry and read as a table. 34% of a PolarNoise frame on an ESP32-S3, at 2 bytes per pixel (4 at full precision); declines the table when free heap minus the reserve cannot hold it, and the caller computes the address instead | PolarNoise, Spiral, Tunnel, Aurora | — |
atan16 |
The angle of a point as a 16-bit turn, smooth enough that a sweep shows no steps on a large fixture | Rings, and the fallback path of every PolarLut caller |
— |
dist16 |
True Euclidean radius — not the octagon dist8 approximates, and it does not saturate at 255 |
Rings, and the fallback path of every PolarLut caller |
— |
kaleido |
Folds an angle into n mirrored wedges, giving any field n-fold symmetry for one modulo | PolarNoise, Tunnel, Aurora | — |
isqrt |
Integer square root with no divide and no float | PaintBrush, WaterRipple | — |
sin8 / cos8 |
The 8-bit oscillators — the internal fast path where a mod-256 result is exactly right | (the 16-bit forms are the contract) | Rotate |
Time, motion and randomness¶
How a value changes between frames, and how to get randomness that behaves.
Two related problems. First, motion: raw linear movement reads as mechanical, so easings shape it, followers smooth it, and peak-hold gives a meter its characteristic instant-rise slow-fall. Second, randomness that is reproducible — addressed by position rather than drawn from a stream, so the same pixel gets the same value on every device and every frame.
The framerate rule lives here too: everything in this group is driven by elapsed time, never by frame count (architecture).
| Power function | What it does | Effects | Modifiers |
|---|---|---|---|
sin16 / cos16 |
16-bit oscillators, smooth where the 8-bit forms visibly step on a large fixture | Echo, SdfShapes | — |
OscillatorBank |
N independent low-frequency oscillators advanced once per frame and read per pixel, each with its own rate, shape, range and phase offset. Their phases are held together, so oscillators sharing a rate keep their relationship for as long as the device runs | Aurora, PolarNoise, Trails, Nebula, Fluid | — |
map32 |
Maps a value between ranges, clamped, with the fencepost handled once so the last column is never lost | FreqMatrix, FreqSaws, GEQ, GEQ3D, Spectrum, StarField | — |
hashInt |
Hashes position and time to a random-looking but reproducible value, so devices agree without exchanging anything | Dissolve, WaterRipple | — |
peakHold |
Rises instantly to a new high then decays slowly — the falling peak dot every VU meter has | Spectrum | — |
smoothFollow |
Moves a value a fraction of the way toward its target each frame, so it stops jittering | Spectrum | — |
easeInOutQuad |
Accelerates from rest and decelerates to rest, so motion reads as deliberate rather than mechanical | Dissolve | — |
easeInOutCubic, easeOutQuad |
The same family with a longer settle, and a fast-start curve that arrives and rests | (no caller yet) | — |
Particles¶
Things that move under forces: sparks, rain, snow, smoke, confetti, debris, a swarm.
Anything that behaves like matter is the same handful of forces over the same state, and the part that differs between one look and another is which forces are applied and how particles are emitted — not the physics. So the state and the integrator live in particles.h and the character stays with the effect.
Storage is structure-of-arrays over the caller's own buffers, so a pass that touches only velocity walks only velocity, and the pool never allocates after prepare(). Positions are the same sub-pixel type splat takes, so a particle at x=3.5 lands half on each pixel instead of snapping.
Frame order matters and is the caller's to get right: forces, then collide(), then step(), then walls, then age(), then render(). Collisions run before the move because resolving an overlap afterwards can shove a particle through a wall the bounce pass already checked.
Prior art: the WLED Particle System by Damian Schneider (@DedeHai), whose vocabulary of emitters, forces and walls over one shared pool is the shape this follows, and Reeves 1983 for the name. The fixed-point implementation and the elapsed-time scaling are ours. His system also settled a design question by having answered it already: he documents trying y-binning in the collision broad phase and measuring it not worth the bookkeeping at these pool sizes, so collide keeps the cheaper sweep along X deliberately rather than by omission.
A script reaches the same kernel through MoonLive's pool / emit / step builtins.
| Power function | What it does | Effects | Modifiers |
|---|---|---|---|
Pool |
The state: SoA positions, velocities, life, hue and optional size over caller-owned buffers. Owns nothing, allocates nothing | Fireworks, Ballpit, Particles | — |
gravity, force, drag, attract |
The forces. Each is one pass over one array, so an effect pays only for the ones it uses | Fireworks, Ballpit | — |
forceSmall |
A force too weak to move an integer velocity, accumulated until it does — what makes a light breeze read as inertia rather than as nothing | (no caller yet) | — |
step |
Semi-implicit Euler: position integrates the already-updated velocity, which is what stays stable under a constant force | Fireworks, Ballpit, Particles | — |
bounce, wrap, killOutside |
What happens at the walls: reflect with restitution, re-enter the opposite edge (snow, rain, marquee), or simply stop existing | Fireworks, Ballpit, Particles | — |
collide |
Particles notice each other. The one non-linear part of the kernel, so it is opt-in | Ballpit | — |
spawn, angleEmit, spray |
Emitters: one particle, a directed cone, or an undirected scatter | Fireworks, Ballpit | — |
age, render |
Life counts down and brightness rides it, so a particle fades as it dies | Fireworks, Ballpit | — |
FrameTime |
Converts elapsed time into a per-frame scale, so the same settings behave identically at 60 fps and at 5000 | Fireworks, Ballpit, Particles, Echo, BouncingBalls, Lissajous, Tetrix | — |
Shaders¶
One function of (position, time) evaluated per pixel — the other way to write an effect.
Everything above draws into a grid: set this pixel, walk this line, move this row. A shader inverts that — it never draws anything, it answers a question. Given where a pixel is and what time it is, what colour is it? The framework runs that function everywhere.
That inversion is why shaders compose so freely. There is no state to keep in step and no order of operations to get right, so an effect is built by transforming the coordinate before answering: fold space and one shape becomes a thousand, rotate it and the whole design turns, displace it by a noise field and everything flows.
shader.h is the standard GLSL vocabulary in fixed point, deliberately using the familiar names so anyone who has read shader code needs no translation. It runs on every target.
| Power function | What it does | Effects | Modifiers |
|---|---|---|---|
each |
The runner: supply one function of position and time, and it handles the loop, the coordinate mapping and the write | Truchet, Raymarch | — |
uv |
Pixel to shader space, centred and scaled by the SHORT side — which is what keeps a circle circular on a non-square panel | Truchet, Raymarch | — |
clamp, mix, fract, step, smoothstep |
The five built-ins in essentially every shader. fract is the one that tiles a pattern; smoothstep is the one that anti-aliases an edge |
Truchet | — |
length, rotate |
Vector basics. Rotating the coordinate spins the entire design for one operation | Truchet | — |
repeat, mirror |
Domain operators: fold space so one shape becomes a lattice. The objects do not multiply — the coordinate does the work | Truchet | — |
opUnion, opIntersect, opSubtract, opShell, opRound |
Combine two shapes into a third, which is how an SDF scene is composed rather than drawn | Truchet | — |
sdRoundBox, sdPolygon |
Shapes beyond the circle/box/segment trio in Signed distance fields | (no caller yet) | — |
cosPalette, mixColor |
A whole colour ramp as twelve numbers instead of a table | (no caller yet) | — |
Raymarching — one technique inside a shader¶
Raymarching is one technique a shader can use, for rendering 3D. A scene is described as a function: say how far the nearest surface is from any point, and the renderer walks a ray outward until it arrives. The world is arithmetic — geometry emerges from the distance function rather than being stored.
raymarch.h is compiled only where the SoC declares a hardware FPU, because a raymarch is per-pixel float by nature. That gate is the one bounded exception to the integer-only render path, and it is a whole-header switch rather than a rule weakened in place. Everything in shader.h stays fixed point and runs everywhere.
| Power function | What it does | Effects | Modifiers |
|---|---|---|---|
march |
Sphere tracing: walk a ray until it hits. Takes the scene as a callable, so that function is the world | Raymarch | — |
normalAt |
The surface normal as the gradient of the distance field — which is why lighting works on a shape that was never modelled | Raymarch | — |
sdSphere, sdBox, sdPlane, sdTorus |
3D distance primitives, same sign contract as the 2D family | Raymarch | — |
smin, opUnion, opIntersect, opSubtract, opRepeat |
The 3D operators. smin melts surfaces together; opRepeat tiles space into an endless lattice |
Raymarch | — |
Camera, diffuse |
Where the viewer stands and how a surface is lit — the parts every raymarch effect would otherwise re-derive | Raymarch | — |
Gather¶
Reading the grid back as a texture.
Everything else writes; this reads. Once a frame can be sampled at an arbitrary sub-pixel coordinate, a whole family follows from a few lines each: feedback and motion trails, zoom, rotation, tunnels, plasma warping. Without it every one of those needs its own bespoke loop.
| Power function | What it does | Effects | Modifiers |
|---|---|---|---|
sampleWrap |
Reads the grid as a texture at a sub-pixel coordinate, bilinear and wrapping — the primitive behind feedback, tunnels and zoom | Echo | — |
combineMax |
Combines two colors by the brighter channel, so a trail brightens instead of averaging away | (no caller yet) | — |
On the "no caller yet" entries. Each was added for a named consumer in the power-function plan: splat, combineMax and the remaining SDF and easing forms are what the particle kernel and the shader tier build on. They are listed rather than hidden so the gap between what exists and what is used stays visible.