diff --git a/CLAUDE.md b/CLAUDE.md index 56d5501c..9d644695 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,10 @@ A high-performance system driving large LED installations and DMX fixtures. One 5. **The whole repo, continuously.** We are responsible for every line in the repository, not only the lines changed today. Anything spotted in passing is ours: a British spelling, a stale comment, a doc describing what the code no longer does, a duplicated block, a test pinning the wrong contract. Fix it in the change that found it, or backlog it by name; walking past a defect you have read is what lets debt accumulate. "Pre-existing", "out of scope" and "not mine" say nothing about whether the code is right, and the next reader meets it unchanged. The one thing provenance IS good for is scope: work belonging to another branch is backlogged rather than smuggled into this one. (Applied to review findings in [§ Handling review findings](#commit).) + **Never say "it is not mine".** For anything a check can find and a one-line edit can fix, an em-dash, a British spelling, a typo, JUST FIX IT, in the same edit that found it. Do not report it, do not ask, do not explain whose line it was: saying it costs more of the product owner's time than fixing it. Provenance is worth a sentence only when the fix is large enough to need its own decision. + + **Scope: the files this change is already editing, not the repo.** "In passing" means a file already open for another reason. A repo-wide sweep for the same defect is its own change with its own review, and folding one into a feature branch buries the feature in noise. A blanket find-and-replace is also how a symbol gets renamed by accident: a spelling fix once rewrote an API name inside `draw.h` and broke two effects that called it, because the word was part of an identifier rather than prose. Read what an edit touches before making it. + 6. **Robustness.** Unbreakable in use: any input, any order, any size. Degrade visibly, never crash, and every discovered crash becomes a test. Every setting applies live; no reboot to apply configuration ([architecture.md § Live reconfiguration](docs/architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)). Out of scope: power loss, brown-out, corrupted updates. ## The Process @@ -194,7 +198,23 @@ the fastest loop, and anything the desktop can prove (UI, logic, tests) is prove through a multi-minute compile and a 60-second flash. A device build comes after the desktop is clean, and only for what the desktop cannot show: the platform layer, timing, memory, real hardware. -**Bench boards are free test rigs.** Build and flash freely to verify work; re-probe ports first. A *rigorous* change (anything that could brick, boot-loop, or wipe a board: flash erases, boot/partition/build-config changes, a first flash of an untested board) gets a one-sentence heads-up and a go-ahead first — the test is reversibility. +**ESP32 build and flash: ONLY when the product owner approves.** Not "when it seems useful", not to +confirm something compiles, not at the end of a phase, not to take a measurement the agent thinks is +interesting. The PO says when a board is written to, every time. Ask, then wait. This is the rule an +agent breaks by being helpful, and it has been broken repeatedly in one session. + +**Desktop build and test: only when needed as a prerequisite to continue.** A build earns its place +when the next step cannot happen without it: code that must compile before it can be measured, a test +that must run before its result can be read. Not after every edit, and not to re-confirm what the +last build already proved. + +**Fast cycles: ASK before running anything slow.** Applies to every expensive step: ESP32 builds, +full scenario sweeps, gate lists, repo-wide sweeps, `collect_kpi`. Run the cheapest thing that +answers the question at hand (one test case, one scenario by name, one check); when the heavy one is +actually needed, say what it is and why, then wait for the go-ahead. A minute per step compounds +across a session into the PO waiting instead of working, and a sweep run twice wastes it twice. + +**Bench boards cost nothing to break, but they cost the PO's time to use.** They are free test rigs in the sense that matters for RISK: nothing on them is precious, so verifying on one needs no ceremony. They are not free in TIME, which is why the flashing rule above stands: the PO says when a board is written to. Re-probe ports first, since they drift between sessions. A *rigorous* change (anything that could brick, boot-loop, or wipe a board: flash erases, boot/partition/build-config changes, a first flash of an untested board) needs a one-sentence heads-up on top of the normal go-ahead, because there the test is reversibility rather than time. **Invite the product owner to test, then STOP.** If the PO could see or judge the result, hand it over ("running on X, look at Y") and wait for their observation before concluding, documenting, or moving on. Leave the state running; don't revert, reflash, or reconfigure what they were about to look at. diff --git a/README.md b/README.md index 1c8425df..5d92e9e3 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,7 @@ Specific people whose work directly shaped parts of projectMM. We study their th - **[WLED](https://github.com/wled/WLED) and [WLED-MM](https://github.com/MoonModules/WLED)**: projectMM is born out of WLED, and takes the usermod idea to a new level. Here *everything* is a mod (a MoonModule): effects, drivers, networking, the file system, the system manager. It also integrates tightly with WLED: a projectMM device can act as a WLED device, and it talks to WLED devices (audio sync, discovery, and more). - **Frank ([softhack007](https://github.com/softhack007))**: main author of the WLED-MM audio-reactive usermod, the most-used open-source audio-reactive LED implementation. The ideas behind [AudioService](docs/moonmodules/core/moxygen/AudioService.md) (including the adaptive noise-gate concept, analyzed with his permission) descend from years of collaboration on WLED-SR / WLED-MM. He also inspired the [Flying Toasters](docs/moonmodules/light/effects.md#flyingtoasters) effect and the sprite support behind it. - **[troyhacks](https://github.com/troyhacks/WLED)**: reworked the WLED-MM audio-reactive DSP to run on Espressif's [esp-dsp](https://github.com/espressif/esp-dsp) FFT (a low-latency, "stupid fast" alternative to ArduinoFFT); the same esp-dsp FFT choice [AudioService](docs/moonmodules/core/moxygen/AudioService.md) makes. See its Prior art notes. +- **[Stefan Petrick](https://github.com/StefanPetrick)**: the generative-field vocabulary the LED world learned from [Animartrix](https://github.com/StefanPetrick/animartrix), [FunkyNoise](https://github.com/StefanPetrick/FunkyNoise) and [ColorTrails](https://github.com/StefanPetrick/ColorTrails): noise read in polar coordinates, layers on independent oscillators, a contrast window that turns a field into curtains, and emitters carried by a flow field. [Aurora](docs/moonmodules/light/effects.md#aurora), [PolarNoise](docs/moonmodules/light/effects.md#polarnoise), [Tunnel](docs/moonmodules/light/effects.md#tunnel) and [Trails](docs/moonmodules/light/effects.md#trails) are written here on the published algorithms underneath (Perlin's noise, Quilez's domain warping, Bridson's curl, Stam's fluids). Stefan brought that shader vocabulary to LED panels and showed what it does there, which is the tradition the [power functions](docs/moonmodules/light/power-functions.md) and these effects sit in. - **[hpwit](https://github.com/hpwit) (Yves Bazin)**: the clockless I2S / RMT / Parlio LED-driver techniques and the [ESPLiveScript](https://github.com/hpwit/ESPLiveScript) live-script engine behind the LED drivers and MoonLive. - **Christophe Gagnier ([@Moustachauve](https://github.com/Moustachauve))**: author of the native [WLED-Android](https://github.com/Moustachauve/WLED-Android) and [WLED-iOS](https://github.com/Moustachauve/WLED-iOS) apps. Their source let us reverse-engineer exactly what those apps read, so projectMM devices appear in (and are controllable from) the native WLED apps. - **The [Improv Wi-Fi](https://github.com/improv-wifi) project**: the open Improv serial provisioning standard ([sdk-cpp](https://github.com/improv-wifi/sdk-cpp) / [sdk-js](https://github.com/improv-wifi/sdk-js)) that the projectMM web installer uses to provision a freshly-flashed device over USB. diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index 948d3fcd..bd0500d3 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -24,6 +24,18 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul ## Unreleased (`next-iteration`) +### Noise2D is gone; Noise renders it + +**Action: re-set one control.** Affects any device with a Noise2D effect on a layer. + +The two noise effects were one effect with two names: `Noise` is `Dim::D3` and draws the identical +field on a panel, so the 2D variant earned nothing. A restored config maps `Noise2DEffect` to +`NoiseEffect` and carries `scale` across. + +What does not carry is `speed`. Noise2D took a 0..15 divisor of its own; Noise takes its rate from +`bpm` on the shared beat clock, so there is no value to map onto. Set `bpm` to taste after +restoring. + ### Infrared is a list of learned rows, and the remote must be re-learned **Action: re-learn the remote.** Affects any device with a configured infrared service. diff --git a/docs/assets/light/Effects.gif b/docs/assets/light/Effects.gif new file mode 100644 index 00000000..41ac0d2b Binary files /dev/null and b/docs/assets/light/Effects.gif differ diff --git a/docs/assets/light/effects/AudioSpectrumEffect.gif b/docs/assets/light/effects/AudioSpectrumEffect.gif new file mode 100644 index 00000000..41fc762f Binary files /dev/null and b/docs/assets/light/effects/AudioSpectrumEffect.gif differ diff --git a/docs/assets/light/effects/AudioSpectrumEffect.png b/docs/assets/light/effects/AudioSpectrumEffect.png new file mode 100644 index 00000000..71434b0c Binary files /dev/null and b/docs/assets/light/effects/AudioSpectrumEffect.png differ diff --git a/docs/assets/light/effects/AudioVolumeEffect.gif b/docs/assets/light/effects/AudioVolumeEffect.gif new file mode 100644 index 00000000..fdc93033 Binary files /dev/null and b/docs/assets/light/effects/AudioVolumeEffect.gif differ diff --git a/docs/assets/light/effects/AudioVolumeEffect.png b/docs/assets/light/effects/AudioVolumeEffect.png new file mode 100644 index 00000000..00889855 Binary files /dev/null and b/docs/assets/light/effects/AudioVolumeEffect.png differ diff --git a/docs/assets/light/effects/AuroraEffect.gif b/docs/assets/light/effects/AuroraEffect.gif new file mode 100644 index 00000000..d739e536 Binary files /dev/null and b/docs/assets/light/effects/AuroraEffect.gif differ diff --git a/docs/assets/light/effects/AuroraEffect.png b/docs/assets/light/effects/AuroraEffect.png new file mode 100644 index 00000000..b12ca714 Binary files /dev/null and b/docs/assets/light/effects/AuroraEffect.png differ diff --git a/docs/assets/light/effects/BallpitEffect.gif b/docs/assets/light/effects/BallpitEffect.gif new file mode 100644 index 00000000..4e7b0403 Binary files /dev/null and b/docs/assets/light/effects/BallpitEffect.gif differ diff --git a/docs/assets/light/effects/BallpitEffect.png b/docs/assets/light/effects/BallpitEffect.png new file mode 100644 index 00000000..ac315a5b Binary files /dev/null and b/docs/assets/light/effects/BallpitEffect.png differ diff --git a/docs/assets/light/effects/BlurzEffect.gif b/docs/assets/light/effects/BlurzEffect.gif new file mode 100644 index 00000000..92bcce96 Binary files /dev/null and b/docs/assets/light/effects/BlurzEffect.gif differ diff --git a/docs/assets/light/effects/BlurzEffect.png b/docs/assets/light/effects/BlurzEffect.png new file mode 100644 index 00000000..10053a76 Binary files /dev/null and b/docs/assets/light/effects/BlurzEffect.png differ diff --git a/docs/assets/light/effects/BouncingBallsEffect.gif b/docs/assets/light/effects/BouncingBallsEffect.gif new file mode 100644 index 00000000..9d47c293 Binary files /dev/null and b/docs/assets/light/effects/BouncingBallsEffect.gif differ diff --git a/docs/assets/light/effects/BouncingBallsEffect.png b/docs/assets/light/effects/BouncingBallsEffect.png new file mode 100644 index 00000000..b922dda4 Binary files /dev/null and b/docs/assets/light/effects/BouncingBallsEffect.png differ diff --git a/docs/assets/light/effects/DemoReelEffect.gif b/docs/assets/light/effects/DemoReelEffect.gif new file mode 100644 index 00000000..7b4602cf Binary files /dev/null and b/docs/assets/light/effects/DemoReelEffect.gif differ diff --git a/docs/assets/light/effects/DemoReelEffect.png b/docs/assets/light/effects/DemoReelEffect.png new file mode 100644 index 00000000..1f908a3f Binary files /dev/null and b/docs/assets/light/effects/DemoReelEffect.png differ diff --git a/docs/assets/light/effects/DissolveEffect.gif b/docs/assets/light/effects/DissolveEffect.gif new file mode 100644 index 00000000..dd10bf4b Binary files /dev/null and b/docs/assets/light/effects/DissolveEffect.gif differ diff --git a/docs/assets/light/effects/DissolveEffect.png b/docs/assets/light/effects/DissolveEffect.png new file mode 100644 index 00000000..260b4617 Binary files /dev/null and b/docs/assets/light/effects/DissolveEffect.png differ diff --git a/docs/assets/light/effects/DistortionWavesEffect.gif b/docs/assets/light/effects/DistortionWavesEffect.gif new file mode 100644 index 00000000..8636085c Binary files /dev/null and b/docs/assets/light/effects/DistortionWavesEffect.gif differ diff --git a/docs/assets/light/effects/DistortionWavesEffect.png b/docs/assets/light/effects/DistortionWavesEffect.png new file mode 100644 index 00000000..89270881 Binary files /dev/null and b/docs/assets/light/effects/DistortionWavesEffect.png differ diff --git a/docs/assets/light/effects/EchoEffect.gif b/docs/assets/light/effects/EchoEffect.gif new file mode 100644 index 00000000..ad4b556c Binary files /dev/null and b/docs/assets/light/effects/EchoEffect.gif differ diff --git a/docs/assets/light/effects/EchoEffect.png b/docs/assets/light/effects/EchoEffect.png new file mode 100644 index 00000000..dece55b7 Binary files /dev/null and b/docs/assets/light/effects/EchoEffect.png differ diff --git a/docs/assets/light/effects/FireworksEffect.gif b/docs/assets/light/effects/FireworksEffect.gif new file mode 100644 index 00000000..e4e92d53 Binary files /dev/null and b/docs/assets/light/effects/FireworksEffect.gif differ diff --git a/docs/assets/light/effects/FireworksEffect.png b/docs/assets/light/effects/FireworksEffect.png new file mode 100644 index 00000000..bb93957f Binary files /dev/null and b/docs/assets/light/effects/FireworksEffect.png differ diff --git a/docs/assets/light/effects/FishTankEffect.gif b/docs/assets/light/effects/FishTankEffect.gif new file mode 100644 index 00000000..1434cbc3 Binary files /dev/null and b/docs/assets/light/effects/FishTankEffect.gif differ diff --git a/docs/assets/light/effects/FishTankEffect.png b/docs/assets/light/effects/FishTankEffect.png new file mode 100644 index 00000000..f92f9876 Binary files /dev/null and b/docs/assets/light/effects/FishTankEffect.png differ diff --git a/docs/assets/light/effects/FixedRectangleEffect.gif b/docs/assets/light/effects/FixedRectangleEffect.gif new file mode 100644 index 00000000..9bb2df03 Binary files /dev/null and b/docs/assets/light/effects/FixedRectangleEffect.gif differ diff --git a/docs/assets/light/effects/FixedRectangleEffect.png b/docs/assets/light/effects/FixedRectangleEffect.png new file mode 100644 index 00000000..ebde5551 Binary files /dev/null and b/docs/assets/light/effects/FixedRectangleEffect.png differ diff --git a/docs/assets/light/effects/FluidEffect.gif b/docs/assets/light/effects/FluidEffect.gif new file mode 100644 index 00000000..2b27c2ef Binary files /dev/null and b/docs/assets/light/effects/FluidEffect.gif differ diff --git a/docs/assets/light/effects/FluidEffect.png b/docs/assets/light/effects/FluidEffect.png new file mode 100644 index 00000000..b9348e8d Binary files /dev/null and b/docs/assets/light/effects/FluidEffect.png differ diff --git a/docs/assets/light/effects/FlyingToastersEffect.gif b/docs/assets/light/effects/FlyingToastersEffect.gif new file mode 100644 index 00000000..48a5ac18 Binary files /dev/null and b/docs/assets/light/effects/FlyingToastersEffect.gif differ diff --git a/docs/assets/light/effects/FlyingToastersEffect.png b/docs/assets/light/effects/FlyingToastersEffect.png new file mode 100644 index 00000000..9e427a43 Binary files /dev/null and b/docs/assets/light/effects/FlyingToastersEffect.png differ diff --git a/docs/assets/light/effects/FreqMatrixEffect.gif b/docs/assets/light/effects/FreqMatrixEffect.gif new file mode 100644 index 00000000..4fc8e3bc Binary files /dev/null and b/docs/assets/light/effects/FreqMatrixEffect.gif differ diff --git a/docs/assets/light/effects/FreqMatrixEffect.png b/docs/assets/light/effects/FreqMatrixEffect.png new file mode 100644 index 00000000..b0211034 Binary files /dev/null and b/docs/assets/light/effects/FreqMatrixEffect.png differ diff --git a/docs/assets/light/effects/FreqSawsEffect.gif b/docs/assets/light/effects/FreqSawsEffect.gif new file mode 100644 index 00000000..43f061ac Binary files /dev/null and b/docs/assets/light/effects/FreqSawsEffect.gif differ diff --git a/docs/assets/light/effects/FreqSawsEffect.png b/docs/assets/light/effects/FreqSawsEffect.png new file mode 100644 index 00000000..82b1d943 Binary files /dev/null and b/docs/assets/light/effects/FreqSawsEffect.png differ diff --git a/docs/assets/light/effects/GEQ3DEffect.gif b/docs/assets/light/effects/GEQ3DEffect.gif new file mode 100644 index 00000000..dc4a42d4 Binary files /dev/null and b/docs/assets/light/effects/GEQ3DEffect.gif differ diff --git a/docs/assets/light/effects/GEQ3DEffect.png b/docs/assets/light/effects/GEQ3DEffect.png new file mode 100644 index 00000000..73b369e1 Binary files /dev/null and b/docs/assets/light/effects/GEQ3DEffect.png differ diff --git a/docs/assets/light/effects/GEQEffect.gif b/docs/assets/light/effects/GEQEffect.gif new file mode 100644 index 00000000..5670ee0c Binary files /dev/null and b/docs/assets/light/effects/GEQEffect.gif differ diff --git a/docs/assets/light/effects/GEQEffect.png b/docs/assets/light/effects/GEQEffect.png new file mode 100644 index 00000000..2417bbd1 Binary files /dev/null and b/docs/assets/light/effects/GEQEffect.png differ diff --git a/docs/assets/light/effects/GameOfLifeEffect.gif b/docs/assets/light/effects/GameOfLifeEffect.gif new file mode 100644 index 00000000..e923893e Binary files /dev/null and b/docs/assets/light/effects/GameOfLifeEffect.gif differ diff --git a/docs/assets/light/effects/GameOfLifeEffect.png b/docs/assets/light/effects/GameOfLifeEffect.png new file mode 100644 index 00000000..762ecb94 Binary files /dev/null and b/docs/assets/light/effects/GameOfLifeEffect.png differ diff --git a/docs/assets/light/effects/LavaLampEffect.png b/docs/assets/light/effects/LavaLampEffect.png new file mode 100644 index 00000000..acb87413 Binary files /dev/null and b/docs/assets/light/effects/LavaLampEffect.png differ diff --git a/docs/assets/light/effects/LissajousEffect.gif b/docs/assets/light/effects/LissajousEffect.gif new file mode 100644 index 00000000..24ae0c78 Binary files /dev/null and b/docs/assets/light/effects/LissajousEffect.gif differ diff --git a/docs/assets/light/effects/LissajousEffect.png b/docs/assets/light/effects/LissajousEffect.png new file mode 100644 index 00000000..ad4f9fee Binary files /dev/null and b/docs/assets/light/effects/LissajousEffect.png differ diff --git a/docs/assets/light/effects/MoonLiveEffect.gif b/docs/assets/light/effects/MoonLiveEffect.gif new file mode 100644 index 00000000..41ac0d2b Binary files /dev/null and b/docs/assets/light/effects/MoonLiveEffect.gif differ diff --git a/docs/assets/light/effects/MoonLiveEffect.png b/docs/assets/light/effects/MoonLiveEffect.png new file mode 100644 index 00000000..61f3c5c0 Binary files /dev/null and b/docs/assets/light/effects/MoonLiveEffect.png differ diff --git a/docs/assets/light/effects/MovingHeadEffect.gif b/docs/assets/light/effects/MovingHeadEffect.gif new file mode 100644 index 00000000..fceed95f Binary files /dev/null and b/docs/assets/light/effects/MovingHeadEffect.gif differ diff --git a/docs/assets/light/effects/MovingHeadEffect.png b/docs/assets/light/effects/MovingHeadEffect.png new file mode 100644 index 00000000..d56ad567 Binary files /dev/null and b/docs/assets/light/effects/MovingHeadEffect.png differ diff --git a/docs/assets/light/effects/NebulaEffect.gif b/docs/assets/light/effects/NebulaEffect.gif new file mode 100644 index 00000000..54cde134 Binary files /dev/null and b/docs/assets/light/effects/NebulaEffect.gif differ diff --git a/docs/assets/light/effects/NebulaEffect.png b/docs/assets/light/effects/NebulaEffect.png new file mode 100644 index 00000000..af90ac50 Binary files /dev/null and b/docs/assets/light/effects/NebulaEffect.png differ diff --git a/docs/assets/light/effects/NetworkReceiveEffect.gif b/docs/assets/light/effects/NetworkReceiveEffect.gif new file mode 100644 index 00000000..41ac0d2b Binary files /dev/null and b/docs/assets/light/effects/NetworkReceiveEffect.gif differ diff --git a/docs/assets/light/effects/NetworkReceiveEffect.png b/docs/assets/light/effects/NetworkReceiveEffect.png new file mode 100644 index 00000000..5fb8c56f Binary files /dev/null and b/docs/assets/light/effects/NetworkReceiveEffect.png differ diff --git a/docs/assets/light/effects/NoiseEffect.png b/docs/assets/light/effects/NoiseEffect.png new file mode 100644 index 00000000..a0c99931 Binary files /dev/null and b/docs/assets/light/effects/NoiseEffect.png differ diff --git a/docs/assets/light/effects/NoiseMeterEffect.gif b/docs/assets/light/effects/NoiseMeterEffect.gif new file mode 100644 index 00000000..0debd1bf Binary files /dev/null and b/docs/assets/light/effects/NoiseMeterEffect.gif differ diff --git a/docs/assets/light/effects/NoiseMeterEffect.png b/docs/assets/light/effects/NoiseMeterEffect.png new file mode 100644 index 00000000..795201bc Binary files /dev/null and b/docs/assets/light/effects/NoiseMeterEffect.png differ diff --git a/docs/assets/light/effects/PacmanEffect.gif b/docs/assets/light/effects/PacmanEffect.gif new file mode 100644 index 00000000..1e363d66 Binary files /dev/null and b/docs/assets/light/effects/PacmanEffect.gif differ diff --git a/docs/assets/light/effects/PacmanEffect.png b/docs/assets/light/effects/PacmanEffect.png new file mode 100644 index 00000000..231c5573 Binary files /dev/null and b/docs/assets/light/effects/PacmanEffect.png differ diff --git a/docs/assets/light/effects/PaintBrushEffect.gif b/docs/assets/light/effects/PaintBrushEffect.gif new file mode 100644 index 00000000..167cae03 Binary files /dev/null and b/docs/assets/light/effects/PaintBrushEffect.gif differ diff --git a/docs/assets/light/effects/PaintBrushEffect.png b/docs/assets/light/effects/PaintBrushEffect.png new file mode 100644 index 00000000..e5aca31e Binary files /dev/null and b/docs/assets/light/effects/PaintBrushEffect.png differ diff --git a/docs/assets/light/effects/PlasmaEffect.png b/docs/assets/light/effects/PlasmaEffect.png new file mode 100644 index 00000000..0aa6e1d9 Binary files /dev/null and b/docs/assets/light/effects/PlasmaEffect.png differ diff --git a/docs/assets/light/effects/PolarNoiseEffect.gif b/docs/assets/light/effects/PolarNoiseEffect.gif new file mode 100644 index 00000000..8b7026fb Binary files /dev/null and b/docs/assets/light/effects/PolarNoiseEffect.gif differ diff --git a/docs/assets/light/effects/PolarNoiseEffect.png b/docs/assets/light/effects/PolarNoiseEffect.png new file mode 100644 index 00000000..8694aaca Binary files /dev/null and b/docs/assets/light/effects/PolarNoiseEffect.png differ diff --git a/docs/assets/light/effects/PongEffect.gif b/docs/assets/light/effects/PongEffect.gif new file mode 100644 index 00000000..96953575 Binary files /dev/null and b/docs/assets/light/effects/PongEffect.gif differ diff --git a/docs/assets/light/effects/PongEffect.png b/docs/assets/light/effects/PongEffect.png new file mode 100644 index 00000000..526ac930 Binary files /dev/null and b/docs/assets/light/effects/PongEffect.png differ diff --git a/docs/assets/light/effects/PraxisEffect.gif b/docs/assets/light/effects/PraxisEffect.gif new file mode 100644 index 00000000..db36eaee Binary files /dev/null and b/docs/assets/light/effects/PraxisEffect.gif differ diff --git a/docs/assets/light/effects/PraxisEffect.png b/docs/assets/light/effects/PraxisEffect.png new file mode 100644 index 00000000..ac30652a Binary files /dev/null and b/docs/assets/light/effects/PraxisEffect.png differ diff --git a/docs/assets/light/effects/RandomEffect.gif b/docs/assets/light/effects/RandomEffect.gif new file mode 100644 index 00000000..d16a5c73 Binary files /dev/null and b/docs/assets/light/effects/RandomEffect.gif differ diff --git a/docs/assets/light/effects/RandomEffect.png b/docs/assets/light/effects/RandomEffect.png new file mode 100644 index 00000000..3e2a4c61 Binary files /dev/null and b/docs/assets/light/effects/RandomEffect.png differ diff --git a/docs/assets/light/effects/RaymarchEffect.gif b/docs/assets/light/effects/RaymarchEffect.gif new file mode 100644 index 00000000..e26633e0 Binary files /dev/null and b/docs/assets/light/effects/RaymarchEffect.gif differ diff --git a/docs/assets/light/effects/RaymarchEffect.png b/docs/assets/light/effects/RaymarchEffect.png new file mode 100644 index 00000000..b8d2c890 Binary files /dev/null and b/docs/assets/light/effects/RaymarchEffect.png differ diff --git a/docs/assets/light/effects/RingsEffect.png b/docs/assets/light/effects/RingsEffect.png new file mode 100644 index 00000000..1109afe3 Binary files /dev/null and b/docs/assets/light/effects/RingsEffect.png differ diff --git a/docs/assets/light/effects/RipplesEffect.png b/docs/assets/light/effects/RipplesEffect.png new file mode 100644 index 00000000..6f9a77dd Binary files /dev/null and b/docs/assets/light/effects/RipplesEffect.png differ diff --git a/docs/assets/light/effects/RubiksCubeEffect.gif b/docs/assets/light/effects/RubiksCubeEffect.gif new file mode 100644 index 00000000..86bbaa0b Binary files /dev/null and b/docs/assets/light/effects/RubiksCubeEffect.gif differ diff --git a/docs/assets/light/effects/RubiksCubeEffect.png b/docs/assets/light/effects/RubiksCubeEffect.png new file mode 100644 index 00000000..209a03c7 Binary files /dev/null and b/docs/assets/light/effects/RubiksCubeEffect.png differ diff --git a/docs/assets/light/effects/SdfShapesEffect.gif b/docs/assets/light/effects/SdfShapesEffect.gif new file mode 100644 index 00000000..820dbb85 Binary files /dev/null and b/docs/assets/light/effects/SdfShapesEffect.gif differ diff --git a/docs/assets/light/effects/SdfShapesEffect.png b/docs/assets/light/effects/SdfShapesEffect.png new file mode 100644 index 00000000..d3c7ab61 Binary files /dev/null and b/docs/assets/light/effects/SdfShapesEffect.png differ diff --git a/docs/assets/light/effects/SineEffect.gif b/docs/assets/light/effects/SineEffect.gif new file mode 100644 index 00000000..ac8d5d0c Binary files /dev/null and b/docs/assets/light/effects/SineEffect.gif differ diff --git a/docs/assets/light/effects/SineEffect.png b/docs/assets/light/effects/SineEffect.png new file mode 100644 index 00000000..277fbddc Binary files /dev/null and b/docs/assets/light/effects/SineEffect.png differ diff --git a/docs/assets/light/effects/SolidEffect.gif b/docs/assets/light/effects/SolidEffect.gif new file mode 100644 index 00000000..8e447d36 Binary files /dev/null and b/docs/assets/light/effects/SolidEffect.gif differ diff --git a/docs/assets/light/effects/SolidEffect.png b/docs/assets/light/effects/SolidEffect.png new file mode 100644 index 00000000..024b8d06 Binary files /dev/null and b/docs/assets/light/effects/SolidEffect.png differ diff --git a/docs/assets/light/effects/SpaceInvadersEffect.gif b/docs/assets/light/effects/SpaceInvadersEffect.gif new file mode 100644 index 00000000..8d6b3d1d Binary files /dev/null and b/docs/assets/light/effects/SpaceInvadersEffect.gif differ diff --git a/docs/assets/light/effects/SpaceInvadersEffect.png b/docs/assets/light/effects/SpaceInvadersEffect.png new file mode 100644 index 00000000..f9a7725c Binary files /dev/null and b/docs/assets/light/effects/SpaceInvadersEffect.png differ diff --git a/docs/assets/light/effects/SpectrumEffect.gif b/docs/assets/light/effects/SpectrumEffect.gif new file mode 100644 index 00000000..896e9f48 Binary files /dev/null and b/docs/assets/light/effects/SpectrumEffect.gif differ diff --git a/docs/assets/light/effects/SpectrumEffect.png b/docs/assets/light/effects/SpectrumEffect.png new file mode 100644 index 00000000..46882fb6 Binary files /dev/null and b/docs/assets/light/effects/SpectrumEffect.png differ diff --git a/docs/assets/light/effects/SphereMoveEffect.gif b/docs/assets/light/effects/SphereMoveEffect.gif new file mode 100644 index 00000000..8d9c1674 Binary files /dev/null and b/docs/assets/light/effects/SphereMoveEffect.gif differ diff --git a/docs/assets/light/effects/SphereMoveEffect.png b/docs/assets/light/effects/SphereMoveEffect.png new file mode 100644 index 00000000..7d0f409d Binary files /dev/null and b/docs/assets/light/effects/SphereMoveEffect.png differ diff --git a/docs/assets/light/effects/SpriteFountainEffect.gif b/docs/assets/light/effects/SpriteFountainEffect.gif new file mode 100644 index 00000000..119411ba Binary files /dev/null and b/docs/assets/light/effects/SpriteFountainEffect.gif differ diff --git a/docs/assets/light/effects/SpriteFountainEffect.png b/docs/assets/light/effects/SpriteFountainEffect.png new file mode 100644 index 00000000..a293f443 Binary files /dev/null and b/docs/assets/light/effects/SpriteFountainEffect.png differ diff --git a/docs/assets/light/effects/StarFieldEffect.gif b/docs/assets/light/effects/StarFieldEffect.gif new file mode 100644 index 00000000..aac3e266 Binary files /dev/null and b/docs/assets/light/effects/StarFieldEffect.gif differ diff --git a/docs/assets/light/effects/StarFieldEffect.png b/docs/assets/light/effects/StarFieldEffect.png new file mode 100644 index 00000000..dced538b Binary files /dev/null and b/docs/assets/light/effects/StarFieldEffect.png differ diff --git a/docs/assets/light/effects/StarSkyEffect.png b/docs/assets/light/effects/StarSkyEffect.png new file mode 100644 index 00000000..1ba656c8 Binary files /dev/null and b/docs/assets/light/effects/StarSkyEffect.png differ diff --git a/docs/assets/light/effects/TetrixEffect.gif b/docs/assets/light/effects/TetrixEffect.gif new file mode 100644 index 00000000..20ab9186 Binary files /dev/null and b/docs/assets/light/effects/TetrixEffect.gif differ diff --git a/docs/assets/light/effects/TetrixEffect.png b/docs/assets/light/effects/TetrixEffect.png new file mode 100644 index 00000000..9f8e77b3 Binary files /dev/null and b/docs/assets/light/effects/TetrixEffect.png differ diff --git a/docs/assets/light/effects/TextEffect.gif b/docs/assets/light/effects/TextEffect.gif new file mode 100644 index 00000000..5eaeb38b Binary files /dev/null and b/docs/assets/light/effects/TextEffect.gif differ diff --git a/docs/assets/light/effects/TextEffect.png b/docs/assets/light/effects/TextEffect.png new file mode 100644 index 00000000..f72e3eda Binary files /dev/null and b/docs/assets/light/effects/TextEffect.png differ diff --git a/docs/assets/light/effects/TrailsEffect.gif b/docs/assets/light/effects/TrailsEffect.gif new file mode 100644 index 00000000..8eb87dc0 Binary files /dev/null and b/docs/assets/light/effects/TrailsEffect.gif differ diff --git a/docs/assets/light/effects/TrailsEffect.png b/docs/assets/light/effects/TrailsEffect.png new file mode 100644 index 00000000..6f164f5f Binary files /dev/null and b/docs/assets/light/effects/TrailsEffect.png differ diff --git a/docs/assets/light/effects/TruchetEffect.gif b/docs/assets/light/effects/TruchetEffect.gif new file mode 100644 index 00000000..feeb6edd Binary files /dev/null and b/docs/assets/light/effects/TruchetEffect.gif differ diff --git a/docs/assets/light/effects/TruchetEffect.png b/docs/assets/light/effects/TruchetEffect.png new file mode 100644 index 00000000..e1bde9e4 Binary files /dev/null and b/docs/assets/light/effects/TruchetEffect.png differ diff --git a/docs/assets/light/effects/TunnelEffect.gif b/docs/assets/light/effects/TunnelEffect.gif new file mode 100644 index 00000000..870e625c Binary files /dev/null and b/docs/assets/light/effects/TunnelEffect.gif differ diff --git a/docs/assets/light/effects/TunnelEffect.png b/docs/assets/light/effects/TunnelEffect.png new file mode 100644 index 00000000..904899be Binary files /dev/null and b/docs/assets/light/effects/TunnelEffect.png differ diff --git a/docs/assets/light/effects/VectorBallsEffect.gif b/docs/assets/light/effects/VectorBallsEffect.gif new file mode 100644 index 00000000..7ade5905 Binary files /dev/null and b/docs/assets/light/effects/VectorBallsEffect.gif differ diff --git a/docs/assets/light/effects/VectorBallsEffect.png b/docs/assets/light/effects/VectorBallsEffect.png new file mode 100644 index 00000000..decab9a8 Binary files /dev/null and b/docs/assets/light/effects/VectorBallsEffect.png differ diff --git a/docs/assets/light/effects/WaterRippleEffect.gif b/docs/assets/light/effects/WaterRippleEffect.gif new file mode 100644 index 00000000..d383fa54 Binary files /dev/null and b/docs/assets/light/effects/WaterRippleEffect.gif differ diff --git a/docs/assets/light/effects/WaterRippleEffect.png b/docs/assets/light/effects/WaterRippleEffect.png new file mode 100644 index 00000000..0d30866c Binary files /dev/null and b/docs/assets/light/effects/WaterRippleEffect.png differ diff --git a/docs/assets/light/effects/WaveEffect.gif b/docs/assets/light/effects/WaveEffect.gif new file mode 100644 index 00000000..5203dda1 Binary files /dev/null and b/docs/assets/light/effects/WaveEffect.gif differ diff --git a/docs/assets/light/effects/WaveEffect.png b/docs/assets/light/effects/WaveEffect.png new file mode 100644 index 00000000..a1a63fae Binary files /dev/null and b/docs/assets/light/effects/WaveEffect.png differ diff --git a/docs/assets/light/modifiers/BlockModifier.gif b/docs/assets/light/modifiers/BlockModifier.gif new file mode 100644 index 00000000..41ac0d2b Binary files /dev/null and b/docs/assets/light/modifiers/BlockModifier.gif differ diff --git a/docs/assets/light/modifiers/BlockModifier.png b/docs/assets/light/modifiers/BlockModifier.png new file mode 100644 index 00000000..149b4a2f Binary files /dev/null and b/docs/assets/light/modifiers/BlockModifier.png differ diff --git a/docs/assets/light/modifiers/CheckerboardModifier.png b/docs/assets/light/modifiers/CheckerboardModifier.png new file mode 100644 index 00000000..1e8b1aff Binary files /dev/null and b/docs/assets/light/modifiers/CheckerboardModifier.png differ diff --git a/docs/assets/light/modifiers/CircleModifier.gif b/docs/assets/light/modifiers/CircleModifier.gif new file mode 100644 index 00000000..41ac0d2b Binary files /dev/null and b/docs/assets/light/modifiers/CircleModifier.gif differ diff --git a/docs/assets/light/modifiers/CircleModifier.png b/docs/assets/light/modifiers/CircleModifier.png new file mode 100644 index 00000000..e4196d92 Binary files /dev/null and b/docs/assets/light/modifiers/CircleModifier.png differ diff --git a/docs/assets/light/modifiers/MirrorModifier.png b/docs/assets/light/modifiers/MirrorModifier.png new file mode 100644 index 00000000..d731481c Binary files /dev/null and b/docs/assets/light/modifiers/MirrorModifier.png differ diff --git a/docs/assets/light/modifiers/MoonLiveModifier.gif b/docs/assets/light/modifiers/MoonLiveModifier.gif new file mode 100644 index 00000000..41ac0d2b Binary files /dev/null and b/docs/assets/light/modifiers/MoonLiveModifier.gif differ diff --git a/docs/assets/light/modifiers/MoonLiveModifier.png b/docs/assets/light/modifiers/MoonLiveModifier.png new file mode 100644 index 00000000..e6a72a5c Binary files /dev/null and b/docs/assets/light/modifiers/MoonLiveModifier.png differ diff --git a/docs/assets/light/modifiers/PinwheelModifier.gif b/docs/assets/light/modifiers/PinwheelModifier.gif new file mode 100644 index 00000000..41ac0d2b Binary files /dev/null and b/docs/assets/light/modifiers/PinwheelModifier.gif differ diff --git a/docs/assets/light/modifiers/PinwheelModifier.png b/docs/assets/light/modifiers/PinwheelModifier.png new file mode 100644 index 00000000..0340d1c5 Binary files /dev/null and b/docs/assets/light/modifiers/PinwheelModifier.png differ diff --git a/docs/assets/light/modifiers/RandomMapModifier.gif b/docs/assets/light/modifiers/RandomMapModifier.gif new file mode 100644 index 00000000..41ac0d2b Binary files /dev/null and b/docs/assets/light/modifiers/RandomMapModifier.gif differ diff --git a/docs/assets/light/modifiers/RandomMapModifier.png b/docs/assets/light/modifiers/RandomMapModifier.png new file mode 100644 index 00000000..e4319ef3 Binary files /dev/null and b/docs/assets/light/modifiers/RandomMapModifier.png differ diff --git a/docs/assets/light/modifiers/RegionModifier.gif b/docs/assets/light/modifiers/RegionModifier.gif new file mode 100644 index 00000000..41ac0d2b Binary files /dev/null and b/docs/assets/light/modifiers/RegionModifier.gif differ diff --git a/docs/assets/light/modifiers/RegionModifier.png b/docs/assets/light/modifiers/RegionModifier.png new file mode 100644 index 00000000..b88b75d5 Binary files /dev/null and b/docs/assets/light/modifiers/RegionModifier.png differ diff --git a/docs/assets/light/modifiers/RippleXZModifier.gif b/docs/assets/light/modifiers/RippleXZModifier.gif new file mode 100644 index 00000000..41ac0d2b Binary files /dev/null and b/docs/assets/light/modifiers/RippleXZModifier.gif differ diff --git a/docs/assets/light/modifiers/RippleXZModifier.png b/docs/assets/light/modifiers/RippleXZModifier.png new file mode 100644 index 00000000..329c9298 Binary files /dev/null and b/docs/assets/light/modifiers/RippleXZModifier.png differ diff --git a/docs/assets/light/modifiers/RotateModifier.gif b/docs/assets/light/modifiers/RotateModifier.gif new file mode 100644 index 00000000..41ac0d2b Binary files /dev/null and b/docs/assets/light/modifiers/RotateModifier.gif differ diff --git a/docs/assets/light/modifiers/RotateModifier.png b/docs/assets/light/modifiers/RotateModifier.png new file mode 100644 index 00000000..0cdae11b Binary files /dev/null and b/docs/assets/light/modifiers/RotateModifier.png differ diff --git a/docs/assets/light/modifiers/TransposeModifier.gif b/docs/assets/light/modifiers/TransposeModifier.gif new file mode 100644 index 00000000..41ac0d2b Binary files /dev/null and b/docs/assets/light/modifiers/TransposeModifier.gif differ diff --git a/docs/assets/light/modifiers/TransposeModifier.png b/docs/assets/light/modifiers/TransposeModifier.png new file mode 100644 index 00000000..281940c6 Binary files /dev/null and b/docs/assets/light/modifiers/TransposeModifier.png differ diff --git a/docs/assets/light/tutorial/aurora.png b/docs/assets/light/tutorial/aurora.png new file mode 100644 index 00000000..71272940 Binary files /dev/null and b/docs/assets/light/tutorial/aurora.png differ diff --git a/docs/assets/light/tutorial/fluid.png b/docs/assets/light/tutorial/fluid.png new file mode 100644 index 00000000..15a92906 Binary files /dev/null and b/docs/assets/light/tutorial/fluid.png differ diff --git a/docs/assets/light/tutorial/nebula.png b/docs/assets/light/tutorial/nebula.png new file mode 100644 index 00000000..1b5d573a Binary files /dev/null and b/docs/assets/light/tutorial/nebula.png differ diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 6465d0b7..6f1030a2 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -173,6 +173,26 @@ of code is fine; a new IDF component is the expensive kind). the rename map and restore report) ships in the File Manager. Remaining: tier 2, a single-archive device endpoint (one request instead of a walk); tier 3, restore hosted on MoonBase's page, the migration answer for future partition-table moves. +- **Restore clones a device's IDENTITY along with its config** (found answering + [#76](https://github.com/MoonModules/projectMM/issues/76), 2026-09-04). Backup bundles every + file including hidden `.config`, and restore writes them all back unfiltered, so restoring one + device's bundle onto another copies four things that must differ per device: + + | field | why it must differ | + |---|---| + | `deviceName` | the single network identity: mDNS hostname, SoftAP SSID and DHCP hostname all derive from it (`SystemModule.h`). Twelve clones all answer to `.local`, resolution goes non-deterministic, MoonDeck's device list collapses to one row | + | WiFi `password` | travels in the bundle (the UI button warns), so a shared or attached backup leaks it | + | a static IP | if set, every clone claims one address | + | `universeStart` and the Art-Net/DDP window | exactly what must differ per device in the light-pole case #76 describes: cloned, every pole shows the same thing | + + This turns "clone this pole to the other eleven" from the feature the issue wants into a trap. + **The fix is small and is a prerequisite for the recipe idea rather than a separate job:** restore + treats identity as per-device, either skipping those fields or prompting once with the target's + current values prefilled. Worth deciding whether the bundle should carry the secrets at all, or + keep an identity section the restoring device is expected to supply. +- **A backup is a file bundle, not a recipe** (#76 step 2): nothing binds it to a `deviceModel`, so + restoring a Dig-Quad bundle onto an S3 writes pin maps that do not fit the board. The 28 profiles + in `deviceModels.json` are the missing half. - **Firmware downgrade guard**: MoonBase installs whatever image it is given; a version display (read from the incoming image's app descriptor) before flashing would make an accidental downgrade visible. diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index 163f197c..75116d20 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -18,6 +18,13 @@ Forward-looking to-build items for the **light domain** (`src/light/`: drivers, so its motion speed is a property of the frame rate. **Build trigger**: a stall we cannot remove at its source, on hardware a user actually has. +## Effects + +### Moving-head effects from MoonLight, including two of troyhack's (2026-09-04) + +MoonLight has several moving-head effects that have no equivalent here, two of them troyhack's. +Migrate them all, on the power functions per the standing mandate rather than traced across. + ## Drivers ### Logarithmic brightness, and a power budget the device knows about (2026-09-02) diff --git a/docs/backlog/effects-power-function-inventory.md b/docs/backlog/effects-power-function-inventory.md new file mode 100644 index 00000000..9c10de5e --- /dev/null +++ b/docs/backlog/effects-power-function-inventory.md @@ -0,0 +1,67 @@ +# Effects × power functions — inventory + +> **Forward-looking backlog document — exception to CLAUDE.md present-tense rule.** One row per effect in the tree on 2026-09-03 (58), recording what each uses today and which power functions it could run on: the ones that exist ([power-functions.md](../moonmodules/light/power-functions.md)) and the ones the [generative-fields top-down](generative-fields-analysis-top-down.md) builds (gradient noise, `PolarLut`, `Oscillators`, `advect` and the velocity rules, `decay`, `lineAA`/`disc`; the 16-bit Layer was proposed and then reversed, top-down § 11). It is the input for the catalog sweep that top-down names as a follow-on plan, under the product owner's rule: **every effect runs on the power functions for whatever they cover; an effect that does not is rewritten on them; algorithmic effects keep their logic; a rewrite lands only if at least as beautiful as the effect ran before.** Rows shrink as effects are rewritten; a rewritten effect's row is deleted, not ticked. + +**Kind.** N = natural motion (physics, fields, noise, trails, rotation, oscillation): rewritten on the kernels. A = algorithmic (a rule set or a game is the effect): keeps its logic, draws through the library. M = audio meter (bars, levels, spectra): the geometry and ballistics rows apply. U = utility or test: no rewrite owed. Anything in doubt is N. + +**Uses today** is read from the source: `noise` (`inoise*`/`fbm`/`warp`), `polar` (`atan16`/`dist16`/`kaleido`), `beat` (`BeatPhase`/`beatsin`), `sin` (`sin16`/`sin8`), `fade` (`fadeToBlackBy`), `particles`, `splat`, `blur`, `geom` (`draw::line`/`rect`/`bar`), `shader`, `scratch` (`ScratchBuffer`), `rnd`, `pal` (`colorFromPalette`), `audio`, and **float** where the effect still computes per light in float. + +| Effect | Kind | Uses today | Could use, existing | Could use, to be built | Note | +|---|---|---|---|---|---| +| AudioSpectrum | M | geom pal audio | `bar`, `smoothFollow`, `peakHold` | 16-bit bars (smooth tops) | | +| AudioVolume | M | pal audio | `smoothFollow` | `decay`, `Oscillators` for the idle breathe | writes raw bytes: migrates to `draw::` in the 16-bit phase | +| Ballpit | N | particles scratch rnd pal | on `particles` already | 16-bit `splat`, `decay` | | +| Blurz | N | fade blur rnd pal audio **float** | `disc` via `coverage`, `blur` | `decay` by half-life, `disc`, `Oscillators`; float to fixed | | +| BouncingBalls | N | fade scratch rnd pal **float** | `particles` (mandated: gains inter-ball collisions), `splat` | `decay` | the power-functions top-down's "worth converging" is now "converge"; writes raw bytes | +| DemoReel | U | rnd | | | orchestration, not rendering | +| Dissolve | N | beat rnd pal | `hashInt`, `easeInOutQuad` already | `Oscillators` for the timing | | +| DistortionWaves | N | beat sin pal **float** | `sin16`, `warp8` | `PolarLut`, `Oscillators`, `warp16`; float to fixed | a Family A shader in all but name | +| Echo | N | polar beat sin particles scratch pal | | `advect` with the spiral rule (zoom + rotation IS a velocity rule), `decay` | the prototype of Family B; becomes a few kernel calls | +| Fire | N | scratch rnd pal | `blur` for heat diffusion | 16-bit heat on the Layer, gradient noise for the sparks, `decay` | writes raw bytes | +| Fireworks | N | beat fade particles splat scratch rnd pal | on `particles` already | `decay` by half-life, `lineAA` for shell trails | | +| FishTank | N | beat particles scratch rnd pal audio | on `particles` for motion | `Oscillators` for tail and fin motion | sprite drawing stays | +| FixedRectangle | U | fade | | | test effect | +| FlyingToasters | A | beat particles scratch rnd audio | on `particles` for drift | | sprites; keeps its choreography | +| FreqMatrix | M | pal audio | `scroll` | 16-bit gradient, `decay` | | +| FreqSaws | M | beat fade pal audio | `bar` | `Oscillators` | its `invert` is a hidden mirror modifier (known extraction) | +| GEQ3D | M | beat fade geom pal audio | `bar`, `peakHold` | 16-bit bars | | +| GEQ | M | fade geom scratch pal audio | `bar`, `peakHold` | 16-bit bars | | +| GameOfLife | **A** | scratch rnd pal | `draw::pixel` only | | **kept as is**: the cautionary case; its logic is the effect | +| LavaLamp | N | beat pal | `blobField`, `smin` | 16-bit falloff, `decay`, `Oscillators` for blob paths | | +| Lines | U | | | | test effect; writes raw bytes | +| Lissajous | N | sin fade pal | `sin16`, `splat` | `lineAA`, `decay`, `Oscillators` | it is a Trails emitter; may be superseded by Trails | +| Metaballs | N | beat pal | `blobField`, `smin` | 16-bit field, `Oscillators` | | +| MovingHead | N | beat sin pal audio | `BeatPhase` | `Oscillators` for formations | fixture roles, not pixels | +| NetworkReceive | U | scratch | | widens into the 16-bit Layer | writes raw bytes by design | +| NoiseMeter | M | noise beat sin fade pal audio | `smoothFollow` | gradient noise, `Oscillators` | | +| Pacman | **A** | beat particles scratch rnd pal audio | on `particles` for motion | | game logic is the effect | +| PaintBrush | N | polar beat sin fade geom rnd pal audio **float** | `lineAA` via `splat` | `lineAA`, `decay`, `PolarLut`, `Oscillators`; float to fixed | | +| Particles | N | particles scratch rnd pal | on `particles` already | 16-bit `splat` | | +| Plasma | N | beat sin pal | `sin16` | `PolarLut`, `fbm`/`warp` as a shader, `Oscillators`, 16-bit | | +| Pong | **A** | beat rnd pal audio | | | game | +| Praxis | **A** | beat sin pal | | `Oscillators` | algorithmic palette pattern | +| Rainbow | N | pal | | 16-bit gradient (no banding), `Oscillators` | writes raw bytes; the default effect | +| Random | U | fade particles rnd pal | `hashInt` | | | +| Raymarch | N | beat sin shader pal **float** | on `raymarch.h` (FPU-gated by design) | 16-bit output | float is the design here | +| Rings | N | polar rnd pal | | `PolarLut`, `decay`, `Oscillators` | | +| Ripples | N | pal **float** | `dist16`, `sin16` | `PolarLut`, 16-bit; float to fixed | a radial shader | +| RubiksCube | **A** | rnd pal **float** | | | keeps its logic; its float rotation can go fixed later | +| SdfShapes | N | beat sin shader pal | on `shader.h` already | `Oscillators`, 16-bit coverage | | +| Sine | N | beat sin **float** | `sin16` | 16-bit; float to fixed | | +| Solid | U | scratch pal | | | | +| SpaceInvaders | **A** | beat rnd pal audio | | | game | +| Spectrum | M | geom scratch pal audio | `bar`, `smoothFollow`, `peakHold` already | 16-bit bars | | +| SphereMove | N | fade rnd pal **float** | `sdSphere` via `raymarch.h`/`shader.h` | 16-bit; float to fixed | | +| SpriteFountain | N | beat sin particles scratch rnd audio | on `particles` already | `decay` | | +| StarField | N | fade shader scratch rnd pal **float** | `shader::project` already | `splat`, `decay`; float to fixed | | +| StarSky | N | fade scratch rnd pal | `hashInt` | `Oscillators` for the twinkle, `decay` | the earlier verdict "not a particle" stands; oscillation is its mechanism | +| Tetrix | **A** | particles scratch rnd pal **float** | `FrameTime` | | state machine is the effect | +| Text | U | pal | `scroll` | 16-bit anti-aliased glyphs later | | +| Truchet | N | beat shader rnd pal | on `shader.h` already | `Oscillators` | | +| VectorBalls | N | beat sin shader pal | on `shader.h` already | 16-bit | | +| WaterRipple | N | scratch rnd pal | already a wide-state kernel | renders into the 16-bit Layer without narrowing; `disc` for drops; `decay` | | +| Wave | N | noise beat sin scratch pal | `sin16` | gradient noise, `Oscillators` | | + +**Counts.** 54 rows remain of the 58 the table opened with, the difference being effects since rewritten on the kernels and deleted per the rule above. 11 still compute per light in float outside the FPU-gated raymarch (BouncingBalls, Blurz, DistortionWaves, PaintBrush, Ripples, RubiksCube, Sine, SphereMove, StarField, Tetrix, Raymarch by design). 17 effect headers write `buffer()` bytes directly rather than going through `draw::`; that is a measured OPTIMIZATION on the hot path rather than debt (a row pointer costs 0.26 ns/light where `draw::pixel` with known coordinates costs +170%), so it is not a migration target. The 16-bit phase that was to migrate them was reversed (top-down § 11). + +**What the sweep would look like when it becomes a plan.** Batches by kernel, as each lands: the noise effects with phase 0; the polar and oscillation effects with phase 1; Echo, Lissajous, PaintBrush and the trail effects with `advect`/`decay`/`lineAA`; BouncingBalls onto `particles` on its own, since it is a rework with a bench judgment. Each batch under ~100 files, each effect compared against how it ran, the golden re-baselined with the reason. Effects a showcase supersedes (Lissajous by Trails, possibly Echo) are deleted rather than kept beside it. diff --git a/docs/backlog/generative-fields-analysis-bottom-up.md b/docs/backlog/generative-fields-analysis-bottom-up.md new file mode 100644 index 00000000..777ebdc2 --- /dev/null +++ b/docs/backlog/generative-fields-analysis-bottom-up.md @@ -0,0 +1,241 @@ +# Generative fields — bottom-up analysis + +> **Forward-looking research document — exception to CLAUDE.md present-tense rule.** A Stage-1 bottom-up analysis of the effect family this document names *generative fields*: images computed from noise fields over a coordinate mapping, either fresh every frame (a shader) or by transporting the previous frame through a velocity field (advection). Part 1 states what we want to build in the terms of the field's primary sources. Part 2 lists implementations that do similar things and where each fills in a spot. Part 3 places the family in projectMM's architecture: what exists, measured, and what is missing. The **top-down** companion (to be written from the prompt at the end) turns Part 3 into the implementation spec. Written 2026-09-03. Modeled on [power-functions-analysis-bottom-up.md](power-functions-analysis-bottom-up.md), which this extends: the fields, shader and particle families it cataloged are taken as read. + +## TL;DR + +> **Status (2026-09-05): the three gaps this document names are closed.** Advection shipped as +> `draw::advect`/`advect16`, gradient noise replaced value noise behind the same names, and color +> state above 8 bits shipped as effect-owned 16-bit planes rather than a wide Layer (the top-down's +> § 11 records why). The "What we need to add" list in Part 3 is therefore built, and the builtin +> count below (55) is now 67. The rest of the document is a Stage-1 snapshot and is left as written. + + +- **The target is two textbook techniques over one block set.** A **procedural shader**: color as a function of (position, time), computed per pixel from noise sampled through a coordinate transform. **Advection**: the previous frame transported by a velocity field and decayed, with sources drawn into it each frame. Both stand on the same primitives: a polar coordinate mapping, gradient noise with fractional Brownian motion and domain warping, low-frequency oscillators, a contrast window and palette, anti-aliased sub-pixel rasterization, a bilinear resampler, framerate-independent exponential decay, and fixed-point arithmetic with quantization last (Part 1). +- **Every algorithm has a name and an originator.** Perlin noise (1985, 2002), fBm, domain warping (Quilez), semi-Lagrangian advection and stable fluids (Stam 1999, 2003), curl noise (Bridson 2007), flow-field particle tracing (Hobbs; Shiffman), Wu's anti-aliased lines (1991), coverage from signed distance, exponential half-life decay, LFO modulation. None needs more than adds, multiplies, a sine table and a noise function (Part 1). +- **The cost model is arithmetic and decides the design.** A shader costs samples per pixel and scales with area × samples; advection costs a few loads and lerps per pixel per pass and scales with area × channels, plus memory for color state above 8 bits. For both, framerate is part of the rendering method: transport must stay sub-pixel per frame, decay must not step, oscillators must not alias. Throughput in pixels per second, not frames per second, is the honest metric (Part 1 § Cost). +- **The field has converged on the same shapes.** A polar-noise shader engine with an oscillator bank and a coordinate mapper; an emitter-plus-flow advection engine with six flow types, a modulator bank and a half-life fade; both now available in Q16.16 fixed point with bit-exact tests, on the ESP32 class we target; anti-aliased canvases; wave and fluid solvers; particle-trail systems. These are similar to what Part 1 describes, not identical, and each fills in a spot: measured throughput, parameter vocabularies users expect, fixed-point choices that work, and precision decisions (Part 2). +- **projectMM has most of the blocks and lacks three.** Present: polar addressing, 16-bit value noise with fBm/warp/turbulence, 16-bit oscillators, palettes, a GLSL-vocabulary shader runner, SDF coverage, a 24.8 sub-pixel splat, particles, a persisting Layer buffer, and 55 MoonLive builtins that already express a polar-noise pixel. Missing: **a bilinear resampler of the previous frame** (so no advection), **color state above 8 bits** (so trails posterize and sub-integer accumulation is impossible), and **gradient noise** (value noise reads coarser at low frequency). The MoonLive gap is structural: per-pixel host calls cost ~5 µs per pixel measured, and a script has no frame of state; both families want whole-frame kernels a script composes (Part 3). +- **The ESP32 budget is known.** ~293 cycles per pixel at 128×128 @ 50 fps on 240 MHz; one noise sample per pixel measures ~750 cycles on the S3 today. A rich shader is a panel-class effect on any MCU; advection scales to walls in cycles and needs PSRAM for state. Every target has an FPU, the P4 and S31 add SIMD and hardware loops, the P4 measures ~3× the S3 and a desktop core 20-40×, so the P4 and S31 are where this family shines on an MCU: the ESP32 class stops at about one noise sample per pixel on a 128² wall, and the desktop continues on the same contract and drives the wall over the network. The levers are standard: LUTs, fixed point, fewer samples, a field below output resolution, a field below frame rate, per-target FPU and SIMD behind one contract (Part 3 § Budget, § Per-target headroom). +- **Out of scope for Stage 1.** API names and signatures; the 8-bit versus 16-bit buffer decision; where advection state lives; the MoonLive frame-kernel shape; which showcase effects come first. All Stage 2 (Part 3 § Bridge). + +## Why this document exists + +The product owner's goals for this family, recorded 2026-09-03: + +1. **projectMM supports the building blocks of these effects**, via compiled functions and especially via MoonLive effects, on the power-function library ([power-functions.md](../moonmodules/light/power-functions.md)), the same way it carries particles and SDFs. +2. **A few genuinely beautiful showcase effects** are built on those blocks, as the proof the blocks are right. +3. **The ESP32 is CPU-bound; memory is not the constraint.** Every choice is made against the per-pixel cycle budget, and the fact that these effects look better the higher the framerate is a design input. +4. **Industry-standard terminology and algorithms throughout**, per [CLAUDE.md § Principles](../../CLAUDE.md#principles): the textbook construct, named by its textbook name, from the primary source. + +--- + +# Part 1: What we want to build + +Stated from the primary sources only. Where a name is given, it is the algorithm's name in the literature; where a person is named, it is the originator of the algorithm, not an implementation. + +## The two techniques + +**A procedural shader** computes each pixel's color as a function of its position and the current time: `color = f(x, y, t)`. Nothing has to survive between frames, which is what makes it composable; state is welcome whenever it saves work: a polar table cached per geometry, a field rendered below the frame rate and interpolated, the dithering error carried forward, a value fed back into the next frame's parameters. Composition happens on the *coordinate*: transform where the pixel samples from (rotate, scale, fold, displace by another field) and the image transforms with it. This is the shader model of the GPU tradition, applied on a CPU one pixel at a time. + +**Advection** transports a quantity (here: the color already in the frame) along a velocity field. Each frame: sources add color where they are drawn; every pixel's new value is the old value found by stepping *backward* along the velocity (the semi-Lagrangian method: Stam, *Stable Fluids*, SIGGRAPH 1999; *Real-Time Fluid Dynamics for Games*, GDC 2003), read with bilinear interpolation; and the result decays. The frame buffer is the simulation state. + +They differ in one property that decides everything downstream: a shader needs no state and its cost is samples per pixel, with state used to cut samples; advection is all state and its cost is a resample per pixel per pass plus the memory to hold color above 8 bits. They share every block below the top. + +## The blocks + +### Coordinate mapping + +- **Cartesian to polar.** For every pixel, `r = hypot(x − cx, y − cy)` and `θ = atan2(y − cy, x − cx)` about a center. Precomputed once per geometry into two tables; nothing trigonometric runs per pixel at render time except the sine and cosine of a *modulated* angle. Polar addressing is what makes motion rotate around a center rather than slide across the panel. +- **Polar transforms.** Add to θ to rotate; multiply r to zoom; add `k·r` to θ to twist into a spiral; fold θ into n mirrored wedges for a kaleidoscope (`θ' = |((θ mod 2π/n) − π/n)|`); take `1/r` for a tunnel. Each is one operation on the coordinate before sampling. +- **Domain warping.** Replace `f(p)` by `f(p + g(p))` where `g` is itself a noise field (Quilez, *Domain warping*). The canonical forms are `fbm(p + fbm(p))` and `fbm(p + fbm(p + fbm(p)))`; the intermediate displacement vectors are free color inputs. Applied in polar space, warping the angle makes the field swirl and warping the radius makes it breathe. +- **Normalized shader space.** Center the pixel and scale by the short side so a circle stays circular on a non-square panel; the GLSL `uv` convention. + +### Noise + +- **Gradient noise** (Perlin, *An Image Synthesizer*, 1985; *Improving Noise*, 2002): a pseudo-random gradient at each lattice corner, a dot product with the offset, a quintic fade, trilinear interpolation. Smooth to the first derivative, statistically isotropic, zero-mean. Simplex noise (Perlin 2001) is the same idea on a simplex lattice, cheaper in higher dimensions. **Value noise** interpolates random values at lattice corners instead; it is cheaper and visibly lumpier at low frequency because its extrema sit on the lattice. +- **Fractional Brownian motion (fBm).** Sum octaves at doubling frequency and halving amplitude (`lacunarity = 2`, `gain = 0.5`); structure at every scale. **Turbulence** sums the absolute value instead; the creases read as flame and smoke. Octave count is the primary cost knob. +- **Noise as a source of motion.** The third noise dimension advanced with time evolves a 2D field without scrolling it. A noise value mapped to an angle (`θ = 2π · noise`) is the classic flow-field construction (Hobbs, *Flow Fields*; Shiffman, *The Nature of Code*, autonomous agents). +- **Curl noise** (Bridson, Houriham, Nordenstam, *Curl-noise for procedural fluid flow*, SIGGRAPH 2007). In 2D the velocity is the perpendicular gradient of a scalar noise potential, `v = (∂ψ/∂y, −∂ψ/∂x)`: exactly divergence-free, so transported color swirls and never piles up, with two finite differences per pixel and no solver. + +### Time: oscillators and modulation + +- A **timer bank**: N independent clocks, each `phase_i = (t + offset_i) · ratio_i`, all scaled by one master speed so a single knob scales the whole animation. +- From each clock the four standard **LFO shapes**: a ramp (the phase itself, for scrolling and zoom), a wrapped phase in `[0, 2π)` (rotation), a bipolar sine in `[−1, 1]` (breathing), and a noise-driven angle `2π · noise(phase)` (wandering). Unipolar variants in `[0, 1]`. +- **Modulation**: a parameter bound to a clock and a depth, applied multiplicatively about its base value (`p' = p · (1 + depth · lfo)`), so nothing in the image is constant and every parameter can breathe. The synthesizer model, by its synthesizer names. +- **Framerate independence.** Every rate is per second, integrated with the measured `dt`; a virtual clock `t += dt · speed` keeps oscillators and decay in step under one speed control. + +### Color + +- **Contrast window**: clamp the field value to `[low, high]` and rescale; a black point and a white point, which is how a soft noise field becomes shapes with edges. +- **Palette mapping**: the windowed value indexes a gradient; or three field layers drive R, G, B directly; or a **cosine palette** (`a + b·cos(2π(c·v + d))`, Quilez) gives a whole ramp from twelve constants. +- **Gamma** as the last nonlinearity before output, and **quantization last**: compute in more than 8 bits, round once, with **temporal dithering** (error spread across frames) where the output is 8-bit. + +### Sources: anti-aliased rasterization + +Anything drawn directly into the frame, at fractional coordinates, additively: + +- A **disc** by coverage from its signed distance: `cov = clamp(radius + 0.5 − dist, 0, 1)` per pixel; edges anti-alias for free. +- A **line** by Wu's algorithm (1991) or its generalization: step along the segment and distribute each step over the 2×2 neighbors with bilinear weights `(1−fx)(1−fy), fx(1−fy), (1−fx)fy, fx·fy`. The same 2×2 **splat** places a point source at sub-pixel precision. +- Sources move on the oscillators above: orbits, Lissajous curves (`x = A·sin(a·t), y = B·sin(b·t + φ)`), swarms with Reynolds steering, and borders or masks that are static geometry. + +### Transport: the velocity field and the resampler + +- **Velocity as a rule per pixel**, evaluated at sample time, not stored: a uniform wind (direction, speed, optional rotation); radial in or out; a spiral (rotate by α, move radially by ρ, in polar space); zoned rings (smoothstep-blended radii, each with its own swirl or drift); noise-driven shift (two independent 1D noise profiles, one per axis, applied separably); curl noise; or a stored field from a solver. +- **The resampler**: for each destination pixel, `src = dst − v·dt`; read the previous frame at `src` with **bilinear interpolation** (four loads, three lerps per channel); **wrap** at edges for a seamless tile or **clamp** so color flows off. A separable field (row shift then column shift) halves the work: two 1D passes through a scratch buffer. +- **Partial transport**: `out = (1 − b)·current + b·sampled` mixes what arrives with what was there; it reads as viscosity without a solver. +- **Stable fluids** (Stam 1999) when real fluid behavior is wanted: diffuse velocity, project to divergence-free (Jacobi or Gauss-Seidel iterations on a Poisson equation), self-advect, project again, optionally reinject lost swirl by **vorticity confinement** (Fedkiw, Stam, Jensen 2001), then diffuse and advect the dye. Orders of magnitude costlier than a velocity rule; curl noise gets most of the look for none of the solve. + +### Decay + +- **Exponential decay by half-life**: `k = 0.5^(dt / t½)`, multiply every channel by `k` each frame. Framerate-independent by construction; a half-life in seconds is the user-facing knob. A per-frame constant multiply (the 8-bit "fade by" idiom) is the same thing only at one fixed framerate. +- **Blur** as a second decay, separable, for softening trails. + +### Arithmetic + +- **Fixed point** throughout the hot path: Q16.16 for coordinates, velocities and color state; angles as a fraction of a turn in an integer (a 16-bit or 24-bit turn) so sine and cosine are table lookups and wraparound is free; square roots avoided by comparing squares or by an integer `isqrt`. +- **Lookup tables** for everything per-geometry (polar), per-turn (sine, cosine), per-curve (fade, gamma). +- **Precision graded by role**: the noise inner loop tolerates 8 fractional bits at a quality cost; color state does not. + +## Cost + +Per pixel, a shader pays `samples × noise_cost + transform + window + palette`; rich compositions run 3 to 12 samples. Cost is `area × samples`. Advection pays, per pixel per pass, four loads, three lerps and one multiply per channel, and per frame the field rule; cost is `area × channels × passes`, independent of noise, plus color-state memory of `area × channels × 4 bytes` (and a scratch copy, and any stored field). A 16-bit or wider color state is not optional for advection: an 8-bit state cannot hold sub-integer accumulation from splats or a slow decay, and a trail fading through 256 levels posterizes at the low end. + +**Framerate is part of the rendering method.** Transport per frame is `v · dt`; when it exceeds about a pixel the bilinear filter smears instead of moving and edges tear. A decay of `0.5^(dt/t½)` with a short half-life becomes a strobe at low fps. Temporal dithering averages only above the eye's integration rate. Oscillators sampled below twice their rate alias. The consequence is a design rule: the per-frame cost must stay small enough that the frame rate stays high, and the metric to report is **pixels per second**, which is what the frame rate at a given area actually measures. + +The standard levers, in the order they are usually pulled: lookup tables; fixed point; fewer samples (octaves, warp depth); render the field below output resolution and upscale bilinearly (a smooth field hides it); update the field below the frame rate and interpolate; vectorize the noise inner loop where the CPU has SIMD; use the hardware FPU for the kernels that are float by nature (square roots, arctangents, a reference algorithm kept in float), behind the same contract; move the field update to a second core; and above all of these, run the field on a machine with the cycles and feed the lights over the network. + +--- + +# Part 2: Examples from the field + +Implementations that do similar things to Part 1. None is the specification; each fills in a spot: a measured number, a parameter vocabulary users expect, a fixed-point choice that is known to work, a precision decision. + +| Example | What it is | Similar to | What it fills in | +|---|---|---|---| +| **ANIMartRIX** (Stefan Petrick; also in FastLED master as `fl::Animartrix`) | A polar-noise shader engine: per-pixel polar tables, a timer bank yielding ramp / phase / bipolar sine / noise-angle signals, a "5D coordinate mapper" (`newx = (offset_x + cx − cos θ·r)·scale_x`, likewise y, `z` from a ramp), gradient noise per layer, a low/high contrast window, per-channel or palette color, gamma, sanity clamp; 50 named animations of 1 to 12 layers | Part 1 shader, polar transforms, LFO bank, contrast window | The author's throughput on the Teensy 4.0 the engine was tuned on: ~730 k RGB pixels/s per layer at 600 MHz ("20 fps on 36k LEDs"), 53 k/s on an ESP32 core, 110 k/s on two; "3 layers at 400+ fps, 10 layers at ~50 fps" on a 16×16 class panel. Float throughout, quantized to 8 bits "in the very last step", temporal dithering. The parameter names users of this style know: `scale_x/y/z`, `offset_x/y/z`, `z`, `center`, `low_limit`, `high_limit`, `master_speed`, `ratio`, `offset` per timer | +| **FastLED master's fixed-point migration of the same engine** (`3e77a096`, 2026-09-02) | Q16.16 (`s16x16`) throughout; angles as A24 (24-bit turn) into `sincos32`; four gradient-noise precisions side by side (`s16x16` reference, `q16`, an `i16`-optimized inner loop "2× faster multiplies", `s8x8` "4× faster, trades accuracy"), a 4-wide SIMD noise; **bit-identical tests** against the float engine | Part 1 arithmetic | Proof that the whole shader family is exact in Q16.16 with a table sine and a table-driven gradient noise, on the ESP32 class; the precision-by-role grading; a fixed-point type family documented for MCUs ("integer math is 5-100× faster … no rounding errors; results are exact and reproducible") | +| **FlowFields** (4wheeljive, from a 2026 concept post by Petrick; forks: `ewowi/flowfields`, Petrick's `ColorTrails`) | An advection engine: **emitters** ("anything that is drawn directly") plus **flows** ("an invisible wind that moves the previous pixels and blends them together"). Six flows as displacement rules (noise via two decoupled 1D profiles, radial, directional wind with a perpendicular wobble, three-zone rings with swirl and drift, spiral, and a full stable-fluids solver with vorticity confinement), eight emitters (orbital, swarming and audio dots, Lissajous line, rainbow border, noise kaleidoscope, cube, fluid jet), a 20-timer modulator bank with multiplicative "breathing", float RGB grids, a half-life fade `0.5^(dt/persistence)`, dithered 8-bit output, a virtual clock under one `globalSpeed` | Part 1 advection, velocity rules, sources, decay, modulation | The flow vocabulary and defaults users expect (`persistence`, `blendFactor`, `windStep`, `angularStep`/`radialStep`, `innerSwirl`/`outerSwirl`/`midDrift`, `xShift`/`yShift`, `noiseFreq`, `viscosity`, `vorticity`, `gravity`; ~35 named controls); that separable noise transport with decoupled axes reads as diagonal flow; that partial transport reads as viscosity; the solver's cost (5 Jacobi iterations per solve, three solves per frame) as the upper bound | +| **`fl::FlowField`** (FastLED master, from the above, 2026-03-21) | The same engine distilled to two emitters and the noise flow, in a float variant and a **Q16.16 variant** with `i32` color state ("allowing sub-integer color accumulation during splats") quantized at output; coverage discs and 2×2 bilinear lines; a "noise punch" impulse into the profiles | Part 1 resampler, sources, decay, arithmetic | The measured share: **advection ~80% of frame time** in fixed point, with the hoisting and `restrict` tricks that get it there; six 32-bit grids (384 KB at 128²), hence "memory is large"; defaults `persistence` 0.86 s, `flow_shift` 1.8 px, `noise_freq` 0.33/0.32 | +| **`fl::gfx` canvas** (FastLED master) | An anti-aliased 2D canvas: line, disc, ring, stroked line with caps, additive by default, float / int / fixed-point coordinates | Part 1 sources | The API shape a sub-pixel rasterizer converges on | +| **Fixed-point sub-pixel graphics** (Sutaburosu; the anti-aliased canvas demo in MoonLight as `FixedPointCanvasDemoEffect`, and in FastLED master as the fixed-integer drawing `fl::gfx` credits him for, the SKIPSM Gaussian blur behind `fl::gfx::blur`, and the Elias water effect `FxWater`) | Twelve sub-demos on a Q16.16 anti-aliased canvas (clock, orbiting discs, star web, spirograph, Lissajous, thin and thick cube, organic walkers, boids, hypotrochoid, branching tree), "blazing fast fixed integer drawing"; a binomial two-pass Gaussian blur; a 2D ripple simulation after Hugo Elias | Part 1 sources, arithmetic, blur | That anti-aliased sub-pixel drawing is fully deterministic and FPU-free in Q16.16 at panel-to-wall sizes; the blur kernel shape a trail idiom converges on; a working catalog of source geometries (orbits, Lissajous, spirograph and hypotrochoid curves, boids, a recursive tree) for the emitter set. The canvas demo itself is parked by the product owner as a port candidate | +| **`fl::WaveFx`** (FastLED master, after Shawn Silverman) | A 2D wave-equation simulation with 2× to 8× supersampling, mapped to color by gradient | Part 1 stored-field transport | Supersampling as the artifact lever for a stored field; the comment that 2× "gives the best results for the CPU consumption" | +| **`fl::Luminova`** (FastLED master) | 256 particles with per-frame fade and blur, "soft white trails" | Part 1 decay + blur, with particles as sources | Fade-plus-blur as the trail idiom for point sources | +| **Flow-field generative art** (Hobbs; Shiffman) | A grid of angles from noise; particles or pen strokes follow the angle in small steps | Part 1 noise-as-motion | The tracing form of a flow field, where the particle is the source and the field never touches the frame; Hobbs's advice to distort with something other than Perlin noise once the look is familiar | +| **Stable fluids** (Stam 1999, GDC 2003) | The reference solver and its "linear backtrace" advection | Part 1 transport | The algorithm every advection effect above descends from, and the reason semi-Lagrangian advection is unconditionally stable at any `dt` | +| **Earlier 8-bit work in the same idiom** (FunkyNoise, FunkyClouds, 2014; a self-modulating simplex noise gist) | Polar angle per pixel shifted by noise; noise modulating the offsets, scale and palette index of the next noise pass | Part 1 warping, feedback modulation | That the idiom predates FPUs and was first done in 8-bit FastLED terms | +| **MoonLight's port of ColorTrails** (2026-03) and **FastLED-MM's FlowFields sketch** (2026-04/05) | The advection engine on `fl::CanvasRGB` + `s16x16`; the same engine at 128×128 inside projectMM's earlier module runtime with 35 registered controls | Part 1 advection, in our own prior work | That the family has already run on our hardware and behind our UI; a bench reference for the advection showcase | + +Two things the examples agree on that Part 1 states as requirements: **compute above 8 bits and quantize last**, and **report pixels per second**. One thing none of them does yet: curl noise as a velocity rule, which Part 1 includes because it is the standard cheap answer to fluid-looking flow. + + +--- + +# Part 3: How this fits projectMM + +## What we have + +Against Part 1's blocks, measured on this tree: + +| Part 1 block | projectMM today | Status | +|---|---|---| +| Polar mapping | `atan16`, `dist16`, `kaleido` ([math16.h](../../src/core/math16.h)), computed per pixel; `PolarNoiseEffect` uses them | ✅ per pixel; ⬜ no precomputed LUT type | +| Polar transforms, `uv` | `shader.h`: `uv`, `rotate`, `repeat`, `mirror`; `kaleido` | ✅ | +| Noise | `inoise8`/`inoise16` **value** noise; `fbm8`/`fbm16`, `turbulence8`, `warp8` ([noise.h](../../src/core/noise.h)) | ✅ fBm, warp, turbulence; ⬜ gradient noise; ⬜ curl noise | +| Oscillators | `BeatPhase`, `beat`/`beatsin`, `sin16`/`cos16`, `smoothFollow`, easings, `hashInt` | ✅ single phase; ⬜ timer bank with the four LFO shapes and modulation binding | +| Contrast, palette | `map32`, `smoothstep`, `colorFromPalette`, `cosPalette` | ✅ | +| Gamma, dithering | `Correction` curves at the driver | ✅ gamma at output; ⬜ temporal dithering | +| Sources | `coverage` (SDF), `draw::splat` (24.8 sub-pixel, additive), `draw::line` (Bresenham, not anti-aliased), `particles.h` | ✅ disc, splat; ⬜ anti-aliased line | +| Resampler | `draw::scroll` (integer shift, wrap or clear) | ⬜ **no bilinear sample of the previous frame** | +| Velocity rules | none | ⬜ | +| Decay | `Layer::fadeToBlackBy` (8-bit multiply, once per frame, MIN across effects) | ✅ 8-bit; ⬜ half-life form on wide state | +| Blur | `draw::blur`, separable, every axis | ✅ | +| Color state | `Layer` buffer, `uint8_t` per channel ([Buffer.h](../../src/light/layers/Buffer.h)), persisting between frames ([ADR-0003](../adr/0003-layer-buffer-persists-frame-to-frame.md)) | ✅ persistence; ⬜ **no state above 8 bits** | +| Fixed point | 16-bit contract, uint8 angle / `angle16` turn, 24.8 positions, 16.0 noise coordinates ([power-functions-analysis-top-down.md § 2](power-functions-analysis-top-down.md)) | ✅ | +| Shader runner | `shader.h`: `each` (one function of position and time, the loop, mapping and write handled) | ✅ | +| Particles | pool, gravity, drag, bounce, collide, splat render | ✅ | +| Stored-field simulation | none | ⬜ (out of scope unless the top-down wants fluid) | + +The persistence contract advection needs already exists: the Layer does not clear between frames, and "a read-prior effect reads last frame's pixels via `draw::get` / `draw::blur`; the persistence *is* its state" ([architecture.md § Buffer persistence](../architecture.md#buffer-persistence--the-layer-does-not-clear-each-frame)). What is missing is the resampler and the bit depth. + +`PolarNoiseEffect` is the Part 1 shader already: polar addressing, `warp8` in polar space (the angle warped by noise), `kaleido`, a palette, with `octaves` and `warp` exposed as the cost knobs and the header stating the cost ("~4 samples/pixel at octaves=2 … on a large wall drop `octaves` to 1"). + +## What MoonLive has + +55 builtins ([MoonLiveBuiltins_light.h](../../src/light/moonlive/MoonLiveBuiltins_light.h), [MoonLiveBuiltins_common.h](../../src/core/moonlive/MoonLiveBuiltins_common.h)): `noise`, `polarA`/`polarR`, `sin`/`cos`, `beat`/`beatsin`, `smoothstep`/`step`/`smin`, `uvX`/`uvY`, `scale`, `mod`/`div`/`fdiv`, `setRGB`/`setXYZ`/`setPaletteColor`/`fill`/`fade`/`line`, the audio set, and the particle set. A script can write a Part 1 shader pixel today (`octopus.mle` is one). The measured cost is the call rate, not the arithmetic ([moonlive-language-roadmap.md § 4c](moonlive-language-roadmap.md)): `plasma.mle` at 9 host calls per pixel is 16,031 µs on a 3,840-pixel S3 fixture (~4.2 µs/px); `polarR` alone ~3.5 µs/px because it wraps a real square root; the particle vocabulary, "one call per FRAME rather than per pixel", is 54× cheaper on the same fixture. The roadmap's conclusions apply unchanged: "per-pixel builtins want to be inline ops, not calls" and "a `frame()` entry shape would sidestep it entirely". + +Advection is not expressible from a script: the 64-byte arena holds no frame of state and no builtin resamples the previous frame. The particle pool shows the shape that works for stateful families: a handle to native state, whole-pool passes per frame, the script composing them. + +## The budget, measured + +Per-pixel cycles at 240 MHz ([power-functions-analysis-bottom-up.md § Shaders](power-functions-analysis-bottom-up.md)): ~15,600 at 16×16 @ 60 fps, **~293 at 128×128 @ 50 fps**. The S3 render-only sweep ([performance.md](../performance.md)), µs per frame: + +| Effect | 16² | 32² | 64² | 128² | per pixel at 128² | +|---|---:|---:|---:|---:|---:| +| Noise (simplex, 1 sample/px) | 913 | 2,951 | 11,661 | 51,230 | 3.1 µs (~750 cycles) | +| Plasma | 352 | 1,020 | 3,744 | 20,020 | 1.2 µs | +| Metaballs | 462 | 1,757 | 6,108 | 28,576 | 1.7 µs | +| LavaLamp | 309 | 974 | 3,612 | 21,243 | 1.3 µs | + +On the classic ESP32 (no FPU, no PSRAM) the Noise effect runs 1,117 / 324 / 71 / 17 fps across the four sizes. So one noise sample per pixel is already 2.5× the wall budget, which is consistent with the field's own numbers in Part 2 once clock and FPU are normalized: **the shader half is a panel-class family on every MCU, and the engineering is in Part 1's levers.** Advection scales to walls in cycles (no noise per pixel; the field's noise is per row and column) and its cost is state: six 32-bit grids at 128² are 384 KB, PSRAM territory, and a panel-class family only on a PSRAM-less classic. + +Framerate protection is therefore the rule for both, in different currencies: **samples per pixel** for a shader, **cycles per frame and bytes of state** for advection. + +## Per-target headroom: FPU, SIMD, clock, and where the desktop takes over + +What each shipped target brings, from the IDF SoC capability headers and our own measurements: + +| Target | Cores × clock | FPU | SIMD | State memory | Measured against the S3 | +|---|---|---|---|---|---| +| classic ESP32 (Xtensa LX6) | 2 × 240 MHz | single precision | none | internal only, or 4 MB PSRAM on WROVER / 2 MB on PICO | slower; beats the S3 only on memory-bound loops (internal RAM vs PSRAM latency) | +| ESP32-S3 (Xtensa LX7) | 2 × 240 MHz | single precision | 128-bit PIE (`SOC_SIMD_INSTRUCTION_SUPPORTED`) | 8 MB octal PSRAM | the reference row in this document | +| ESP32-P4 (RISC-V) | 2 × 400 MHz | single precision | PIE + hardware loops (`SOC_CPU_HAS_PIE`, `SOC_CPU_HAS_HWLOOP`) | 32 MB PSRAM | ~3× on heavy compute ([performance.md](../performance.md)) | +| ESP32-S31 (RISC-V) | 2 × 320 MHz | single precision | PIE + hardware loops | PSRAM | between the S3 and the P4 | +| desktop | GHz class | double and single | NEON / SSE / AVX | unbounded | 20-40× an S3 per core, plus SIMD ([performance.md](../performance.md), the `collide` measurement) | +| Teensy 4.x (Cortex-M7), a future target | 1 × 600 MHz | single and double | none (DSP instructions) | 1 MB internal, no PSRAM | not measured; listed in [architecture.md § Scaling to available memory](../architecture.md#scaling-to-available-memory) as a supported class | + +**Can the FPU help?** Every target has one, so a float kernel is legal everywhere, and the repo already has the precedent: `raymarch.h` is compiled only where the SoC declares an FPU, as "the one bounded exception to the integer-only render path", while `shader.h` stays fixed point and runs everywhere ([power-functions.md § Raymarching](../moonmodules/light/power-functions.md#raymarching--one-technique-inside-a-shader)). The honest expectation: on these cores a float multiply costs about what an integer multiply costs, so an FPU does not make a noise sample cheaper; it makes square roots, arctangents and trig cheap enough to skip the tables, and it lets a float reference algorithm run unconverted where an exact fixed-point port is not worth writing yet. The portable contract stays fixed point; the FPU is a per-target acceleration behind it, per the standing decision. + +**Where the family shines on an MCU.** The S3 is the baseline this document measures against because it is the bench board with numbers, not because it is the target. The P4 is the natural home of the shader half: the highest clock, four-lane SIMD, hardware loops and 32 MB of PSRAM put a 64² composition and a 128² single-layer field inside its budget, and the S31 sits next to it on every axis. The classic is the portable floor, the target that keeps the contract honest. The top-down should size the showcases for the P4 and S31, keep them running on the S3, and let the classic degrade by the cost knobs. + +**Can SIMD help?** Yes, and the P4 is where it pays: PIE processes four 32-bit lanes per instruction and the hardware loop removes the branch per iteration, which is exactly the shape of a noise inner loop, a bilinear lerp across four channels, or a separable advection pass. The S3 has the same width. This is the lever the field has already pulled (a 4-wide fixed-point noise exists in the wild), and it is a per-target implementation of the same function, never part of the contract. The classic has neither, which is why it is the target that decides the portable budget. + +**Where the border is.** Per pixel at 50 fps, the cycle budget is `clock / (area × 50)`; one noise sample costs ~750 cycles on the S3 today, ~250 on the P4 by the measured ratio, ~20-40 on a desktop core. Samples per pixel affordable at 50 fps, rounded down: + +| Grid | S3 (240 MHz) | P4 (400 MHz, ~3×) | desktop (one core, ~30×) | +|---|---:|---:|---:| +| 16×16 | 24 | 72 | hundreds | +| 32×32 | 6 | 18 | ~180 | +| 64×64 | 1 | 4 | ~45 | +| 128×128 | 0 (one sample = 19 fps) | 1 | ~11 | + +So the ESP32 class stops at one sample per pixel on a 128² wall (S3) to one or two (P4), and carries a rich 3-to-12-layer composition only up to about 32×32 (S3) or 64×64 (P4). Beyond that line the desktop continues without a change of code: it is the same effect on the same contract, with SIMD and the clock on its side, and it already drives lights over the network as a processing node ([architecture.md § Drivers](../architecture.md#drivers): ArtNet, DDP, E1.31). Advection moves the border differently: its per-pixel cost is fixed and small, so the S3 and P4 carry it to a 128² wall as long as the wide color state fits PSRAM; the classic without PSRAM stops at panel size for lack of memory, not cycles. + +## What we need to add + +In Part 1's order, against the table above: + +1. **Gradient noise** beside value noise, in the existing 16-bit fixed vocabulary, as the quality upgrade for every field effect; keep value noise as the cheap inner-octave option (Part 2's precision-by-role grading). +2. **A polar LUT type**: angle and radius per pixel, built on the cold path, invalidated on geometry change; `PolarNoiseEffect` and every polar effect read it instead of calling `atan16`/`dist16` per pixel. +3. **An oscillator bank**: N clocks with offset and ratio under one master speed, the four LFO shapes, and a modulation binding (`parameter · (1 + depth · lfo)`), as one power function family rather than per-effect members. +4. **Curl noise** as a velocity rule, and the plain rules (wind, radial, spiral, rings, separable noise) as small functions of (pixel, t). +5. **The resampler**: bilinear sample of the previous frame at a fractional source, wrap and clamp variants, separable and full 2D forms. This is the advection kernel and the single largest missing block. +6. **Half-life decay** and an **anti-aliased line**, completing the sources-and-decay set beside `splat` and `coverage`. +7. **Color state above 8 bits** for advection and for dim trails: either a 16-bit Layer buffer (the LED-driver analysis already backlogs a "16-bit pipeline + dither", [backlog-light.md](backlog-light.md)) or effect-owned Q16.16 state quantized into the 8-bit Layer each frame. The examples are unanimous about *where* quantization goes (last) and split on *where the state lives*; the top-down decides. +8. **Temporal dithering** at the quantization step, once the state is wide. +9. **MoonLive frame kernels**: the prepare / emit / advect / decay passes and the polar-shader inner loop exposed as whole-frame builtins (inline ops or handle-based native passes), so a script composes frames the way it composes particle passes. Depends on the multi-argument host-call and arena work the language roadmap already names. +10. **Showcases**, three: a pure shader in the polar-noise idiom beyond `PolarNoiseEffect`; an advection effect with the noise, spiral and directional rules, two sources and the oscillator bank; and one that feeds a shader's output into a flow. + +## Bridge to the top-down + +The bottom-up settles what the top-down can take as given: the family is two techniques over one shared block set, every block has a textbook name and algorithm, the cost model is arithmetic and makes a shader a samples-per-pixel problem and advection a cycles-per-frame-and-memory problem, framerate is the rendering method for both, the field has proven the whole set exact in Q16.16 on our silicon class, and projectMM lacks three blocks (resampler, wide color state, gradient noise) plus the bank, the LUT and the kernels that make them composable. What it leaves open is design: names and signatures in our fixed-point vocabulary; where wide state lives and whether the Layer goes to 16 bits; the MoonLive frame-kernel shape and its dependency on the host-call ABI; which per-target accelerations sit behind the one contract; the three showcases to the control level; the tests, the timing contracts, and the order the work lands in. The top-down owns those decisions and their resource accounting. + +### Prompt for the agent that writes the top-down + +> Read `CLAUDE.md`, `docs/architecture.md` (§ Hot path discipline, § Effects and Layer, § Buffer persistence, § MoonLive), `docs/coding-standards.md`, `docs/backlog/generative-fields-analysis-bottom-up.md` (this document, in full: Part 1 is the specification language, Part 2 the examples, Part 3 the gap), `docs/backlog/power-functions-analysis-top-down.md` (the shape and the standing decisions: dimension-generic, one contract everywhere with per-target acceleration, fixed point invisible to the writer, the 16-bit contract, particles as the stateful precedent), `docs/moonmodules/light/power-functions.md` (what exists), and `docs/backlog/moonlive-language-roadmap.md` § 4c and § 2 (the per-pixel call cost and the multi-argument host-call blocker). +> +> Write `docs/backlog/generative-fields-analysis-top-down.md`: the implementation spec for the generative-fields family in projectMM, for compiled effects and for MoonLive scripts, on the power-function library. Use the house format of the power-functions top-down (status legend, TL;DR, numbered sections, decisions for sign-off, out of scope). Present tense for what exists, forward-looking only under the banner. American spelling, no em-dashes. Specify every kernel from Part 1's primary sources by its textbook name and algorithm; use Part 2 only for measured numbers, parameter vocabularies and precision choices, never as a source of code, and record prior art per module the way `PolarNoiseEffect.h` does. +> +> Decide, with rationale and resource accounting per target (classic ESP32 without PSRAM, S3, P4, desktop): (1) the power functions to add for Part 3's ten items, with signatures in the repo's fixed-point vocabulary; (2) where wide color state lives (a 16-bit Layer buffer versus effect-owned Q16.16 state quantized into the 8-bit Layer), with the memory table per fixture size and the dithering plan; (3) the MoonLive frame-kernel shape: how a script composes whole-frame passes and reads a per-pixel shader through inline ops rather than per-pixel host calls, and which language-roadmap blockers it depends on; (4) the performance plan against Part 3's measured budget and per-target headroom table: samples-per-pixel targets for shaders, cycles-per-frame and bytes-of-state targets for advection, the field-below-resolution and field-below-frame-rate levers, which kernels get FPU or SIMD (PIE) variants per target behind the one contract, where the desktop takes over as the processing node, and how framerate is protected as the rendering method; (5) the three showcases, specified to the control level; (6) the tests that pin each kernel (golden frames on the desktop, per-target timing contracts in scenarios) and the bench criteria the product owner judges by eye on the S3 and on a wall; (7) the order the work lands in, each step verifiable on the desktop first. +> +> Where a decision needs the product owner, list it under decisions for sign-off with the options and your recommendation rather than deciding silently. The bar is the measured quality and cost of the effects themselves. diff --git a/docs/backlog/generative-fields-analysis-top-down.md b/docs/backlog/generative-fields-analysis-top-down.md new file mode 100644 index 00000000..8554643b --- /dev/null +++ b/docs/backlog/generative-fields-analysis-top-down.md @@ -0,0 +1,582 @@ +# Generative fields — top-down build spec + +> **Forward-looking design document — exception to CLAUDE.md present-tense rule.** Stage 2 of the generative-fields work: turns the [bottom-up analysis](generative-fields-analysis-bottom-up.md) (Part 1 the specification language, Part 2 the examples, Part 3 the gap) into an implementable spec: homes, types, signatures, the MoonLive surface, budgets, tests, and a step-by-step plan. Written 2026-09-03 against the five product-owner decisions in § 0. Where this document makes a NEW decision it is marked **(proposal)** and listed in § 10. Companions: the [power-functions top-down](power-functions-analysis-top-down.md) owns the library's homes, types and standing decisions, which this document extends rather than restates; the [MoonLive roadmap](moonlive-language-roadmap.md) owns the engine. + +## Status legend + +| | Meaning | +|---|---| +| ✅ | **Done.** Built and in the tree. | +| 🔨 | **To do.** Open, with the reason stated. | +| ❓ | **Unsure.** A claim or number not yet verified; a question, not a fact. | + +An unmarked line is context or rationale. + +## TL;DR + +- **Five decisions taken by the product owner (2026-09-03)** (the first was later reversed on measurement, § 11): the Layer buffer goes to 16 bits per channel as the one format, subject to the memory analysis in § 2; gradient noise replaces value noise in place (one solution, the goldens move once); MoonLive gets each kernel in the same phase as the compiled function, with a shipped script; the stable-fluids solver is in scope as the last phase, a P4 and desktop showcase; every effect that does not run on the power functions is rewritten on them (algorithmic effects such as Game of Life excepted), judged against how it ran before and never made worse. +- **(proposal) The Layer width is a per-layer property fixed on the cold path, one template instantiated twice, never a per-light branch.** 16-bit is the default wherever it fits; 8-bit is the degradation step the existing adaptive-allocation cascade already has, so the classic without PSRAM keeps its proven 128² pipeline. The pipeline stays 16-bit end to end and quantizes **once, per driver, in `Correction`**, to the width that driver's wire takes: 8-bit with temporal dithering for WS2812-class LEDs and every network, Hue, video and preview output, 16-bit straight through for a 16-bit LED. Memory doubles per layer and for the output buffer (98 KB each at 128² RGB), bandwidth doubles in blend, extrude and preview, and the S3's PSRAM latency makes that measurable on cheap effects; the KPI gate reads it per batch (§ 2). +- **(proposal) Seven kernels, in the existing homes.** `core/noise.h`: gradient noise in place, plus `curl16`. `light/polar.h` (new): `PolarLut`. `core/oscillators.h` (new): `Oscillators`, the timer bank with the four LFO shapes and modulation. `light/draw.h`: `advect` (the bilinear previous-frame resampler, separable and 2D, wrap and clamp), the velocity rules, `decay` by half-life, `lineAA`, `disc`, and the 16-bit quantize with temporal dithering. `light/fluid.h` (new, last): the Stam solver. Every function names its canonical source; nothing is ported (§ 3). +- **(proposal) MoonLive operates on the Layer, not on a handle.** With the Layer at 16 bits the frame buffer IS the wide state, so advection, decay and the emitters are whole-frame builtins on the script's own layer, the shape `fill` and `fade` already have; the polar LUT and the oscillator bank are handles declared in `defineControls()`, the shape `pool()` has. Multi-argument host calls already ship (`emit` takes seven), so nothing here waits on the engine; the one engine change is the pixel-store inline ops learning the 16-bit element (§ 4). +- **Budgets are the bottom-up's, restated as targets per target.** Shaders: samples per pixel against ~750 cycles per sample on the S3, ~250 on the P4; advection: cycles per frame plus bytes of state, PSRAM-class for walls. Framerate is protected by rule: every kernel is `dt`-driven, transport stays sub-pixel per frame, and a showcase must hold its stated fps on its stated fixture or its cost knob is the default (§ 5). +- **Four showcases, three plus fluid**, specified to the control level: *Aurora* (a pure shader), *Trails* (emitters and flows), *Nebula* (a shader feeding a flow), *Fluid* (the solver, P4 and desktop) (§ 6). +- **Eight phases, desktop first, bench last; the product owner decides when enough is done to commit.** Noise swap; fields and Aurora; three dimensions everywhere; Coord3D in MoonLive; the 16-bit Layer; advection and Trails; composition, dithering and SIMD; fluid. The catalog sweep that decision 5 mandates is a follow-on plan, fed by the [effects × power functions inventory](effects-power-function-inventory.md); only the effects a phase touches directly (the noise effects in phase 0, PolarNoise in phase 1, the raw-byte writers in phase 2) are rewritten inside this plan. Each phase lands with its unit tests, its goldens, its scenario, its MoonLive builtins and script, and its catalog cards (§ 8). + +## 0. Inputs and the decisions taken + +From the bottom-up: two techniques over one block set; every block named from its primary source; the cost model; projectMM's three missing blocks (previous-frame resampler, color state above 8 bits, gradient noise) plus the LUT, the bank and the kernels that compose them; the per-target headroom table with the P4 and S31 as the MCU home of the family. From the power-functions top-down, carried unchanged: dimension-generic; one contract everywhere with per-target acceleration; fixed point invisible to the writer; the 16-bit contract (`angle16`, `frac16`, `pos_t` 24.8); particles as the stateful precedent; determinism (pure function of position, time, seed; stateful kernels declare a resync point); golden frames as hashes; the `Canvas` descriptor. + +Product-owner decisions, 2026-09-03: + +1. **Wide color state: the 16-bit Layer**, as the cleanest, least-code, fastest alternative; decided per Layer at run time from free memory with 8-bit as the fallback (option B, agreed); and **quantized once per driver in `Correction`**, so the pipeline is 16-bit end to end and a 16-bit LED is a driver, not a pipeline change (agreed). Detail in § 2. +2. **Gradient noise replaces value noise in place.** One solution; no second function kept for compatibility (CLAUDE.md minimalism). +3. **MoonLive per phase, same PR.** +4. **Stable fluids in scope**, as the last phase; if it costs CPU it is a P4 and desktop showcase. +5. **Every effect runs on the power functions for whatever they cover; an effect that does not is rewritten.** Most effects were developed quickly to demonstrate a mechanism, and keeping them rendering the same is not a goal. The rule is a mandate, not an option: an effect whose mechanism is one the library provides (motion, fields, noise, polar addressing, trails, particles, oscillators) is rewritten on the library's kernel. Bouncing balls run on `particles`; every noise-like effect runs on the noise this plan builds; every trail decays by half-life; every polar effect reads the LUT. The one exception is the **algorithmic** effect, where a rule set *is* the effect rather than a natural motion: Game of Life is the type case, and it keeps its logic and uses the library only to draw. The guard that came with "behave identically first" stays: an early Game of Life port was much worse than its MoonLight origin, so a rewrite is compared against the effect as it ran and lands only if at least as beautiful on the panel. The aim is effects at a level no other LED firmware reaches, not a 1:1 rendition of another project's catalog. + +## 1. Homes and style ✅ *(the rule; the headers are new or grown)* + +The power-functions rule stands: grow existing homes, one style, free functions over `const Canvas&`, fixed-point vocabulary, a doc block naming the canonical source. **(proposal)** + +| Home | Gains | Notes | +|---|---|---| +| `core/noise.h` | gradient noise behind the existing `inoise8/16`, `fbm`, `warp`, `turbulence` names; `curl16` | Domain-neutral. The name promises the field; the algorithm changes underneath (decision 2). | +| `core/oscillators.h` **(new)** | `Oscillators`: N clocks, four LFO shapes each, modulation binding, one master speed, `dt`-driven | Domain-neutral: audio meters and services want the same LFOs. | +| `light/polar.h` **(new)** | `PolarLut`: `angle16` and radius per pixel, built at `prepare()`, 8-bit default with a 16-bit opt-in (the power-functions decision) | Was planned there and never built; `kaleido` stays in `math16.h`. | +| `light/draw.h` | `advect` (separable and 2D), the velocity rules, `decay`, `lineAA`, `disc`, `quantize` with dithering | The frame-ops family, which is where `scroll` and `blur` already live. | +| `light/layers/Buffer.h`, `BlendMap.h`, `Layer.h` | bytes per channel; the 16-bit format and its quantize-last | § 2. | +| `light/fluid.h` **(new, last)** | `Fluid`: velocity grids, diffuse, project, advect, vorticity confinement, dye transport | Stateful kernel with a resync point, like particles. | +| `light/moonlive/MoonLiveBuiltins_light.h` | one builtin per kernel, per phase | § 4. | + +## 2. The 16-bit Layer + +### What the codebase says + +[architecture.md § Memory strategy](../architecture.md#memory-strategy): all buffers are raw `uint8_t*` arrays sized `channelsPerLight × nrOfLights`; "there is no fixed channel layout: `channelsPerLight` is a runtime value, so RGB, RGBW and multi-channel DMX fixtures all use the same code path; the buffer simply gets wider". Adaptive allocation checks heap before every allocation with a 32 KB reserve, and the degradation cascade "reduces layer dimensions until the buffer fits, minimum 8×8". The architecture "does not assume PSRAM"; the classic without PSRAM is "proven up to 16 K lights (128×128 measured live on Olimex)". CLAUDE.md: minimal memory, fastest hot path, "the standard, complete construct beats a hand-rolled special case", and "no `#ifdef`, no per-light virtual call, data over objects". The LED-driver analysis already planned this as its mode 3, "16-bit pipeline (incl. dither): doubles RAM; best gradient quality; required for 16-bit-native LEDs (UCS7604, HD108)", with the driver declaring its input width and the pipeline building Layer buffers accordingly ([leddriver-analysis-top-down.md § 7.3](../history/leddriver-analysis-top-down.md)). + +So the width is a property the architecture already treats as runtime data (like `channelsPerLight`), the cascade already knows how to shrink a layer that does not fit, and the pipeline decision was already made in principle. What is new is doing it. + +### Memory, per fixture, RGB, one layer + +The last column is an **illustration** from the classic's measured free heap today (~104 KB running at 128² with mirror, Ethernet and mDNS up), not a threshold: the decision is made at run time from free memory, never from the grid size, so the same layout may run 16-bit on one device and 8-bit on another depending on what else is loaded. + +| Grid | 8-bit | 16-bit | Illustration: would it fit today's measured classic heap without PSRAM? | +|---|---:|---:|---| +| 16×16 | 768 B | 1.5 KB | yes | +| 32×32 | 3 KB | 6 KB | yes | +| 64×64 | 12 KB | 24.6 KB | yes | +| 96×96 | 27 KB | 55 KB | yes, one layer | +| 128×128 | 49 KB | 98 KB | **no** as a second allocation beside the 49 KB output buffer: the reserve is 32 KB | +| 48×256 (the wall) | 37 KB | 74 KB | PSRAM already required by `nrOfLightsType` | + +The driver output buffer, where one exists (two or more layers, or a mapping LUT), follows the Layer's width, so the composite case costs two 16-bit buffers; it is part of the same runtime allocation check. Advection needs a scratch copy for the separable pass (a second buffer of the same size) and, for the fluid solver, velocity, pressure and divergence grids (four more `int32` planes). Those are effect-owned `ScratchBuffer`s, allocated at `prepare()`, reported through `dynamicBytes()`, PSRAM-preferred; the memory ladder shows them exactly as it shows a particle pool. + +### Can 8 or 16 be decided dynamically without hot-path cost? Yes, and it is the existing mechanism + +The width is decided **per Layer at `prepare()`**, on the cold path, by the same allocation check that decides whether a LUT or an output buffer exists: the Layer attempts the 16-bit allocation against the free heap after the 32 KB reserve, keeps it when it succeeds, and otherwise takes 8-bit as the first step of the cascade (before dimension reduction). It is a measurement, not a table: no grid size is special, and the answer re-decides on every `prepare()` (a layout change, an added layer, a freed module). Every per-pixel loop that reads or writes Layer bytes is written **once as a template on the sample type** and instantiated for `uint8_t` and `uint16_t`; the choice happens **once per pass per frame** (a function pointer or a branch at the top of `blendMap`, `extrude`, `fadeToBlackBy`, `advect`), never per light. That is the "data over objects, no virtual call per light" rule satisfied, and it is one algorithm, not two implementations. The price is code size (two instantiations of the frame-op set) and a second configuration to test, which the goldens cover by running both widths. + +**(proposal)** Two options, with the recommendation: + +- **A. 16-bit only.** One format, the cascade shrinks a layer that does not fit. Least code and one test configuration; the classic without PSRAM loses its stated 128² full pipeline (it drops to 96² single-layer, 64² with two layers), which is a documented capability regression on a shipping board class. +- **B. 16-bit default, 8-bit as the first cascade step** (recommended). One template, two instantiations, chosen per layer on the cold path; the classic keeps 128²; the hot path pays nothing per light. Minimalism is respected in the way that matters here: one algorithm with one home, and the 8-bit path is a fallback the architecture already promises, not a second feature. + +### Quantize last: once, per driver, in `Correction` (agreed) + +The pipeline is 16-bit end to end: Layer → `blendMap` → output buffer, all at the Layer's width. Quantization happens **once, in `Correction::apply`, per driver**, which every driver already runs through `DriverBase` (LED, ArtNet/DDP/E1.31, PanelCard, Hue, NDI, HLS, Preview): white balance, brightness, the power limiter and the perceptual curve run at 16-bit input, and the last step narrows to the width the driver's wire declares. **8-bit with temporal dithering** for WS2812-class LEDs and every network, video and preview output; **16-bit straight through** for a 16-bit LED. This is mode 3 of the LED-driver analysis ("driver input 16-bit, wire 8-bit, the driver downsamples and dithers; required for 16-bit-native LEDs"), and its `inputBitsPerChannel()` stub becomes real: the driver declares its wire width, `Correction` quantizes to it. + +Why here and not at `blendMap`: quantizing earlier throws the extra bits away before the perceptual curve, which is exactly where 8-bit input crushes the low end, and it makes a 16-bit LED impossible. One site for the whole system also means one dither state (per driver, one byte per channel of carried error, or a stateless ordered matrix) and one place to test. + +Two consequences. The zero-copy path (a single layer with no LUT, the driver reading the Layer directly) needs no view: `Correction` reads the Layer's width like any other source. The `Correction` LUTs stay 256 entries with interpolation on the 16-bit input (the shape `sin16` uses), not a 64K table. In phase 2 the narrowing is `>> 8` so every existing effect renders bit-identically and no golden moves; dithering is the phase 4 switch. + +**The four combinations, and the hot path.** The Layer's width (decided by memory) and the wire's width (declared by the driver) are independent facts that meet in one `Correction` template; the combinations fall out, none is a mode to maintain: + +| Layer | Wire | `Correction` does | When | +|---|---|---|---| +| 16 | 16 | curve and brightness at 16, no quantization | the full pipeline: a PSRAM board with a 16-bit LED | +| 16 | 8 | curve and brightness at 16, quantize once with dithering | the common case: 16-bit fits, WS2812-class LEDs, and every network, video and preview output | +| 8 | 8 | today's path, byte for byte | a board where memory forced the 8-bit fallback | +| 8 | 16 | widen (`v · 257`), curve and brightness at 16, out at 16 | the fallback board driving a 16-bit LED; smoother dimming than 8-bit scaling, the effect's own 8-bit steps remain | + +No path you have today gets slower: the 8-bit instantiations are the same code compiled for `uint8_t`, chosen once per pass per frame, so the per-light instruction count on an 8-bit Layer is unchanged. The 16-bit paths cost what they carry: twice the bytes moved (the S3's PSRAM latency shows this on cheap effects; the KPI gate reads it per batch), the same ALU ops on a 32-bit core, one interpolation per channel in `Correction` on top of the LUT lookup, and dithering as one add and one compare per channel, only on a 16-to-8 wire and only when switched on. No per-light branch, no virtual call, no allocation on the path. + +**16-bit LEDs are enabled, not built.** HD108 (16-bit per channel, clocked SPI) and UCS7604 (16-bit per channel, one-wire) are the known families; APA102/SK9822 are 8-bit color with a 5-bit current gain and do not qualify. Each is its own driver increment: verify the datasheet first, then an encoder that takes 16-bit wire values from `Correction` (an SPI driver for HD108, a wider symbol table for UCS7604). + +### The surface, counted + +What touches Layer bytes and therefore changes in phase 2: `Buffer` (bytes per channel, 1 or 2), `Canvas` (a sample-width field), the 25 `Canvas` and 12 `Buffer` overloads in `draw.h` (one template each), `BlendMap` (identity and LUT paths, both widths), `Layer::extrude` and `fadeToBlackBy` (bytes-per-light already, width-agnostic after the template), `Correction::apply` (16-bit input, quantize to the declared wire width; the one quantization site), `DriverBase` (the declared wire width), `PreviewDriver` (quantized through the same path), `NetworkReceiveEffect` (widens), the **7 effects that write raw bytes** (AudioVolume, BouncingBalls, Fire, Lines, NetworkReceive, Rainbow, Noise), `colorFromPalette` (unchanged: returns 8-bit RGB, widened at the write), the MoonLive `StoreElem` / `FillElems` inline ops on all three backends (element = `channelsPerLight` × the Layer's bytes per channel, NOT a fixed 3 × 2: the ops already store N bytes rather than a fixed RGB, and a phase that hardcoded three channels would break the RGBW and multi-channel DMX fixtures the same buffer serves), and the zero-copy driver path. Modifiers are coordinate-only and untouched; the mapping LUT is index-only and untouched. + +## 3. The kernels + +Signatures are shapes, not final names; the PR is the spec. All fixed point, all `dt`-driven where time enters, all pure functions of (position, time, seed) unless stated. **(proposal)** + +### 3.1 Gradient noise, in place (decision 2) + +`inoise8/16(x[,y[,z]])` keep their names, their 16.0 coordinate convention and their output ranges; the implementation becomes **Perlin's improved noise** (2002): a 256-entry permutation table, gradients from the 12 edge directions of a cube (8 in 2D), the quintic fade `6t⁵ − 15t⁴ + 10t³`, integer throughout, output rescaled to the full range the header already promises. `fbm`, `warp` and `turbulence` sit on top unchanged. The seven noise effects (Noise, NoiseMeter, Noise2D, PolarNoise, Wave, Tunnel, and EffectBase's users) change look in one commit; their goldens move in that commit with the reason in the message. Under decision 5 the question on the panel is whether they look at least as good as before; any that look worse are re-tuned before the swap is accepted, since a noise effect that ran beautifully on value noise may not degrade on gradient noise. The header's note that ports from FastLED "look slightly different for a reason the author cannot see" is deleted: they no longer do. + +Cost target: within 1.3× of the current value noise per sample (the gradient dot products replace the value lerps; the fade is the same), measured by the micro-bench in phase 0 before the swap is accepted. + +### 3.2 `PolarLut` (`light/polar.h`) + +```cpp +polar::Lut lut; // POD over ScratchBuffer +lut.build(dims, center, precision); // prepare(): angle16 + radius per pixel; 8-bit each by default, 16-bit opt-in +lut.angle(i); lut.radius(i); // hot path: two loads per pixel, no atan, no sqrt +``` + +Rebuilt on geometry or center change through `affectsPrepare`; reported as `dynamicBytes`. `PolarNoiseEffect` migrates to it (pixel-identical at the 16-bit precision, golden-pinned; the 8-bit default is a deliberate, bench-judged divergence). A polar modifier consuming the same LUT is the modifier-first path the earlier decision named; it is not in these phases. + +### 3.3 `Oscillators` (`core/oscillators.h`) + +```cpp +Oscillators osc; // N clocks; N is the effect's choice (4 to 20) +osc.set(i, ratio, offset); // per clock, at prepare() or on control change +osc.advance(dtMs, masterSpeed); // once per frame: phase_i += dt · ratio_i · master +osc.ramp(i); osc.phase(i); // 32-bit ramp; angle16 wrapped phase +osc.bipolar(i); osc.unipolar(i); // sin16 of the phase; 0..65535 form +osc.noiseAngle(i); // angle16 from inoise16(ramp), the wandering angle +osc.modulate(base, i, depth); // base · (1 + depth · bipolar(i)), the multiplicative binding +``` + +Frame-rate independent by construction (`dt`, never a frame count, per coding-standards § Animate on elapsed time). Determinism: the phases derive from the shared time origin, so two devices agree (§ 6b of the power-functions top-down). `BeatPhase` stays for single-tempo effects. + +### 3.4 Velocity rules and `curl16` (`draw.h`, `noise.h`) + +A velocity rule is a function `(x, y, t, params) → (vx, vy)` in `pos_t` per frame; none is stored. The set from Part 1: `flowWind` (direction, speed, rotation rate, perpendicular wobble), `flowRadial` (in or out), `flowSpiral` (angular step, radial step), `flowRings` (three zones with smoothstep boundaries, per-zone swirl and drift), `flowNoise` (two 1D profiles, one per axis, decoupled), and `curl16(x, y, t, scale)`: the perpendicular gradient of a scalar noise potential by two central differences, divergence-free by construction (Bridson 2007). The profiles of `flowNoise` are computed once per row and column per frame, not per pixel. + +### 3.5 `advect` (`draw.h`) + +```cpp +draw::advect(dst, src, rule, Edge::Wrap|Clamp); // backward sample, bilinear, per channel +draw::advectSeparable(cv, scratch, xProf, yProf, Edge); // two 1D passes through the scratch plane +``` + +**Shipped (2026-09-04), with the 3D cost measured.** The signature took `dst, src` rather than +`cv, scratch` and dropped `dtMs`: the two planes must differ (reading and writing one buffer samples +pixels the same pass already moved, smearing along the walk order instead of the flow), and the rule +returns sub-pixel units per frame, so only the caller knows the cadence to scale by. `sampleWrap` +already existed and covered the Wrap half; `sampleClamp` and `sampleEdge` are new, and Clamp is what +a wind wants, since a trail leaving the panel must not reappear on the far side. + +**The PO's prerequisite, that 3D awareness must not cost the 2D path, holds: 1%.** Desktop, median +of 11, every destination byte verified written: a 20x20 panel is 16.98 ns/light, the 20x20x20 cube +17.09. The z loop runs once at depth 1, so a panel pays a loop counter. In absolute terms the cube +advects in **0.14 ms**, under 1% of a 20 ms frame, so transport is cheap and the flow rules' +noise sampling is what will set the cost. + +The semi-Lagrangian step (Stam 1999): for each destination pixel, `src = dst − v · dt`, read the previous frame at `src` with bilinear interpolation (four loads, three lerps per channel), write to `scratch`, swap. On the 16-bit Layer the color state is the Layer itself; `scratch` is one effect-owned plane of the same width. Partial transport (`blend` fraction) is a parameter. Budget: ~40 cycles per pixel per channel per pass on the S3 (the field's 80% share is what this is measured against). + +### 3.6 `decay` (`draw.h`) + +`draw::decay(cv, halfLifeMs, dtMs)`: multiply every sample by `k = 0.5^(dt / t½)`, `k` computed once per frame from a 16-bit `pow2` table (`mm::halfLifeKeep`). Framerate-independent by definition; a unit test pins `decay(2·dt) == decay(dt)²` within one LSB. + +**Shipped, with a measured limit (2026-09-04): an 8-BIT plane cannot hold this decay at a high framerate, and no rounding rule fixes it.** Decaying 200 over a 500 ms half-life in 500 ms of frames, exact answer 100: truncating gives 96 at 50 ms frames, 73 at 5 ms and **0** at 1 ms; rounding gives 100, 100 and **200**, the trail frozen solid. Both are the QUANTIZATION rather than the weight, the value being re-rounded to a byte hundreds of times a second. A 16-bit accumulator holds 100/101/102. So `decay` on a byte canvas is honest at a slow cadence and `decay16` on a wide scratch plane is what a trail uses: the precision belongs in the ACCUMULATOR, not the frame buffer, which is the same conclusion phase 2 reached from the other side. The Layer's collected `fadeToBlackBy` remains the one 8-bit-per-frame decay for effects that want it; `decay` is the half-life form on wide state. + +### 3.7 `lineAA`, `disc` (`draw.h`) + +`lineAA` steps the segment at ~3 samples per pixel and splats each through the existing 2×2 bilinear `splat` (Wu 1991). `disc(cv, center, radius, color)` writes coverage `clamp(r + 0.5 − dist, 0, 1)` per pixel in the bounding box, using the squared-distance `coverage` path already in `draw.h`. Both additive with saturation, the particle default. + +### 3.8 `quantize` with dithering (`draw.h`, phase 4) + +`draw::quantize(src16, dst8, ditherState)`: `>> 8` in phase 2; in phase 4 the truncation error is carried per pixel into the next frame (one byte per channel of state, effect-owned, or a 4×4 ordered matrix with no state, selectable). Applies at every 16-to-8 boundary listed in § 2. + +### 3.9 `Fluid` (`light/fluid.h`, phase 5) + +The Stam 1999 solver over effect-owned `int32` Q16.16 grids: `diffuse(velocity)`, `project()`, `advect(velocity by itself)`, `project()`, then dye advected by `draw::advect16` along the stored field. Wall boundaries; `addVelocity(x, y, dvx, dvy, z)` and the dye splat are the source terms. SHIPPED with three differences: the relaxation is Gauss-Seidel rather than Jacobi (it converges in fewer sweeps for the same code, and the sweep count is the `iterations` control, default 5); vorticity confinement was not built; and the dye is the EFFECT's own 16-bit plane rather than the Layer, which is what kept the buffer 8-bit (§ 11). Declares a resync point (re-seed to rest). Sized for panels; the P4 and desktop are its home. + +## 4. MoonLive: the frame kernels, per phase (decision 3) + +**The Layer is the state, so the kernels are whole-frame builtins on the script's layer**, the shape `fill(r,g,b)` and `fade(amt)` already have: no handle, no arena bytes, one host call per frame. Stateful helpers that are not the frame (the polar LUT, the oscillator bank, the fluid grids) are **handles declared in `defineControls()`**, exactly like `pool(n)`: sized once, allocated by the binding outside the arena, whole-object passes per frame. Multi-argument host calls ship (`emit` takes seven), the builtin table holds 96, so nothing waits on the engine. **(proposal)** + +**What shipped, against this table.** The vocabulary below is the design proposal; the names in the +code differ where implementation taught otherwise, and the third column says which. Read the +registered set in `MoonLiveBuiltins_light.h` as the authority. + +| Phase | Builtins | Script shipped | +|---|---|---| +| 1 fields | SHIPPED as `osc(rate, ms, shape)` alone, which is STATELESS: two oscillators sharing a rate hold their phase relationship without a bank, so the handle and its eight accessors were not needed. `polarA`/`polarR` still compute per pixel (no LUT handle). The gradient-noise swap is invisible to scripts, as planned | `aurora.mle` | +| 3 advection | SHIPPED as `trail(1)`, `flowNoise(zoom, strength)`, `flowCurl(zoom, strength)`, `trailDecay(halfLifeMs)`, `emitTrail(x, y, z, index, bri, radius)`. The flow builtins ADVECT rather than setting a rule for a separate `advect()`, which halves the host calls; `decay` became `trailDecay` because a plain `decay` is a name scripts already declare. `flowWind`/`flowRadial`/`flowSpiral` exist in `draw.h` with no script binding; `lineAA` shipped earlier (2026-08-07) and has no script binding either; only `flowRings` is absent | `trails.mle` | +| 4 composition | SHIPPED as `fieldRate(n)`, the frame-skip lever a per-pixel script needs on a large fixture. `fieldScale` stayed compiled-only: a script cannot own the second plane it needs | `nebula.mle` | +| 5 fluid | NOT BUILT: the solver has no script binding, so `fluid.mle` is jets pouring into a curl flow through the phase-3 builtins, which looks like the effect without being it. Exposing `Fluid` to scripts is open | `fluid.mle` | + +The per-pixel shader path improves without new builtins: the LUT turns `polarA`/`polarR` from a 3.5 µs square root into two loads, and the roadmap's inline-op work (§ 4c) is what removes the remaining call cost; that work is the roadmap's, not this document's. + +The one engine change this plan requires: the `StoreElem` and `FillElems` inline ops lower a 16-bit element on all three backends when the layer is 16-bit (phase 2). The lowering already carries the element width as "N bytes"; the width becomes a per-binding constant. + +## 5. Performance plan + +**Currencies.** Shaders: samples per pixel, against ~750 cycles per sample on the S3 and ~250 on the P4 (bottom-up, Part 3), with the gradient-noise swap held within 1.3× of that. Advection: cycles per frame and bytes of state. The target is ~40 cycles per pixel per channel PER PASS, and separable advection is two passes (x then y), so a 128² RGB advect is 128·128·3·40·2 cycles: **~16 ms on the S3** at 240 MHz and **~10 ms on the P4** at 400 MHz. (An earlier draft said 4 ms and 1.3 ms, which matched neither one pass nor two: the arithmetic gives 8.2 ms for a single S3 pass. The numbers below follow from the corrected figures.) That is 60 fps on the P4 with room for the emitters, and on the S3 it is the whole frame at 60 fps, so a 128² wall runs advection at 30 fps or drops to 64² for 50. The phase-3 scenario sets the contract per target from measurement rather than from this estimate. + +**Framerate as the rendering method, by rule.** Every kernel takes `dt`; no kernel takes a frame count. `advect` clamps the per-frame displacement to one pixel and reports when it clamps (the `frameTime`-style status), so a slow frame smears rather than tears. `decay` is a half-life. The oscillator bank is `dt`-integrated. A showcase's stated fps on its stated fixture is a scenario contract; when it fails, the default of its cost knob moves, not the contract. + +**Levers, in the order the plan pulls them.** LUTs (phase 1); fixed point (already); cost knobs as controls with honest defaults (every showcase); field below output resolution with bilinear upscale (phase 4: a `fieldScale` control, the shader renders into a smaller scratch plane and `draw::upscale` bilinearly fills the layer); field below frame rate (phase 4: `fieldRate`, the shader updates every N frames and the oscillators still advance every frame); SIMD (phase 4: a PIE variant of the gradient-noise inner loop and of the bilinear lerp for the S3 and P4, behind the platform boundary, selected by `platform_config.h` capability flags, bit-identical to the scalar form and tested as such); FPU (a float variant of `curl16` and the fluid solver where `hasFpu`, the `raymarch.h` precedent, same bit-identical-within-tolerance test); a second core (the field update as a `multicore` producer, not in these phases). + +**Where the desktop takes over.** The same effects, the same controls; the desktop as processing node feeds a wall over ArtNet or DDP. The per-target headroom table in the bottom-up is the guide: rich compositions above 32² (S3) or 64² (P4) are desktop work, and a showcase's card says so. + +## 6. Showcases + +Each is a compiled effect and a MoonLive script of the same look, so the script proves the builtin surface. Controls are the cost knobs and the look knobs, with defaults that hold the stated fps on the stated fixture. **(proposal)** + +**Aurora** (phase 1; shader; 💫🖌️ 3D). Layers of gradient noise over a `PolarLut`, each layer's transform driven by its own oscillators; which layer wins picks the palette region, and a contrast window decides what is visible at all. SHIPPED controls: `speed`, `scale`, `layers` (1 to 4, the cost knob), `warp`, `twist`, `segments` (kaleidoscope), `contrast`, `octaves`, plus `PolarLut`'s `polarTable`, `polarTable16` and `mapping`. + +**Trails** (phase 3; advection; 💫🖌️ 3D). Emitters drawn into the effect's own 16-bit plane, then advected and decayed. SHIPPED controls: `speed`, `dots` (a density, scaled by the fixture), `scale`, `persistence` (a half-life), `breathe`. The planned `emitter`, `orbit`, `flow` and `blend` controls were not built: one Lissajous walk and one flow rule proved enough, and a flow SELECT is open. + +**Nebula** (phase 4; composition; 💫🖌️ 3D). A noise field, thresholded against its own measured range so most of it is black, is the emitter; a curl flow carries it. SHIPPED controls: `speed`, `scale`, `contrast`, `persistence`, `octaves`, `fieldScale` (compute the field at half or quarter resolution), `fieldRate` (recompute it every N frames). + +**Fluid** (phase 5; solver; 💫🖌️ 3D; P4 and desktop). Jets pour velocity and dye into a Stam solver, one independent medium per depth slice. SHIPPED controls: `jets` (1 to 4), `force`, `swirl`, `viscosity`, `persistence`, `iterations` (the pressure solve, and the cost knob). `angle`, `vorticity` and `gravity` were not built. + +Each showcase gets its card in [effects.md](../moonmodules/light/effects.md) in the PolarNoise form (one line per control, the cost knob named as such), and its numbers in [performance.md](../performance.md). + +## 7. Tests and bench criteria + +- **Unit, per kernel, behavior-named.** Gradient noise: zero-mean over a large sample, range, continuity across cell boundaries, the same value on every ISA (a fixed-seed table of 64 samples). `PolarLut`: the LUT equals per-pixel `atan16`/`dist16` at 16-bit precision; the 8-bit default within one LSB of angle. `Oscillators`: `dt`-independence (two steps of `dt` equal one of `2·dt`), wrap, modulation bounds. `advect`: a single lit pixel moves exactly `v·dt` (sub-pixel, so two neighbors share it by the bilinear weights), wrap and clamp edges, the separable form equals the 2D form for an axis-aligned field, no write outside the buffer. `decay`: `decay(2·dt) == decay(dt)²` within one LSB, and a value never rises. `curl16`: divergence of the sampled field is zero within tolerance on a grid. `lineAA`/`disc`: total coverage of a disc equals its area within 2%; a line's coverage is independent of direction within 5%. `quantize`: `>> 8` bit-identical to the 8-bit path; dithering averages to the 16-bit value over 256 frames. 16-bit Layer: every `draw::` primitive tested at both widths. +- **Goldens.** A golden pins the plumbing, not the look (decision 5): phase 2 moves none, because a bit-identical quantize is the proof that widening the Layer is mechanical; phase 0 and phase 6 move goldens deliberately, with the reason in the commit and the product owner's comparison against the old frame on the panel, and that is the harness working. Each showcase adds its golden at both widths. +- **Scenarios, per family.** Fields: a `PolarLut` sized at prepare and rebuilt on a grid resize, with the memory ladder. Advection: Trails on a live pipeline at 16², 32², 64², 128² with the scratch plane in the ladder and the tick within its per-target contract. The 16-bit Layer: the existing `scenario_Layer_memory_1to1` and `scenario_MultiplyModifier_memory_lut` at both widths, and the cascade stepping from 16 to 8 bits on a constrained heap. Fluid: its grids in the ladder and its tick contract on the P4. Every showcase's fps target is a `contract` in its scenario. As shipped the contracts are +`desktop-macos` only and expressed in `tick_us`; no device contract exists yet, because the S3 and P4 +numbers have not been taken (§ 8, phase 3 step 5 and phase 5 step 3). +- **Perf.** The micro-bench target from the power-functions plan gains a row per kernel; `collect_kpi.py --commit` per phase on the S3 and P4; the desktop tick is the fast alarm. +- **The final gate is the product owner's eyes.** Per phase: the S3 bench board (shiffy, 80×48) for the panel class, the P4 (.139) for the MCU home, the desktop at 128×96 for the ceiling, and a wall run for Trails and Nebula. The criteria are visual: no tearing at the stated fps, no visible steps in a trail's tail, a shader that turns rather than scrolls, and the classic at 32² still smooth. + +## 8. Step-by-step implementation plan + +The steps below are work order, not commit order. Each step is finished on the desktop first, then the S3, then the P4, then the wall where it applies, and the product owner tests as steps land. **When to commit, and how much rides in one commit or PR, is the product owner's call** ([CLAUDE.md § Commit](../../CLAUDE.md#commit)): the pre-commit and pre-merge gates cost real time, so a commit carries several steps and a PR carries several phases. Nothing here implies one PR per phase. Order chosen so the first showcase lands before the one cross-cutting change. + +### Phase 0: measure, then swap the noise (small) +1. Add the kernel rows to the micro-bench target: value noise as it is, per sample, on desktop; record. +2. Implement Perlin improved noise behind `inoise8/16` (2D and 3D; 1D as the 2D with y = 0), integer, same coordinate convention, same output ranges. Delete the "value noise" note in `noise.h`. +3. Run the micro-bench: accept only within 1.3× per sample; else optimize (the 8-gradient 2D form, a 12-entry gradient table) before continuing. +4. Desktop build, `ctest`: the seven noise goldens fail as expected; update them in the same commit with the reason. +5. On the product owner's go-ahead (CLAUDE.md § Build: a board is written to only when they say so), flash S3 and P4; `collect_kpi.py --commit`; they judge Noise, PolarNoise and Tunnel on the panel. Catalog cards: no text change unless the look note in a card mentions value noise. + +### Phase 1: fields and Aurora (medium) +1. `core/oscillators.h` with its unit tests. +2. `light/polar.h`: `PolarLut` with 8-bit default and 16-bit opt-in; unit tests against `atan16`/`dist16`. +3. Migrate `PolarNoiseEffect` to the LUT (golden-pinned at 16-bit; the 8-bit default is a deliberate move judged on the panel) and to the bank for its drift. +4. `AuroraEffect`: the three-layer shader, controls per § 6, card in effects.md, golden. +5. MoonLive: `polar()` and `oscillators()` handles with their builtins; `polarA`/`polarR` read the LUT when declared; `aurora.mle`; the compile-every-script test and a script golden. +6. Scenario: fields (LUT rebuild on resize, memory ladder), Aurora's fps contract per target. +7. Desktop first; then S3 and P4 on the product owner's go-ahead; performance.md rows; their look. + +### Open from phase 1 + +**Aurora hitches on the desktop and not on the S3 (2026-09-04).** The product owner sees a periodic +hitch in the desktop preview; the same firmware on an ESP32-S3 at 64x64 looks smooth, and the S3's +LEDs were not yet judged. Five causes were tested and ruled out with measurements: + +| ruled out | the measurement | +|---|---| +| an integer overflow | every expression stays in range at every control setting, checked by hand | +| the drift sawtooth wrapping | worst single-frame field change at a wrap is 7 of 255, no spike | +| the field moving too fast | per FRAME the desktop moves 15x LESS than the S3 (0.009 against 0.136 noise cells), so it should read smoother | +| the contrast window's per-frame easing | its absolute worst frame is SMALLER at 385 fps (17.6) than at 25 fps (30.2) | +| the preview transport at a large grid | the product owner set the desktop to 64x64, matching the S3, and the hitch stayed | + +What is known: brightness changes are distributed rather than discrete (median 17 per frame, 99th +percentile 101), so there is no single glitching frame; and the desktop renders at ~385 fps against +the S3's 25, which is the one large difference left standing. The next thing to try is a frame-rate +cap on the desktop: if the hitch tracks the render rate rather than the wall clock, something in the +pipeline is per-frame where it should be per-unit-time, and the effect is not the place to look. + +**Aurora's layer crossover (2026-09-04).** With a winner-takes-all palette index, 12.7% of pixels +change which layer owns their color every frame, and 35% on the worst frame; each change moves the +hue a whole palette region. A weighted crossover (cubed weights, so a dominant layer still +dominates) removes the discontinuity and measured 2.7x less frame-to-frame change, for 3.4% more +time per pixel (42.8 against 41.4 ns on the host). Built, measured, then REVERTED on the product +owner's call: it did not address the hitch above, and the cost is real. Revisit when the hitch is +understood, since the two may be related. + +### Phase 1b: three dimensions everywhere (medium) + +Dimension-generic is a stated input of this plan (§ 0), and the library is not there yet. The gap is +uneven rather than absent, and the hot-path worry it raises turns out not to be the blocker. + +**Where it already holds.** `draw::` is `Coord3D` throughout (pixel, line, blendPixel, addPixel, +glyph). `blur` runs a third pass behind `if (z > 1)`, so a 2D layout pays nothing for the capability. +`inoise8` and `inoise16` have real 1D, 2D and 3D forms, each compiling to its own straight-line body +over exactly its corners after the phase 0 swap. + +**Where it does not.** `fbm16`, `turbulence8` and `warp8` stop at 2D; only `fbm8` has a 3D overload. +`PolarLut` stores angle and radius on a plane, with no z. `atan16` and `dist16` are planar by nature. + +**A 3D-capable kernel costs a 2D layout nothing**, and the tree already shows both ways to get that: +the third axis is either a template parameter that compiles away (`if constexpr (Dims > 2)` in the +noise core) or a runtime guard on a value that is 1 (`blur`). Neither is a per-pixel branch. So the +missing overloads are mechanical. + +The one that is not mechanical is **polar in three dimensions**, and the answer is a control rather +than a decision. Spherical (two angles and a radius), cylindrical (an angle, a radius and a height) +and distance-from-center are three different looks, and which one is right is a property of the +FIXTURE: a sphere wants the first, a tube or curtain the second, a cube may want any of them. The +choice costs nothing per pixel because it is made once when the table is built, and the table is the +same size either way. Cylindrical is the default: it reduces to exactly today's behavior when depth +is 1, so no existing 2D fixture changes. + +**What "dimension-generic" does NOT claim.** The KERNELS take any arity; an EFFECT still looks good +only at the arities its design has. Aurora on a strip is one radial line through the field, which +reads as a slow flicker rather than as curtains, and no amount of library work changes that. The +Layer already handles the mismatch (a D2 effect on a 3D layer has its z=0 slice copied across z, a +D1 effect its column copied across x), so an effect that declares less than the fixture is correct +rather than broken. Which effects earn a genuine 1D or 3D form is per-effect judgment, made in the +catalog sweep, not a property this phase delivers. + +1. `fbm16`, `turbulence8` and `warp8` gain their 3D overloads, matching `fbm8`'s existing shape. +2. `PolarLut` gains a z axis and a `mapping` control (cylindrical default, spherical, radial), with + the 2D path bit-identical to today's at depth 1. +3. The effects that are `Dim::D2` only because their kernels were: Aurora, PolarNoise and Tunnel are + the three that a volumetric fixture would show something new, and all three are blocked on step 2 + rather than on their own code. Noise2D needs no polar work at all: it already samples 3D noise + with time on z, so a volumetric form is passing the light's own z instead. +4. ✅ **Noise2D and Noise were the same effect**, and are now one: Noise + already uses the light's real z when depth > 1, and the two differ only in control names and + scale defaults (`bpm`/4 against `speed`/64). Merging them is a catalog decision with a golden and + a card, so it was done here rather than deferred: `motion` chooses drift (the old Noise) or morph + (the old Noise2D), the drift golden is unchanged, and the second effect is deleted. +5. Unit tests per kernel that a 2D call is exactly the 3D call with z held at zero, which is what + makes the extension safe to make everywhere else. ✅ *(shipped: `fbm8`, `fbm16`, `turbulence8` and + `warp8` all have 3D forms, and `warp8`'s 2D form is now literally the 3D one at z = 0, so the two + cannot drift apart.)* + +### Phase 1c: Coord3D in MoonLive (medium) + +A script computes a position as three loose integers today, and flattens it by hand +(`mod(bx+dx, width) + mod(by+dy, height) * width`), which is the buffer layout leaking into every +effect. The engine already passes `Coord3D` everywhere, so the script vocabulary is the odd one out. + +This is **not** general struct support. The MoonLive roadmap settles that: a predefined struct is one +the compiler knows by layout, needing no user-declarable struct machinery +([roadmap § 4b](moonlive-language-roadmap.md), with user structs deferred to § 10 for readability +rather than capability). It depends on the multi-value call ABI (§ 2 there), which is what makes a +coordinate in and a color out expressible at all. + +**`Coord3D` yes; the color struct waited for phase 2, which was reversed (§ 11), so the dependency is void and the struct is its own question.** The roadmap pairs `Coord3D` with a `CRGB`, +and that pairing predates the 16-bit decision. A script-visible color fixed at three `uint8_t` would +be the one place in the pipeline where the channel width stops being runtime data, exactly as phase 2 +makes `draw.h` a template over both widths and has `Correction` quantize once at the driver. So the +color type is specified AFTER phase 2 knows what it is, and it takes our own name rather than +FastLED's (CLAUDE.md: our own code, our own names). A coordinate has no such dependency, which is +why it goes first. + +**How far it goes: as far as every other type.** A predefined struct that only appears in builtin +signatures would be a special case, and the language does not need another one. `Coord3D` is a +class member, a local, a function argument and a return value, the same as `int`, `byte`, `bool` and +`fixed`. Arguments and returns are roadmap § 6, which this step therefore depends on. Two +consequences worth stating: a member costs three of the 8 member records and 6 of the 64 arena bytes +unless the compiler packs it as one record, which is worth deciding when the type is added rather +than after; and a local costs one frame slot per field under today's flat allocator, so a script +holding several coordinates meets the 16-slot ceiling faster (§ 8b there). + +Ordering note: this is the step that makes a 3D script possible, so it follows phase 1b rather than +leading it. A script that can hold a coordinate but calls kernels that ignore z has gained nothing. + +### Phase 2: the 16-bit Layer: BUILT, MEASURED, REVERSED (2026-09-04) + +**Decision: the wide frame buffer is not the way to precision; phases 3 onward are 8-bit with +effect-owned 16-bit planes.** The whole arc, the measurement that reversed it, what replaced it and +what is worth salvaging: [§ 11](#11-the-16-bit-question-what-it-was-both-answers-and-what-is-left). + + +### Phase 3: advection and Trails (medium) + +**Volumetric from the start (PO, 2026-09-04).** What shipped is per-slice: every slice gets its own +flow and they differ, but `advect16`'s rule yields vx and vy only, so light is carried WITHIN a slice +and not between them. The trilinear sampler and the vz below were not built, and 3D transport is +open. TrailsEffect is `Dim::D3`, as Aurora is: the bench +S3 is a 20x20x20 cube, so a flat trail would be the thing on the wall. Per function: `decay` and +`decay16` are already dimension-blind (they walk the buffer flat); `advect` needs a TRILINEAR +sampler for 3D, since `sampleWrap` takes `pos_t x, y` with a whole-pixel z, which is 8 corner reads +instead of 4 plus a `vz` from the rule; `flowWind`, `flowRadial`, `flowSpiral` and `curl16` all have +3D forms (3D curl is a cross product of gradients, not one perpendicular) while `flowRings` stays +planar; `disc` becomes a sphere through `sdSphere`. The effect is written 3D-aware from the start +because `dimensions()` and the plane sizing are decided once, but `advect` ships 2D first and gains +the trilinear path in step 4, so step 3 still reaches the wall early. **Memory is the constraint a +volumetric trail meets first**: a 16-bit RGB plane is 48 KB on a 20-cube and 1.5 MB on a 64-cube +(PSRAM only), against 24 KB for a 64x64 panel. + +1. ✅ `advect`, the velocity rules, `curl16`, `decay`, `disc`, with their unit tests. Two parts of the step did not ship: the SEPARABLE form of `advect` (the 2D and 16-bit forms are what exist), and `lineAA`, which turned out to be already in `draw.h` from 2026-08-07. +2. ✅ `TrailsEffect`: emitters, flows, the bank for breathing, scratch plane in `ScratchBuffer`, card, golden. +3. ✅ MoonLive: the flow, advect, decay and emitter builtins; `trails.mle`. +4. ✅ Scenario: `scenario_Trails_ladder`. +5. Desktop ✅ and the product owner's look ✅. S3, P4 and the wall are open, and performance.md has no Trails row yet. + +### Phase 4: composition, dithering, levers, SIMD (medium) +1. ✅ `quantize` with temporal and ordered dithering; tests. +2. ✅ `fieldScale` and `fieldRate` (`draw::upscale16`, and a frame counter the binding owns). +3. ✅ `NebulaEffect` and `nebula.mle`; card and golden. No Nebula scenario: the Fluid one covers the shared field machinery, so it was not worth its own. +4. The PIE variants of the noise inner loop and the bilinear lerp for S3 and P4 behind `src/platform/`, bit-identical tests against the scalar forms; the FPU `curl16` where `hasFpu`. + - **The P4's PPA is for `upscale`, not for advection (2026-09-04).** The Pixel Processing + Accelerator is a 2D-DMA blitter: it transforms a whole rectangle by ONE affine transform + (scale, rotate, translate, blend) with no CPU in the loop. Advection displaces every pixel by + its OWN velocity, so the interesting rules (`flowNoise`, `curl16`) do not map onto it at all, + and the ones that would (a rigid scroll, `flowRadial`) are the cheap cases anyway. Where it + does fit is `fieldScale`'s bilinear `draw::upscale` above, which is exactly a scaled blit, and + the sprite blit already backlogged under [Sprite follow-ups](backlog-light.md). Both are P4 + only, so PPA can be an acceleration behind an existing signature, never the main path. +5. KPI per target; the product owner's look on the wall. + +### A scripted control's name can disagree with the file that declares it (2026-09-04, open) + +Reproduced on the desktop with `Nebula.mle`, and the three facts contradict each other: + +- the module's `script` control holds `Nebula.mle` (so the UI is NOT lowercasing the selection); +- `GET /api/file?path=/moonlive/Nebula.mle` returns a file whose line 20 is `addControl("rate", ...)`; +- the UI renders that slider as **`rate1`**, a name the file no longer contains anywhere. + +So the compiled program disagrees with its own source. `resolveScript` is exact-match and prefers +the user's copy, which is correct, and the picker sets `option.value` to the exact filename, so +neither is the cause. The `rate1` text is a name the file held EARLIER in the session, which points +at the control list surviving a recompile rather than at the resolver or the UI. + +**Ruled out by measurement, so a future look need not repeat it**: the built-in table has room (67 of +96); `kMaxCtrls` is 8 against 5 declared; the 64-byte arena holds five `byte` members; declaration +ORDER is irrelevant (moving the control first changed nothing); the control NAME is irrelevant +(renaming it changed nothing); `aurora.mle` and `trails.mle` have the identical shape and are fine. + +**Where to start**: `rebuildControls()` after a recompile, and whether a declared control that +vanishes from the script is cleared from `ControlList` or merely left behind. A test that compiles a +script, edits one control's name, recompiles and asserts the OLD name is gone would pin it. + +**A second, smaller thing this exposed**: `/moonlive/Nebula.mle` and `/.moonlive/nebula.mle` are two +files on the device and one on a case-insensitive desktop, and nothing stops a user creating a +capital-N file beside a lowercase catalog entry. The fork mechanism assumes the two names match. + +### Phase 5: fluid (medium; P4 and desktop) +1. ✅ `light/fluid.h` with its unit tests (divergence after `project` within tolerance; a jet moves dye; rest state is stable). +2. ✅ `FluidEffect`; card, golden, and `scenario_Fluid_solver` (host; a P4 run needs a board). `fluid.mle` ships too, but NOT on the solver: the plan's `fluid`/`fluidJet`/`fluidStep` builtins were not built, so the script is jets pouring into a curl flow, which looks like the effect without being it. Exposing the solver to scripts is open. +3. ✅ performance.md carries the desktop rows; the P4 and S3 rows are open, since they need a board. + +**Two bugs the jets taught, both worth keeping.** The first shipped picture was a hollow RING, and +the cause was the forcing rather than the solver: jets pinned to one circle at `w/3`, all sweeping +the same direction, all aimed purely tangentially. Measuring speed by radius showed the energy in a +band (1.35 at r5 against 0.33 at the center) while `|grad v|` was a healthy 0.30 per cell, so the +medium was working and being asked the wrong question. The fix is three-part: the radius breathes +between center and wall, the aim leans either side of the tangent, and alternate jets sweep against +each other so they collide, because colliding jets are what roll up vortex pairs. After it, the peak +sits mid-panel (0.78 at r2) and the outer band halves. + +The second: a purely time-paced pour never fires on the opening tick, because `dt` is deliberately 0 +there, and on a fast device it then takes many frames to owe a whole period. The panel stays black +in the meantime and every resize repeats it. The first pour is now unconditional. The scenario's +`buffer non-zero after render` check is what caught it, at 16×16. + +### After the phases: the catalog sweep (large; its own plan) +Decision 5 mandates that every natural-motion effect runs on the power functions. That sweep is not a phase of this plan: it is sized and sequenced in its own plan once the kernels exist, from the [effects × power functions inventory](effects-power-function-inventory.md), which records for each of the 58 effects what it uses today and what it could use. Two rules carry over: each rewrite is compared on the panel against the effect as it ran and lands only if at least as beautiful, and an effect a showcase supersedes outright is deleted, the way a particle-system effect replaces its non-PS twin. + +## 9. Resource accounting + +- **Flash:** the gradient noise is a 16-entry gradient table (Perlin's twelve cube-edge gradients padded to sixteen so the index is a mask) and NO permutation table: the corner hash is a multiply, which is what made it branch-free on Xtensa; the LFO bank and LUT are small; the 16-bit Layer instantiates the frame-op set twice (estimate low single-digit KB per target ❓, read from the repo-health flash table per batch); the SIMD variants are per-target only. +- **RAM:** all sized buffers are `prepare()`-time, PSRAM-preferred, `dynamicBytes`-reported, zero static (`check_footprint`). As shipped: `PolarLut` 2 B/pixel default; a wide-plane effect carries two 16-bit planes plus a 1-byte dither carry, so 15 B/light on top of the Layer's 3 (5 bytes per channel, three channels) (measured, Trails at 64x64x10: 614,400 B against the Layer's 122,880); the fluid holds two velocity grids sized to the volume plus four working grids sized to ONE SLICE (`int32` throughout). The 16-bit Layer's row is gone with the decision (§ 11). +- **Cycles:** budgets in § 5; the KPI gate per phase; the desktop tick as the fast alarm. +- **Repo:** goldens are hashes; scenarios record their observations; each phase adds ~1 effect, ~1 script, ~1 scenario. +- **Boundary:** `noise`/`oscillators` core; `polar`/`draw`/`fluid` light; SIMD and FPU variants only under `src/platform/`, selected by `platform_config.h` flags, never an `#ifdef` outside. +- **Complete construct, real consumer:** every kernel lands with its showcase and its script in the same PR. + +## 10. Decisions for sign-off + +1. ↩️ **Layer width mechanism: B** (16-bit default, 8-bit as the first cascade step, decided per Layer at prepare from free memory, one template). Agreed 2026-09-03, BUILT, then REVERSED on the measurement (§ 11): the buffer is 8 bits and precision is held in effect-owned planes instead. +10. ⬜ **Quantization lives in `Correction`, once per driver, to the driver's declared wire width**; the pipeline is 16-bit end to end and 16-bit LEDs become driver increments. Agreed 2026-09-03, NOT built: it followed from decision 1, which was reversed. Quantization lives in `draw::blit16`, at the one point a wide plane meets the 8-bit buffer. +2. **Gradient noise swap accepted on the 1.3× per-sample bound**, goldens moved in one commit, judged on S3 and P4 (§ 3.1). +3. **MoonLive kernels operate on the script's Layer; LUT, bank and fluid are handles** (§ 4). +4. **Phase order**: noise, fields and Aurora, the 16-bit Layer, advection and Trails, composition, fluid (§ 8). Alternative: the 16-bit Layer first, if the cross-cutting risk should be retired before any showcase. +5. **Showcase names and targets** (§ 6): the fps-per-fixture numbers become scenario contracts on acceptance. +6. **`PolarLut` 8-bit default** (the earlier decision, restated because Aurora's angular resolution on a 128² wall may want 16-bit as its default) ❓. +7. **Temporal dithering default**: on for 16-bit layers once phase 4 lands, or off with a per-driver switch (the leddriver analysis's per-driver mode) ❓. +8. **Fluid on the S3 and classic**: runs with its card stating the panel size it holds, or hidden behind a capability flag. Recommendation: runs, honestly labeled. +9. **Two documents still state the old stance and need the PO's edit under decision 5**: [power-functions.md § Migrating an effect](../moonmodules/light/power-functions.md) ("Step 1, the port: behave identically") and the header of `test/unit/light/golden_frame.h` ("pixel-identical by default"). Proposal: "faithful first" stays, and its purpose is stated: it guards against degrading an effect into a quick-and-dirty one (the early Game of Life port), not against improving it. Two sentences follow it: every effect runs on the power functions for whatever they cover, and an effect that does not is rewritten on them, with the effect as it ran as the reference and "at least as beautiful on the panel" as the bar; algorithmic effects (a rule set is the effect) keep their logic. The golden is re-baselined in the same commit with the reason, and the harness comment reads "a golden pins the plumbing; a deliberate re-baseline records an improvement the product owner judged". + +## 11. The 16-bit question: what it was, both answers, and what is left + +The plan opened with a decision to widen the Layer buffer to 16 bits per channel (§ 10, decision 1). +That was built, measured, and reversed, and the precision problem it existed to solve was then solved +a different way. This section is the whole arc, because the reversal is the most useful thing the +work produced and a stash is a poor place to keep a lesson. + +### The problem is real, and it is narrower than it looks + +An 8-bit channel multiplied by slightly less than one, many times a second, has nowhere to go. A +decay to a half-life of two seconds at 60 fps wants to multiply by about 0.994 per frame: at 8 bits +a value of 100 either truncates to 99 and keeps truncating, reaching 0 far too fast, or rounds back +to 100 and never fades at all. The tail either vanishes or freezes, and neither is a tail. + +That is the case that motivated the phase. The important observation, which took building the wrong +thing to see, is WHERE it happens: in a plane that DECAYS, frame after frame. The Layer buffer is +written fresh by every effect on every frame, so its precision is a display question, not an +accumulation one. Banding on a buffer is a dither problem. Banding on a decaying plane is a width +problem. + +### Answer A: widen the Layer (built, measured, reversed) + +Phase 2 made `Buffer`, `Canvas` and every `draw::` primitive width-templated, taught every driver and +both preview paths to read at the layer's width, and kept every golden matching through a narrowed +hash. It worked. Then it was measured: + +| | 8-bit | 16-bit | cost | +|---|---|---|---| +| ESP32-S3, 128x128 | 18.25 ms | 28.58 ms | **+56.5%** (effect +50%, driver read +63%) | +| desktop arm64, 128x128 | 0.48 ms | 0.76 ms | +57.9% | + +Two architectures that usually disagree agreed within 1.5 points, which is what makes the number +trustworthy. **On the S3 the DRIVER READ was the larger half.** Reassembling two bytes into a channel +happens `lights x channels` times per frame whatever the effect does, and an in-order core with no +branch predictor punishes it. The 8-bit dispatch itself cost nothing measurable, so the work was +correct; it was aimed one layer too high. + +### Answer B: widen the plane that accumulates (shipped) + +Trails, Nebula, Fluid and the scripted trail each own a 16-bit plane, advect and decay it at full +width, and narrow ONCE on the way out through `draw::blit16` with temporal dithering. The buffer +stays 8 bits, so the driver read is untouched, which is the entire +56% avoided. + +Measured on the desktop at 64x64x10, the same grid for both: + +| | tick | dynamic bytes | +|---|---:|---:| +| Trails (two 16-bit planes + carry) | 1913 us | 614,400 | +| Noise (8-bit, no state) | 920 us | 0 | +| the Layer buffer itself | | 122,880 | + +**The cost moved to memory, and it is larger per effect than the wide buffer would have been**: 5 +bytes per light (two 16-bit RGB planes plus a 1-byte dither carry) against the 3 the Layer holds, so +2.5x what a 16-bit buffer would have cost. Three properties make that acceptable where the buffer was +not: only effects that need it pay, it is `prepare()`-time and PSRAM-preferred, and it is freed when +the effect is removed. A 64-cube would want 3.75 MB for the set (two 1.5 MB planes plus the carry), which is a PSRAM-class effect and the +cards say so. + +### What to learn from it + +**Measure the whole path, not the kernel.** The effect-side cost of a wide buffer was the obvious +half and the smaller one. Nobody predicted the driver read, and no host benchmark would have found it +either: it is an in-order-core property. + +**Precision is not a pipeline property, it is a per-consumer one.** The plan reasoned "the pipeline +should be 16-bit end to end", which is a tidy sentence and an expensive design. What the code needed +was 16 bits in the four places that accumulate. + +**A reversal is worth more than the feature.** Phase 2 cost a day and produced a number that will +stop this being proposed again for years. That is why the numbers live here rather than in a commit +message. + +**Building it was the only way to measure it.** The estimate before the work was "some overhead"; the +answer was +56%. Neither number could have been argued to. + +### Follow-ups, in the order they are worth doing + +1. **Tag the stash, or lose it.** The phase-2 work is 65 files, +1826/-362, in `stash@{0}` and + NOWHERE else: no branch, no tag. A stash does not survive `git stash drop`, a fresh clone, or a + dead disk. `git tag phase2-16bit-layer stash@{0}` costs nothing and makes "recoverable if the wall + says otherwise" true. Product owner's call, since it is a git operation. +2. **Cherry-pick `Correction`'s dithering** (`src/light/drivers/Correction.h`, +158 in the stash). + Decision 10 put quantization in `Correction`, once per driver, at the driver's declared wire + width. That decision followed from the wide buffer and so was never built, but the DRIVER-side + half stands on its own: an 8-bit buffer dithered at the wire is the cheapest remaining win for + banding on a real panel, and it is the one piece of phase 2 whose cost does not scale with lights + x channels. +3. **The five shared-primitive bugs the phase found are still real** under any wide buffer: `fill`, + Canvas `fade`, `blur`, `Layer::extrude` and `effectSetChannel` all assume one byte per channel. + They are correct at 8 bits, so nothing is broken today, but they are a trap for the next wide + thing. Worth a comment at each site rather than a fix. +4. **16-bit LEDs remain unanswered.** Some drivers can carry more than 8 bits to the wire. With the + buffer at 8 that is now a driver-increment question rather than a pipeline one, which is a smaller + and better-shaped problem than the plan assumed. + +## Out of scope + +16-bit-native LED drivers (HD108, UCS7604): enabled by the per-driver quantization, each its own increment starting with the datasheet; a polar modifier on the LUT (modifier-first path, its own increment); the second-core field producer; 3D variants of the velocity rules and the LUT (the dimension audit's "waits for a consumer" rule); a MoonLive per-pixel inline-op program beyond the LUT loads (the roadmap's § 4c work); GPU acceleration on desktop; supersync's protocol (each stateful kernel declares its resync point, no more). diff --git a/docs/backlog/moonlive-language-roadmap.md b/docs/backlog/moonlive-language-roadmap.md index aeabea33..3322d5b1 100644 --- a/docs/backlog/moonlive-language-roadmap.md +++ b/docs/backlog/moonlive-language-roadmap.md @@ -32,16 +32,22 @@ Five hard limits, all found by hitting them: | limit | value | where | |---|---|---| -| script state | **64 bytes** shared by all members | `kCtrlBytes`, `MoonLiveBuiltins.h:132` | +| script state | **64 bytes** shared by all members | `kCtrlBytes`, `MoonLiveBuiltins.h:188` | | distinct members | **8** | `kMaxCtrls`, same file | -| branch labels | **16** (an `if` or `for` takes up to 2) | `kIrLabels`, `MoonLiveIr.h:201` | +| branch labels | **16** (an `if` or `for` takes up to 2) | `kIrLabels`, `MoonLiveIr.h:228` | +| frame slots | **32**, shared by live variables, loop counters and staged call arguments ✅ | `kMaxLocals`, `MoonLiveIr.h` | | ~~numeric types~~ | ~~`uint8_t`, `uint16_t`, `int16_t`~~ → **`int`, `byte`, `bool`, `fixed`, `string`** ✅ | still no float: `fixed` is Q16.16 | -| ~~builtin table~~ | ~~16, and 16 used~~ → **64** ✅ | `BuiltinTable::kMax` — raised, with an overflow assert | +| ~~builtin table~~ | ~~16, and 16 used~~ → **96** ✅ | `BuiltinTable::kMax` — raised, with an overflow assert | The branch budget was binary-searched with generated scripts: **6 `if`/`else` + 2 `for` compiles, 7 does not.** The state budget is what caps the balls effect at 4 balls rather than 25 — six fields per ball needs ~150 bytes and six members. +The frame-slot budget was hit writing `aurora.mle` (2026-09-04), the first two-layer shader: it +compiles only after folding intermediates back into the expressions that use them, which is the +readability the language exists to give. Factoring the loop body into a helper would relieve it, +once helpers can take arguments and return values (§ 6). See § 8b. + ## What a simulation effect gives up today Each row is a compromise the balls effect makes, and the language feature that would remove it: @@ -91,8 +97,7 @@ than "add a language feature": 3. **Typed multi-argument host calls** (≤ 6 args, optional return). Today a builtin takes one `uint32_t` and returns one, which is why `line()` had to be given a bespoke seven-argument staging path and why most of the library is inexpressible. -4. **Script symbols** `x/y/z/w/h/d/time` — already threaded to the runtime entry point, needing - only grammar exposure. +4. ✅ **Script symbols** — SHIPPED as `t`, `width`, `height`, `depth`, `xPos`, `yPos`, `zPos`. 5. **Two entry shapes:** `frame()` for composing kernels (the scalable path) and `pixel(x,y,z)` for per-pixel ergonomics (honest ceiling around 32×32). 6. **Stateful handles** — a `Pool`, a `BeatPhase` — script-declared, arena-allocated at compile @@ -211,13 +216,17 @@ Ordered by **what removing it buys**, not by implementation cost. and it failed SILENTLY: `add()` returned false, no caller checked it, and the next builtin would have surfaced as "unknown function" in a script with nothing pointing at the cause. -Now 64 (what the power-functions spec asks for), with `MM_ASSERT_NO_BUILTIN_OVERFLOW` so a dropped +Now 96 (what the power-functions spec asks for), with `MM_ASSERT_NO_BUILTIN_OVERFLOW` so a dropped registration is loud at startup rather than silent. This gated every other builtin; the palette work below went in immediately behind it. -### 2. Typed multi-argument host calls — *the library's blocker* +### 2. Typed multi-argument host calls — ✅ SHIPPED + +SHIPPED: a builtin takes `const uintptr_t* args` with arities up to seven, plus typed flags +(`fixedArgs`, `fixedReturn`, `byRef`, `byStr`), and `emit` uses all seven. The paragraph below is the +problem as it stood before that. -A builtin takes one `uint32_t` and returns one. `line()` needed a bespoke seven-argument staging +A builtin took one `uint32_t` and returned one. `line()` needed a bespoke seven-argument staging path to exist at all, and most of the power-functions surface cannot be expressed without this. The spec asks for ≤ 6 arguments plus an optional return. @@ -246,7 +255,7 @@ Not purely a constants bump, and the blockers are known: - `kCtrlBytes` / `kMaxCtrls` are both `uint8_t`, so the arena caps at 255 bytes before any type change. 150 bytes of particle state fits under that; much more does not. - `static_assert(kCtrlBytes <= 64, "seeded_ is a 64-bit mask, one bit per script arena byte")` - (`MoonLive.h:285`) is the real gate. Past 64 bytes the seeded-member mask needs re-indexing — + (`MoonLive.h:355`) is the real gate. Past 64 bytes the seeded-member mask needs re-indexing — and there is a worked example, because it was widened 16 → 64 once already. The assert exists because the earlier `uint32_t` version silently aliased members mod 32. - Watch `sizeof(MoonLive)`. It is held BY VALUE in every scripted module and constructed on the @@ -335,7 +344,7 @@ variables, so a new builtin should take a name a script would not: `polarA`/`pol the obvious ones. The compile-every-script test caught it immediately, which is the argument for keeping that test cheap to run. -### 4b. Two predefined structs: `Coord3D` and `CRGB` — *and they make #4 land properly* +### 4b. Predefined structs: `Coord3D` now, a color type after the 16-bit Layer — *and they make #4 land properly* Most of what a script manipulates is a POSITION or a COLOUR, and today both are loose integers: a coordinate is three separate values or an index the script computes by hand @@ -343,7 +352,22 @@ coordinate is three separate values or an index the script computes by hand travel together. Two predefined types would carry them: - **`Coord3D`** — `{x, y, z}`, the shape `setXYZ`, `addLight` and every layout already think in. -- **`CRGB`** — `{r, g, b}`, the FastLED name, matching `RGB` in `core/color.h`. +- **A color struct** — `{r, g, b}`, matching `RGB` in `core/color.h`. + +**No `HSV` struct.** The builtins already record the decision and the reason +(`MoonLiveBuiltins_light.h`): a hue wheel is how an effect picks color while IGNORING the user's +palette, and 47 of 52 compiled effects were moved off that habit. A predefined `HSV` would put it +back as the easy default. HSV stays where it earns its place, in `setPalEntryHSV`, which is +authoring a palette rather than bypassing one. + +**The color one waits, and does not take FastLED's `CRGB` name.** The +[generative-fields plan](generative-fields-analysis-top-down.md) PROPOSED making the Layer 16-bit +with the channel width decided at run time and `draw.h` templated over both widths, then reversed it +on the measurement (that plan's § 11), so this reason is void and the color struct is its own +question. As written: a script-visible color +fixed at three `uint8_t` would be the one place the width stops being runtime data. Specify it after +that phase lands, under our own name (CLAUDE.md: our own code, our own names). `Coord3D` has no such +dependency and goes first. Predefined rather than user-declarable structs (#10): these two are what the ENGINE already passes around, so they need no general struct machinery — just two known layouts the compiler understands @@ -355,6 +379,14 @@ The payoff is that #4 becomes the natural signature rather than a special case: setColorFromPalette(pos, index, brightness); // pos is a Coord3D ``` +**Scope: a predefined struct is a type, not a calling convention.** One that appeared only in +builtin signatures would be a special case the language does not need. `Coord3D` is a class member, +a local, a function argument and a return value, exactly as `int`, `byte`, `bool` and `fixed` are, +which makes § 6 (arguments and returns) a prerequisite rather than a nicety. Two costs to settle +when it is added: whether a member is one member record or three of the eight, and hence 6 of the 64 +arena bytes; and that a local occupies one frame slot per field under today's flat allocator, so a +script holding a few coordinates reaches the 32-slot ceiling sooner (§ 8b). + One call, one brightness evaluation, and the index arithmetic stops being open-coded at every call site. It also removes the `mod(...) + mod(...) * width` flattening a script writes today, which is the buffer layout leaking into every effect. @@ -383,12 +415,33 @@ does not foreclose float. **Settled** by the type-system design above: `fixed` is Q16.16 on a uniform int slot, float stays out, and the range analysis lives there. -### 6. Function arguments — *moderate, removes a real footgun* +### 6. Function arguments and return values — *moderate, removes a real footgun* `draw(i)` instead of setting a member the helper reads. The current shape is not just verbose: caller and callee agree by convention and nothing checks it, so a helper called from two places with different state silently does the wrong thing. It is also what makes helpers composable. +Script functions DO exist and ship: `balls.mle` calls `drawBall()`, `crosshair.mle` calls three +helpers, and every layout and modifier is one. Recursion works. Three limits sit on top of them, and +`aurora.mle` hit all three at once writing a `bright(v)` helper to apply one contrast window to two +layers (2026-09-04). **The return value shipped the same day**, so two remain: + +| limit | what the compiler says | +|---|---| +| no parameters | `a script function takes no arguments yet` | +| no forward calls: a helper must be declared ABOVE its caller | `unknown function`, with the column but not the name | + +A function may now RETURN a value: `int f() { return ...; }` and the call is an expression. Each +backend's `callLabel` preserves the whole vreg pool and delivers the result into the destination +register, so a value survives the calls that follow it in the same expression. + +The forward call is the cheapest to build and the most confusing to meet, since the message is the +one an actual typo produces: a second pass over the class body resolves it, which the code comment +names as the reason it is refused rather than half-supported. + +Parameters and returns also relieve § 8b: a helper's variables are live only inside it, so factoring +a loop body into a function is how a script stays under the frame budget. + ### 7. Signed values: ✅ *shipped* `int16_t` members, signed comparison, signed `/` and `%`, and `uvX`/`uvY` returning a signed @@ -425,6 +478,35 @@ script value cannot express however signed it is. compile-time table space and nothing at run time. Measure what a realistic effect needs before picking a number — the balls port wanted ~12 and had to be folded down. +### 8b. More frame slots — *the encoding is NOT the limit; measure the stack* + +**32 live variables**, shared by a script's named variables, its loop counters, and the arguments it +stages for a call. The budget is what is live AT ONCE rather than a total: a call hands its staging +slots back, and an `if`, `else` or `for` block hands its locals back at the closing brace +([MoonLiveEffect.md](../moonmodules/light/MoonLiveEffect.md) documents both). A script that exceeds +it fails with "too many variables in this function", "too many arguments to hold" or "too many loop +variables". + +Sixteen is enough for the shipped corpus and was not enough for the first two-layer shader: +`aurora.mle` wants an oscillator per layer, the grid centre, a polar angle and radius, and a field +sample per layer, all live in the same scope inside two nested loops, with the loop counters and +each call's staged arguments drawn from the same 16. + +`kMaxLocals`'s own comment says raising it "means widening the frame on all three backends together +— the slot index is an instruction field". **Measured on the shipped assemblers, that is not so:** + +| backend | how a slot is addressed | slots the encoding allows | +|---|---|---| +| Xtensa | `s32i/l32i`, an 8-bit offset counting 4-byte words | 256 | +| RISC-V | `sw/lw`, a 12-bit signed offset from the frame pointer | 2048 | +| host (arm64, x86-64) | an offset from a parked frame pointer | not encoding-bound | + +So the real cost is **stack**, not encoding: each slot is 4 bytes of frame on the render task, and +`kAsmLabels`/`kAsmFixups` next door carry the warning that this project has already bootlooped a P4 +on an oversized stack frame. 16 slots is 64 bytes; 32 would be 128. That is a cheap change on its +face, and the measurement settled it: **`kMaxLocals` is 32** (2026-09-04), 128 bytes of frame, and +the encoding was never the limit on any backend. + ### 9. Division: ✅ *shipped* `/` and `%` are operators, at multiplication's precedence. Both lower to a host call the way `mod` @@ -460,12 +542,19 @@ Not exposed, each with a reason: `spray` (`emit` with a wide cone is one), `spawn` (per-particle in a whole-pool API), `force`/`forceSmall` (needs the `acc` buffer for wind nothing needs yet), `attract`, `wrap`, `liveCount`, `clear`. -### 10. Structs — *readability, once the arena is bigger* +### 10. User structs, and arrays of them — *readability, once the arena is bigger* `ball[i].x` instead of parallel arrays. Genuinely nicer and closer to how a precompiled effect reads, but parallel arrays work the moment the arena is big enough. Last because #1 removes most of the pain, not because it does not matter. +Arrays of the five scalar types already ship (`byte heat[16]` in `ember.mle`), so what is missing +here is arrays OF STRUCTS, and it splits in two. An array of a PREDEFINED struct (§ 4b) is the +smaller half and the one an effect reaches for first: a script holding `Coord3D pos[8]` is exactly +the parallel-array flattening this item exists to remove, and the element width the array path +already carries (`idxPack`) is the machinery it needs. An array of a USER struct needs the general +declaration machinery above it. Worth building in that order if this is picked up. + ## How to know a step landed Two measures, one local and one external. diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index bdd5d250..81b3e7d2 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,7 +1,7 @@ { - "commit": "803cae97", + "commit": "df8a8b0f", "flash": { - "esp32s3-n16r8": 1902352, + "esp32s3-n16r8": 1998944, "desktop": 1692152, "esp32": 1923264, "esp32p4rev1-eth": 1675216, @@ -17,20 +17,20 @@ }, "perf": { "desktop": { - "tick_us": 845, - "fps": 1183, + "tick_us": 135, + "fps": 7407, "scenario_p50": { "Layer_base_pipeline": { - "p50": 75, + "p50": 107, "p95": 211, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "Layer_memory_1to1": { - "p50": 9, - "p95": 40, + "p50": 10, + "p95": 41, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" } } }, @@ -44,7 +44,7 @@ "p50": 131, "p95": 246, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "esp32-eth-wifi": { "p50": 89895, @@ -157,10 +157,10 @@ }, "Audio_mutation": { "desktop-macos": { - "p50": 33, - "p95": 307, + "p50": 45, + "p95": 790, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "desktop-windows": { "p50": 40, @@ -181,12 +181,20 @@ "last": "2026-07-24" } }, + "Aurora_fps": { + "desktop-macos": { + "p50": 937, + "p95": 1423, + "n": 4, + "last": "2026-09-04" + } + }, "Driver_mutation": { "desktop-macos": { - "p50": 37, - "p95": 665, + "p50": 49, + "p95": 285, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "desktop-windows": { "p50": 42, @@ -209,10 +217,10 @@ }, "Effects_composition": { "desktop-macos": { - "p50": 343, + "p50": 497, "p95": 2259, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "desktop-windows": { "p50": 549, @@ -221,12 +229,20 @@ "last": "2026-07-08" } }, + "Fields_polar_lut": { + "desktop-macos": { + "p50": 981, + "p95": 1206, + "n": 6, + "last": "2026-09-04" + } + }, "GridBlacks_blackpixel": { "desktop-macos": { - "p50": 5, + "p50": 7, "p95": 25, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "esp32s3-n16r8": { "p50": 267, @@ -249,10 +265,10 @@ }, "GridLayout_resize": { "desktop-macos": { - "p50": 167, - "p95": 311, + "p50": 132, + "p95": 298, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "esp32-eth-wifi": { "p50": 82231, @@ -293,10 +309,10 @@ }, "Layer_base_pipeline": { "desktop-macos": { - "p50": 75, + "p50": 107, "p95": 211, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "desktop-windows": { "p50": 118, @@ -307,10 +323,10 @@ }, "Layer_memory_1to1": { "desktop-macos": { - "p50": 9, - "p95": 40, + "p50": 10, + "p95": 41, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "desktop-windows": { "p50": 1, @@ -321,10 +337,10 @@ }, "Layouts_mutation": { "desktop-macos": { - "p50": 130, + "p50": 171, "p95": 2190, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "desktop-windows": { "p50": 111, @@ -373,10 +389,10 @@ }, "MoonLiveEffect_livescript": { "desktop-macos": { - "p50": 8, - "p95": 482, + "p50": 14, + "p95": 151, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "esp32s3-n16r8": { "p50": 8255, @@ -423,10 +439,10 @@ "last": "2026-08-20" }, "desktop-macos": { - "p50": 7, + "p50": 11, "p95": 185, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "desktop-windows": { "p50": 1, @@ -437,10 +453,10 @@ }, "MultiplyModifier_memory_lut": { "desktop-macos": { - "p50": 4, + "p50": 6, "p95": 165, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "desktop-windows": { "p50": 3, @@ -451,10 +467,10 @@ }, "MultiplyModifier_pipeline": { "desktop-macos": { - "p50": 133, + "p50": 129, "p95": 283, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "desktop-windows": { "p50": 225, @@ -465,10 +481,10 @@ }, "modifier_chain": { "desktop-macos": { - "p50": 62, + "p50": 78, "p95": 473, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "desktop-windows": { "p50": 69, @@ -485,10 +501,10 @@ }, "modifier_swap": { "desktop-macos": { - "p50": 31, + "p50": 46, "p95": 456, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "esp32-eth": { "p50": 1010, @@ -523,10 +539,10 @@ }, "perf_full": { "desktop-macos": { - "p50": 429, - "p95": 2011, + "p50": 589, + "p95": 2027, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "esp32s3-n16r8": { "p50": 16915, @@ -555,10 +571,10 @@ }, "perf_light": { "desktop-macos": { - "p50": 23, + "p50": 35, "p95": 134, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "esp32s3-n16r8": { "p50": 2485, @@ -599,10 +615,10 @@ "last": "2026-07-25" }, "desktop-macos": { - "p50": 378, - "p95": 1666, + "p50": 586, + "p95": 2098, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "desktop-windows": { "p50": 649, @@ -625,10 +641,10 @@ "last": "2026-07-24" }, "desktop-macos": { - "p50": 6, + "p50": 9, "p95": 38, "n": 32, - "last": "2026-09-02" + "last": "2026-09-03" }, "esp32p4rev1-eth": { "p50": 217, @@ -652,54 +668,54 @@ } }, "loc": { - "core": 24602, - "light": 30425, + "core": 24938, + "light": 31807, "platform": 17463, - "ui": 9964, - "test": 53126, - "moondeck": 22414 + "ui": 10089, + "test": 54681, + "moondeck": 22488 }, "comments": { "core": { - "lines": 9786, - "ratio": 0.43 + "lines": 9940, + "ratio": 0.431 }, "light": { - "lines": 11631, - "ratio": 0.421 + "lines": 12233, + "ratio": 0.423 }, "platform": { "lines": 6117, "ratio": 0.385 }, "ui": { - "lines": 2895, - "ratio": 0.307 + "lines": 2969, + "ratio": 0.311 }, "test": { - "lines": 9875, + "lines": 10179, "ratio": 0.213 }, "moondeck": { - "lines": 3641, - "ratio": 0.186 + "lines": 3643, + "ratio": 0.185 } }, "tests": { - "cases": 1794, - "scenarios": 23 + "cases": 1876, + "scenarios": 25 }, "docs": { - "md_files": 214, - "md_lines": 34049, - "plans_files": 114, - "backlog_lines": 5520, - "lessons_lines": 648, - "claude_md_lines": 216 + "md_files": 218, + "md_lines": 35121, + "plans_files": 115, + "backlog_lines": 6338, + "lessons_lines": 678, + "claude_md_lines": 237 }, "complexity": { - "functions": 3214, - "over_threshold": 212, + "functions": 3290, + "over_threshold": 220, "worst_ccn": 108 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 6c1082a3..0c6d62fa 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `803cae97`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `df8a8b0f`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -10,15 +10,15 @@ Current state only; the trend is this file's git history (`git log -p docs/metri |---|---:|---:|---:|:--:| | desktop | 1,652 KB | - | - | carried | | esp32 | 1,878 KB | 2,496 KB | 75% | carried | -| esp32-16mb | 1,767 KB | 4,096 KB | 43% | carried | -| esp32-eth | 1,365 KB | 2,496 KB | 55% | carried | -| esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried | +| esp32-16mb | 1,767 KB | - | - | carried | +| esp32-eth | 1,365 KB | - | - | carried | +| esp32-wrover | 1,801 KB | - | - | carried | | esp32p4rev1-eth | 1,636 KB | 4,096 KB | 40% | carried | -| esp32p4rev1-eth-wifi | 1,972 KB | 4,096 KB | 48% | carried | -| esp32p4rev3-eth | 1,605 KB | 4,096 KB | 39% | carried | -| esp32s3-n16r8 | 1,858 KB | 4,096 KB | 45% | carried | -| esp32s3-n8r8 | 1,925 KB (+135 KB) ⚠ | 3,072 KB | 63% | yes | -| esp32s3-zero | 1,747 KB | 2,496 KB | 70% | carried | +| esp32p4rev1-eth-wifi | 1,972 KB | - | - | carried | +| esp32p4rev3-eth | 1,605 KB | - | - | carried | +| esp32s3-n16r8 | 1,952 KB (+94 KB) ⚠ | 4,096 KB | 48% | yes | +| esp32s3-n8r8 | 1,925 KB | - | - | carried | +| esp32s3-zero | 1,747 KB | - | - | carried | | esp32s31 | 2,056 KB | 4,096 KB | 50% | carried | | qemu | 1,351 KB | - | - | carried | @@ -28,47 +28,49 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Tick | FPS | |---|---:|---:| -| desktop | 845 µs (+388 µs) ⚠ | 1,183 (−1,005) ⚠ | +| desktop | 135 µs (−710 µs) ✓ | 7,407 (+6,224) ✓ | | esp32 | 8,354 µs | 119 | ### Scenario tick by target (p50 of each sample window) | Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | unknown | |---|---|---|---|---|---|---|---|---|---| -| Audio_mutation | 33 (−1) ✓ | 40 ? | 13,152 | 47 ? | - | - | - | - | - | -| Driver_mutation | 37 | 42 ? | 12,812 | 39 ? | - | - | - | - | - | -| Effects_composition | 343 (+14) ⚠ | 549 ? | - | - | - | - | - | - | - | -| GridBlacks_blackpixel | 5 (+1) ⚠ | 8 ? | 269 ? | 267 ? | - | - | - | - | - | -| GridLayout_resize | 167 | 219 ? | 1,352 ? | 1,011 ? | 1,143 ? | - | 95,771 ? | 82,231 ? | - | -| Layer_base_pipeline | 75 (+2) ⚠ | 118 ? | - | - | - | - | - | - | - | -| Layer_memory_1to1 | 9 | 1 ? | - | - | - | - | - | - | - | -| Layouts_mutation | 130 (+11) ⚠ | 111 ? | 13,692 | 45 ? | - | - | 27 ? | - | - | +| Audio_mutation | 45 (+12) ⚠ | 40 ? | 13,152 | 47 ? | - | - | - | - | - | +| Aurora_fps | 937 | - | - | - | - | - | - | - | - | +| Driver_mutation | 49 (+12) ⚠ | 42 ? | 12,812 | 39 ? | - | - | - | - | - | +| Effects_composition | 497 (+154) ⚠ | 549 ? | - | - | - | - | - | - | - | +| Fields_polar_lut | 981 | - | - | - | - | - | - | - | - | +| GridBlacks_blackpixel | 7 (+2) ⚠ | 8 ? | 269 ? | 267 ? | - | - | - | - | - | +| GridLayout_resize | 132 (−35) ✓ | 219 ? | 1,352 ? | 1,011 ? | 1,143 ? | - | 95,771 ? | 82,231 ? | - | +| Layer_base_pipeline | 107 (+32) ⚠ | 118 ? | - | - | - | - | - | - | - | +| Layer_memory_1to1 | 10 (+1) ⚠ | 1 ? | - | - | - | - | - | - | - | +| Layouts_mutation | 171 (+41) ⚠ | 111 ? | 13,692 | 45 ? | - | - | 27 ? | - | - | | MoonLiveEffect_controls | 11 ? | - | 12,901 | 4,624 ? | - | - | - | - | - | -| MoonLiveEffect_livescript | 8 (+1) ⚠ | - | 13,433 ? | 8,255 ? | 11,336 ? | - | - | - | - | -| MoonLive_pipeline | 7 (+1) ⚠ | 1 ? | 9,604 ? | 3,278 ? | - | 11,398 ? | - | - | 4,393 ? | +| MoonLiveEffect_livescript | 14 (+6) ⚠ | - | 13,433 ? | 8,255 ? | 11,336 ? | - | - | - | - | +| MoonLive_pipeline | 11 (+4) ⚠ | 1 ? | 9,604 ? | 3,278 ? | - | 11,398 ? | - | - | 4,393 ? | | MoonModule_control_change | 131 | 262 ? | 212 ? | 166 ? | 165 ? | - | 111,731 ? | 89,895 ? | - | | MqttModule_haDiscovery_toggle | 3 ? | - | 36 ? | 36 ? | - | - | - | - | - | -| MultiplyModifier_memory_lut | 4 | 3 ? | - | - | - | - | - | - | - | -| MultiplyModifier_pipeline | 133 | 225 ? | - | - | - | - | - | - | - | +| MultiplyModifier_memory_lut | 6 (+2) ⚠ | 3 ? | - | - | - | - | - | - | - | +| MultiplyModifier_pipeline | 129 (−4) ✓ | 225 ? | - | - | - | - | - | - | - | | NetworkModule_eth_reconfigure | - | - | 1,169 ? | 97,843 ? | - | - | - | - | - | | NetworkModule_mdns_toggle | 13 ? | - | 36 ? | 36 ? | 21 ? | - | 109,767 ? | 93,963 ? | - | -| modifier_chain | 62 | 69 ? | 13,337 | - | - | - | - | - | - | -| modifier_swap | 31 (+1) ⚠ | 41 ? | 12,250 | 354 ? | 362 ? | - | 1,010 ? | - | - | -| perf_full | 429 (+58) ⚠ | 592 ? | 10,392 | 16,915 ? | 17,433 ? | - | - | - | - | -| perf_light | 23 (+2) ⚠ | 35 ? | 2,183 | 2,485 ? | 2,038 ? | - | - | - | - | -| peripheral_grid_sweep | 378 (+9) ⚠ | 649 ? | 6,991 ? | - | 11,495 ? | 12,273 ? | - | - | - | -| peripheral_switch | 6 | 9 ? | 437 | 46 ? | 217 ? | - | - | - | - | +| modifier_chain | 78 (+16) ⚠ | 69 ? | 13,337 | - | - | - | - | - | - | +| modifier_swap | 46 (+15) ⚠ | 41 ? | 12,250 | 354 ? | 362 ? | - | 1,010 ? | - | - | +| perf_full | 589 (+160) ⚠ | 592 ? | 10,392 | 16,915 ? | 17,433 ? | - | - | - | - | +| perf_light | 35 (+12) ⚠ | 35 ? | 2,183 | 2,485 ? | 2,038 ? | - | - | - | - | +| peripheral_grid_sweep | 586 (+208) ⚠ | 649 ? | 6,991 ? | - | 11,495 ? | 12,273 ? | - | - | - | +| peripheral_switch | 9 (+3) ⚠ | 9 ? | 437 | 46 ? | 217 ? | - | - | - | - | Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first impression rather than a percentile; several are months old and were captured during a network reconfigure, so they read as whole milliseconds. `-` means that target has never run that scenario. -**Coverage: 94/207 cells measured (45%), 28 of them with 4+ samples (13%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off. +**Coverage: 96/225 cells measured (42%), 30 of them with 4+ samples (13%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off. ### desktop: isolated scenarios (p50 of the sample window) | Scenario | p50 | p95 | n | |---|---:|---:|---:| -| Layer_base_pipeline | 75 µs (+2 µs) ⚠ | 211 µs | 32 | -| Layer_memory_1to1 | 9 µs | 40 µs | 32 | +| Layer_base_pipeline | 107 µs (+32 µs) ⚠ | 211 µs | 32 | +| Layer_memory_1to1 | 10 µs (+1 µs) ⚠ | 41 µs | 32 | These build a bare pipeline with no optional modules, so a change here is a change in the pipeline itself rather than in what was measured. A new module belongs in an advanced scenario, which keeps its own numbers. @@ -76,36 +78,36 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 24,602 (+56) ⚠ | 9,786 | 43.0 % (+0.1 %) ⚠ | -| light | 30,425 (+3) ⚠ | 11,631 | 42.1 % (−0.1 %) ✓ | -| platform | 17,463 (+117) ⚠ | 6,117 | 38.5 % (+0.1 %) ⚠ | -| ui | 9,964 (+3) ⚠ | 2,895 | 30.7 % | -| test | 53,126 (+94) ⚠ | 9,875 | 21.3 % | -| moondeck | 22,414 | 3,641 | 18.6 % | +| core | 24,938 (+336) ⚠ | 9,940 | 43.1 % (+0.1 %) ⚠ | +| light | 31,807 (+1,382) ⚠ | 12,233 | 42.3 % (+0.2 %) ⚠ | +| platform | 17,463 | 6,117 | 38.5 % | +| ui | 10,089 (+125) ⚠ | 2,969 | 31.1 % (+0.4 %) ⚠ | +| test | 54,681 (+1,555) ⚠ | 10,179 | 21.3 % | +| moondeck | 22,488 (+74) ⚠ | 3,643 | 18.5 % (−0.1 %) ✓ | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,794 (+4) ✓ | -| scenarios | 23 | +| unit cases | 1,876 (+82) ✓ | +| scenarios | 25 (+2) ✓ | ## Complexity | Metric | Value | |---|---:| -| functions | 3,214 (+8) ✓ | -| over threshold | 212 | +| functions | 3,290 (+76) ✓ | +| over threshold | 220 (+8) ⚠ | | worst CCN | 108 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 214 | -| markdown lines | 34,049 (+234) ⚠ | -| plan files | 114 | -| backlog lines | 5,520 (+51) ⚠ | -| lessons lines | 648 | -| CLAUDE.md lines | 216 | +| markdown files | 218 (+4) ⚠ | +| markdown lines | 35,121 (+1,072) ⚠ | +| plan files | 115 (+1) ⚠ | +| backlog lines | 6,338 (+818) ⚠ | +| lessons lines | 678 (+30) ⚠ | +| CLAUDE.md lines | 237 (+21) ⚠ | diff --git a/docs/moonmodules/light/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md index 372dc542..6031ee64 100644 --- a/docs/moonmodules/light/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -159,11 +159,11 @@ Registered by the light domain, not built into the compiler (the core owns only | `mod(a, b)` | `a % b` — the wrap a cyclic animation needs | | `beat(bpm, t)` | a `0..65535` sawtooth at `bpm` | | `beatsin(bpm, t, high)` | a sine `0..high` at `bpm` | -| `noise(x, y, z)` | `0..255` value noise at that point — the field behind fire, clouds and plasma | +| `noise(x, y, z)` | `0..255` gradient noise at that point, the field behind fire, clouds and plasma | | `scale(value, n)` | a `0..65535` value onto `0..n-1` — lands a wave on an axis | | `sin(angle)`, `cos(angle)` | the circle; one turn is `0..65535`, result biased to `1..65535` centered at 32768 | | `turn(n)` | one revolution split `n` ways — the angle step for placing `n` points on a circle | -| `print(v)` | log a value and return it ([what it costs](writing-scripts.md#debugging-print)) | +| `print(v)` | log a value and return it. A host call per invocation, so it belongs in a cold path rather than a per-pixel loop | | `a / b`, `a % b` | divide and remainder. Both are host calls: cheap on a cold path, deliberate per light. Dividing by zero **saturates** toward the numerator's sign rather than faulting, so no script needs a zero-check of its own; the remainder is 0 | | `toFixed(v)`, `toInt(v)` | convert between a whole number and a `fixed` one, each a single instruction | | `smoothstep(e0, e1, v)` | a soft `0..65535` ramp between two edges, the anti-aliasing primitive | @@ -171,9 +171,19 @@ Registered by the light domain, not built into the compiler (the core owns only | `smin(a, b, k)` | the smooth minimum of two distances, so shapes melt into one surface rather than overlapping | | `fade(amt)` | dim every light toward black, FastLED's `fadeToBlackBy`. The trail primitive | | `polarA(dx, dy)`, `polarR(dx, dy)` | angle and distance from a center, for a radial effect | +| `fbm(x, y, octaves)` | octaves of noise summed at doubling frequency and halving amplitude, `0..255`: the cloud, smoke and terrain field. `octaves` is the cost knob, one noise sample each | +| `warp(x, y, strength)` | the field sampled where the field itself displaced it, `0..255`: the flowing, marbled look. Three noise samples | +| `fbm3(x, y, z, octaves)`, `warp3(x, y, z, strength)` | the same two fields with a third axis, so a volumetric fixture samples through the field rather than repeating one slice. On a panel, pass `0` for `z` and the result is the 2D form exactly | +| `osc(rate, ms, shape)` | a low-frequency oscillator, `0..65535`, at `rate` cycles per minute. Shapes: 0 sine, 1 triangle, 2 sawtooth, 3 square. Stateless, so two oscillators sharing a rate hold their phase relationship | | `escape(cx, cy, jx, jy, iters)` | the Mandelbrot/Julia escape count, `0..255`, `0` inside the set. Zero seed = Mandelbrot; the four coordinates are `fixed`, so uv output flows straight in. The one loop a script cannot write: it squares signed values in 64 bits | | `setPaletteColor(x, y, index, bri)` | one light from the ACTIVE palette, in one call | +| `setPaletteColorZ(x, y, z, index, bri)` | the same, addressing a light in a volume | | `paletteR(i, bri)`, `paletteG`, `paletteB` | one palette channel, when a script needs the value rather than a pixel | +| `trail(1)` | ask for a trail plane, from `defineControls()`. A 16-bit plane the flow builtins carry and the binding blits, so a script gets tails without owning a buffer. Returns whether it got one | +| `flowNoise(zoom, strength)`, `flowCurl(zoom, strength)` | carry the whole trail plane one frame along a flow: noise for a wandering field, curl for a divergence-free one where nothing clumps. One call, because a per-pixel rule would cross the script boundary 8000 times on a cube | +| `trailDecay(halfLifeMs)` | fade the trail by a half-life in milliseconds, so a tail's length is in seconds and holds at any framerate. Named `trailDecay` because `decay` is an ordinary word a script may want for its own member | +| `emitTrail(x, y, z, index, bri, radius)` | throw light into the trail as a disc of the given radius. A single-pixel head arrives at a fraction of a count after a long tail, which is why the radius is a parameter | +| `fieldRate(n)` | true once every n frames: the lever that makes a per-pixel loop affordable on a large fixture. The flow and the decay still run every frame, so what it costs is detail rather than smoothness | | `pool(n)` | size this script's particle pool, from `defineControls()`. Returns what it got | | `emit(x, y, angle, speed, n, life, hue)` | throw `n` particles from a point | | `gravity(g)`, `drag(k)` | the two forces | @@ -204,7 +214,7 @@ vocabulary follows the [WLED Particle System](https://github.com/wled/WLED) by D A class may define functions beside its entry point and call them, including calling itself. `effects/crosshair.mle` is the worked example: a `column()` and a `row()`, both called from `tick()`. -These are real calls, not text pasted in by the compiler: the callee allocates its own frame when it runs, which is what lets one helper call another and what makes recursion work. A function takes no arguments and returns nothing yet, so a helper does a whole job rather than computing a value. +These are real calls, not text pasted in by the compiler: the callee allocates its own frame when it runs, which is what lets one helper call another and what makes recursion work. A function takes no arguments yet, but it may RETURN a value: declare it `int` and the helper computes where a number is needed (`setRGB(0, level(), 0, 0)`); declare it `void` and it does a whole job instead. Two rules a script author meets: diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index 756c0195..f65825b3 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -48,6 +48,9 @@ The card reads top-down as **invariant controls → `peripheral` divider → per - `pins` — data GPIO list, e.g. `18,17,16`, or inclusive ranges like `20-23` (= `20,21,22,23`) mixed freely (`20-22,35,38-40`). One strand each — or, with the `MoonI80` pin expander, one *group of 8*. Empty idles until set; changing it re-inits live. - `ledsPerPin` — lights per **strand**, following the broadcasting idiom (cf. NumPy / CSS shorthand): **empty** = even split of the window; **one number** = that many on *every* strand (`64` → 64 each); **a list** `3,4,5` = one per strand by position (a short list even-splits the remainder). Shorter strands go dark early while the longest finishes. Through an expander an entry is one strand, not one pin, so two strands on one '595 can differ. +- `timing` (RMT only) — the bit rate on the wire. **`800kHz WS2812B/SK6812`** is the default and drives WS2812, WS2812B and SK6812 alike, which is why it fits nearly every strip. **`400kHz WS2811`** doubles the bit cell for a 12V WS2811 strip in its low-speed mode: on the default timing such a strip decodes the first few lights and then reads noise, which looks like flicker and stale colors past a handful of LEDs. **`800kHz WS2811 fast`** is the same 1.25 µs cell with narrower pulses. **`custom`** reveals `t0hNs` / `t1hNs` / `periodNs` so a strip matching no preset is a control change rather than a firmware release. Named by speed rather than by chip because the names do not partition the timings: SK6812 and WS2812B decode identically, and "WS2811" covers two different rates. A 400 kHz strip takes twice as long per frame, so it halves the achievable frame rate at a given light count. + + **The parallel driver has no `timing` control**: its bit timing comes from the bus pixel clock, which the nanosecond fields only approximate, so changing it means changing that clock in the platform layer. A strip that needs non-default timing runs on the RMT driver. - `peripheral` (the **divider**) — the DMA peripheral driving the bus (`i80` / `Parlio` / `MoonI80`), filtered to what the chip supports. Everything **above** it is invariant (*which LEDs and how many*); everything **below** is what the chosen peripheral supports. Switching it re-surfaces that peripheral's own controls and re-inits live. Always shown — with a single option it reads as a labeled indicator of what's driving the LEDs. - *peripheral-specific* (below the divider) — each shown only on the peripherals that support it, so the set changes when you switch `peripheral`: - `doubleBuffer` — the async second frame buffer (encode overlaps the wire). Shown on `i80` and `Parlio` (they route through a real transaction queue); **hidden on `MoonI80`**, which runs single-buffer (its speed comes from the streaming ring, not from double-buffering a whole frame). diff --git a/docs/moonmodules/light/effects.md b/docs/moonmodules/light/effects.md index 0e8abab4..a199103a 100644 --- a/docs/moonmodules/light/effects.md +++ b/docs/moonmodules/light/effects.md @@ -1,8 +1,8 @@ # Effects -Every effect, one block each: its preview, what it does, and what each control means — together. An effect writes per-pixel color into its [Layer](moxygen/Layer.md)'s buffer each tick; [modifiers](modifiers.md) reshape the result and a [driver](moxygen/PreviewDriver.md) sends it out. Effects that name an index color read the global palette (the `palette` control on [Drivers](moxygen/Drivers.md)) via `colorFromPalette`. Each block's emoji are its `tags()` (origin/creator/audio — see the [tag emoji legend](../../architecture.md#tag-emoji-legend)); **Dim** is its native axes ([Layer](moxygen/Layer.md) extrudes a lower-dim effect onto a bigger grid). Effects are grouped into sections by origin, and each block carries that effect's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../adr/0015-library-is-a-tag-not-a-folder.md).) +Every effect, one block each: its preview, what it does, and what each control means: together. An effect writes per-pixel color into its [Layer](moxygen/Layer.md)'s buffer each tick; [modifiers](modifiers.md) reshape the result and a [driver](moxygen/PreviewDriver.md) sends it out. Effects that name an index color read the global palette (the `palette` control on [Drivers](moxygen/Drivers.md)) via `colorFromPalette`. Each block's emoji are its `tags()` (origin/creator/audio: see the [tag emoji legend](../../architecture.md#tag-emoji-legend)); **Dim** is its native axes ([Layer](moxygen/Layer.md) extrudes a lower-dim effect onto a bigger grid). Effects are grouped into sections by origin, and each block carries that effect's preview, behavior, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../adr/0015-library-is-a-tag-not-a-folder.md).) -Effects are built from the shared [power functions](power-functions.md) — the drawing, field and motion routines every effect composes; that page lists each one with its callers. +Effects are built from the shared [power functions](power-functions.md): the drawing, field and motion routines every effect composes; that page lists each one with its callers. **Jump to:** [MoonLight](#moonlight-effects) · [MoonModules](#moonmodules-effects) · [WLED](#wled-effects) · [FastLED](#fastled-effects) · [projectMM-native](#projectmm-native-effects) @@ -14,10 +14,12 @@ Effects are built from the shared [power functions](power-functions.md) — the ### DistortionWaves 💫 · 2D +DistortionWaves effect preview + Two interfering sine waves beat against each other into a moiré color field. -- `freq_x` / `freq_y` — horizontal/vertical wave frequency (1–8). -- `speed` — animation rate (0 = frozen). +- `freq_x` / `freq_y`: horizontal/vertical wave frequency (1–8). +- `speed`: animation rate (0 = frozen). Origin: WLED · by ldirko & blazoncek (WLED port) · [gallery](https://editor.soulmatelights.com/gallery/1089-distorsion-waves) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) @@ -29,12 +31,14 @@ Detail: [technical](moxygen/DistortionWavesEffect.md) ### FixedRectangle 💫 · 3D +FixedRectangle effect preview + A solid color filling a positioned box within the grid, with an optional alternating-white checker on the box's pixels. -- `red` / `green` / `blue` / `white` — the box color. -- `X position` / `Y position` / `Z position` — the box's origin corner. -- `Rectangle width` / `Rectangle height` / `Rectangle depth` — the box extent on each axis. -- `alternateWhite` — alternate box pixels to white in a checker pattern. +- `red` / `green` / `blue` / `white`: the box color. +- `X position` / `Y position` / `Z position`: the box's origin corner. +- `Rectangle width` / `Rectangle height` / `Rectangle depth`: the box extent on each axis. +- `alternateWhite`: alternate box pixels to white in a checker pattern. Origin: MoonLight · by [limpkin](https://github.com/limpkin) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -44,17 +48,19 @@ Detail: [technical](moxygen/FixedRectangleEffect.md) -### FreqSaws 💫📊 · 2D +### FreqSaws 💫🎶 · 2D + +FreqSaws effect preview Audio-reactive sawtooth waves: each column maps to a frequency band whose magnitude drives a per-band oscillator speed, so louder bands sweep their sawtooth up the column faster, with three phase methods. -- `fade` — background decay per frame. -- `increaser` — how fast a band's speed ramps up with its magnitude. -- `decreaser` — how fast a silent band's speed decays. -- `bpmMax` — ceiling on a band's oscillation speed. -- `invert` — flip alternate columns vertically. -- `keepOn` — keep oscillating even when a band is silent. -- `method` — phase model (`Chaos`, `Chaos fix`, `BandPhases`). +- `fade`: background decay per frame. +- `increaser`: how fast a band's speed ramps up with its magnitude. +- `decreaser`: how fast a silent band's speed decays. +- `bpmMax`: ceiling on a band's oscillation speed. +- `invert`: flip alternate columns vertically. +- `keepOn`: keep oscillating even when a band is silent. +- `method`: phase model (`Chaos`, `Chaos fix`, `BandPhases`). Origin: MoonLight (audio) · by [@TroyHacks](https://github.com/troyhacks) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -68,11 +74,11 @@ Detail: [technical](moxygen/FreqSawsEffect.md) LavaLamp effect preview -Three slow blobs through a black→red→orange→yellow→white ramp — atmospheric lava look. +Three slow blobs through a black→red→orange→yellow→white ramp: atmospheric lava look. -- `bpm` — blob drift speed. -- `radius` — blob influence radius. -- `intensity` — field gain into the black→red→orange→yellow→white ramp. +- `bpm`: blob drift speed. +- `radius`: blob influence radius. +- `intensity`: field gain into the black→red→orange→yellow→white ramp. Origin: projectMM original (metaball lava lamp) @@ -82,14 +88,14 @@ Detail: [technical](moxygen/LavaLampEffect.md) -### Lines 💫 · — +### Lines 💫 · 3D Lines effect preview -Sweeps axis-aligned planes in sync; red/green/blue name the X/Y/Z axis — a preview-orientation test pattern. +Sweeps axis-aligned planes in sync; red/green/blue name the X/Y/Z axis: a preview-orientation test pattern. -- `speed` — sweep BPM. -- `axis` — which plane sweeps (`all`, `x (red)`, `y (green)`, `z (blue)`). +- `speed`: sweep BPM. +- `axis`: which plane sweeps (`all`, `x (red)`, `y (green)`, `z (blue)`). Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -101,12 +107,12 @@ Detail: [technical](moxygen/LinesEffect.md) Metaballs effect preview -`count` blobs orbit via integer sin/cos; metaball field per pixel — bright HSV merge/split. +`count` blobs orbit via integer sin/cos; metaball field per pixel: bright HSV merge/split. -- `bpm` — orbit speed. -- `radius` — blob influence radius. -- `count` — number of orbiting balls (1–8). -- `hue_shift` — rotate the palette index. +- `bpm`: orbit speed. +- `radius`: blob influence radius. +- `count`: number of orbiting balls (1–8). +- `hue_shift`: rotate the palette index. Origin: projectMM original (metaballs) @@ -116,16 +122,16 @@ Detail: [technical](moxygen/MetaballsEffect.md) -### Particles 💫🦅 · 2D +### Particles 💫🦅✨ · 2D Particles effect preview A swarm of drifting particles with persistent fading trails. -- `count` — number of particles (1–255). -- `speed` — drift velocity. -- `fade` — trail persistence (higher = longer tails). -- `hue_shift` — rotate every particle's hue. +- `count`: number of particles (1–255). +- `speed`: drift velocity. +- `fade`: trail persistence (higher = longer tails). +- `hue_shift`: rotate every particle's hue. Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -141,9 +147,9 @@ Detail: [technical](moxygen/ParticlesEffect.md) Summed sine waves on orthogonal + diagonal axes; large rolling blobs (3D on volumetric layouts). -- `bpm` — roll speed. -- `scale_x` / `scale_y` — blob size on each axis (larger = bigger, calmer blobs, lower spatial frequency). -- `hue_shift` — rotate the palette index. +- `bpm`: roll speed. +- `scale_x` / `scale_y`: blob size on each axis (larger = bigger, calmer blobs, lower spatial frequency). +- `hue_shift`: rotate the palette index. Origin: FastLED / WLED lineage (classic plasma) @@ -155,10 +161,12 @@ Detail: [technical](moxygen/PlasmaEffect.md) ### Praxis 💫 · 2D +Praxis effect preview + An algorithmic palette pattern driven by two beat oscillators (a macro and a micro mutator) whose frequencies and ranges reshape the hue field over time. -- `macroMutatorFreq` / `macroMutatorMin` / `macroMutatorMax` — the coarse mutator's beat frequency and its oscillation range. -- `microMutatorFreq` / `microMutatorMin` / `microMutatorMax` — the fine mutator's beat frequency and range. +- `macroMutatorFreq` / `macroMutatorMin` / `macroMutatorMax`: the coarse mutator's beat frequency and its oscillation range. +- `microMutatorFreq` / `microMutatorMin` / `microMutatorMax`: the fine mutator's beat frequency and range. Origin: MoonLight · by MONSOONO / @Flavourdynamics · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -172,9 +180,9 @@ Detail: [technical](moxygen/PraxisEffect.md) Rainbow effect preview -Diagonal animated rainbow — always-visible default/test effect. +Diagonal animated rainbow: always-visible default/test effect. -- `speed` — animation BPM (one full hue cycle per beat). +- `speed`: animation BPM (one full hue cycle per beat). Origin: FastLED · Mark Kriegsman (rainbow) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_FastLED.h) @@ -184,11 +192,13 @@ Detail: [technical](moxygen/RainbowEffect.md) -### Random 💫 · 3D +### Random 💫✨ · 3D + +Random effect preview -Lights one random light per frame in a random palette color over a fading background — a sparse, palette-tinted sparkle. +Lights one random light per frame in a random palette color over a fading background: a sparse, palette-tinted sparkle. -- `fade` — how fast prior sparkles fade to black. +- `fade`: how fast prior sparkles fade to black. Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -198,16 +208,16 @@ Detail: [technical](moxygen/RandomEffect.md) -### Rings 💫🦅 · 2D +### Rings 💫🦅🖌️ · 2D Rings effect preview -Expanding concentric rings from random centres, additive overlap (calm defaults). +Expanding concentric rings from random centers, additive overlap (calm defaults). -- `count` — number of concentric rings (1–255). -- `speed` — expansion rate. -- `thickness` — ring band width. -- `hue_shift` — rotate every ring's hue. +- `count`: number of concentric rings (1–255). +- `speed`: expansion rate. +- `thickness`: ring band width. +- `hue_shift`: rotate every ring's hue. Origin: projectMM original (concentric rings) @@ -217,14 +227,14 @@ Detail: [technical](moxygen/RingsEffect.md) -### Ripples 💫🟦🦅 · 3D +### Ripples 💫🦅 · 3D Ripples effect preview -Distance-from-centre sets a per-column wave phase; the lit surface ripples like water. +Distance-from-center sets a per-column wave phase; the lit surface ripples like water. -- `speed` — wave animation rate (0 = frozen, 99 = fast). -- `interval` — wavefront spacing (low = tight rings, high = wide). +- `speed`: wave animation rate (0 = frozen, 99 = fast). +- `interval`: wavefront spacing (low = tight rings, high = wide). Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -234,14 +244,16 @@ Detail: [technical](moxygen/RipplesEffect.md) -### RubiksCube 💫🧊 · 3D +### RubiksCube 💫 · 3D + +RubiksCube effect preview A 3D Rubik's Cube projected onto the volume: it scrambles, then plays its solution back one turn at a time, the six faces in their standard colors. -- `turnsPerSecond` — how fast the cube turns. -- `cubeSize` — the cube order (2×2 up to 8×8). -- `randomTurning` — turn endlessly at random instead of scramble-then-solve. -- `usePalette` — color the six faces from the system-wide palette instead of the classic Rubik's colors. +- `turnsPerSecond`: how fast the cube turns. +- `cubeSize`: the cube order (2×2 up to 8×8). +- `randomTurning`: turn endlessly at random instead of scramble-then-solve. +- `usePalette`: color the six faces from the system-wide palette instead of the classic Rubik's colors. Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -251,17 +263,19 @@ Detail: [technical](moxygen/RubiksCubeEffect.md) -### Fireworks 🔬 · 2D +### Fireworks 💫✨ · 2D -Shells rise, stall, and burst into sparks that arc over and fall. Every stage is a particle-kernel call: spawn, gravity, angleEmit, drag, age. Nothing schedules the apex — the shell decelerates under gravity and bursts when its vertical velocity crosses zero, so a faster launch bursts higher without a second control. +Fireworks effect preview -- `launchRate` — how often a new shell goes up. -- `launchSpeed` — how hard it is thrown, and so how high it bursts. -- `gravity` — how fast everything falls, per 60 Hz of simulated time. -- `sparks` — sparks per burst. -- `sparkLife` — how long a spark survives. -- `drag` — air resistance flattening the arc. -- `fade` — trail length (the Layer's decay, not the pool's). +Shells rise, stall, and burst into sparks that arc over and fall. Every stage is a particle-kernel call: spawn, gravity, angleEmit, drag, age. Nothing schedules the apex: the shell decelerates under gravity and bursts when its vertical velocity crosses zero, so a faster launch bursts higher without a second control. + +- `launchRate`: how often a new shell goes up. +- `launchSpeed`: how hard it is thrown, and so how high it bursts. +- `gravity`: how fast everything falls, per 60 Hz of simulated time. +- `sparks`: sparks per burst. +- `sparkLife`: how long a spark survives. +- `drag`: air resistance flattening the arc. +- `fade`: trail length (the Layer's decay, not the pool's). Physics is driven by elapsed time, not frame count, so the same settings behave identically on a desktop at thousands of fps and an ESP32 at a few hundred ([architecture § tick rate](../../architecture.md#effects)). @@ -269,16 +283,18 @@ Origin: projectMM original, on the WLED Particle System's firework family by Dam -### Fish Tank 📊 · 2D +### Fish Tank 💫🎶✨👾 · 2D + +Fish Tank effect preview An aquarium on a light wall: fish of three shapes swim across a dark tank, each in its own color from the active palette, tails beating. Movement is a particle-pool entry per fish with constant velocity, respawning at the far edge when it swims off; the shape is drawn through the `draw::sprite` power function. Unlike the other sprite effects, the art carries shade ROLES (body, outline, highlight, fin, eye, band) rather than fixed colors, and each fish fills them from its own place on the palette, so one drawing yields as many colorways as there are fish. -- `fish` — how many broad tropical fish (0-8). -- `slim` — how many slender fish (0-8). -- `school` — how many tiny schooling fish (0-8). -- `speed` — swim rate in body-lengths, so motion reads the same on any grid; each fish varies around it, and the smaller shapes drift slower, which reads as depth. -- `spriteSize` — integer magnification (crisp nearest-neighbor); 0 = auto, scaling with the grid so a fish reads as a fish on a 16x16 matrix and on a 768-wide desktop grid alike. -- `soundReactive` — move to the music: each sprite follows its own frequency band, so the scene breathes rather than surging as one block, and silence stands it still. Without an audio source the sprites keep moving normally. +- `fish`: how many broad tropical fish (0-8). +- `slim`: how many slender fish (0-8). +- `school`: how many tiny schooling fish (0-8). +- `speed`: swim rate in body-lengths, so motion reads the same on any grid; each fish varies around it, and the smaller shapes drift slower, which reads as depth. +- `spriteSize`: integer magnification (crisp nearest-neighbor); 0 = auto, scaling with the grid so a fish reads as a fish on a 16x16 matrix and on a 768-wide desktop grid alike. +- `soundReactive`: move to the music: each sprite follows its own frequency band, so the scene breathes rather than surging as one block, and silence stands it still. Without an audio source the sprites keep moving normally. Uses the global palette: every fish takes a body color from it, with its band a paler version of that same color rather than a second pick, which would read as two fish fused together. @@ -286,23 +302,27 @@ Origin: projectMM original; inspired by the aquarium screensavers of the After D -### Flying Toasters 🔬📊 · 2D +### Flying Toasters 💫🎶✨👾 · 2D + +Flying Toasters effect preview The classic screensaver on a light wall: chrome toasters with flapping wings and slices of toast drift diagonally across the dark, forever. Each flier is a particle-pool entry with constant velocity (respawning off the upper-right when it leaves the lower-left), rendered through the `draw::sprite` power function; the wing flap runs on a shared BeatPhase with a per-toaster offset so the flock never syncs. -- `toasters` — how many fly (1–12). -- `toast` — how many slices trail along (0–8). -- `speed` — drift rate in sprite-widths, so flight reads the same on any grid; each flier varies ±25% around it. -- `spriteSize` — integer magnification for toasters AND toast (crisp nearest-neighbor); 0 = auto, scaling with the grid so a toaster reads as a toaster on a big wall. -- `soundReactive` — move to the music: each sprite follows its own frequency band, so the scene breathes rather than surging as one block, and silence stands it still. Without an audio source the sprites keep moving normally. +- `toasters`: how many fly (1–12). +- `toast`: how many slices trail along (0–8). +- `speed`: drift rate in sprite-widths, so flight reads the same on any grid; each flier varies ±25% around it. +- `spriteSize`: integer magnification for toasters AND toast (crisp nearest-neighbor); 0 = auto, scaling with the grid so a toaster reads as a toaster on a big wall. +- `soundReactive`: move to the music: each sprite follows its own frequency band, so the scene breathes rather than surging as one block, and silence stands it still. Without an audio source the sprites keep moving normally. The sprites carry their own colors (chrome, wing, crust), so the global palette does not apply. Needs a grid at least the toaster's size (12×9). -Origin: projectMM original; inspired by After Dark's Flying Toasters (Berkeley Systems, 1989), suggested by Frank ([softhack007](https://github.com/softhack007)) — the pixel art here is drawn fresh for this effect +Origin: projectMM original; inspired by After Dark's Flying Toasters (Berkeley Systems, 1989), suggested by Frank ([softhack007](https://github.com/softhack007)): the pixel art here is drawn fresh for this effect -### MovingHead 🔬📊 · 1D +### MovingHead 💫🎶🎯 · 1D + +MovingHead effect preview Aims a rig of moving heads as one instrument. Pan and tilt sweep on two sine waves at different rates, so a beam traces a path rather than a line, and `formation` decides how the heads relate to @@ -313,18 +333,18 @@ The first effect that AIMS a fixture rather than only coloring it. It writes pan the role setters, which do nothing on a light that carries no such channel, so the same effect on an LED strip paints the color pattern and moves nothing. -- `formation` — how the heads relate: - - **fan** — neighbours differ by a fraction of the sweep, so the beams open and close like a hand. - - **mirror** — the halves face each other; the classic look, best on an even-numbered rig. - - **chase** — a wave travelling down the row, the same sweep delayed head by head. - - **cross** — alternate heads oppose, a tight scissoring that looks fast at a low BPM. - - **unison** — every head as one, the reference the others read against. -- `panBpm` / `tiltBpm` — sweep rates (60 = one sweep a second). Different rates are what turn two +- `formation`: how the heads relate: + - **fan**: neighbors differ by a fraction of the sweep, so the beams open and close like a hand. + - **mirror**: the halves face each other; the classic look, best on an even-numbered rig. + - **chase**: a wave travelling down the row, the same sweep delayed head by head. + - **cross**: alternate heads oppose, a tight scissoring that looks fast at a low BPM. + - **unison**: every head as one, the reference the others read against. +- `panBpm` / `tiltBpm`: sweep rates (60 = one sweep a second). Different rates are what turn two sines into a path instead of a diagonal. -- `panRange` / `tiltRange` — how much of the fixture's travel to use. A head at full pan spends +- `panRange` / `tiltRange`: how much of the fixture's travel to use. A head at full pan spends much of its sweep pointing away from the audience, so the default is a band around center. -- `panCenter` / `tiltCenter` — where the sweep is centered (128 = the fixture's middle). -- `soundReactive` — move and light with the music: the beam swings wider as the room gets louder, +- `panCenter` / `tiltCenter`: where the sweep is centered (128 = the fixture's middle). +- `soundReactive`: move and light with the music: the beam swings wider as the room gets louder, each head takes its brightness from its own frequency band so the rig ripples rather than pulsing as one block, and a beat widens the sweep and flares the color with a short decay so a kick is visible rather than a one-frame flicker. Silence holds the rig still, which is what makes it read @@ -337,17 +357,19 @@ Uses the global palette. Origin: projectMM original -### Pacman 🔬📊 · 2D +### Pacman 💫🎶✨👾 · 2D + +Pacman effect preview The arcade cast crossing a light wall: Pacman chomps his way along while the four ghosts drift past, each in its own color, wrapping around the edges forever. Movement is a particle-pool entry per character and the shapes go through the `draw::sprite` power function; one ghost drawing serves all four colors because the art carries palette slots rather than fixed colors, and a single drawing serves both travel directions because `draw::sprite` can mirror it. In this first iteration the characters travel independently and do not notice each other. The maze, the pellets and the chase are the next step, built on the shapes and the movement grid this one establishes. -- `pacmen` — how many Pacmen (0-4). -- `ghosts` — how many ghosts (0-8); the arcade cast is four. -- `speed` — travel rate in sprite-widths, so motion reads the same on any grid; Pacman runs slightly ahead of the ghosts, as in the original. -- `spriteSize` — integer magnification (crisp nearest-neighbor); 0 = auto, scaling with the grid so the characters read on a 16x16 matrix and on a 768-wide desktop grid alike. -- `soundReactive` — move to the music: each sprite follows its own frequency band, so the scene breathes rather than surging as one block, and silence stands it still. Without an audio source the sprites keep moving normally. +- `pacmen`: how many Pacmen (0-4). +- `ghosts`: how many ghosts (0-8); the arcade cast is four. +- `speed`: travel rate in sprite-widths, so motion reads the same on any grid; Pacman runs slightly ahead of the ghosts, as in the original. +- `spriteSize`: integer magnification (crisp nearest-neighbor); 0 = auto, scaling with the grid so the characters read on a 16x16 matrix and on a 768-wide desktop grid alike. +- `soundReactive`: move to the music: each sprite follows its own frequency band, so the scene breathes rather than surging as one block, and silence stands it still. Without an audio source the sprites keep moving normally. Pacman is always his own yellow; the ghosts take their body colors from the active palette, so they stay four distinguishable characters whatever palette is loaded. @@ -355,66 +377,96 @@ Origin: projectMM original; inspired by Namco's Pac-Man (1980), the pixel art dr -### Space Invaders 🔬📊 · 2D +### Space Invaders 💫🎵👾 · 2D + +Space Invaders effect preview The 1978 formation marching down the wall: five ranks of squid, crab and octopus stepping sideways in the two-frame wiggle, dropping a row and reversing at each wall, and speeding up as the ranks thin. That acceleration is the defining mechanic rather than a flourish, because the arcade original sped up for a mechanical reason (fewer invaders meant a shorter loop for the hardware to draw) and the tension it produced is the reason anyone remembers the game. Invaders fire down, the cannon tracks the lowest one and fires back, and when the formation lands the board resets so the attract loop runs forever. On a panel narrower than the formation the ranks scroll through the court instead of turning at the walls, so a 16-wide matrix shows the march passing rather than a block stuck at the top. -- `marchBpm` — steps per minute at a full formation; the effective rate rises to four times this as the ranks are cleared. -- `stepX` — how far a step moves the formation sideways, in pixels. -- `dropY` — how far a wall turn drops it, in pixels. -- `size` — integer magnification per art pixel; 1 on a matrix, 2 or more on a wall. -- `soundReactive` — the beat becomes the clock: the formation steps on transients and stands still in silence, so the march locks to the track. +- `marchBpm`: steps per minute at a full formation; the effective rate rises to four times this as the ranks are cleared. +- `stepX`: how far a step moves the formation sideways, in pixels. +- `dropY`: how far a wall turn drops it, in pixels. +- `size`: integer magnification per art pixel; 1 on a matrix, 2 or more on a wall. +- `soundReactive`: the beat becomes the clock: the formation steps on transients and stands still in silence, so the march locks to the track. The invaders take their body color from the active palette. Origin: projectMM original; inspired by Taito's Space Invaders (1978), the pixel art drawn fresh for this effect -### Sprite Fountain 🔬📊 · 2D +### Sprite Fountain 💫🎶✨👾 · 2D + +Sprite Fountain effect preview A fountain that throws the project's whole sprite cast: fish, Pacman and his ghosts, toasters and toast, and the three invaders, launched from the floor on a sweeping nozzle and falling back under gravity. The pixel art is SHARED with the effects that introduced it rather than copied, so a fix to a fish fixes it in both places. The particle pool's one spare byte per particle carries which character a slot is, which is what makes a mixed cast free: widening the pool for a sprite id would cost every particle system in the project memory for a field only this effect reads. Physics run on elapsed time, not per frame, so the plume looks the same on a 60 fps board and a 1200 fps desktop. -- `lift` — how hard the nozzle throws; scales with the grid, so it fills a small panel and a wall alike. -- `pull` — gravity. Measured rather than guessed: 3 gives a two-second arc, which is long enough to read a 12x8 toaster. -- `rate` — sprites launched per beat of the emit clock. -- `emitBpm` — launches per minute, so the plume's density is a choice rather than a side effect of how fast the device runs. -- `size` — integer magnification per art pixel. -- `soundReactive` — one sprite per frequency band, thrown when that band is loud, so the cast maps onto the spectrum in order: the bass bands throw fish, the treble bands throw invaders. Silence throws nothing. +- `lift`: how hard the nozzle throws; scales with the grid, so it fills a small panel and a wall alike. +- `pull`: gravity. Measured rather than guessed: 3 gives a two-second arc, which is long enough to read a 12x8 toaster. +- `rate`: sprites launched per beat of the emit clock. +- `emitBpm`: launches per minute, so the plume's density is a choice rather than a side effect of how fast the device runs. +- `size`: integer magnification per art pixel. +- `soundReactive`: one sprite per frequency band, thrown when that band is loud, so the cast maps onto the spectrum in order: the bass bands throw fish, the treble bands throw invaders. Silence throws nothing. Colors come from the active palette, one entry per sprite, held for its whole flight. Origin: projectMM original -### Pong 🔬📊 · 2D +### Pong 💫🎵👾 · 2D + +Pong effect preview Two paddles rallying a ball across the grid, the attract-mode reading of the 1972 original where both players are the machine. A perfect tracker would rally forever and never look like a game, so each paddle has a reaction delay and a small aiming error, re-rolled every exchange: it starts moving a moment after the ball turns and meets it slightly off center. That is what produces near-misses, edge hits and the occasional point. Where on the paddle the ball lands sets the angle it leaves at, which was the one piece of skill the original had. The court is fixed point rather than pixels, so the game plays identically on a 16x16 matrix and a 256-wide wall; positions are scaled to the grid only when they are drawn. -- `rallyBpm` — ball crossings per minute, so the rally takes the same wall-clock time on any grid. -- `paddle` — paddle length as a percentage of the court height; short paddles miss more, which is what makes points happen. -- `reflex` — how sharply a paddle chases the ball. Below full speed it lags a fast ball, which is where the misses come from. -- `size` — integer magnification, when the ball is a sprite. -- `spriteBall` — swap the classic square for a member of the shared sprite cast, re-picked on every hit, so a paddle knocks one character away and another back. -- `soundReactive` — the ball advances only on the beat, so it crosses the court in time with the track and stands still in silence. +- `rallyBpm`: ball crossings per minute, so the rally takes the same wall-clock time on any grid. +- `paddle`: paddle length as a percentage of the court height; short paddles miss more, which is what makes points happen. +- `reflex`: how sharply a paddle chases the ball. Below full speed it lags a fast ball, which is where the misses come from. +- `size`: integer magnification, when the ball is a sprite. +- `spriteBall`: swap the classic square for a member of the shared sprite cast, re-picked on every hit, so a paddle knocks one character away and another back. +- `soundReactive`: the ball advances only on the beat, so it crosses the court in time with the track and stands still in silence. Uses the global palette. Origin: projectMM original; inspired by Atari's Pong (1972) + + +### Aurora 💫🖌️ · 3D + +Aurora effect preview + +Several noise fields, each drifting on its own clock, read in polar coordinates and composited into curtains of light. Nothing is simulated: layers of the same field at different scales, moved by independent oscillators, interfere with each other, and the interference is what reads as curtains folding through one another. The strongest layer at each pixel wins, so the layers stay distinct instead of averaging into haze, and which layer won picks the region of the palette. Every palette gives a different aurora. + +- `speed`: master rate; every layer's motion scales from it, and 0 freezes the composition. +- `scale`: noise cells across the grid: low is broad curtains, high is fine structure. +- `layers`: how many fields are composited, and the main cost knob. +- `warp`: how far the field displaces its own sample angle, which is what makes a curtain fold over itself rather than sweep past. +- `twist`: how much the radius shears the angle, giving the curtains their lean. +- `segments`: kaleidoscope wedges; 1 leaves the composition unfolded. +- `contrast`: how much of the field lights. Low is cloud, high is a few sharp curtains. The window is placed against the field's own measured range, so this means the same thing on any grid and at any octave count. +- `octaves`: detail within each layer, multiplying the cost knob. +- `polarTable`, `polarTable16`: as PolarNoise above. + +Cost is one warped field sample per layer per pixel. With `warp` above zero each of those is a `warp8`, which spends two noise samples finding where to look before the `octaves` samples of the field itself, so the budget is `layers` × (`octaves` + 2); at `warp` 0 it is `layers` × `octaves`. The polar address is a table read rather than an angle and a distance per pixel. + +Origin: projectMM original, in the shader vocabulary Stefan Petrick made recognizable in the LED world + -### Ballpit 🔬 · 2D +### Ballpit 💫✨ · 2D -Falling balls that pile up and shove each other aside. The heap is emergent: gravity pulls, the floor stops, and contact between neighbours produces the shape. `tilt` turns the pit into a slope and the whole pile slides and re-settles. +Ballpit effect preview -- `balls` — how many share the pit. -- `gravity` — how hard they fall. -- `size` — contact radius in pixels: how far apart balls sit when touching. -- `bounce` — restitution: how much speed a contact keeps. -- `tilt` — sideways force, turning the pit into a slope. -- `drag` — damping, so the heap settles instead of sloshing. +Falling balls that pile up and shove each other aside. The heap is emergent: gravity pulls, the floor stops, and contact between neighbors produces the shape. `tilt` turns the pit into a slope and the whole pile slides and re-settles. + +- `balls`: how many share the pit. +- `gravity`: how hard they fall. +- `size`: contact radius in pixels: how far apart balls sit when touching. +- `bounce`: restitution: how much speed a contact keeps. +- `tilt`: sideways force, turning the pit into a slope. +- `drag`: damping, so the heap settles instead of sloshing. Exercises the half of the particle kernel [Fireworks](#fireworks) leaves untouched: sparks never notice each other, these do. Collisions are the one non-linear part of the kernel, so the pool is deliberately small. @@ -422,28 +474,32 @@ Origin: projectMM original, on the WLED Particle System's ballpit family by Dami -### Dissolve 🔬 · 2D +### Dissolve 💫 · 2D + +Dissolve effect preview Two color fields trade places pixel by pixel in an order that looks random but is computed, so the transition needs no per-pixel state and no shuffled index list. Two devices rendering the same frame dissolve identically without exchanging anything. -- `bpm` — how fast one transition completes. -- `spread` — how much of the transition pixels spend mid-flight; 0 gives a hard edge. -- `eased` — ease the progress instead of sweeping linearly. -- `scatter` — random order; off gives a positional wipe from the same code. +- `bpm`: how fast one transition completes. +- `spread`: how much of the transition pixels spend mid-flight; 0 gives a hard edge. +- `eased`: ease the progress instead of sweeping linearly. +- `scatter`: random order; off gives a positional wipe from the same code. Origin: projectMM original, on the classic dissolve transition in its position-addressed (shader) form -### Echo 🔬 · 2D +### Echo 💫✨ · 2D + +Echo effect preview -The previous frame fed back through a zoom and rotation, dimmed, with a bright source drawn on top — trails that spiral away from themselves, like a camera pointed at its own monitor. +The previous frame fed back through a zoom and rotation, dimmed, with a bright source drawn on top: trails that spiral away from themselves, like a camera pointed at its own monitor. -- `bpm` — how fast the source orbits. -- `zoom` — how much the feedback grows each frame. -- `rotate` — rotation per frame, which turns the trail into a spiral. -- `decay` — how fast the echo fades; higher is a shorter trail. -- `size` — radius of the bright source. +- `bpm`: how fast the source orbits. +- `zoom`: how much the feedback grows each frame. +- `rotate`: rotation per frame, which turns the trail into a spiral. +- `decay`: how fast the echo fades; higher is a shorter trail. +- `size`: radius of the bright source. Shows that feedback is not a primitive: once the grid can be read as a texture (`sampleWrap`), the whole family of trails, zoom blur and smear is a few lines. @@ -451,15 +507,17 @@ Origin: projectMM original, on video feedback and the standard texture-feedback -### Spectrum 🔬📊 · 2D +### Spectrum 💫🎶 · 2D + +Spectrum effect preview An audio analyser with real meter ballistics: bars rise fast enough to catch a transient and fall slowly enough to read, and a peak dot marks the recent maximum and drifts down. -- `attack` — how fast a bar rises toward a new level. -- `release` — how fast it falls back. -- `peakDecay` — how fast the peak dot drifts down. -- `showPeaks` — draw the floating peak dots. -- `colorByColumn` — color per band instead of by height. +- `attack`: how fast a bar rises toward a new level. +- `release`: how fast it falls back. +- `peakDecay`: how fast the peak dot drifts down. +- `showPeaks`: draw the floating peak dots. +- `colorByColumn`: color per band instead of by height. The asymmetry is the whole point; a symmetric follower either misses the hit or flickers. @@ -467,64 +525,133 @@ Origin: projectMM original, on standard VU/PPM meter ballistics and WLED's GEQ b -### Truchet 🔬 · 2D +### Truchet 💫🖌️ · 2D + +Truchet effect preview -A maze of interlocking arcs that never repeats, drawn without storing a single tile. Randomly-turned tiles with arcs at their edges join into continuous winding paths across the whole surface — the pattern looks designed, and nothing designed it. +A maze of interlocking arcs that never repeats, drawn without storing a single tile. Randomly-turned tiles with arcs at their edges join into continuous winding paths across the whole surface: the pattern looks designed, and nothing designed it. -- `bpm` — how fast the pattern drifts. -- `scale` — tiles across the short side. -- `thickness` — how fat the arcs are. -- `softness` — edge softness: the anti-aliasing width. -- `shuffle` — reshuffles which way the tiles face. -- `drift` — slide the pattern instead of holding still. +- `bpm`: how fast the pattern drifts. +- `scale`: tiles across the short side. +- `thickness`: how fat the arcs are. +- `softness`: edge softness: the anti-aliasing width. +- `shuffle`: reshuffles which way the tiles face. +- `drift`: slide the pattern instead of holding still. -**The representative 2D shader**, and a better introduction to the form than [Raymarch](#raymarch): no 3D, no rays, no float, cheap on any target. It shows the three moves most shader effects are built from — folding space so one tile becomes hundreds (`repeat`), deciding each tile's orientation from its position alone (`hashInt`, so no array remembers it and two devices agree without exchanging anything), and turning a distance into a soft edge (`smoothstep`). +**The representative 2D shader**, and a better introduction to the form than [Raymarch](#raymarch): no 3D, no rays, no float, cheap on any target. It shows the three moves most shader effects are built from: folding space so one tile becomes hundreds (`repeat`), deciding each tile's orientation from its position alone (`hashInt`, so no array remembers it and two devices agree without exchanging anything), and turning a distance into a soft edge (`smoothstep`). Origin: projectMM original, on Sébastien Truchet's 1704 tiling and the standard shader fract/hash/smoothstep idiom + + +### Fluid 💫🖌️ · 3D + +Fluid effect preview + +Light poured into a simulated medium and carried by it. Every other flow in this library is a function of position and time; this one is state, so a jet fired now changes where everything downstream goes for seconds afterwards and the same settings never quite repeat a minute. The solver is Stam's stable fluid (diffuse, project, advect, project), which is unconditionally stable at any timestep, and the projection is what keeps the flow divergence-free so dye neither piles up nor drains away. + +The jets are the effect's character, and they are deliberately not on a fixed circle: each one's radius breathes between the center and the wall, its aim leans either side of the tangent, and alternate jets sweep against each other. Jets pinned to one circle all turning the same way sum into a single rotation, which the solver faithfully renders as a hollow ring with a dead middle. Colliding jets are what roll up vortex pairs. + +- `jets`: how many places light is poured in. +- `force`: how hard each one pushes the medium. +- `swirl`: how fast the jets sweep, which is what stirs vortices rather than pumping in one direction. +- `viscosity`: how much the medium drags on itself; higher is syrup, lower is smoke. +- `persistence`: how long dye survives, as a half-life. +- `iterations`: pressure-solve effort, and the honest cost knob. At 1 the flow reads springy because the medium is not properly divergence-free. + +The dye is held at 16 bits and narrowed once on the way out, dithered temporally: a value multiplied by slightly less than one many times a second has nowhere to go at 8 bits. + +On a cube every depth slice is its own medium and the jets drift through the slices, so each one is stirred in turn and the slices differ rather than one plane repeating. Nothing is carried between slices: that is a volumetric solve, a different solver rather than a flag, and the same per-slice shape Trails has. A panel is depth 1 and pays nothing for it. Cost is several passes over the grid per frame plus `iterations` more for the pressure solve, so it is sized for the desktop and the P4. What an S3 can carry is unmeasured (performance.md holds the desktop rows). + +Origin: projectMM original, after Stam 1999 "Stable Fluids" + + + +### Nebula 💫🖌️ · 3D + +Nebula effect preview + +A noise field decides where light is born, a curl flow decides where it goes, and between them the cloud keeps folding into itself. The field is thresholded hard, so only its top survives and the rest is black; the flow is divergence-free, so nothing piles up or thins out. Neither half is new: what is, is that the emitter is a FIELD rather than a handful of dots, so light enters everywhere at once and the flow shapes a whole cloud instead of drawing trails. + +- `speed`: how fast the medium moves, and with it the whole cloud. +- `scale`: the field's cell size; low is broad clouds, high is wisps. +- `contrast`: what FRACTION of the field is bright enough to be born, placed against the field's own measured range rather than an absolute value, so the same setting means the same thing on any fixture. Measured on a 64x64 panel: 0 floods it, 128 is a haze, 192 (the default) a cloud with bright cores, 255 a few wisps. +- `persistence`: how long light survives once it is in the flow, as a half-life. +- `octaves`: detail within the field, and its cost knob. +- `fieldScale`: compute the field at half or quarter resolution and stretch it. A field is smooth, so this costs little visually and saves a great deal: measured 3.0x at half and 6.6x at quarter on a curl field. +- `fieldRate`: recompute the field every N frames. The flow still carries the cloud every frame, so this costs detail rather than smoothness. + +The cloud is held at 16 bits and narrowed once on the way out, dithered temporally, which is what keeps a slow fade smooth rather than stepped. + +Origin: projectMM original, composing the noise-field and curl-flow kernels: the contrast window is Aurora's, in the shader vocabulary Stefan Petrick made recognizable in the LED world, and the flow is Bridson's curl noise (SIGGRAPH 2007) + + + +### Trails 💫🖌️ · 3D + +Trails effect preview + +Dots thrown into a moving medium, leaving tails the flow carries and bends. Nothing draws a tail: the tail is the previous frames' dots, transported along a velocity field and dimmed, which is why the shape of the flow is visible in it. On a cube each depth slice gets its own flow, so the slices differ rather than one plane repeating, though light is carried within a slice and not yet between them: the transport is 2D per slice until 3D advection ships. + +- `speed`: how fast the medium moves, and with it every tail. +- `dots`: how many emitters are throwing light in. +- `scale`: the flow field's cell size; low is broad sweeps, high is eddies. +- `persistence`: how long a tail survives, as a half-life, so it is the same length in seconds at any framerate. +- `breathe`: how much the flow's strength rises and falls. + +The trail plane the effect owns is 16-bit, which is what lets a tail fade smoothly: a byte plane multiplied by slightly less than one hundreds of times a second either truncates the tail away or, rounded, never fades at all. + +Origin: projectMM original, in the flow-field idiom (4wheeljive's FlowFields, from a Stefan Petrick concept), with Stam's backward advection for the transport + -### Tunnel 🔬 · 2D +### Tunnel 💫🖌️ · 3D + +Tunnel effect preview -A texture mapped onto the inside of an infinite tube, so the viewer appears to fly down it forever. Nothing is 3D: the angle around the centre is one texture coordinate and the reciprocal of the distance is the other, which is perspective for the price of a divide. +A texture mapped onto the inside of an infinite tube, so the viewer appears to fly down it forever. Nothing is 3D: the angle around the center is one texture coordinate and the reciprocal of the distance is the other, which is perspective for the price of a divide. -- `bpm` — how fast the tunnel flies past. -- `depth` — texture scale along the tunnel; higher is finer rings. -- `twist` — rotation per unit depth, so the tunnel corkscrews. -- `segments` — kaleidoscope the wall; 1 leaves it plain. -- `octaves` — wall texture detail, and the cost knob. -- `vignette` — darken toward the vanishing point so it reads as receding. +- `bpm`: how fast the tunnel flies past. +- `depth`: texture scale along the tunnel; higher is finer rings. +- `twist`: rotation per unit depth, so the tunnel corkscrews. +- `segments`: kaleidoscope the wall; 1 leaves it plain. +- `octaves`: wall texture detail, and the cost knob. +- `vignette`: darken toward the vanishing point so it reads as receding. Origin: projectMM original, on the standard demoscene tunnel -### VectorBalls 🔬 · 2D +### VectorBalls 💫🖌️ · 2D -A rotating 3D object drawn as shaded spheres — the demoscene classic that named the technique. The smallest complete demonstration of putting 3D on a panel: rotate, project, sort back-to-front, shade by distance, draw. +VectorBalls effect preview -- `bpm` — rotation speed. -- `size` — ball radius at the object's centre, in pixels. -- `spread` — how far apart the balls sit. -- `distance` — how far the object is from the viewer. -- `fade` — dim the far balls, which is what reads as depth. +A rotating 3D object drawn as shaded spheres: the demoscene classic that named the technique. The smallest complete demonstration of putting 3D on a panel: rotate, project, sort back-to-front, shade by distance, draw. -Painter's ordering matters more than it sounds: without it a far ball can paint over a near one and the object reads as turning inside out. Costs a few microseconds a frame at default settings — 14 points rather than a per-pixel loop, so it is the cheapest of the showcases. +- `bpm`: rotation speed. +- `size`: ball radius at the object's center, in pixels. +- `spread`: how far apart the balls sit. +- `distance`: how far the object is from the viewer. +- `fade`: dim the far balls, which is what reads as depth. + +Painter's ordering matters more than it sounds: without it a far ball can paint over a near one and the object reads as turning inside out. Costs a few microseconds a frame at default settings: 14 points rather than a per-pixel loop, so it is the cheapest of the showcases. Origin: projectMM original, on the Amiga-era demoscene vector-ball effect -### WaterRipple 🔬 · 2D +### WaterRipple 💫🧬 · 2D + +WaterRipple effect preview A propagating wave simulation: drops land, their rings spread outward, reflect off the edges and interfere where they cross. The crossing is what a closed-form ripple cannot fake, because two rings meeting have to add and cancel. -- `speed` — simulation steps per second: how fast the water itself moves, independent of the framerate. -- `dropRate` — how often drops land, in time rather than per frame. -- `damping` — how fast waves lose energy; higher is calmer water. -- `strength` — how hard a drop hits. -- `colorByHeight` — color the surface by height so crests and troughs read differently. -- `hueBase` / `hueSpread` — where in the palette the still surface sits, and how far a crest and a trough reach from it. +- `speed`: simulation steps per second: how fast the water itself moves, independent of the framerate. +- `dropRate`: how often drops land, in time rather than per frame. +- `damping`: how fast waves lose energy; higher is calmer water. +- `strength`: how hard a drop hits. +- `colorByHeight`: color the surface by height so crests and troughs read differently. +- `hueBase` / `hueSpread`: where in the palette the still surface sits, and how far a crest and a trough reach from it. Distinct from [Ripples](#ripples), which draws expanding rings from a closed-form radius: that one is cheaper and always looks like clean concentric circles, this one behaves like water. Costs two int16 buffers sized to the grid. @@ -532,32 +659,38 @@ Origin: projectMM original, on Hugo Elias's water surface algorithm -### Raymarch 🔬 · 2D +### Raymarch 💫🖌️ · 2D -A lit 3D scene rendered by marching a ray through a distance field, one ray per pixel. Nothing draws a sphere: the scene is a function returning the distance to the nearest surface, and the spheres emerge because each ray stops where that function says a surface is. The lighting is derived too — the surface normal is the gradient of the distance field. +Raymarch effect preview -- `bpm` — how fast the scene animates. -- `steps` — ray marching steps: the quality and cost knob. -- `blend` — how much the two spheres melt into each other. -- `cameraY` — camera height above the floor. -- `showFloor` — include the ground plane. +A lit 3D scene rendered by marching a ray through a distance field, one ray per pixel. Nothing draws a sphere: the scene is a function returning the distance to the nearest surface, and the spheres emerge because each ray stops where that function says a surface is. The lighting is derived too: the surface normal is the gradient of the distance field. -**Compiled only where the SoC declares a hardware FPU** (`SOC_CPU_HAS_FPU`, which every ESP32 variant and the desktop satisfy). This is the one stated exception to the integer-only render-path rule, and it is gated rather than assumed. The cost is per *pixel*, not per chip — measured at 0.30 ms/frame for 32×32 on desktop, and 1.64 ms for 4096 lights on an ESP32-S3 while still holding 409 fps. What limits it is pixel count; `steps` trades quality for cost. Frames also stream over NetworkSend, so a desktop can drive a fixture that could never compute this locally. +- `bpm`: how fast the scene animates. +- `steps`: ray marching steps: the quality and cost knob. +- `blend`: how much the two spheres melt into each other. +- `cameraY`: camera height above the floor. +- `showFloor`: include the ground plane. + +**Compiled only where the SoC declares a hardware FPU** (`SOC_CPU_HAS_FPU`, which the desktop and every ESP32 target this project builds satisfy: S3, P4, S31 and the classic ESP32 were each checked). A target without one simply does not carry the effect, rather than failing to build. This is the one stated exception to the integer-only render-path rule, and it is gated rather than assumed. The cost is per *pixel*, not per chip: measured at 0.30 ms/frame for 32×32 on desktop, and 1.64 ms for 4096 lights on an ESP32-S3 while still holding 409 fps. What limits it is pixel count; `steps` trades quality for cost. Frames also stream over NetworkSend, so a desktop can drive a fixture that could never compute this locally. Origin: projectMM original, on Iñigo Quilez's raymarching and distance-function articles -### PolarNoise 🔬 · 2D +### PolarNoise 💫🖌️ · 3D + +PolarNoise effect preview -A warped noise field addressed by angle and radius, folded into a kaleidoscope. The field turns and breathes around the centre rather than scrolling past it. +A warped noise field addressed by angle and radius, folded into a kaleidoscope. The field turns and breathes around the center rather than scrolling past it. -- `bpm` — how fast the field drifts. -- `scale` — noise cells across the grid: low is broad shapes, high is fine detail. -- `segments` — kaleidoscope wedges; 1 disables the fold. -- `warp` — domain-warp strength; 0 gives a plain field. -- `octaves` — fbm octaves, and the main cost knob. -- `twist` — how much the radius shears the angle, setting the spiral. +- `bpm`: how fast the field drifts. +- `scale`: noise cells across the grid: low is broad shapes, high is fine detail. +- `segments`: kaleidoscope wedges; 1 disables the fold. +- `warp`: domain-warp strength; 0 gives a plain field. +- `octaves`: fbm octaves, and the main cost knob. +- `twist`: how much the radius shears the angle, setting the spiral. +- `polarTable`: read each pixel's angle and radius from a table instead of computing them every frame. On by default: measured 34% faster on an ESP32-S3, at 2 bytes per pixel. The 8-bit table quantizes the angle to 256 steps, so it is not pixel-identical to computing the address: a minority of channels differ, and only where the field is steepest. Turn it off on a device short of memory, and the effect computes the address per pixel instead. +- `polarTable16`: hold that table at full 16-bit precision, at 4 bytes per pixel instead of 2. This one IS pixel-identical to computing the address, which a unit test pins. Cost scales with `octaves` and `warp`: at `warp` > 0 and `octaves` 2 it is roughly 4 noise samples per pixel. On a large wall set `octaves` to 1 or `warp` to 0, which degrades to a plain polar noise that still reads well. @@ -565,16 +698,18 @@ Origin: projectMM original, after Stefan Petrick's polar/noise vocabulary and I -### SdfShapes 🔬 · 2D +### SdfShapes 💫🖌️ · 2D + +SdfShapes effect preview A circle and a box orbit and melt into each other, drawn as signed distance fields rather than rasterized outlines. One distance per pixel yields three looks at once: an anti-aliased fill, an outline (`|d| - width`), and a glow that falls off into the surrounding field. -- `bpm` — orbit speed. -- `radius` — circle radius, as a fraction of the short side. -- `boxSize` — box half-extent, same scale. -- `blend` — melt radius; 0 unions the shapes hard. -- `outline` — 0 fills the shape; higher draws an outline of that width. -- `glow` — tint the field around the shape by distance. +- `bpm`: orbit speed. +- `radius`: circle radius, as a fraction of the short side. +- `boxSize`: box half-extent, same scale. +- `blend`: melt radius; 0 unions the shapes hard. +- `outline`: 0 fills the shape; higher draws an outline of that width. +- `glow`: tint the field around the shape by distance. Measured on an ESP32-S3 at 128×128: 20 fps, 728 cycles/pixel using the true-distance form, alongside StarSky (692) and Metaballs (647) at the same size. @@ -584,13 +719,15 @@ Origin: projectMM original, after Iñigo Quilez's distance-function catalogue an ### Solid 💫 · 3D +Solid effect preview + A flat fill with five color modes: a plain RGB(W) color, the active palette spread across the lights, an RMS-averaged single palette color, or the palette banded along the grid's rows or columns. -- `red` / `green` / `blue` / `white` — the flat color in `RGB(W)` mode (ignored in the palette modes). -- `brightness` — scales the flat and palette-spread output. -- `colorMode` — `RGB(W)`, `Palette` (spread across the lights), `Palette avg` (RMS mean of the palette), `Palette rows`, `Palette cols` (palette banded along that axis). -- `minRGB` — in the band modes, drops palette entries whose every channel is below this floor. -- `randomColors` — in the band modes, deterministically shuffles the surviving palette entries. +- `red` / `green` / `blue` / `white`: the flat color in `RGB(W)` mode (ignored in the palette modes). +- `brightness`: scales the flat and palette-spread output. +- `colorMode`: `RGB(W)`, `Palette` (spread across the lights), `Palette avg` (RMS mean of the palette), `Palette rows`, `Palette cols` (palette banded along that axis). +- `minRGB`: in the band modes, drops palette entries whose every channel is below this floor. +- `randomColors`: in the band modes, deterministically shuffles the surviving palette entries. Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -600,11 +737,13 @@ Detail: [technical](moxygen/SolidEffect.md) -### SphereMove 💫🧊 · 3D +### SphereMove 💫 · 3D + +SphereMove effect preview A hollow spherical shell that bounces through the 3D volume, its surface colored from the palette, leaving no trail. -- `speed` — how fast the sphere moves through the volume. +- `speed`: how fast the sphere moves through the volume. Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -614,15 +753,15 @@ Detail: [technical](moxygen/SphereMoveEffect.md) -### Spiral 💫🦅 · 2D +### Spiral 💫🦅🖌️ · 2D Spiral effect preview Rotating spiral from angle + distance (`atan2_8`/`dist8`). -- `bpm` — rotation speed. -- `twist` — how tightly the arm winds (hue gain per unit of distance). -- `hue_shift` — rotate the palette index. +- `bpm`: rotation speed. +- `twist`: how tightly the arm winds (hue gain per unit of distance). +- `hue_shift`: rotate the palette index. Origin: projectMM original (rotating spiral) @@ -632,14 +771,16 @@ Detail: [technical](moxygen/SpiralEffect.md) -### StarField 💫 · 2D +### StarField 💫🖌️ · 2D + +StarField effect preview A perspective starfield: stars approach the viewer from a vanishing point, brightening as they near, then respawn at depth. -- `speed` — how fast stars approach (frame throttle). -- `numStars` — how many stars are active. -- `blur` — motion-trail fade per frame. -- `usePalette` — color the stars from the palette instead of white. +- `speed`: how fast stars approach (frame throttle). +- `numStars`: how many stars are active. +- `blur`: motion-trail fade per frame. +- `usePalette`: color the stars from the palette instead of white. Origin: MoonLight · by [@Brandon502](https://github.com/Brandon502), inspired by Daniel Shiffman / [Coding Train](https://www.youtube.com/watch?v=17WoOqgXsRM) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -655,9 +796,9 @@ Detail: [technical](moxygen/StarFieldEffect.md) Twinkling stars at random light positions, each fading in and out independently over a dark background. -- `speed` — fade rate per frame (how fast each star brightens/dims). -- `star_fill_ratio` — how many stars (as a fraction of the light count). -- `usePalette` — color the stars from the active palette instead of white. +- `speed`: fade rate per frame (how fast each star brightens/dims). +- `star_fill_ratio`: how many stars (as a fraction of the light count). +- `usePalette`: color the stars from the active palette instead of white. Origin: MoonLight · by [limpkin](https://github.com/limpkin) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -669,13 +810,15 @@ Detail: [technical](moxygen/StarSkyEffect.md) ### Text 💫 · 2D +Text effect preview + Renders a multi-line string in a bitmap font. Static by default (laid out top-left, each newline dropping one font-height, clipped where it runs off the grid); turn on `scroll` to march the whole block leftwards as a wrapping marquee. Text color comes from the active palette. -- `text` — the string to show; a **multi-line text area** (each line renders on its own row). -- `scroll` — off (default) = static; on = horizontal marquee. -- `font` — glyph size (`4x6` compact, `6x8` larger). -- `speed` — marquee speed (only used when `scroll` is on). -- `hue` — palette index for the text color. +- `text`: the string to show; a **multi-line text area** (each line renders on its own row). +- `scroll`: off (default) = static; on = horizontal marquee. +- `font`: glyph size (`4x6` compact, `6x8` larger). +- `speed`: marquee speed (only used when `scroll` is on). +- `hue`: palette index for the text color. Origin: projectMM original, on MoonLight's Scrolling Text · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -687,21 +830,23 @@ Detail: [technical](moxygen/TextEffect.md) -### GameOfLife 💫🌙 · 2D/3D +### GameOfLife 💫🌙🧬 · 2D/3D + +GameOfLife effect preview -Conway's cellular automaton generalised to 2D/3D: selectable rulesets (+ custom `B#/S#`), cells that inherit a neighbour's palette color on birth, optional green→red age coloring, a dead-cell blur fading toward the background color, toroidal `wrap`, a 1.5 s settle pause, and 3-CRC stasis self-respawn (R-pentomino/glider) when the board goes static. +Conway's cellular automaton generalised to 2D/3D: selectable rulesets (+ custom `B#/S#`), cells that inherit a neighbor's palette color on birth, optional green→red age coloring, a dead-cell blur fading toward the background color, toroidal `wrap`, a 1.5 s settle pause, and 3-CRC stasis self-respawn (R-pentomino/glider) when the board goes static. -- `backgroundColorR` / `backgroundColorG` / `backgroundColorB` — the color dead cells fade toward (0–255 each). -- `ruleset` — the birth/survive rule (Conway, HighLife, InverseLife, Maze, Mazecentric, DrighLife, or Custom). -- `customRuleString` — a custom `B#/S#` rule, read only when `ruleset` = Custom. -- `GameSpeed (FPS)` — generation rate (0–100, 100 = uncapped). -- `startingLifeDensity` — % of cells alive at start (10–90). -- `mutationChance` — % chance a newborn gets a random color (0–100). -- `wrap` — toroidal edges (cells wrap around). -- `disablePause` — skip the 1.5 s settle pause between boards. -- `colorByAge` — green→red aging instead of inheriting a neighbour's palette color. -- `infinite` — respawn on stasis (R-pentomino/glider) instead of resetting. -- `blur` — dead-cell fade strength toward the background color. +- `backgroundColorR` / `backgroundColorG` / `backgroundColorB`: the color dead cells fade toward (0–255 each). +- `ruleset`: the birth/survive rule (Conway, HighLife, InverseLife, Maze, Mazecentric, DrighLife, or Custom). +- `customRuleString`: a custom `B#/S#` rule, read only when `ruleset` = Custom. +- `GameSpeed (FPS)`: generation rate (0–100, 100 = uncapped). +- `startingLifeDensity`: % of cells alive at start (10–90). +- `mutationChance`: % chance a newborn gets a random color (0–100). +- `wrap`: toroidal edges (cells wrap around). +- `disablePause`: skip the 1.5 s settle pause between boards. +- `colorByAge`: green→red aging instead of inheriting a neighbor's palette color. +- `infinite`: respawn on stasis (R-pentomino/glider) instead of resetting. +- `blur`: dead-cell fade strength toward the background color. Origin: MoonModules · by Ewoud Wijma (2022), mods by Brandon Butler / [@Brandon502](https://github.com/Brandon502) · [natureofcode](https://natureofcode.com/book/chapter-7-cellular-automata/) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) @@ -711,16 +856,18 @@ Detail: [technical](moxygen/GameOfLifeEffect.md) -### GEQ 💫🐙📊 · 2D +### GEQ 💫🐙🎶 · 2D -GEQ effect preview +GEQ effect preview + +GEQ effect preview A flat graphic equaliser: the 16 audio bands rise as vertical bars from the bottom, with optional smoothing between bars, per-bar palette coloring, and falling peak markers. -- `fadeOut` — how fast bars fade each frame. -- `ripple` — falling-peak marker decay. -- `colorBars` — color each bar from the palette by band instead of by row. -- `smoothBars` — blend neighbouring bands for smoother bar heights. +- `fadeOut`: how fast bars fade each frame. +- `ripple`: falling-peak marker decay. +- `colorBars`: color each bar from the palette by band instead of by row. +- `smoothBars`: blend neighboring bands for smoother bar heights. Origin: WLED (audio) · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) @@ -730,16 +877,18 @@ Detail: [technical](moxygen/GEQEffect.md) -### GEQ3D 💫🌙📊 · 2D +### GEQ3D 💫🌙🎶 · 2D + +GEQ3D effect preview A 3D-perspective graphic equaliser: audio bands rise as bars with faked depth, their side/top lines drawn toward a "projector" vanishing point (sweeping left↔right) and shortened by `depth`. Bands left of the projector are painted right-to-left, bands right of it left-to-right; per-face darkening (side/top/front) and optional `borders`. -- `speed` — projector sweep rate (1–10, higher = faster). -- `frontFill` — bar front-face fill strength (0–255). -- `horizon` — vanishing-point row the projector sits on. -- `depth` — how far the side/top perspective lines reach toward the projector. -- `numBands` — bands shown (2–16, fewer = wider bars). -- `borders` — outline each bar. +- `speed`: projector sweep rate (1–10, higher = faster). +- `frontFill`: bar front-face fill strength (0–255). +- `horizon`: vanishing-point row the projector sits on. +- `depth`: how far the side/top perspective lines reach toward the projector. +- `numBands`: bands shown (2–16, fewer = wider bars). +- `borders`: outline each bar. Origin: MoonModules (audio) · by [@TroyHacks](https://github.com/troyhacks) (GPLv3) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) @@ -747,33 +896,20 @@ Detail: [technical](moxygen/GEQ3DEffect.md) [Tests](../../tests/unit-tests.md#geq3deffect) - - -### Noise2D 💫🌙🐙 · 2D - -A smoothly drifting value-noise field: each pixel samples 3D noise (grid position × `scale`, time on the Z axis) and indexes the palette directly, giving an organic plasma wash that morphs over time. - -- `speed` — how fast the field morphs (time-flow rate). -- `scale` — noise zoom (higher = finer, more detailed). - -Origin: WLED · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) - -Detail: [technical](moxygen/Noise2DEffect.md) - -[Tests](../../tests/unit-tests.md#noise2deffect) - -### PaintBrush 💫🌙📊 · 3D +### PaintBrush 💫🌙🎶 · 3D + +PaintBrush effect preview Audio-reactive brush strokes: lines whose 3D endpoints oscillate on the beat (`beatsin8`, audio-band timebase), each stroke shortened to a band-magnitude length so the moving tip sweeps a curve over the fading field. -- `oscillatorOffset` — phase-spread between the oscillating endpoints (0–16). -- `numLines` — parallel animated strokes (2–255). -- `fadeRate` — background decay per frame (0–128, higher = shorter strokes). -- `minLength` — a stroke draws only if longer than this, so quiet bands stay dark. -- `color_chaos` — per-line random hue vs a per-band gradient. -- `phase_chaos` — random per-frame phase jitter. +- `oscillatorOffset`: phase-spread between the oscillating endpoints (0–16). +- `numLines`: parallel animated strokes (2–255). +- `fadeRate`: background decay per frame (0–128, higher = shorter strokes). +- `minLength`: a stroke draws only if longer than this, so quiet bands stay dark. +- `color_chaos`: per-line random hue vs a per-band gradient. +- `phase_chaos`: random per-frame phase jitter. Origin: MoonModules (audio) · by [@TroyHacks](https://github.com/troyhacks) (GPLv3) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) @@ -783,13 +919,15 @@ Detail: [technical](moxygen/PaintBrushEffect.md) -### Tetrix 💫🌙 · 2D +### Tetrix 💫🌙✨ · 2D + +Tetrix effect preview Falling Tetris-style blocks: each column drops a brick that lands on the growing stack, fills the column, then clears and restarts. -- `speed` — fall speed (0 = randomised per brick). -- `width` — brick height (0 = randomised). -- `oneColor` — one advancing palette color for all bricks instead of random per-brick colors. +- `speed`: fall speed (0 = randomised per brick). +- `width`: brick height (0 = randomised). +- `oneColor`: one advancing palette color for all bricks instead of random per-brick colors. Origin: WLED · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) @@ -801,16 +939,18 @@ Detail: [technical](moxygen/TetrixEffect.md) -### Blurz 🐙📊 · 2D +### Blurz 🐙🎶 · 2D + +Blurz effect preview -Blurz effect preview +Blurz effect preview Audio-reactive blurred dots: one frequency band per frame lights a dot whose position maps to that band (or to the major-peak frequency), then the whole frame is blurred for soft trails. -- `fadeRate` — background decay per frame. -- `blur` — blur strength applied each frame. -- `freqMap` — place the dot by the major-peak frequency instead of scanning bands. -- `geqScanner` — scan the dot across the strip in a GEQ-like sweep. +- `fadeRate`: background decay per frame. +- `blur`: blur strength applied each frame. +- `freqMap`: place the dot by the major-peak frequency instead of scanning bands. +- `geqScanner`: scan the dot across the strip in a GEQ-like sweep. Origin: WLED (audio) · by Andrew Tuline (WLED-SR), enhancements by [@softhack007](https://github.com/softhack007) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) @@ -820,14 +960,16 @@ Detail: [technical](moxygen/BlurzEffect.md) -### BouncingBalls 🐙 · 2D +### BouncingBalls 💫🐙 · 2D -BouncingBalls effect preview +BouncingBalls effect preview + +BouncingBalls effect preview A row of balls per column bounce under gravity, each losing energy on impact and relaunching when it stops, palette-colored by ball index over a fading background. -- `grav` — gravity strength (higher = faster fall, snappier bounce). -- `numBalls` — balls per column (1–16). +- `grav`: gravity strength (higher = faster fall, snappier bounce). +- `numBalls`: balls per column (1–16). Origin: WLED · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) @@ -837,17 +979,19 @@ Detail: [technical](moxygen/BouncingBallsEffect.md) -### FreqMatrix 🐙📊 · 1D +### FreqMatrix 🐙🎶 · 1D + +FreqMatrix effect preview -FreqMatrix effect preview +FreqMatrix effect preview A 1D scrolling frequency display: each frame shifts the strip and injects a new pixel at one end whose hue comes from the dominant frequency and whose brightness from the volume. -- `speed` — scroll rate. -- `fx` — sound-effect intensity (scales the injected brightness). -- `lowBin` / `highBin` — the frequency window mapped across the hue range. -- `sensitivity` — input gain (10–100). -- `audioSpeed` — let the volume modulate the scroll speed. +- `speed`: scroll rate. +- `fx`: sound-effect intensity (scales the injected brightness). +- `lowBin` / `highBin`: the frequency window mapped across the hue range. +- `sensitivity`: input gain (10–100). +- `audioSpeed`: let the volume modulate the scroll speed. Origin: WLED (audio) · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) @@ -859,13 +1003,15 @@ Detail: [technical](moxygen/FreqMatrixEffect.md) ### Lissajous 🐙 · 2D -Lissajous effect preview +Lissajous effect preview + +Lissajous effect preview A Lissajous curve traced across the grid from two phase-shifted `sin8`/`cos8` sweeps, palette-colored along its length, with a fading trail. -- `xFrequency` — the x-axis sweep frequency (sets the curve's lobe count). -- `fadeRate` — trail fade per frame. -- `speed` — how fast the curve's phase advances. +- `xFrequency`: the x-axis sweep frequency (sets the curve's lobe count). +- `fadeRate`: trail fade per frame. +- `speed`: how fast the curve's phase advances. Origin: WLED · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) @@ -875,14 +1021,16 @@ Detail: [technical](moxygen/LissajousEffect.md) -### NoiseMeter 🐙📊 · 3D +### NoiseMeter 🐙🎵 · 3D -NoiseMeter effect preview +NoiseMeter effect preview + +NoiseMeter effect preview An audio VU meter rendered as a noise bar: the volume sets how many rows light from the bottom, each row colored by drifting Perlin noise, filling the full width and depth. -- `fadeRate` — trail decay per frame (200–254). -- `width` — how strongly the volume drives the bar height. +- `fadeRate`: trail decay per frame (200–254). +- `width`: how strongly the volume drives the bar height. Origin: WLED (audio) · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) @@ -892,13 +1040,15 @@ Detail: [technical](moxygen/NoiseMeterEffect.md) -### Wave 🌊 · 2D +### Wave 💫 · 2D + +Wave effect preview An oscilloscope waveform scrolls across the grid with a fading trail; six selectable shapes. -- `bpm` — travel speed (phase advance per minute). -- `fade` — trail fade per frame (0 = instant clear, 255 = long tail). -- `type` — waveform shape (`Sawtooth`, `Triangle`, `Sine`, `Square`, `Sin3`, `Noise`). +- `bpm`: travel speed (phase advance per minute). +- `fade`: trail fade per frame (0 = instant clear, 255 = long tail). +- `type`: waveform shape (`Sawtooth`, `Triangle`, `Sine`, `Square`, `Sin3`, `Noise`). Origin: MoonLight · by Ewoud Wijma · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -910,16 +1060,16 @@ Detail: [technical](moxygen/WaveEffect.md) -### Fire ⚡️🦅 · 2D +### Fire ⚡️🦅🧬 · 2D Fire effect preview -Fire2012-style heat field — sparks at the base rise and cool through the active palette (heat = palette index, cold at the low end, hottest at the high end); spark count scales with width. +Fire2012-style heat field: sparks at the base rise and cool through the active palette (heat = palette index, cold at the low end, hottest at the high end); spark count scales with width. -- `cooling` — how fast heat dissipates as it rises (higher = shorter flames). -- `sparking` — chance of a new spark at the base each frame (higher = livelier fire). +- `cooling`: how fast heat dissipates as it rises (higher = shorter flames). +- `sparking`: chance of a new spark at the base each frame (higher = livelier fire). -The flame color comes from the **active palette**. For the classic fire look pick the **Lava** palette (black→red→orange→yellow→white — the recommended default); any palette works, so an Ocean or Forest palette turns the flame blue or green. +The flame color comes from the **active palette**. For the classic fire look pick the **Lava** palette (black→red→orange→yellow→white: the recommended default); any palette works, so an Ocean or Forest palette turns the flame blue or green. Origin: FastLED / MoonLight · Mark Kriegsman's Fire2012; MoonLight adapts [MatrixFireFast](https://github.com/toggledbits/MatrixFireFast) (toggledbits) @@ -929,16 +1079,17 @@ Detail: [technical](moxygen/FireEffect.md) -### Noise ⚡️ · 2D/3D +### Noise ⚡️💫🌙🐙 · 1D/2D/3D Noise effect preview -Smooth animated value noise; true 3D field on volumetric layouts. +A gradient-noise field indexed straight into the palette: the plainest way to turn the field into light, and the effect every other noise effect is a variation on. -- `scale` — spatial frequency of the field (1–32, higher = finer detail). -- `bpm` — scroll speed (8 noise cells per beat). +- `motion`: what moves. **`drift`** scrolls the sample coordinates, so the field slides across the fixture like weather, each axis at its own rate so it flows rather than translating rigidly; on a volumetric fixture the third axis is the light's own depth, so the slices differ. **`morph`** holds the coordinates still and puts time on the third axis, so the field changes in place without going anywhere, which on a panel is the classic plasma wash; there is then no axis left for depth, so a volumetric fixture shows the same field in every slice. +- `scale`: spatial frequency: low is broad blobs, high is fine detail. +- `bpm`: how fast it moves. -Origin: FastLED · inoise field (Mark Kriegsman) +Origin: FastLED · inoise field (Mark Kriegsman); the `morph` form from WLED via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h), which shipped it as a separate Noise2D effect until the two were merged Detail: [technical](moxygen/NoiseEffect.md) @@ -948,11 +1099,13 @@ Detail: [technical](moxygen/NoiseEffect.md) -### AudioSpectrum 📊 +### AudioSpectrum 💫🎶 + +AudioSpectrum effect preview The 16 mic frequency bands spread across X, each column lit bottom-up by its magnitude. -- `colorMode` — bar coloring: `height` (green base → red top, the VU look) or `per-band` (each column its own hue, the rainbow analyser look). +- `colorMode`: bar coloring: `height` (green base → red top, the VU look) or `per-band` (each column its own hue, the rainbow analyser look). Origin: projectMM original, on the WLED-SR GEQ / spectrum concept (Andrew Tuline) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) @@ -962,11 +1115,13 @@ Detail: [technical](moxygen/AudioSpectrumEffect.md) -### AudioVolume 🔊 +### AudioVolume 💫🎵 + +AudioVolume effect preview A whole-grid VU meter: every light pulses with the mic level, color indexing the palette by loudness. -- `brightness` — overall brightness ceiling for the VU pulse (1–255). +- `brightness`: overall brightness ceiling for the VU pulse (1–255). Origin: projectMM original (VU meter) @@ -976,14 +1131,16 @@ Detail: [technical](moxygen/AudioVolumeEffect.md) -### DemoReel 🎬 · 3D +### DemoReel 💫 · 3D + +DemoReel effect preview -A demo reel: plays every other registered effect in turn, auto-advancing on a timer, so one Layer cycles the whole library hands-free — the showcase/test tool for everything. It hosts a single live effect at a time (created from the effect registry, rendered into this Layer) and swaps to the next when the interval elapses — new effects are picked up automatically. It can also pick a fresh palette each cycle and overlay the playing effect's name. The `status` line shows which effect is playing (e.g. `playing: Plasma (3/20)`). It never hosts itself, and it plays effects in sequence rather than compositing them (layering is the [Layer](moxygen/Layer.md) stack's job). +A demo reel: plays every other registered effect in turn, auto-advancing on a timer, so one Layer cycles the whole library hands-free: the showcase/test tool for everything. It hosts a single live effect at a time (created from the effect registry, rendered into this Layer) and swaps to the next when the interval elapses: new effects are picked up automatically. It can also pick a fresh palette each cycle and overlay the playing effect's name. The `status` line shows which effect is playing (e.g. `playing: Plasma (3/20)`). It never hosts itself, and it plays effects in sequence rather than compositing them (layering is the [Layer](moxygen/Layer.md) stack's job). -- `interval` — seconds each effect plays before advancing (1–120). -- `shuffle` — jump to a random next effect instead of registry order. -- `randomPalette` — pick a random palette on each cycle (showcases the palette set); default on. -- `showName` — overlay the playing effect's name in a small font; default on. +- `interval`: seconds each effect plays before advancing (1–120). +- `shuffle`: jump to a random next effect instead of registry order. +- `randomPalette`: pick a random palette on each cycle (showcases the palette set); default on. +- `showName`: overlay the playing effect's name in a small font; default on. Origin: FastLED · Mark Kriegsman's [DemoReel100](https://github.com/FastLED/FastLED/blob/master/examples/DemoReel100/DemoReel100.ino); projectMM reel @@ -995,10 +1152,12 @@ Detail: [technical](moxygen/DemoReelEffect.md) ### NetworkReceive 📡🌙 -Receives lights-over-UDP (Art-Net, E1.31/sACN, DDP) and writes it into the layer — the receive side for Resolume/Madrix/xLights/LedFx. +NetworkReceive effect preview -- `universe_start` — the first incoming universe to map onto the layer (mirrors the sender). -- `channels_per_universe` — bytes each universe maps to (510 = whole RGB lights per universe, the xLights/Falcon convention; 512 for Madrix-style senders that pack pixels across universe boundaries). +Receives lights-over-UDP (Art-Net, E1.31/sACN, DDP) and writes it into the layer: the receive side for Resolume/Madrix/xLights/LedFx. + +- `universe_start`: the first incoming universe to map onto the layer (mirrors the sender). +- `channels_per_universe`: bytes each universe maps to (510 = whole RGB lights per universe, the xLights/Falcon convention; 512 for Madrix-style senders that pack pixels across universe boundaries). Origin: projectMM original (E1.31 / Art-Net receive) @@ -1010,13 +1169,15 @@ Detail: [technical](moxygen/NetworkReceiveEffect.md) -### Sine 🌀 · 3D +### Sine 💫 · 3D + +Sine effect preview -R/G/B each follow a sine along one axis at 120° phase offset — a glowing, scrolling color box. +R/G/B each follow a sine along one axis at 120° phase offset: a glowing, scrolling color box. -- `frequency` — spatial frequency, waves across the box (1–20). -- `amplitude` — peak brightness (0–255, 255 = full). -- `bpm` — scroll speed. +- `frequency`: spatial frequency, waves across the box (1–20). +- `amplitude`: peak brightness (0–255, 255 = full). +- `bpm`: scroll speed. Origin: MoonLight (Sinus, AI-generated) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) diff --git a/docs/moonmodules/light/layouts.md b/docs/moonmodules/light/layouts.md index 783825af..0c09f7f7 100644 --- a/docs/moonmodules/light/layouts.md +++ b/docs/moonmodules/light/layouts.md @@ -97,9 +97,10 @@ Detail: [technical](moxygen/RingLayout.md) ### Rings 241 -The classic 241-LED concentric-ring disc: nested rings of 1, 8, 12, 16, 24, 32, 40, 48, 60 LEDs sharing a centre. +The classic 241-LED concentric-ring disc: nested rings of 1, 8, 12, 16, 24, 32, 40, 48, 60 LEDs sharing a center. - `scale` — overall radius scale (1–10). +- `outside in`: light 0 on the outer ring, wired inward, instead of at the center wired outward. The direction around each ring is unchanged. Origin: MoonLight · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) diff --git a/docs/moonmodules/light/power-functions.md b/docs/moonmodules/light/power-functions.md index 7c04efc2..fb75b0a3 100644 --- a/docs/moonmodules/light/power-functions.md +++ b/docs/moonmodules/light/power-functions.md @@ -124,14 +124,39 @@ One sample is a soft blur; the character comes from composing them. Summing octa | Power function | What it does | Effects | Modifiers | |---|---|---|---| -| `inoise8` | Value noise in 1D, 2D or 3D: a smooth, deterministic pseudo-random field | Noise, Noise2D, NoiseMeter, Wave | — | -| `fbm8` | Sums noise octaves at doubling frequency and halving amplitude, turning a blur into cloud and terrain structure | PolarNoise, Tunnel | — | -| `warp8` | Samples noise at a coordinate that noise itself displaced — the flowing, marbled look | PolarNoise | — | +| `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.** @@ -144,9 +169,10 @@ The 16-bit forms matter here: the 8-bit versions step visibly on a large fixture | Power function | What it does | Effects | Modifiers | |---|---|---|---| -| `atan16` | The angle of a point as a 16-bit turn, smooth enough that a sweep shows no steps on a large fixture | PolarNoise, Spiral, Tunnel | — | -| `dist16` | True Euclidean radius — not the octagon `dist8` approximates, and it does not saturate at 255 | PolarNoise, Rings, Spiral, Tunnel | — | -| `kaleido` | Folds an angle into n mirrored wedges, giving any field n-fold symmetry for one modulo | PolarNoise, Tunnel | — | +| `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** | @@ -165,6 +191,7 @@ The framerate rule lives here too: everything in this group is driven by elapsed | 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 | — | diff --git a/docs/performance.md b/docs/performance.md index ba55e946..430f26a0 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -33,6 +33,92 @@ Binary sizes: | macOS arm64 | 358 KB | debug-arm64 (release-strip is smaller) | | Windows x64 | 432 KB | MSVC Release, static CRT | +### Kernel micro-bench (host) + +`uv run moondeck/check/bench_kernels.py` (the `mm_bench` target, Release, best of 5 over a 256×256 sweep of 16.0 fixed coordinates). A report that gates a kernel swap: the generative-fields plan accepts gradient noise behind the same names only within 1.3× of the row it replaces. Host figures; the S3 is 20-40× slower per core, so the ratio between rows is what transfers to a board. + +**Phase 0, before and after the swap to Perlin improved gradient noise (2026-09-04, macOS arm64).** The bound for accepting the swap was 1.3x per sample against the row it replaces, and it had to hold on a board, not only here: the first gradient cut was 2.8x FASTER on this host and 1.5x SLOWER on an ESP32-S3, because a runtime arity argument, select expressions that compile to branches, and eight corners in flight cost nothing on an out-of-order core and everything on an in-order one without a branch predictor. The shipped core is arity-templated, branch-free (a gradient table), 32-bit on the 8-bit tier, and hashes each corner to four bits with one multiply (three hashed to a byte nothing read). Host, ns per sample: + +| Kernel | value noise | gradient noise | ratio | +|---|---:|---:|---:| +| inoise8 1D | 13.0 | 2.6 | 0.20 | +| inoise8 2D | 22.2 | 5.4 | 0.24 | +| inoise8 3D | 29.3 | 11.5 | 0.39 | +| inoise16 1D | 4.7 | 3.2 | 0.68 | +| inoise16 2D | 9.6 | 7.0 | 0.73 | +| inoise16 3D | 17.8 | 13.3 | 0.75 | +| fbm8 2D, 2 octaves | 22.8 | 11.7 | 0.51 | +| fbm8 2D, 4 octaves | 38.0 | 24.2 | 0.64 | +| fbm16 2D, 2 octaves | 14.8 | 13.5 | 0.91 | +| fbm16 2D, 4 octaves | 22.2 | 27.1 | 1.22 | +| turbulence8 2D, 2 octaves | 18.3 | 12.8 | 0.70 | +| warp8 2D, 1 octave | 30.6 | 21.9 | 0.72 | +| warp8 2D, 2 octaves | 36.5 | 29.8 | 0.82 | + +**ESP32-S3 (esp32s3-n16r8, 64x64, the four noise effects, tick in µs)**, the measurement that decided it. Same board, same grid, value noise re-flashed from the previous commit for the before column: + +| Effect (path) | value noise | gradient, first cut | gradient, shipped | ratio | +|---|---:|---:|---:|---:| +| Noise (inoise8 2D) | 4,621 | 7,009 | 5,049 | 1.09 | +| Noise2D (inoise8 3D) | 6,681 | 10,661 | 8,453 | 1.27 | +| Tunnel (fbm8) | 16,385 | 21,304 | 16,649 | 1.02 | +| PolarNoise (warp8) | 20,356 | 29,199 | 20,490 | 1.01 | + +The two noise effects have since merged: `Noise` is `Dim::D3` and renders what `Noise2D` did, so the +two rows above are one effect's 2D and 3D paths under the names they carried when the swap was +measured. + +The 3D path is the closest to the bound and the reason: 3D gradient noise does eight dot products the value form never did, and the S3 instruction count for a 3D sample is 1.3x the old one. Method worth keeping: compile the kernel with the target's own compiler (`xtensa-esp32s3-elf-g++ -O2 -S`) and count instructions, branches and stack spills BEFORE flashing; three restructurings were compared that way in seconds, and the one flash went to the winner. The P4 and S31 numbers are open until those boards are back on the bench. + +**All rows, ns per sample, best of 5.** Re-measured 2026-09-04 after the benchmark stopped dispatching through `std::function`: a type-erased call cannot be inlined, so it added an indirect call to every sample and the old figures were part kernel and part harness. Every row roughly halved, which is the size of what was being attributed to the kernels: + +| Kernel | ns/sample | Msamples/s | +|---|---:|---:| +| inoise8 1D | 1.8 | 554.4 | +| inoise8 2D | 4.6 | 218.5 | +| inoise8 3D | 10.1 | 99.5 | +| inoise16 1D | 1.0 | 954.4 | +| inoise16 2D | 2.1 | 481.1 | +| inoise16 3D | 12.5 | 80.1 | +| fbm8 2D, 2 octaves | 10.0 | 100.0 | +| fbm8 2D, 4 octaves | 21.3 | 47.0 | +| fbm16 2D, 2 octaves | 5.2 | 190.7 | +| fbm16 2D, 4 octaves | 11.8 | 84.9 | +| turbulence8 2D, 2 octaves | 10.2 | 98.2 | +| warp8 2D, 1 octave | 16.6 | 60.1 | +| warp8 2D, 2 octaves | 25.5 | 39.2 | +| atan16 | 1.5 | 649.1 | +| dist16 | 8.0 | 125.3 | +| polar address (dist16 + atan16 + kaleido) | 9.9 | 101.3 | + +That reading held: the 8-bit tier improved most (2D by 4x on the host), because the value form quantized at every stage where the gradient form carries its dot products at full width. + +### Fluid solver cost (host) + +`scenario_Fluid_solver`, desktop macOS arm64, tick in µs. The solver is Stam's stable fluid: several +passes over the grid per frame, plus `iterations` Gauss-Seidel sweeps for the pressure projection +that keeps the flow divergence-free. + +| Grid | iterations | tick µs | +|---|---:|---:| +| 32×32 | 1 | 20 | +| 32×32 | 5 (default) | 30 | +| 32×32 | 20 | 69 | +| 64×64 | 5 | 133 | +| 20×20×20 cube | 5 | 249 | +| 16×16 | 5 | 7 | + +Two properties an author picks a setting from. **`iterations` is near-linear**: 1 to 20 is 20 to +69 µs, since each is another sweep over the whole grid. **The forcing is free next to the solver**: +going from 2 jets to 4, and persistence from 150 to 255, moved 135 µs to 134 µs, inside the noise. +So the grid and the iteration count are the two knobs that matter, and the jets are a look rather +than a cost. **A cube is depth times one panel**: twenty 20×20 slices, each its own medium, cost +249 µs against 133 for one 64×64 panel with about half the lights, which is the per-slice solve +paying its boundary and projection twenty times over. + +Device rows are open: the P4 and S3 numbers need a board and have not been measured, so what +this effect can carry on either is an open question rather than a claim. + ### Memory at 128×128 with mirror | Module | dynamicBytes | Breakdown | @@ -216,7 +302,7 @@ A render-only per-effect sweep on the S3 (`observed.esp32s3-n16r8`, build `Jun 1 | LavaLamp | 309 | 974 | 3,612 | 21,243 | | GameOfLife | 138 | 413 | 1,870 | 16,127 | -The cheapest (Lines, Checkerboard, PlasmaPalette) clear ~100 FPS even at 16K; the heaviest is **Noise** (51 ms = ~19 FPS at 16K — simplex noise per pixel), then Rings and GlowParticles. Effect-compute differences stay visible across the whole range because nothing is output-bound here. +The cheapest (Lines, Checkerboard, PlasmaPalette) clear ~100 FPS even at 16K; the heaviest is **Noise** (51 ms = ~19 FPS at 16K, a noise sample per pixel), then Rings and GlowParticles. Effect-compute differences stay visible across the whole range because nothing is output-bound here. **Free internal heap** holds ~8.54 MB at small grids and ~8.46–8.49 MB at 16K — the ~50–100 KB delta is just the grid-sized render buffer (the `model` array), and it returns to ~8.54 MB whenever the grid shrinks: **no leak, no fragmentation creep** across the sweep. Largest free internal block stays ~90–110 KB throughout. (Internal RAM is not the constraint on this PSRAM board; the Layer buffer is in PSRAM.) diff --git a/docs/tutorials/generative-effects.md b/docs/tutorials/generative-effects.md new file mode 100644 index 00000000..485aaf18 --- /dev/null +++ b/docs/tutorials/generative-effects.md @@ -0,0 +1,294 @@ +# Making beautiful effects + +Most LED effects are written a light at a time: loop over the pixels, work out a +color, write it. That works, and it takes a long time to get from "it lights up" +to "I want to keep watching it". + +This page is about the other way. projectMM ships a set of **power functions**: +the handful of algorithms that computer graphics has used for forty years to make +things look alive. You do not implement them, you compose them. And because most of +that vocabulary is reachable from **MoonLive** scripts as well as from compiled +C++, a few dozen lines of script gets you an effect that would otherwise be a +project. + +> Never opened the interface? Start with **[Install & first light](../gettingstarted.md)** +> and **[How projectMM works](how-projectmm-works.md)**, then come back. + +--- + +## 1. The one idea: a field + +Here is the shift that makes everything else easy. + +Instead of asking *"what should pixel 37 be?"*, ask *"what is the value of this +field at (x, y, z), right now?"* The field is a function of position and time. You +sample it once per light, turn the number into a color, and the picture falls out. + +That is what a **shader** is, and it is why an effect written this way is +resolution-independent: the same code fills a 16-light strip and a 12,288-light +wall, because it never counted pixels in the first place. + +Fields also compose. Add two, and you get a third. Use one to bend another's +coordinates, and you get something neither could produce alone. That is the whole +trick, and the rest of this page is the vocabulary. + +--- + +## 2. Noise: the raw material + +Random numbers look like static. **Noise** looks like nature, because a noise +value is close to its neighbors: it wanders instead of jumping. + +**Ken Perlin** published it in 1985 (*An Image Synthesizer*), after developing it +on the movie *Tron*; it later won him an Academy Award. projectMM uses his own +revision, *improved noise* (SIGGRAPH 2002), which removes the directional bias of +the original. + +``` +int v = noise(x * 20, y * 20, div(t, 32)); // 0..255, smooth in every direction +``` + +Three things to know, and then you can use it: + +**Scale is everything.** `noise(x, y, 0)` reads one value per light and looks like +static, because adjacent lights land in completely different parts of the field. +Multiply the coordinate down and you zoom in: `noise(x * 20, y * 20, 0)` gives +broad soft blobs. Getting this wrong is the single most common reason a noise +effect looks like colored snow. + +**Time is just another axis.** Do not recompute a 2D field and shift it. Sample a +3D field and walk along z: `noise(x * 20, y * 20, div(t, 32))`. The picture morphs +instead of scrolling, which is what makes it look organic rather than mechanical. + +**1D, 2D and 3D are the same call.** One axis for a strip, two for a panel, three +for a cube, or two of space and one of time. `noise()` takes what you give it. + +(`div(t, 32)` rather than `t / 32`: MoonLive spells integer division `div`, and +`t` is the millisecond clock every script is handed.) + +--- + +## 3. Octaves: detail at every size + +One noise sample is a soft blur. Nature is not soft: a coastline has bays, and the +bays have inlets, and the inlets have rocks. + +**fBm** (fractional Brownian motion, the term is Mandelbrot's) is the answer, and +it is embarrassingly simple. Sample the field several times, each at double the +frequency and half the strength, and add them up. Each pass is an **octave**, the +name borrowed from music, where an octave is also a doubling. + +``` +int v = fbm(x * 20, y * 20, 2); // 2 octaves: shape, plus texture on it +``` + +| Octaves | What you get | What it costs | +|---|---|---| +| 1 | soft blobs | 1 sample | +| 2 | shape with texture on it | 2 samples | +| 4 | rock, cloud, terrain | 4 samples | + +Octaves are the **cost knob** of every field effect: doubling them doubles the +work per light. Two is usually the sweet spot; four is for when the fixture is +small enough to afford it. + +One measured detail worth knowing: octaves partly cancel, so their sum is +narrower than one octave's range. Four octaves span roughly 54..199 of 0..255 +rather than the full sweep. projectMM re-widens the result for you, so `fbm` at +any octave count still uses the whole range and your thresholds keep working. + +--- + +## 4. Warp: the trick that sells it + +This one is worth learning on its own, because it is the difference between "some +noise" and "that looks like smoke". + +**Domain warping** (popularized by **Inigo Quilez**, of Shadertoy) means using +noise to move the place where you sample noise. You are not adding a field to a +field, you are bending one field's coordinates with another. + +``` +int v = warp(x * 20, y * 20, 60); // 60 = how far the field displaces itself +``` + +The result flows, folds and curls back on itself. Almost every "liquid" or +"marbled" look you have admired is domain warping. + +![Aurora: layered warped noise read in polar coordinates](../assets/light/tutorial/aurora.png) + +That is **Aurora**: a few layers of warped noise, each drifting on its own clock, +read around the center instead of across the grid, with a contrast window that +pushes most of the field to black so distinct curtains survive. + +Every technique on this page is a published one, credited to the people named +throughout. **Stefan Petrick** is the person who brought that shader vocabulary to +LED panels and showed what it does there, through +[Animartrix](https://github.com/StefanPetrick/animartrix), FunkyNoise and +ColorTrails: noise read in polar coordinates, layers on independent oscillators, a +contrast window that turns a soft field into distinct curtains, emitters carried by +a flow field. That is the tradition this page's effects sit in. + +--- + +## 5. From lines to fields: the ladder + +The power functions are a ladder, and the rungs are worth knowing because each one +buys a different kind of beauty. + +**Lines and shapes** are the bottom rung. `line` (Bresenham 1962), `circle` +(Bresenham's 1965 midpoint form), `disc`, `sphere`, `text`, `sprite`. Exact, +cheap, and the right answer when you want a *thing* on the panel rather than a +texture. + +**Signed distance fields** are the same shapes, made soft. Instead of "is this +pixel inside the circle", `sdCircle` answers "how far is this pixel from the +edge", negative inside. A distance is a number you can threshold, glow, outline or +blend, and two distance fields blend into shapes neither one describes. The +catalog follows **Inigo Quilez**'s. + +**Particles** give you motion with memory. A particle pool carries position and +velocity, and `gravity`, `drag`, `bounce` and `collide` act on all of them at +once. Use this when the thing that matters is that a spark *fell*, and the pixels +are just where it happens to be. + +**Fields** are where this page started: noise, fBm, warp, sampled per light. + +**Transport** is the top rung, and it is what this release added. A field says +where things go; transport actually *carries* light along it, frame after frame. +`advect` moves a whole plane along a velocity field by asking, for each +destination, where its contents came from. `decay` fades what is already there by +a half-life in milliseconds. Together they make a trail that is not drawn: it is +the previous frames' light, moved and dimmed. + +**Curl noise** (**Robert Bridson**, 2007) is the field transport wants. Take a +noise field and use its perpendicular gradient, and the result is +*divergence-free*: it swirls, but nothing ever piles up or drains away. That +property is why a curl flow reads as a real medium rather than as arrows. + +![Nebula: a noise field born into a curl flow](../assets/light/tutorial/nebula.png) + +**Simulation** is the last rung. **Jos Stam**'s *Stable Fluids* (SIGGRAPH 1999) +solves the medium's own motion: diffuse, project, advect, project. Every other +flow is a function of position and time; this one is state. A jet fired now +changes where everything downstream goes for seconds afterwards. + +![Fluid: dye poured into a Stam solver](../assets/light/tutorial/fluid.png) + +--- + +## 6. Why 16 bits, in one paragraph + +A trail is a value multiplied by slightly less than one, sixty times a second. At +8 bits that does not work: a value of 100 multiplied by 0.994 either truncates +back to 99 and keeps falling too fast, or rounds back to 100 and never fades at +all. The tail either vanishes or freezes. + +So every effect that carries light over time keeps its own plane at **16 bits** +and narrows to 8 once, at the end, with dithering. The layer buffer stays 8-bit, +which keeps every driver fast. You get this for free: `trail(1)` in a script +allocates it, and the blit happens for you. + +--- + +## 7. Compiled or scripted + +Much of the vocabulary above exists twice: as a C++ kernel, and as a MoonLive builtin. Same +algorithm, same numbers. The shapes are worth knowing, though, because they are not identical. + +The noise family (`noise`, `fbm`, `warp`), `line`, `circle` and the palette calls are the same +function under both names. The **transport** kernels are reached differently: `advect16`, `decay16`, +`quantize`, `blit16`, `upscale16` and `halfLifeKeep` are compiled-only, and a script uses them +through six builtins that wrap them, `trail(1)`, `flowNoise`, `flowCurl`, `trailDecay`, `emitTrail` +and `fieldRate`. That is deliberate: a script names a wind and a persistence, and the binding owns +the planes, the ping-pong and the narrowing, which is what keeps a whole-plane operation one host +call rather than one per light. Compiled-only for now: `disc`, `sphere`, `text`, `sprite`, the SDF +catalog and the fluid solver. + +**MoonLive** is projectMM's scripting language. Scripts are compiled to native +code on the device, so a script is not interpreted per pixel: it runs at machine +speed. You edit one in the browser and the picture changes as you type. + +The interesting number is how little a script has to say: + +| Effect | MoonLive script | Compiled C++ | +|---|---:|---:| +| Fluid | 44 lines | 276 lines | +| Trails | 50 lines | 261 lines | +| Aurora | 58 lines | 216 lines | +| Nebula | 63 lines | 271 lines | + +These are not stripped-down versions: each is a real effect someone would happily +run. The reason they are a fifth of the size is that **the heavy lifting is in the +kernels, and the kernels are available to both.** A script does not implement +advection; it calls it. The C++ version is longer mostly because it also handles +allocation, resizing and control registration, which the script binding does on the +script's behalf. + +One honest caveat on that table: `fluid.mle` is not the Stam solver. The solver has no script +binding yet, so the script pours jets into a curl flow, which LOOKS like a fluid without simulating +one. Aurora, Trails and Nebula are genuinely the same effect in both columns. + +This is the point where the two halves reinforce each other. Every kernel added +for a compiled effect immediately makes scripts more capable, and every effect +that turns out to be expressible as a script is one that does not need to be +compiled in at all. `fluid.mle` is 44 lines: + +``` +void tick() { + flowCurl(zoom, div(force * 4 * span, 64)); // the medium + trailDecay(40 + persistence * persistence / 12); // the fade + + for (int i = 0; i < jets; i = i + 1) { + int a = osc(rate, t, 2) + i * 10922; // where this jet points + ... + emitTrail(jx, jy, z, color, 255, radius); // pour dye in + } +} +``` + +Three kernel calls and a loop. That is a fluid-looking effect. + +**Which to use.** Script first, always: you iterate in seconds instead of minutes, +and a script that fails to compile leaves the previous one running rather than +taking the device down. It is bounded rather than sandboxed, though. A script gets +a fixed member budget and a fixed number of live variables, and the compiler +refuses a script that exceeds them; what it cannot refuse is a per-light loop too +expensive for the fixture, which shows up as a slow frame rate and, on a large +grid, can starve the network stack enough that the web UI stops answering while +the lights keep running. Move to C++ when you need something the vocabulary does +not have, or when the per-light cost is the problem (`fieldRate(n)` exists +precisely to make a per-light loop affordable on a big fixture). + +--- + +## 8. Everything is 3D, when the fixture is + +Every kernel on this page takes a depth. On a panel you pass 1 and pay nothing: +the loop is the 2D loop exactly. On a cube, the field is sampled along a real +third axis, so the slices differ instead of one plane repeating. + +Worth knowing where the line currently is: the **fields** are genuinely +volumetric, while **transport** carries light within each slice and not yet +between them. A trail on a cube travels across its slice, not through the volume. + +--- + +## 9. Try this + +The fastest way to feel it is to break something on purpose. Open a MoonLive +script, pick one number, and move it a long way. + +| Change | What you should see | The lesson | +|---|---|---| +| `zoom` very low, then very high | soft blobs, then colored static | scale is everything | +| octaves 1 to 4 | texture appears on the shape, frame time rises | octaves are the cost knob | +| `warp` from 0 upward | the field stops drifting and starts folding | domain warping | +| `persistence` low to high | tails from a flicker to seconds long | half-life decay | +| Fluid's `iterations` to 1 (the compiled effect, not the script) | the flow reads springy | the pressure solve is what makes it a fluid | + +## Where to go next + +- **[Power functions](../moonmodules/light/power-functions.md)**: the catalog, with what each one costs and who calls it +- **[Writing scripts](../moonmodules/light/writing-scripts.md)**: the MoonLive language reference +- **[Effects](../moonmodules/light/effects.md)**: every effect in the tree, with its controls diff --git a/mkdocs.yml b/mkdocs.yml index 15c9f506..eee832a9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -136,6 +136,7 @@ nav: - Installing projectMM on a desktop: tutorials/installing-to-desktop.md - Driving LED panels with a receiving card: tutorials/panel-cards.md - Driving projectMM from a phone or tablet: tutorials/control-surface.md + - Making beautiful effects: tutorials/generative-effects.md - Build your own MoonModules: usecases/build-your-own-moonmodules.md - Home automation: usecases/home-automation.md - LED signal integrity: usecases/led-signal-integrity.md diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index 1f9ca6cd..219d8390 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -204,6 +204,17 @@ The ESP32 half reads `esp32/monitor.log`, and refreshes it by opening the serial In `--commit` mode it also writes the repo-health snapshot (below), reusing the tick/FPS it just measured. +### bench_kernels + +Kernel micro-bench: nanoseconds per call for the power-function kernels (noise, fBm, warp, and every kernel added since), on this host. + +```bash +uv run moondeck/check/bench_kernels.py # build (Release) and run +uv run moondeck/check/bench_kernels.py --no-build # run the binary already built +``` + +A report, not a check: the Markdown table it prints is pasted into performance.md, and a kernel swap is accepted against the previous rows (the gradient-noise swap's bound is 1.3x per sample). Host timings; the S3 is 20-40x slower per core, so the ratio between rows is what transfers, and `collect_kpi.py` on a board gives the absolute cost. Builds only the `mm_bench` target, so it does not drag the test suite through the compiler. + ### repo_health Measure the repo's current state into `repo-health.json` — flash per firmware variant, tick/FPS per target, lines of code by area, comment density, test counts, docs inventory. @@ -775,8 +786,15 @@ uv run moondeck/docs/screenshot_modules.py # requires projectMM running on lo uv run moondeck/docs/screenshot_modules.py --host 192.168.1.210:8080 uv run moondeck/docs/screenshot_modules.py --gif # also record 3-second GIF previews uv run moondeck/docs/screenshot_modules.py --force # re-capture and overwrite existing screenshots +uv run moondeck/docs/screenshot_modules.py --all-registered # every registered module, not just the listed ones ``` +`--all-registered` is what keeps the set complete. The script carries a hand-written MODULES list +for the few modules that need particular props or a parent that is not a Layer; every other +registered effect and modifier is captured on a Layer with its defaults. Without it a module added +today is silently skipped until someone remembers to edit the list, which is how 42 effects came to +have no preview. + The **GIF** and **Force** checkboxes in MoonDeck toggle these flags. Connects to a running projectMM server, builds a minimal pipeline scaffold (Layouts → Grid, Layer, Drivers), adds each module, screenshots its card, then removes it. Saves: diff --git a/moondeck/check/bench_kernels.py b/moondeck/check/bench_kernels.py new file mode 100644 index 00000000..c1fc08b6 --- /dev/null +++ b/moondeck/check/bench_kernels.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Kernel micro-bench: ns per call for the power-function kernels, on this host. + +Builds and runs the ``mm_bench`` target (``test/bench/bench_kernels.cpp``) in the desktop build +tree and prints its Markdown table. A report, not a check: nothing passes or fails, and the +numbers are read against the previous rows in performance.md before a kernel swap is accepted +(the generative-fields plan's 1.3x-per-sample bound on the gradient-noise swap is the first). + +Host timings only. The S3 is 20-40x slower per core (performance.md), so the RATIO between two +rows is what transfers to a board; run collect_kpi.py on the device for the absolute cost. + +Usage: + uv run moondeck/check/bench_kernels.py # build (Release) and run + uv run moondeck/check/bench_kernels.py --no-build # run the binary already built +""" + +import argparse +import os +import platform +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BUILD_DIR = ROOT / "build" / "macos" + + +def binary() -> Path: + exe = "mm_bench.exe" if platform.system() == "Windows" else "mm_bench" + for candidate in (BUILD_DIR / "test" / exe, BUILD_DIR / "test" / "Release" / exe): + if candidate.exists(): + return candidate + return BUILD_DIR / "test" / exe + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--no-build", action="store_true", help="run the already-built binary") + args = ap.parse_args() + + if not args.no_build: + # Same tree and build type build_desktop.py uses for the test binaries, one target only. + cfg = ["cmake", "-B", str(BUILD_DIR), "-DCMAKE_BUILD_TYPE=Release"] + if subprocess.run(cfg, cwd=ROOT).returncode != 0: + sys.exit(1) + cmd = ["cmake", "--build", str(BUILD_DIR), "--target", "mm_bench"] + if platform.system() == "Windows": + cmd += ["--config", "Release"] + if subprocess.run(cmd, cwd=ROOT).returncode != 0: + sys.exit(1) + + exe = binary() + if not exe.exists(): + print(f"mm_bench not built: {exe}\n build: uv run moondeck/check/bench_kernels.py") + sys.exit(1) + print(f"Kernel micro-bench on {platform.machine()} ({os.cpu_count()} cores), Release, best of 5:", flush=True) + sys.exit(subprocess.run([str(exe)], cwd=ROOT).returncode) + + +if __name__ == "__main__": + main() diff --git a/moondeck/docs/screenshot_modules.py b/moondeck/docs/screenshot_modules.py index d2312d26..c199f1eb 100644 --- a/moondeck/docs/screenshot_modules.py +++ b/moondeck/docs/screenshot_modules.py @@ -101,6 +101,11 @@ def asset_dir_for(type_name: str) -> Path: # Effects ("RainbowEffect", "Layer", {}, True), ("NoiseEffect", "Layer", {}, True), + # The generative-fields showcases: each is Dim::D3, so the preview shows a volume. + ("AuroraEffect", "Layer", {}, True), + ("TrailsEffect", "Layer", {}, True), + ("NebulaEffect", "Layer", {}, True), + ("FluidEffect", "Layer", {}, True), ("FireEffect", "Layer", {}, True), ("PlasmaEffect", "Layer", {}, True), ("PlasmaPaletteEffect", "Layer", {}, True), @@ -684,6 +689,10 @@ def main() -> int: parser.add_argument("--grid", type=int, default=0, metavar="N", help="Resize the GridLayout to NxN before capturing (e.g. 128) for " "higher-resolution previews. Default: leave the boot grid (16).") + parser.add_argument("--all-registered", action="store_true", + help="Also capture every registered effect/modifier that has no MODULES " + "entry, on a Layer with default controls. Fixes the list going stale " + "the moment a module is added.") parser.add_argument("--extras-only", action="store_true", help="Skip projectMM module captures; only run the extra shots " "(MoonDeck tabs, installer). Useful for recapturing the " @@ -717,13 +726,27 @@ def main() -> int: # effect/modifier with no list entry is silently never captured (how # GameOfLife/MultiplyModifier/CheckerboardModifier went imageless). listed = {t for t, *_ in MODULES} + # Filtered against what the RUNNING server offers, not just what main.cpp registers: a type + # the server does not have (a capability-gated driver, a different firmware) would be handed + # to add_module and fail. That is what the three add_module failures in the first full sweep + # were. + offered = set(server_types) if server_types else None uncaptured = sorted( t for t in source_registered_types() - if ("Effect" in t or "Modifier" in t) and t not in listed) - if uncaptured: + if ("Effect" in t or "Modifier" in t) and t not in listed + and (offered is None or t in offered)) + if uncaptured and args.all_registered: + # Capture them anyway: an effect goes on a Layer, a modifier on a Layer, both want a + # GIF. The hand-kept list stays for the ones that need special props or a parent that + # is not a Layer; everything else needs no entry at all. + MODULES.extend((t, "Layer", {}, True) for t in uncaptured) + print(f" + {len(uncaptured)} registered effect/modifier(s) added from the types " + f"registered in src/main.cpp (--all-registered)") + elif uncaptured: print(f" ⚠️ {len(uncaptured)} registered effect/modifier(s) are NOT in this " f"script's MODULES list, so they get no screenshot: {', '.join(uncaptured)}") - print(" Add them to MODULES (near the top of this file) to capture them.") + print(" Run with --all-registered to capture them, or add an entry to MODULES " + "if one needs special props.") # Optional pipeline tweaks for nicer effect previews (raw, higher-res). if args.no_modifier or args.grid: diff --git a/moonlive/effects/aurora.mle b/moonlive/effects/aurora.mle new file mode 100644 index 00000000..9dd091ba --- /dev/null +++ b/moonlive/effects/aurora.mle @@ -0,0 +1,58 @@ +// Aurora: layered noise curtains, each drifting on its own clock, read around the center. + +class AuroraEffect { + byte speed = 30; + byte zoom = 40; + byte fold = 60; + byte twist = 40; + byte contrast = 150; + + int dimensions() { return 3; } + + string tags() { return "💫"; } + + void defineControls() { + addControl("speed", speed, 0, 120); // how fast the curtains drift + addControl("zoom", zoom, 1, 255); // noise cells across the grid + addControl("fold", fold, 0, 255); // how far the field folds over itself + addControl("twist", twist, 0, 255); // how much the radius leans the curtains + addControl("contrast", contrast, 0, 255); // how much of the field lights + } + + void tick() { + int driftA = div(osc(speed, t, 2), 64); + int driftB = div(osc(mod(speed * 3, 127) + 1, t, 2), 64); + int driftC = div(osc(mod(speed * 7, 113) + 1, t, 2), 64); + + for (int z = 0; z < depth; z = z + 1) { + for (int y = 0; y < height; y = y + 1) { + for (int x = 0; x < width; x = x + 1) { + int a = polarA(x - div(width, 2), y - div(height, 2)); + int r = polarR(x - div(width, 2), y - div(height, 2)); + a = div(a + r * twist, 64) * zoom; + + int d = (z - div(depth, 2)) * zoom; + + int best = warp3(a, r * zoom + driftA, d, fold * 4); + int other = warp3(a + zoom * 3, r * zoom + driftB + 4096, d, fold * 4); + + int index = 40; + if (other > best) { + best = other; + index = 170; + } + other = warp3(a + zoom * 6, r * zoom + driftC + 8192, d, fold * 4); + if (other > best) { + best = other; + index = 100; + } + if (best > contrast) { + setPaletteColorZ(x, y, z, index, div((best - contrast) * 255, 256 - contrast)); + } else { + setPaletteColorZ(x, y, z, index, 0); + } + } + } + } + } +} diff --git a/moonlive/effects/fluid.mle b/moonlive/effects/fluid.mle new file mode 100644 index 00000000..4c9187b5 --- /dev/null +++ b/moonlive/effects/fluid.mle @@ -0,0 +1,44 @@ +// Fluid: dye poured from sweeping jets and carried away by the flow. + +class FluidEffect { + byte jets = 3; + byte swirl = 90; + byte zoom = 30; + byte force = 140; + byte persistence = 170; + + int dimensions() { return 3; } + + string tags() { return "💫🖌️"; } + + void defineControls() { + trail(1); + addControl("jets", jets, 1, 6); // how many places dye is poured in + addControl("swirl", swirl, 0, 255); // how fast the jets sweep + addControl("zoom", zoom, 1, 255); // the flow's cell size + addControl("force", force, 0, 255); // how hard the flow carries + addControl("persistence", persistence, 0, 255); // how long dye survives + } + + void tick() { + int span = width; + if (height > span) { span = height; } + + flowCurl(zoom, div(force * 4 * span, 64)); + trailDecay(40 + persistence * persistence / 12); + + int radius = div(span, 32) + 1; + + for (int i = 0; i < jets; i = i + 1) { + int rate = 3 + div(swirl, 12) + i * 2; + int a = osc(rate, t, 2) + i * 10922; + int reach = 40 + div(osc(div(rate, 3) + 1, t, 1), 280); + int jx = div(width, 2) + div((cos(a) - 32768) * div(width - 2, 2) * reach, 255 * 32768); + int jy = div(height, 2) + div((sin(a) - 32768) * div(height - 2, 2) * reach, 255 * 32768); + + for (int z = 0; z < depth; z = z + 1) { + emitTrail(jx, jy, z, mod(i * div(255, jets) + div(t, 64), 256), 255, radius); + } + } + } +} diff --git a/moonlive/effects/nebula.mle b/moonlive/effects/nebula.mle new file mode 100644 index 00000000..efbe0c11 --- /dev/null +++ b/moonlive/effects/nebula.mle @@ -0,0 +1,63 @@ +// Nebula: a noise field births light, a curl flow carries it into a folding cloud. + +class NebulaEffect { + byte speed = 40; + byte zoom = 40; + byte contrast = 120; + byte persistence = 140; + byte rate = 1; + byte peak = 200; + byte floorv = 40; + + int dimensions() { return 3; } + + string tags() { return "💫🖌️"; } + + void defineControls() { + trail(1); + addControl("speed", speed, 0, 255); // how fast the cloud moves + addControl("zoom", zoom, 1, 255); // the field's cell size + addControl("contrast", contrast, 0, 255); // 0 floods the fixture, 255 leaves a few wisps + addControl("persistence", persistence, 0, 255); // how long light survives + addControl("rate", rate, 1, 8); // birth the field every n frames + } + + void tick() { + int span = width; + if (height > span) { span = height; } + + int grain = div(zoom * 256, span * 16) + 1; + int push = div(speed * 4 * span, 64); + int radius = div(span, 32) + 1; + + flowCurl(zoom, push); + trailDecay(40 + persistence * persistence / 12); + + int low = floorv; + if (peak < low) { low = peak - 8; } + if (low < 0) { low = 0; } + int threshold = low + div((peak - low) * contrast, 255); + int frameMax = 0; + int frameMin = 255; + + if (fieldRate(rate) > 0) { + for (int z = 0; z < depth; z = z + 1) { + for (int y = 0; y < height; y = y + 1) { + for (int x = 0; x < width; x = x + 1) { + int v = fbm3(x * grain, y * grain + div(t, 32), z * grain, 2); + if (v > frameMax) { frameMax = v; } + if (v < frameMin) { frameMin = v; } + if (v > threshold) { + int bri = div((v - threshold) * 255, peak - threshold + 1); + if (bri > 255) { bri = 255; } + emitTrail(x, y, z, bri, bri, radius); + } + } + } + } + if (frameMax < 32) { frameMax = 32; } + peak = div(peak * 7 + frameMax, 8); + floorv = div(floorv * 7 + frameMin, 8); + } + } +} diff --git a/moonlive/effects/trails.mle b/moonlive/effects/trails.mle new file mode 100644 index 00000000..a93ab4e6 --- /dev/null +++ b/moonlive/effects/trails.mle @@ -0,0 +1,50 @@ +// Trails: dots thrown into a flowing medium, leaving tails the flow carries and bends. + +class TrailsEffect { + byte speed = 60; + byte zoom = 30; + byte dots = 3; + byte persistence = 120; + byte curl = 1; + + int dimensions() { return 3; } + + string tags() { return "💫🖌️"; } + + void defineControls() { + trail(1); + addControl("speed", speed, 0, 255); // how fast the medium moves + addControl("zoom", zoom, 1, 255); // the flow's cell size + addControl("dots", dots, 1, 8); // how many emitters throw light in + addControl("persistence", persistence, 0, 255); // how long a tail survives + addControl("curl", curl, 0, 1); // 1 = divergence-free, so nothing clumps + } + + void tick() { + int span = width; + if (height > span) { span = height; } + + int cells = div(zoom * 64, span) + 1; + int push = div(speed * 4 * span, 64); + + if (curl > 0) { flowCurl(cells, push); } else { flowNoise(cells, push); } + trailDecay(20 + persistence * persistence / 16); + + int head = div(span, 256) + 1; + if (head > 4) { head = 4; } + + int area = (head * 2 + 1) * (head * 2 + 1); + int n = div(dots * width * height, 4096 * div(area, 9)); + if (n < 1) { n = 1; } + if (n > 64) { n = 64; } + + for (int i = 0; i < n; i = i + 1) { + int a = osc(speed / 4 + 4 + i * 3, t, 0) + i * div(65535, n); + int b = osc(speed / 3 + 5 + i * 5, t, 0) + i * 9973; + int px = scale(sin(a), width); + int py = scale(cos(b), height); + int pz = scale(sin(b * 2), depth); + emitTrail(px, py, pz, mod(i * div(255, n) + div(t, 64), 256), 255, head); + } + } +} diff --git a/src/core/math16.h b/src/core/math16.h index 6562fbed..8846a7de 100644 --- a/src/core/math16.h +++ b/src/core/math16.h @@ -1,6 +1,6 @@ #pragma once -// 16-bit fixed-point tier — the power-function contract's numeric vocabulary. +// 16-bit fixed-point tier: the power-function contract's numeric vocabulary. // // Why a 16-bit tier alongside math8.h: an 8-bit result positions to 256 levels, which visibly steps // on a large fixture (a 12K-light wall shows the staircase in a slow gradient or a slow-moving @@ -9,10 +9,10 @@ // design) and as the internal fast path. // // Cost discipline, measured rather than assumed: interpolating the existing 8-bit `sin8_lut` was -// tried first (zero new bytes) and REJECTED — rounding the endpoints to 8 bits distorts the segments +// tried first (zero new bytes) and REJECTED: rounding the endpoints to 8 bits distorts the segments // the interpolation runs between, giving 1.1% of amplitude, worse than the 0.69% of FastLED's // classic `lib8tion` sin16. A 130-byte quarter-wave 16-bit table with the same linear interpolation -// measures 0.031% — 22x better than lib8tion at a cost that rounds to nothing. FastLED master's +// measures 0.031%: 22x better than lib8tion at a cost that rounds to nothing. FastLED master's // `fl::sin32` is near-exact but spends 1040 bytes plus two int64 multiplies per call; this is the // middle that keeps large-fixture gradients smooth without that. A quadratic core can swap in behind // this same name if a field-heavy effect ever needs it. @@ -43,14 +43,14 @@ inline constexpr int16_t sin16_quarter[65] = { 32767 }; -/// Sine over a 16-bit angle, returning SIGNED -32767..32767 — FastLED's sin16 contract. +/// Sine over a 16-bit angle, returning SIGNED -32767..32767: FastLED's sin16 contract. /// /// Verified against the two codebases users port FROM, at master rather than at a release, because /// FastLED 4.0 is close and the contract we bind to should be the one users will have: /// - FastLED master (6a120dedc9, 2026-08-07): `fl/math/sin32.h` declares `i16 sin16lut(u16)`, /// commented "output is between -32767 and 32767". /// - WLED main (c1838ed4, 2026-08-05): `constexpr int16_t (*sinFunction)(uint16_t) = &sin16_t`, -/// and its effects write `sin16_t(x) + 32768` when they want an unsigned value — precisely the +/// and its effects write `sin16_t(x) + 32768` when they want an unsigned value: precisely the /// arithmetic that broke silently against our old unsigned return. /// /// The unsigned 0..65535 form (32768 at the zero crossing) was tried first, to match `sin8`. It is @@ -69,7 +69,7 @@ constexpr int16_t sin16(angle16 theta) { // Odd quadrants run the quarter wave backwards (sin descends from the peak). const uint16_t walk = (quadrant & 1) ? static_cast(0x4000 - pos) : pos; // uint16 for the index, NOT uint8: walk reaches 0x4000 exactly at a quadrant boundary in an odd - // quadrant, and >>8 is then 64 — which a uint8 holds fine but only by luck of the cast order. + // quadrant, and >>8 is then 64: which a uint8 holds fine but only by luck of the cast order. // Keeping it wide makes the "table has 65 entries so idx==64 is valid" invariant explicit. const uint16_t idx = static_cast(walk >> 8); // 0..64 const uint8_t frac = static_cast(walk & 0xFF); @@ -101,7 +101,7 @@ constexpr int32_t map32(int32_t v, int32_t inLo, int32_t inHi, int32_t outLo, in // in an int32 subtraction, so computing the spans in 32 bits overflows at the extremes. The // product of two 32-bit spans fits in int64 for every range this maps between: an extent, a // byte, a band count, a frequency. Mapping a FULL int32 range onto another full int32 range - // would exceed it (offset and outSpan both approach 2^32), which no caller does — the widest + // would exceed it (offset and outSpan both approach 2^32), which no caller does: the widest // real case is a 16-bit phase onto a grid extent, five orders of magnitude short of it. const int64_t inSpan = static_cast(inHi) - static_cast(inLo); const int64_t outSpan = static_cast(outHi) - static_cast(outLo); @@ -117,7 +117,7 @@ constexpr int32_t map32(int32_t v, int32_t inLo, int32_t inHi, int32_t outLo, in /// once: the per-tick product `dt * bpm * scale / 60000` rounds to ZERO when dt is under a /// millisecond (every desktop frame, and an ESP32 running a small fixture fast), so the animation /// silently freezes. The fix all nine converged on is to accumulate the RAW numerator in 64 bits and -/// divide only at the read — which is what this does. +/// divide only at the read: which is what this does. /// /// Usage: one member per animated quantity; call `advance(elapsedMs, rate)` once per frame, then /// read as often as needed. `rate` is BPM-like: the caller's speed control, whatever its units. @@ -125,7 +125,7 @@ class BeatPhase { public: /// Accumulate this frame's contribution. Safe to call with a rate of 0 (the phase holds). /// The first call only establishes the time base, so a large `elapsed` at startup cannot jump - /// the phase — the same first-tick guard three of the nine effects carried by hand. + /// the phase: the same first-tick guard three of the nine effects carried by hand. void advance(uint32_t elapsedMs, uint32_t rate) { if (!started_) { started_ = true; lastMs_ = elapsedMs; return; } const uint32_t dt = elapsedMs - lastMs_; // unsigned: correct across the millis() wrap @@ -133,7 +133,7 @@ class BeatPhase { num_ += static_cast(dt) * rate; } - /// The phase scaled by `scale` and divided late — `phase(256)` is the uint8 angle form + /// The phase scaled by `scale` and divided late: `phase(256)` is the uint8 angle form /// (256 = full turn) the effects use, `phase(65536)` the angle16 form. /// Returns the raw scaled value; the caller truncates to its angle width, which is where the /// free wrap happens. @@ -146,7 +146,7 @@ class BeatPhase { /// scales for its second axis). uint64_t numerator() const { return num_; } - /// Feed a pre-scaled product directly — for the callers whose rate already carries a factor. + /// Feed a pre-scaled product directly: for the callers whose rate already carries a factor. void advanceScaled(uint32_t elapsedMs, uint64_t scaledRate) { if (!started_) { started_ = true; lastMs_ = elapsedMs; return; } const uint32_t dt = elapsedMs - lastMs_; @@ -163,14 +163,14 @@ class BeatPhase { }; /// Integer square root of a 32-bit value, rounded down. Binary restoring method: no divide, no -/// float, ~16 iterations worst case — which matters because the ESP32 has no fast divide and effects +/// float, ~16 iterations worst case: which matters because the ESP32 has no fast divide and effects /// call this per pixel when they need a true distance. /// /// PaintBrushEffect hand-rolled this; it lives here so a caller reaching for a distance gets the /// same one. Where a comparison against a threshold will do (is this point inside a circle?), prefer -/// comparing SQUARED values and skip this entirely — measured on hardware, the squared form is ~14 +/// comparing SQUARED values and skip this entirely: measured on hardware, the squared form is ~14 /// cycles/pixel against ~108 for the sqrt. -/// Integer square root of a 64-bit value — the wide form, for values whose square exceeds 32 bits. +/// Integer square root of a 64-bit value: the wide form, for values whose square exceeds 32 bits. /// `dist16` needs it: two coordinates of only 70000 already square-and-sum past UINT32_MAX, so the /// 32-bit form saturated at 65535 and reported the wrong distance well inside normal range. /// @@ -208,9 +208,9 @@ constexpr uint32_t isqrt(uint32_t v) { // `atan2_8`/`dist8` in math8.h are the 8-bit forms: an octant atan2 and an OCTAGONAL distance // (max + half-min), which is up to ~12% off a true radius and saturates at 255. On a small panel // neither shows; on a large one the octagon reads as visible corners on what should be a circle, -// and the saturation flattens everything past 255 lights from the centre. +// and the saturation flattens everything past 255 lights from the center. // -// These are the 16-bit forms for addressing a grid in polar coordinates — the vocabulary behind +// These are the 16-bit forms for addressing a grid in polar coordinates: the vocabulary behind // every radial look: rings, spirals, rotation, kaleidoscopes, tunnels, radial wipes, a spectrum // bent around a circle. `atan16` returns an angle16 (65536 = one turn) so it feeds sin16 and the // palette directly; `dist16` returns a true Euclidean radius via isqrt. Both are general grid @@ -229,10 +229,10 @@ inline constexpr int16_t atan16_octant[33] = { /// Angle of (x, y) as an angle16, measured counter-clockwise from +x. Full 16-bit resolution, so a /// gradient swept around the circle has no visible steps on a large fixture. inline angle16 atan16(int32_t y, int32_t x) { - if (x == 0 && y == 0) return 0; // the centre has no direction + if (x == 0 && y == 0) return 0; // the center has no direction // Fold into the first octant, remembering which one, then interpolate the arctangent there. // Unsigned magnitudes: negating INT32_MIN has no int32 representation, so the obvious - // `x < 0 ? -x : x` is undefined behaviour at exactly one input per axis. Widening first keeps + // `x < 0 ? -x : x` is undefined behavior at exactly one input per axis. Widening first keeps // the fold exact across the whole range. // // `0u - v` rather than `-v`: the unary form is well-defined on an unsigned (it is modular @@ -260,7 +260,7 @@ inline angle16 atan16(int32_t y, int32_t x) { return static_cast(a); } -/// True Euclidean distance from the origin to (dx, dy) — a real radius, not the octagon `dist8` +/// True Euclidean distance from the origin to (dx, dy): a real radius, not the octagon `dist8` /// approximates, and it does not saturate at 255. inline uint32_t dist16(int32_t dx, int32_t dy) { // Squared in UNSIGNED 64-bit and rooted with the 64-bit isqrt. The obvious 32-bit form is wrong @@ -297,7 +297,7 @@ constexpr frac16 easeInOutCubic(frac16 t) { return static_cast(65535ull - ((4ull * u * u * u) >> 32)); } -/// Quadratic ease out: fast start, gentle settle — the "arrives and rests" curve. +/// Quadratic ease out: fast start, gentle settle. The "arrives and rests" curve. /// /// The squared term divides by 65535 rather than shifting by 16: `(65535·65535) >> 16` is 65534, /// which would leave the curve one unit short at t=0 and start a fade barely lit. @@ -309,7 +309,7 @@ constexpr frac16 easeOutQuad(frac16 t) { // --- Followers and meters --------------------------------------------------------------------- /// A one-pole low-pass follower: `current` moves a fraction of the way toward `target` each frame. -/// This is how a value stops jittering — a meter, a control, a beat-driven size. `rate` is 0..255, +/// This is how a value stops jittering: a meter, a control, a beat-driven size. `rate` is 0..255, /// where 255 snaps instantly and small values glide. /// /// Deliberately frame-rate dependent in its simple form, which is what every LED codebase uses; a @@ -320,7 +320,7 @@ constexpr uint8_t smoothFollow(uint8_t current, uint8_t target, uint8_t rate) { const int32_t delta = static_cast(target) - current; // Round the step AWAY from zero so every nonzero rate makes progress. A plain shift truncates // toward zero, which for a small rate means an upward step rounds to 0 and the value never - // rises, while a downward step of the same size rounds to -1 and does move — a follower that + // rises, while a downward step of the same size rounds to -1 and does move: a follower that // could fall but not climb (measured: rate 1 moved 100->99 but left 0 at 0). const int32_t step = (delta * rate) / 256; if (step != 0) return static_cast(current + step); @@ -328,7 +328,7 @@ constexpr uint8_t smoothFollow(uint8_t current, uint8_t target, uint8_t rate) { } /// The falling-peak meter: rise INSTANTLY to a new high, then decay slowly. The asymmetry is the -/// whole point — a peak that eased upward would miss transients, and one that dropped instantly +/// whole point: a peak that eased upward would miss transients, and one that dropped instantly /// would show nothing to read. Every VU meter with a floating peak dot is this function. constexpr uint8_t peakHold(uint8_t peak, uint8_t value, uint8_t decay) { if (value > peak) return value; // instant attack @@ -337,7 +337,7 @@ constexpr uint8_t peakHold(uint8_t peak, uint8_t value, uint8_t decay) { // --- Position-addressable randomness ----------------------------------------------------------- -/// A hash of up to four integers to 0..65535 — random-LOOKING but a pure function of its inputs, so +/// A hash of up to four integers to 0..65535: random-LOOKING but a pure function of its inputs, so /// the same pixel gets the same value on every device and every frame. /// /// This is what a stream RNG cannot do: `Random8` advances per CALL, so a device that renders one @@ -353,7 +353,7 @@ constexpr uint16_t hashInt(uint32_t a, uint32_t b = 0, uint32_t c = 0, uint32_t return static_cast(h >> 16); } -/// Fold an angle into `segments` mirrored wedges — the kaleidoscope. Any field sampled through +/// Fold an angle into `segments` mirrored wedges: the kaleidoscope. Any field sampled through /// this gains n-fold symmetry, which is why a mediocre noise field becomes a mandala for the cost /// of one modulo: the fold is applied to the ANGLE, and everything downstream is unchanged. /// @@ -364,7 +364,7 @@ inline angle16 kaleido(angle16 a, uint8_t segments) { const uint32_t wedge = 65536u / segments; uint32_t within = a % wedge; // position inside this wedge const uint32_t index = a / wedge; - // Reflect alternate wedges and return the FOLDED coordinate — deliberately one wedge wide, not + // Reflect alternate wedges and return the FOLDED coordinate: deliberately one wedge wide, not // the original angle. That is what a kaleidoscope is: every wedge maps onto the same range, so a // field sampled through it repeats n times around the circle, and mirroring every other wedge is // what makes the seams join rather than showing a hard edge. A caller that wants the full turn @@ -382,7 +382,7 @@ inline angle16 kaleido(angle16 a, uint8_t segments) { // it. These are the same textbook shapes at full 16-bit range, so a position scales to any axis // length without the caller rescaling. -// Triangle wave: 0 to 65535 over the first half of the cycle and back over the second — the fold of +// Triangle wave: 0 to 65535 over the first half of the cycle and back over the second. The fold of // a ramp, the 16-bit twin of triwave8. Cheaper and sharper than a sine where an effect wants a // linear sweep out and back. constexpr uint16_t triwave16(uint16_t i) { @@ -391,7 +391,7 @@ constexpr uint16_t triwave16(uint16_t i) { } // beat16: a 0..65535 sawtooth completing `bpm` cycles per minute, measured from `timebase`. -// The 16-bit twin of beat8 — same FastLED semantics, full range, so `beat16(bpm) * n >> 16` lands +// The 16-bit twin of beat8: same FastLED semantics, full range, so `beat16(bpm) * n >> 16` lands // on any axis length evenly rather than in 1/256ths. constexpr uint16_t beat16(uint8_t bpm, uint32_t ms, uint32_t timebase = 0) { if (bpm == 0) return 0; @@ -401,4 +401,47 @@ constexpr uint16_t beat16(uint8_t bpm, uint32_t ms, uint32_t timebase = 0) { return static_cast((pos * 65536u) / period); } +/// Half-life decay: the fraction of a value that SURVIVES after `dtMs`, as a 0..65536 weight. +/// +/// `0.5^(dt / halfLifeMs)`, the textbook exponential decay written so the caller states the thing +/// they actually mean: "half of it is gone after N milliseconds". Framerate independence is then a +/// property of the formula rather than of the caller's arithmetic, because the exponent carries dt: +/// two 10 ms steps and one 20 ms step reach the same place, which `decay(2·dt) == decay(dt)²` pins. +/// +/// This exists because the per-frame form does not survive a fast device. A "keep 240 of 255 each +/// frame" fade is a decay whose half-life moves with the framerate, so the same setting is a long +/// tail at 60 fps and an instant clear at 1200; the effects that hit this carry a remainder by hand +/// (ParticlesEffect's `fadeCarry_`, and the Layer's own) to stop the fade truncating to nothing. +/// +/// Returns 65536 (unchanged) for a zero dt or a zero half-life, and 0 once the value could not +/// survive rounding anyway: a caller multiplies by this and shifts down 16. +inline uint32_t halfLifeKeep(uint32_t dtMs, uint32_t halfLifeMs) { + if (dtMs == 0 || halfLifeMs == 0) return 65536; + // 2^-x by table lookup on the fraction, then a shift for the whole halvings. 33 entries at + // 1/32 of a half-life, linearly interpolated: measured worst error 3.95 of 65536 (0.006%), + // which is under one part in 255 at the byte width every caller narrows to. + // Held as the DROP below 65536, so the first entry (a full 65536, one past uint16_t) fits the + // same 16-bit table as the rest rather than widening every entry to carry one value. + static constexpr uint16_t kPow2Drop[33] = { + 0, 1404, 2779, 4123, 5439, 6727, 7987, 9220, + 10427, 11608, 12763, 13894, 15001, 16084, 17143, 18180, + 19195, 20188, 21160, 22111, 23041, 23952, 24843, 25715, + 26568, 27403, 28220, 29020, 29802, 30568, 31317, 32050, + 32768, + }; + // How many half-lives have passed, in 1/32nds. A long stall shifts the result to zero rather + // than wrapping: 32 halvings is already below one part in 4 billion. + const uint64_t units = (static_cast(dtMs) * 32u) / halfLifeMs; + const uint32_t whole = static_cast(units >> 5); + if (whole >= 32) return 0; + const uint32_t i = static_cast(units & 31u); + // Interpolate between the table's entries on the sub-1/32 remainder, which the multiply above + // has already discarded, so recover it at the finer scale the division allows. + const uint64_t fine = (static_cast(dtMs) * 32u * 256u) / halfLifeMs; + const uint32_t f = static_cast(fine & 255u); + const uint32_t a = kPow2Drop[i], b = kPow2Drop[i + 1]; + const uint32_t drop = a + (((b - a) * f) >> 8); + return (65536u - drop) >> whole; +} + } // namespace mm diff --git a/src/core/moonlive/MoonLiveBuiltins.h b/src/core/moonlive/MoonLiveBuiltins.h index 0db7c844..3de32e3d 100644 --- a/src/core/moonlive/MoonLiveBuiltins.h +++ b/src/core/moonlive/MoonLiveBuiltins.h @@ -4,16 +4,16 @@ #include #include // the builtin-table overflow diagnostic -// MoonLive built-in table — the neutral seam by which a HOST registers the functions a script +// MoonLive built-in table: the neutral seam by which a HOST registers the functions a script // may call (the ESPLiveScript `arti_external_function` / ARTI / doc §3.4 model). The core // compiler knows only *that a name maps to a descriptor*; it owns no function names and no // domain semantics. The light domain (or any other host) populates the table with its own -// vocabulary — setRGB/fill/random16 for LEDs, something else for a display or a sensor. +// vocabulary: setRGB/fill/random16 for LEDs, something else for a display or a sensor. // // A descriptor says how a call lowers: -// - Call — a pure host helper: lower to a generic call to `fn` (a C function pointer), +// - Call : a pure host helper: lower to a generic call to `fn` (a C function pointer), // one argument in, one result out. (random16, later sin/cos/hsvToRgb…) -// - Inline — a routine the backend emits inline (no per-call overhead — the hot-path +// - Inline: a routine the backend emits inline (no per-call overhead: the hot-path // writers): the descriptor carries an `inlineOp` TAG, a neutral opcode the // per-ISA lowering knows how to emit. The core never interprets the tag; it just // threads it through. The light domain decides which names map to which tags. @@ -26,7 +26,7 @@ namespace mm::moonlive { // A script member's TYPE. A semantic, not a storage width: every SCALAR occupies one uniform // 4-byte slot whatever its type, and only ARRAYS pack by element. That is what removes the width // machinery a script used to spell for itself (uint8_t/uint16_t/int16_t), which is where four -// bugs came from — a wrapped member, a sentinel read through a 16-bit window, a one-byte store +// bugs came from: a wrapped member, a sentinel read through a 16-bit window, a one-byte store // into a two-byte member, a sign-blind array load. Here rather than with the IR because a builtin // descriptor names the type its by-reference argument takes. // @@ -50,7 +50,7 @@ constexpr uint8_t ctrlSlotBytes(CtrlType) { return 4; } -// Neutral inline opcodes — "store shapes a backend can emit", not "LED operations". A host maps +// Neutral inline opcodes: "store shapes a backend can emit", not "LED operations". A host maps // its function names onto these; a backend implements them. StoreElem = store N bytes (one // element) at a computed index; FillElems = a counted loop writing one element per slot. The // core treats them as opaque tags; the per-ISA backend and the host both know the element is 3 @@ -70,7 +70,7 @@ enum class BuiltinKind : uint8_t { Call, Inline }; // // The compiler already evaluates every argument into a CONSECUTIVE frame slot (the stack machine's // argument staging), so the call only has to say where they start. Each backend materialises that -// address from its own frame pointer — the arithmetic spillStore/spillLoad already do — which means +// address from its own frame pointer: the arithmetic spillStore/spillLoad already do: which means // the number of arguments is bounded by frame slots rather than by how many the calling convention // can carry. `draw::line` takes seven; a fixed three would have forced it to be split into bespoke // halves, and every power function added after it would inherit the same distortion. @@ -81,7 +81,7 @@ enum class BuiltinKind : uint8_t { Call, Inline }; // // `arena` is the control/system-variable block, as before. // `args` points at `argc` frame slots. The element type is uintptr_t because a frame slot IS one -// machine word — 8 bytes on arm64, 4 on Xtensa and RISC-V — and the backends store a whole word per +// machine word: 8 bytes on arm64, 4 on Xtensa and RISC-V: and the backends store a whole word per // slot. Reading them as uint32_t made args[1] land on the upper half of slot 0 on a 64-bit host, // which is a value of 0 rather than the argument: correct on both devices, wrong on the desktop. using HostCallFn = uint32_t (*)(const uintptr_t* args, uint32_t argc, const uint8_t* arena); @@ -112,7 +112,7 @@ struct Builtin { // speaks fixed declares it here and the checker follows. // // Almost every builtin is whole numbers: a channel, a light index, an angle16, a count. The - // exceptions are the ones a shader hands coordinates to — uvX/uvY return a fixed coordinate, + // exceptions are the ones a shader hands coordinates to: uvX/uvY return a fixed coordinate, // and escape() takes four of them. uint8_t fixedArgs = 0; bool fixedReturn = false; @@ -121,29 +121,30 @@ struct Builtin { /// Assert a host's builtin table did not silently drop a registration. /// /// `BuiltinTable::add()` returns false when the table is full, and a host registers dozens of -/// names in a row without checking each one — so an overflow used to surface as a script failing +/// names in a row without checking each one: so an overflow used to surface as a script failing /// with "unknown function" for a builtin that plainly exists in the source. This turns it into a /// failure at the point of registration. A host calls it once, after building its table. #define MM_ASSERT_NO_BUILTIN_OVERFLOW(t) \ do { \ if ((t).full()) { \ - std::printf("MoonLive: builtin table FULL at %u entries — a registration " \ + std::printf("MoonLive: builtin table FULL at %u entries, a registration " \ "was dropped. Raise BuiltinTable::kMax.\n", \ static_cast((t).registered())); \ } \ } while (0) // A fixed-capacity table the host fills and the compiler reads. No heap; a host registers a -// handful of functions. Lookup is by name (linear — the table is tiny). +// handful of functions. Lookup is by name (linear: the table is tiny). struct BuiltinTable { - // 64, not 16. The light domain filled all 16, and a table at capacity fails SILENTLY: add() - // returned false, no caller checked it, and the script found out as "unknown function" at - // compile time with nothing pointing at the real cause. 64 is what the power-functions spec - // asks for; the cost is `sizeof(Builtin) * 48` more in a table the host builds once. - static constexpr uint8_t kMax = 64; + // 96, not 16 and no longer 64. The light domain filled all 16, and a table at capacity fails + // SILENTLY: add() returned false, no caller checked it, and the script found out as "unknown + // function" at compile time with nothing pointing at the real cause. 64 was reached at 61 of 64 + // when the flow builtins arrived (2026-09-04), which is too little headroom for a table that + // fails this way; the cost is `sizeof(Builtin)` per unused slot in a table the host builds once. + static constexpr uint8_t kMax = 96; Builtin items[kMax]; uint8_t count = 0; - bool overflowed = false; // set when an add() was DROPPED — see full() + bool overflowed = false; // set when an add() was DROPPED: see full() bool add(const Builtin& b) { if (b.name == nullptr) return false; // a null name would null-deref in find() @@ -153,7 +154,7 @@ struct BuiltinTable { } /// True when a registration was dropped for lack of room. A host builds its table once at - /// startup, so this is asserted there rather than checked per call — the point is that + /// startup, so this is asserted there rather than checked per call: the point is that /// running out of table is LOUD, which is exactly what the 16-entry version was not. bool full() const { return overflowed; } @@ -194,13 +195,13 @@ static constexpr uint8_t kMaxCtrls = 8; // records: how many members/co // [kCtrlBytes .. kCtrlBytes+kMaxSysVars) host system variables (width/height/…), offset assigned // by the host and CONSTANT for the program's life // [kDepthSlot] the recursion depth counter, owned by the emitted code -// System variables sit ABOVE the script's range so that adding or removing a control — which -// renumbers every control offset — cannot move them. The binding caches their slot pointers, so a +// System variables sit ABOVE the script's range so that adding or removing a control: which +// renumbers every control offset: cannot move them. The binding caches their slot pointers, so a // moving offset would silently write the wrong byte. // The emitted-code buffer is sized to THE SCRIPT (codeCapFor below), not to a constant, for the -// same reason the IR op array is: the backends differ by up to 1.9x on identical source — RISC-V is +// same reason the IR op array is: the backends differ by up to 1.9x on identical source: RISC-V is // fixed-4-byte and saves the whole register pool around every call where Xtensa has 3-byte narrow -// forms — so any single number is either too small for the sparsest backend or wasteful for the +// forms: so any single number is either too small for the sparsest backend or wasteful for the // densest. A fixed 2 KB let `plasma.mle` run on an S3 and desktop and REFUSED it on an S31 by 96 // bytes, which is the second time one constant made a script's portability depend on its ISA. // @@ -210,7 +211,7 @@ static constexpr size_t kCodeCap = 16384; /// Bytes to reserve for a script of `tokens` tokens. Over-estimating costs one cold-path allocation /// that is freed when the compile ends; under-estimating fails a script that would have fit, so the -/// direction of the error is deliberate — the same rule the IR's op estimate follows. +/// direction of the error is deliberate: the same rule the IR's op estimate follows. /// /// 48 bytes/token, measured across every shipped script on all three backends with `countTokens` /// (which skips comments, so a long header does not inflate the count). The densest is @@ -274,7 +275,7 @@ static constexpr uint8_t kMaxCallDepth = 32; static constexpr uint8_t kArenaBytes = kCtrlBytes + kMaxSysVars * kSysVarBytes + 1; // +1: kDepthSlot -/// A name the HOST defines and the script only reads: `width`, `height`, `depth`. Reserved — a +/// A name the HOST defines and the script only reads (`width`, `height`, `depth`). Reserved: a /// script cannot declare one, so the name means the same thing in every script (the `t` rule, one /// construct wider). Distinct from a control: nobody sets it in the UI, and it never appears in /// declaredControls(), so no binding has to hide it. @@ -285,7 +286,7 @@ static constexpr uint8_t kArenaBytes = kCtrlBytes + kMaxSysVars * kSysVarBytes /// declaring it. enum class SysVarKind : uint8_t { Arena, // a byte in the controls arena the binding writes per frame (width/height/depth) - Arg, // an argument register the host passes on every run (t) — costs no instruction + Arg, // an argument register the host passes on every run (t): costs no instruction }; struct SysVar { @@ -294,7 +295,7 @@ struct SysVar { uint8_t where = 0; // Arena: byte offset into the arena. Arg: the VReg (kArg0..kArg4). }; -/// The system variables one host domain defines. Same shape and lookup as BuiltinTable — a host +/// The system variables one host domain defines. Same shape and lookup as BuiltinTable: a host /// hands the compiler both, and the compiler resolves names against them without knowing the domain. struct SysVarTable { // Bounded by the arena's system range, not chosen independently: a host that could register diff --git a/src/core/moonlive/MoonLiveBuiltins_common.h b/src/core/moonlive/MoonLiveBuiltins_common.h index 0544e6c2..62390917 100644 --- a/src/core/moonlive/MoonLiveBuiltins_common.h +++ b/src/core/moonlive/MoonLiveBuiltins_common.h @@ -3,7 +3,7 @@ #include "core/moonlive/MoonLiveBuiltins.h" #include "core/math8.h" // beatsin16: the shared time vocabulary #include "core/math16.h" // beat16 / sin16 / cos16: full-range waveforms -#include "core/noise.h" // inoise8: the shared value-noise field +#include "core/noise.h" // inoise8: the shared gradient-noise field #include #include @@ -165,7 +165,7 @@ extern "C" inline uint32_t mm_ml_beatsin(const uintptr_t* args, uint32_t, const return beatsin16(static_cast(bpm), ms, 0, static_cast(high)); } -// noise(x, y, z) → the 0..255 value-noise field at that point, the primitive behind fire, clouds, +// noise(x, y, z) → the 0..255 gradient-noise field at that point, the primitive behind fire, clouds, // plasma and lava. Coordinates are 16.0 fixed point: the HIGH byte picks the noise cell and the low // byte interpolates within it, so `x * 256 / scale` zooms and feeding `t` into an axis makes the // field flow. Three arguments is exactly a Call's budget, and 2D is the same call with z held at a diff --git a/src/core/moonlive/MoonLiveCompiler.cpp b/src/core/moonlive/MoonLiveCompiler.cpp index 97879c0c..64c76851 100644 --- a/src/core/moonlive/MoonLiveCompiler.cpp +++ b/src/core/moonlive/MoonLiveCompiler.cpp @@ -683,7 +683,25 @@ struct Parser { lex.advance(); if (!expect(Tok::LParen, "expected '(' after the function name")) return; if (!expect(Tok::RParen, "a script function takes no arguments yet")) return; - if (resultOut) { fail("a script function returns nothing yet"); return; } + // A call used as a VALUE takes what the callee returned. Refused when the callee + // declares void, since there is nothing to take and the script would otherwise + // read whatever the return register happened to hold. + if (resultOut) { + if (fns[i].ret == RetType::Void) { + fail("this function returns nothing, so it cannot be used as a value"); return; + } + if (fns[i].ret == RetType::Str) { + fail("a string return cannot be used in an expression yet"); return; + } + // The value stays in its register: a script call preserves the caller's + // vregs (every backend's callLabel saves the pool around it, exactly as a + // builtin call does), so a second call in the same expression cannot overwrite + // it. The first version left the pool unsaved and `a() + b()` read 6. + const VReg v = alloc(); + emit({IrOp::CallScript, v, 0, 1, 0, 0, static_cast(i), nullptr, {}}); + *resultOut = v; + return; + } // `imm` is the callee's FUNCTION NUMBER, not its position in the op array. An IR // index would be the more obvious choice and was the first one, but the spill pass // rewrites the array and every index past its first insertion shifts: the call then diff --git a/src/core/moonlive/MoonLiveIr.h b/src/core/moonlive/MoonLiveIr.h index 7b419bf5..6bbe557a 100644 --- a/src/core/moonlive/MoonLiveIr.h +++ b/src/core/moonlive/MoonLiveIr.h @@ -85,10 +85,12 @@ enum class IrOp : uint8_t { // a null arena. Each backend reloads them from the frame before the call; where they // go is the per-ISA part. // - // NOTHING MAY BE LIVE IN A REGISTER ACROSS ONE. Script variables are slot-resident - // and temporaries die within their statement, so this holds today and is why no - // save-set is emitted. Xtensa's window rotation would hide a violation that - // corrupts RISC-V, so it is stated here rather than left to be discovered. + // Every caller vreg IS preserved across one: each backend's callLabel saves the + // pool and restores it, the same guarantee a builtin call gives, because a value + // computed before the call and read after it (`a() + b()`) is ordinary in an + // expression. `b` says whether the caller wants the callee's value, and when it does + // the result is delivered into `dst` after the pool is back. The allocator reads + // that same flag to decide the op defines `dst` (MoonLiveSpill's writesDst). ConstPtr, // dst = the pointer in `ptr`: a full-width address materialized into a register. // Distinct from Const because `imm` is int32_t and a pointer is 64 bits on the // desktop, so an address cannot ride an immediate. Every backend already builds one @@ -250,11 +252,18 @@ static constexpr uint8_t kIrLabels = 16; static constexpr uint8_t kAsmLabels = 48; static constexpr uint8_t kAsmFixups = 96; -/// Script variables live in FRAME SLOTS, and this bounds how many one program may hold at once. -/// Sixteen matches what every backend's frame can address (`kMaxSpillSlots`), so a program that -/// parses is a program the assembler can encode. Raising it means widening the frame on all three -/// backends together — the slot index is an instruction field, not just a table size. -static constexpr uint8_t kMaxLocals = 16; +/// Script variables live in FRAME SLOTS, and this bounds how many one program may hold at once: +/// named variables, loop counters and limits, and the arguments staged for a call, all at the same +/// moment. A block hands its slots back at its closing brace and a call hands back its staging, so +/// the budget is what is LIVE at once rather than a total. +/// +/// 32 is what a volumetric script needs: three nested loops cost six slots before a five-argument +/// write stages anything. The budget's cost is STACK, not encoding (Xtensa's s32i/l32i offset +/// reaches 256 slots, RISC-V's 2048, and the host addresses from a frame pointer), and it is +/// measured: the script's own frame is 148 bytes on the render task, and only for a script that +/// uses the slots, since the prologue reserves the peak the program actually reached; the compile +/// chain's deepest nesting is 2512 bytes of the main task's 12288, at 24 bytes per parser local. +static constexpr uint8_t kMaxLocals = 32; /// The most arguments one call can carry. Bounded by the FRAME, not by the register file: the parser /// stages arguments into consecutive local slots, so a call can take as many as the locals range diff --git a/src/core/moonlive/MoonLiveSpill.cpp b/src/core/moonlive/MoonLiveSpill.cpp index 88606380..2bf682d4 100644 --- a/src/core/moonlive/MoonLiveSpill.cpp +++ b/src/core/moonlive/MoonLiveSpill.cpp @@ -114,16 +114,23 @@ uint8_t sourcesOf(const IrInst& in, VReg* out) { // Does this op WRITE its dst? Branches, Label, Spill and the inline ops do not — their dst field is // a zero the front-end never fills in, and reading it as a definition would give vreg 0 (kArg0, the // buffer pointer) a spurious live range that the allocator would then try to manage. -bool writesDst(IrOp op) { - switch (op) { +/// Whether an op DEFINES its `dst`, which is what gives it a live interval and what makes the +/// rewriter remap it. Takes the whole instruction because `CallScript` answers per call: the +/// statement form writes nothing, the expression form writes its result. +bool writesDst(const IrInst& in) { + switch (in.op) { case IrOp::Label: case IrOp::BranchGe: case IrOp::BranchGeS: case IrOp::BranchNe: // A member store writes MEMORY, not a register: its `a` is the value and `imm` the arena // offset, so reading its dst as a definition would give vreg 0 a spurious live range. case IrOp::StoreCtrl: case IrOp::StoreCtrl32: - // CallScript writes no dst either: a script function returns nothing today, so the call is - // a statement rather than an expression. When it gains a return value this moves. - case IrOp::CallScript: + // CallScript writes its dst only when the caller WANTED the value (`b != 0`). The flag has + // to be consulted: a statement call's dst field is 0, which is kArg4's neighbour kArg0, so + // treating every call as a definition would give that argument a spurious live interval. + // Reading it as never-defining is the other error, and the expensive one: the rewriter + // remaps every source but leaves an unmapped dst, so after a compaction the call wrote the + // pre-compaction register while its consumer read the new one, and the value was lost. + case IrOp::CallScript: return in.b != 0; case IrOp::Spill: case IrOp::Inline: return false; default: return true; } @@ -240,7 +247,7 @@ bool spillToBudget(IrProgram& ir, const RegBudget& budget, uint8_t& slotsUsed) { VReg src[4]; const uint8_t n = sourcesOf(in, src); for (uint8_t s = 0; s < n; s++) mention(src[s], i); - if (writesDst(in.op)) mention(in.dst, i); + if (writesDst(in)) mention(in.dst, i); } // A value whose live range TOUCHES a loop is live to that loop's end. Naive first-def-to-last-use @@ -415,9 +422,9 @@ bool spillToBudget(IrProgram& ir, const RegBudget& budget, uint8_t& slotsUsed) { if (n > 2) in.c = mapped(src[2], 2); if (n > 3) in.d = mapped(src[3], 3); - const bool dstSpilled = writesDst(in.op) && in.dst < kMaxVRegs && iv[in.dst].spilled; + const bool dstSpilled = writesDst(in) && in.dst < kMaxVRegs && iv[in.dst].spilled; const uint8_t dstSlot = dstSpilled ? iv[in.dst].slot : 0; - if (writesDst(in.op)) { + if (writesDst(in)) { // A spilled destination is computed into a reload temp and then stored. It may reuse a // temp that carried a source: the op reads its sources and writes its destination as one // instruction, so the aliasing is the ordinary `add d, d, b` every ISA here defines. diff --git a/src/core/moonlive/moonlive_lower.h b/src/core/moonlive/moonlive_lower.h index b90d710e..dc811dec 100644 --- a/src/core/moonlive/moonlive_lower.h +++ b/src/core/moonlive/moonlive_lower.h @@ -372,7 +372,10 @@ size_t lowerWith(IrProgram& ir, uint8_t* out, size_t cap, const RegBudget* squee // down, and for recursion it is the enclosing function), so this binds a label and // the assembler patches the displacement once every function is placed. if (op.imm < 0 || op.imm >= ir.fnCount) break; - a.callLabel(fnLabel[op.imm]); + // `b` marks a call whose value the caller wants; the backend delivers it into + // dst, since which register holds it is ABI knowledge (Xtensa's call8 rotates the + // window, so the callee's a2 is the caller's a10). + a.callLabel(fnLabel[op.imm], reg(op.dst), op.b != 0); break; } case IrOp::Ret: diff --git a/src/core/noise.h b/src/core/noise.h index 9393cc2f..40f63056 100644 --- a/src/core/noise.h +++ b/src/core/noise.h @@ -2,166 +2,244 @@ #include -// Value noise: a smooth, deterministic pseudo-random field, the staple "organic motion" -// source for LED effects. inoise8 returns a 0..255 value that varies smoothly across space, -// so neighbouring coordinates give similar values (unlike a raw hash). Sample it across a -// grid for clouds/plasma/fire-like fields; scroll a coordinate (or pass a time offset) to -// animate. 1D, 2D and 3D variants share one hash + a smoothstep interpolation. +// Gradient noise: a smooth, deterministic pseudo-random field, the staple "organic motion" source +// for LED effects. inoise8 returns a 0..255 value that varies smoothly across space, so +// neighboring coordinates give similar values (unlike a raw hash). Sample it across a grid for +// clouds/plasma/fire-like fields; scroll a coordinate (or pass a time offset) to animate. 1D, 2D +// and 3D share one gradient set and one interpolation. // -// **This is VALUE noise, and FastLED's `inoise8` is GRADIENT noise.** The name is kept because it is -// the one every LED effect author reaches for and the 0..255 contract matches, but the character -// differs: value noise reads blockier and more axis-aligned, and uses the full output range where -// FastLED's compresses toward the middle. A ported effect will therefore LOOK slightly different for -// a reason its author cannot see from the call site, which is why it is stated here rather than left -// to be discovered. Prior art: Ken Perlin's method by way of FastLED; the hash + smoothstep + lerp -// are ours, promoted from NoiseEffect so every effect shares one field generator. +// The algorithm is Perlin's improved noise (Ken Perlin, "Improving Noise", SIGGRAPH 2002): a +// pseudo-random GRADIENT at every lattice corner (one of the twelve cube-edge directions, chosen by +// a hash), the dot product of that gradient with the offset from the corner, a quintic fade +// 6t⁵ − 15t⁴ + 10t³ on the fraction, and a linear blend across the corners. Gradient noise is zero +// at every lattice point and has no value bias toward the corners, which is why it reads as smooth +// and isotropic where value noise (a random VALUE per corner) reads blocky and axis-aligned at low +// frequency. 2D is 3D with the z offset at zero (inoise8(x, y) == inoise8(x, y, 0)); 1D takes ±1 +// gradients of its own, since the cube-edge set projected onto one axis is zero for six codes of +// sixteen. The cost scales with the corners: 2, 4 and 8 corner dot products. // -// Coordinates are 16.0 fixed scaled however the caller likes — the high byte selects the -// noise CELL, the low byte the interpolation position within it. So a larger coordinate step -// per pixel = finer noise (more cells across the grid); a smaller step = broader, smoother. +// Output uses the full range. The gradient dot product is signed offsets added, so the blend spans +// ±one cell rather than the ±√3/2 the unit-vector bound suggests: an exact search over every +// gradient choice at every fraction gives ±half a cell in 1D, ±one cell in 2D and ±1.035 cells in +// 3D. Each tier halves the raw blend onto its range, so the midpoint is the field's mean and 0 and +// the top value are both reached; the clamp is a guard only the 3D extreme touches. Written fresh, +// integer throughout: the hash and the fade table are ours, the gradient trick is Perlin's. +// +// Coordinates are 16.0 fixed scaled however the caller likes: the high byte selects the noise +// CELL, the low byte the interpolation position within it. So a larger coordinate step per pixel is +// finer noise (more cells across the grid); a smaller step is broader and smoother. namespace mm { namespace noise { -// Integer hash → 0..255. Three coords (z=0 for 1D/2D) feed one well-mixed avalanche. -constexpr uint8_t hash(uint32_t x, uint32_t y, uint32_t z) { - uint32_t h = x * 1619u + y * 31337u + z * 6271u; - h = (h >> 13) ^ h; - h = h * (h * h * 60493u + 19990303u) + 1376312589u; - return static_cast((h >> 16) & 0xFF); +/// Lattice hash: a corner's gradient index, 0..15. Each coordinate times its own odd constant, +/// xored (the products of x and x + 1 are shared across a cell's corners, so this part is six +/// multiplies per 3D sample rather than 24), then one xorshift, one multiply, and the top nibble +/// of the product, its best-mixed part. Four bits is all a gradient needs, so this is the whole +/// hash: a byte-wide avalanche spent three multiplies per corner on bits nothing read. Checked +/// against that avalanche: the sixteen codes are uniform to 0.6%, and adjacent corners are +/// independent to the same 5% the avalanche managed (a linear pre-mix with one multiply was NOT: +/// adjacent corners correlated six-fold, which reads as lattice patterns). Both tiers share it, so +/// the 16-bit field is the 8-bit one at finer resolution rather than an unrelated field. +constexpr uint32_t corner(uint32_t x, uint32_t y, uint32_t z) { + uint32_t h = (x * 0x8da6b343u) ^ (y * 0xd8163841u) ^ (z * 0xcb1ab31fu); + h ^= h >> 16; + h *= 0x7feb352du; + return h >> 28; } -// Smoothstep 3t²−2t³ on 0..255 — turns the linear cell fraction into an eased one so the -// field has no hard creases at cell boundaries (the difference between value noise and a -// blocky grid). -constexpr uint8_t smoothstep(uint8_t t) { - uint16_t t2 = static_cast(t) * t / 255; - uint16_t t3 = static_cast(t2) * t / 255; - return static_cast((3 * t2 - 2 * t3) & 0xFF); +/// Perlin's twelve cube-edge gradients (every signed permutation of (1, 1, 0)), padded to sixteen +/// with four repeats so the hash's low nibble indexes them by a mask rather than a modulo. A table +/// rather than Perlin's select expression because the selects compile to branches, and a core +/// without a branch predictor pays for each one: the select form was 44 branches per 2D sample on +/// Xtensa, the table is loads and small multiplies. +inline constexpr int8_t kGradient[16][3] = { + {1, 1, 0}, {-1, 1, 0}, {1, -1, 0}, {-1, -1, 0}, + {1, 0, 1}, {-1, 0, 1}, {1, 0, -1}, {-1, 0, -1}, + {0, 1, 1}, {0, -1, 1}, {0, 1, -1}, {0, -1, -1}, + {1, 1, 0}, {0, -1, 1}, {-1, 1, 0}, {0, -1, -1}, +}; + +/// Dot product of corner gradient `h` (0..15) with the offset, over the axes the arity samples. +template +constexpr int32_t grad(uint32_t h, int32_t x, int32_t y, int32_t z) { + // 1D takes ±1 gradients of its own: six of the sixteen cube-edge gradients have no x component, + // which would leave a 1D cell flat wherever both corners drew one. + if constexpr (Dims == 1) return (h & 1u) ? -x : x; + const int8_t* g = kGradient[h]; + int32_t d = g[0] * x; + if constexpr (Dims > 1) d += g[1] * y; + if constexpr (Dims > 2) d += g[2] * z; + return d; } -// Linear interpolate a→b by t/255. -constexpr uint8_t lerp8(uint8_t a, uint8_t b, uint8_t t) { - int16_t delta = static_cast(b) - static_cast(a); - return static_cast(static_cast(a) + delta * t / 255); +/// The quintic fade 6t⁵ − 15t⁴ + 10t³ as a table over t = i/256, i = 0..256, in 0.16 fixed +/// (0..65536). The 16-bit tier interpolates between entries on its low byte; the 8-bit tier +/// indexes it directly. A table because the polynomial needs a 40-bit intermediate per sample. +struct FadeTable { + uint32_t v[257]; + constexpr FadeTable() : v{} { + for (uint64_t i = 0; i <= 256; i++) { + // (6i⁵ − 3840i⁴ + 655360i³) / 2^24 == (6t⁵ − 15t⁴ + 10t³) · 65536 for t = i/256. + const uint64_t i3 = i * i * i; + v[i] = static_cast((6u * i3 * i * i - 3840u * i3 * i + 655360u * i3) >> 24); + } + } +}; +inline constexpr FadeTable kFade{}; + +/// fade for an 8-bit fraction: a table lookup. +constexpr uint32_t fade8(uint8_t t) { return kFade.v[t]; } + +/// fade for a 16-bit fraction: the table on the high byte, linear on the low byte. +constexpr uint32_t fade16(uint16_t t) { + const uint32_t a = kFade.v[t >> 8], b = kFade.v[(t >> 8) + 1]; + return a + (((b - a) * (t & 0xFFu)) >> 8); } -/// The 16-bit tier's internals. The 8-bit forms above quantise at EVERY stage — the corner values, -/// the interpolation and the octave sum — so a gradient that should be smooth steps visibly once a -/// fixture is large. These are the same three functions at 16 bits. -constexpr uint16_t hash16(uint32_t x, uint32_t y, uint32_t z) { - uint32_t h = x * 374761393u + y * 668265263u + z * 2147483647u; - h = (h ^ (h >> 13)) * 1274126177u; - return static_cast((h ^ (h >> 16)) & 0xFFFF); +/// The two tiers as policies for the one core below: the fraction width, the fade of a fraction, and the blend a→b by a 0.16 weight sized to the tier's operand range. +struct Tier8 { + static constexpr int kFrac = 8; + static constexpr uint32_t fade(int32_t f) { return fade8(static_cast(f)); } + /// Operands stay within ±2^11 (three offsets of a 256 cell), so the product fits 32 bits. + static constexpr int32_t blend(int32_t a, int32_t b, uint32_t w) { + return a + (((b - a) * static_cast(w)) >> 16); + } +}; +struct Tier16 { + static constexpr int kFrac = 16; + static constexpr uint32_t fade(int32_t f) { return fade16(static_cast(f)); } + /// Operands reach 2^18 against a 2^16 weight: a widening multiply (two on a 32-bit core). + static constexpr int32_t blend(int32_t a, int32_t b, uint32_t w) { + return a + static_cast((static_cast(b - a) * static_cast(w)) >> 16); + } +}; + +/// The core: gradient noise over the corners of one lattice cell. `x/y/z` are fixed point with +/// `Tier::kFrac` fraction bits (the whole part selects the cell); an unused axis is passed as 0. +/// One instantiation per arity so each compiles to straight-line code over exactly its corners. +/// `layer` is one z-slice (2 or 4 corners with the blends between them) and 3D is that slice at z +/// and z + 1 blended by the z fade: written this way because one body with eight corners in +/// flight spilled half its registers on a 16-register window (52 stack stores per sample on +/// Xtensa), while the slice form keeps at most four. Returns the raw blend in cell units: ±half a +/// cell in 1D, ±one cell in 2D, ±1.035 cells in 3D (the exact extremes, from a search over every +/// gradient choice at every fraction). +template +constexpr int32_t layer(uint32_t ix, uint32_t iy, uint32_t iz, int32_t fx, int32_t fy, int32_t fz, uint32_t wx, uint32_t wy) { + constexpr int32_t kCell = 1 << Tier::kFrac; + int32_t v = Tier::blend(grad(corner(ix, iy, iz), fx, fy, fz), + grad(corner(ix + 1, iy, iz), fx - kCell, fy, fz), wx); + if constexpr (Dims > 1) { + v = Tier::blend(v, Tier::blend(grad(corner(ix, iy + 1, iz), fx, fy - kCell, fz), + grad(corner(ix + 1, iy + 1, iz), fx - kCell, fy - kCell, fz), wx), wy); + } + return v; +} + +template +constexpr int32_t raw(uint32_t x, uint32_t y, uint32_t z) { + constexpr int32_t kCell = 1 << Tier::kFrac; + const uint32_t ix = x >> Tier::kFrac, iy = y >> Tier::kFrac, iz = z >> Tier::kFrac; + const int32_t fx = static_cast(x & (kCell - 1)); + const int32_t fy = static_cast(y & (kCell - 1)); + const int32_t fz = static_cast(z & (kCell - 1)); + const uint32_t wx = Tier::fade(fx); + const uint32_t wy = Dims > 1 ? Tier::fade(fy) : 0; + int32_t v = layer(ix, iy, iz, fx, fy, fz, wx, wy); + if constexpr (Dims > 2) v = Tier::blend(v, layer(ix, iy, iz + 1, fx, fy, fz - kCell, wx, wy), Tier::fade(fz)); + return v; } -/// Smoothstep at 16 bits: 3t² − 2t³, the standard ease that removes the linear kink between cells. -constexpr uint16_t smoothstep16(uint16_t t) { - const uint32_t t1 = t; - const uint32_t t2 = (t1 * t1) >> 16; - const uint32_t t3 = (t2 * t1) >> 16; - const uint32_t v = 3u * t2 - 2u * t3; - return static_cast(v > 65535u ? 65535u : v); +/// Map a raw blend onto the tier's unsigned range, centered on the midpoint. +/// +/// The scale is per ARITY, because the extreme is: ±half a cell in 1D, ±one cell in 2D and ±1.035 +/// in 3D (the exact figures, from a search over every gradient choice at every fraction). Halving +/// regardless is right for 2D and 3D and wrong for 1D, which then spans only the middle half of the +/// range: measured 64..192 of 0..255, so a 1D field read washed out against the same field sampled +/// in 2D. The clamp is a guard that only the 3D extreme past one cell reaches. +template +constexpr uint32_t out(int32_t raw) { + constexpr int32_t kCell = 1 << Tier::kFrac; + const int32_t v = kCell / 2 + (Dims == 1 ? raw : (raw >> 1)); + return static_cast(v < 0 ? 0 : (v > kCell - 1 ? kCell - 1 : v)); } -/// Linear interpolate a→b by t/65535. +/// Linear interpolate a→b by t/65536, unsigned 16-bit endpoints. The 16-bit tier's general lerp, +/// kept for callers that blend field values rather than gradients. constexpr uint16_t lerp16(uint16_t a, uint16_t b, uint16_t t) { const int32_t delta = static_cast(b) - static_cast(a); // 64-bit intermediate: `delta * t` reaches 4.29e9 against an INT32_MAX of 2.15e9, so the 32-bit - // form was signed overflow (undefined behaviour) on roughly a quarter of samples. It happened to + // form was signed overflow (undefined behavior) on roughly a quarter of samples. It happened to // produce the right low bits on wrap-around hardware, which is what kept it invisible. return static_cast(static_cast(a) + static_cast((static_cast(delta) * t) >> 16)); } +/// Octave sums narrow, and this is what widens them back. +/// +/// fbm divides its octave sum by the sum of the AMPLITUDES, which is what keeps the result inside +/// range. But octaves are near-independent, so their spread grows like the root of the sum of +/// squares rather than like the sum: dividing by the latter shrinks the field every octave added. +/// Measured on the shipped noise, 4 octaves spanned 54..199 of 0..255, so a caller stretching the +/// top of the field (a contrast window, a threshold) could never reach full brightness however it +/// was configured, and every fbm read flatter than the noise underneath it. +/// +/// The gain is that ratio per octave count, in 8.8 fixed, applied around the midpoint so the field +/// stays centered. One octave needs none and the series settles by about six. +inline constexpr uint16_t kFbmGain[9] = {256, 256, 343, 391, 417, 430, 437, 440, 442}; + +/// Re-widen an octave sum around the midpoint. `mid` is 128 at the 8-bit tier, 32768 at 16-bit. +constexpr int32_t fbmWiden(int32_t v, int32_t mid, uint8_t octaves) { + const uint16_t g = kFbmGain[octaves > 8 ? 8 : octaves]; + return mid + (((v - mid) * g) >> 8); +} + } // namespace noise -// 1D value noise: x is a 16.0 fixed coordinate (high byte = cell, low byte = position). +// 1D gradient noise: x is a 16.0 fixed coordinate (high byte = cell, low byte = position). constexpr uint8_t inoise8(uint32_t x) { - const uint32_t ix = x >> 8; - const uint8_t fx = noise::smoothstep(static_cast(x & 0xFF)); - return noise::lerp8(noise::hash(ix, 0, 0), noise::hash(ix + 1, 0, 0), fx); + return static_cast(noise::out(noise::raw(x, 0, 0))); } -// 2D value noise with bilinear interpolation over the 4 cell corners. +// 2D gradient noise over the 4 cell corners. constexpr uint8_t inoise8(uint32_t x, uint32_t y) { - const uint32_t ix = x >> 8, iy = y >> 8; - const uint8_t fx = noise::smoothstep(static_cast(x & 0xFF)); - const uint8_t fy = noise::smoothstep(static_cast(y & 0xFF)); - const uint8_t v00 = noise::hash(ix, iy, 0); - const uint8_t v10 = noise::hash(ix + 1, iy, 0); - const uint8_t v01 = noise::hash(ix, iy + 1, 0); - const uint8_t v11 = noise::hash(ix + 1, iy + 1, 0); - return noise::lerp8(noise::lerp8(v00, v10, fx), noise::lerp8(v01, v11, fx), fy); + return static_cast(noise::out(noise::raw(x, y, 0))); } -// 3D value noise with trilinear interpolation over the 8 cube corners. +// 3D gradient noise over the 8 cube corners. constexpr uint8_t inoise8(uint32_t x, uint32_t y, uint32_t z) { - const uint32_t ix = x >> 8, iy = y >> 8, iz = z >> 8; - const uint8_t fx = noise::smoothstep(static_cast(x & 0xFF)); - const uint8_t fy = noise::smoothstep(static_cast(y & 0xFF)); - const uint8_t fz = noise::smoothstep(static_cast(z & 0xFF)); - const uint8_t v000 = noise::hash(ix, iy, iz); - const uint8_t v100 = noise::hash(ix + 1, iy, iz); - const uint8_t v010 = noise::hash(ix, iy + 1, iz); - const uint8_t v110 = noise::hash(ix + 1, iy + 1, iz); - const uint8_t v001 = noise::hash(ix, iy, iz + 1); - const uint8_t v101 = noise::hash(ix + 1, iy, iz + 1); - const uint8_t v011 = noise::hash(ix, iy + 1, iz + 1); - const uint8_t v111 = noise::hash(ix + 1, iy + 1, iz + 1); - const uint8_t z0 = noise::lerp8(noise::lerp8(v000, v100, fx), noise::lerp8(v010, v110, fx), fy); - const uint8_t z1 = noise::lerp8(noise::lerp8(v001, v101, fx), noise::lerp8(v011, v111, fx), fy); - return noise::lerp8(z0, z1, fz); + return static_cast(noise::out(noise::raw(x, y, z))); } // --- 16-bit noise --------------------------------------------------------------------------- // -// The same value noise at 16 bits. math16.h states why the tier exists: 256 levels band visibly on -// a large fixture, so everything an effect writes against is 16-bit. The 8-bit forms above stay for -// the cases where a byte is what the caller needs anyway (a palette index, a brightness). +// The same gradient noise at 16 bits. math16.h states why the tier exists: 256 levels band visibly +// on a large fixture, so everything an effect writes against is 16-bit. The 8-bit forms above stay +// for the cases where a byte is what the caller needs anyway (a palette index, a brightness). -/// 1D value noise at 16 bits. `x` is 16.16 fixed point: the whole part selects the cell, the +/// 1D gradient noise at 16 bits. `x` is 16.16 fixed point: the whole part selects the cell, the /// fraction interpolates within it. constexpr uint16_t inoise16(uint32_t x) { - const uint32_t ix = x >> 16; - const uint16_t fx = noise::smoothstep16(static_cast(x & 0xFFFF)); - return noise::lerp16(noise::hash16(ix, 0, 0), noise::hash16(ix + 1, 0, 0), fx); + return static_cast(noise::out(noise::raw(x, 0, 0))); } -/// 2D value noise at 16 bits — the common case for a panel. +/// 2D gradient noise at 16 bits: the common case for a panel. constexpr uint16_t inoise16(uint32_t x, uint32_t y) { - const uint32_t ix = x >> 16, iy = y >> 16; - const uint16_t fx = noise::smoothstep16(static_cast(x & 0xFFFF)); - const uint16_t fy = noise::smoothstep16(static_cast(y & 0xFFFF)); - const uint16_t v00 = noise::hash16(ix, iy, 0); - const uint16_t v10 = noise::hash16(ix + 1, iy, 0); - const uint16_t v01 = noise::hash16(ix, iy + 1, 0); - const uint16_t v11 = noise::hash16(ix + 1, iy + 1, 0); - return noise::lerp16(noise::lerp16(v00, v10, fx), noise::lerp16(v01, v11, fx), fy); + return static_cast(noise::out(noise::raw(x, y, 0))); } -/// 3D value noise at 16 bits — z is the axis a 2D effect uses as time, so the field evolves in +/// 3D gradient noise at 16 bits. `z` is the axis a 2D effect uses as time, so the field evolves in /// place instead of scrolling past. constexpr uint16_t inoise16(uint32_t x, uint32_t y, uint32_t z) { - const uint32_t ix = x >> 16, iy = y >> 16, iz = z >> 16; - const uint16_t fx = noise::smoothstep16(static_cast(x & 0xFFFF)); - const uint16_t fy = noise::smoothstep16(static_cast(y & 0xFFFF)); - const uint16_t fz = noise::smoothstep16(static_cast(z & 0xFFFF)); - const uint16_t z0 = noise::lerp16( - noise::lerp16(noise::hash16(ix, iy, iz), noise::hash16(ix + 1, iy, iz), fx), - noise::lerp16(noise::hash16(ix, iy + 1, iz), noise::hash16(ix + 1, iy + 1, iz), fx), fy); - const uint16_t z1 = noise::lerp16( - noise::lerp16(noise::hash16(ix, iy, iz + 1), noise::hash16(ix + 1, iy, iz + 1), fx), - noise::lerp16(noise::hash16(ix, iy + 1, iz + 1), noise::hash16(ix + 1, iy + 1, iz + 1), fx), fy); - return noise::lerp16(z0, z1, fz); + return static_cast(noise::out(noise::raw(x, y, z))); } /// Fractal Brownian motion at 16 bits: `octaves` samples at doubling frequency, halving amplitude. inline uint16_t fbm16(uint32_t x, uint32_t y, uint8_t octaves) { if (octaves == 0) return 32768; // no octaves: flat mid-field // The sum is 64-bit so each octave keeps its full 16 bits. Shifting each sample down by 8 to fit - // a 32-bit accumulator made the result 8-bit wearing a 16-bit type — measured at 195 distinct - // values over 20,000 samples, low byte never set — which is exactly the banding this tier is for. + // a 32-bit accumulator made the result 8-bit wearing a 16-bit type: measured at 195 distinct + // values over 20,000 samples, low byte never set: which is exactly the banding this tier is for. uint64_t sum = 0; uint32_t norm = 0, amp = 32768; for (uint8_t o = 0; o < octaves && amp > 0; o++) { @@ -170,29 +248,48 @@ inline uint16_t fbm16(uint32_t x, uint32_t y, uint8_t octaves) { x <<= 1; y <<= 1; // double the frequency amp >>= 1; // halve the contribution } - return norm ? static_cast(sum / norm) : 32768; + if (!norm) return 32768; + const int32_t widened = noise::fbmWiden(static_cast(sum / norm), 32768, octaves); + return static_cast(widened < 0 ? 0 : (widened > 65535 ? 65535 : widened)); +} + +/// 3D fbm at 16 bits: the same sum with a z axis, so a volumetric fixture samples a real field +/// rather than a plane repeated along z, and a 2D effect can use z as time. +inline uint16_t fbm16(uint32_t x, uint32_t y, uint32_t z, uint8_t octaves) { + if (octaves == 0) return 32768; + uint64_t sum = 0; + uint32_t norm = 0, amp = 32768; + for (uint8_t o = 0; o < octaves && amp > 0; o++) { + sum += static_cast(inoise16(x, y, z)) * amp; + norm += amp; + x <<= 1; y <<= 1; z <<= 1; + amp >>= 1; + } + if (!norm) return 32768; + const int32_t widened = noise::fbmWiden(static_cast(sum / norm), 32768, octaves); + return static_cast(widened < 0 ? 0 : (widened > 65535 ? 65535 : widened)); } // --- Field composition ------------------------------------------------------------------------ // -// One noise sample is a smooth blur; the looks people actually recognise come from COMPOSING +// One noise sample is a smooth blur; the looks people actually recognize come from COMPOSING // samples. Three standard compositions cover most of it, and each is a few lines over `inoise8` // rather than a new field generator: // -// fbm — sum octaves at doubling frequency and halving amplitude. Turns the blur into +// fbm : sum octaves at doubling frequency and halving amplitude. Turns the blur into // cloud/terrain/smoke structure: large shapes with fine detail on them. -// turbulence — the same sum over |noise|, whose creases read as billows and flame. -// warp — sample noise at a coordinate that noise itself displaced (domain warping). This +// turbulence: the same sum over |noise|, whose creases read as billows and flame. +// warp : sample noise at a coordinate that noise itself displaced (domain warping). This // is the one that produces the flowing, marbled, liquid look; Iñigo Quilez's // "warping" article is the canonical description. // // Cost is stated per call because it is the thing that decides whether an effect fits: each // octave is one `inoise8`, so fbm(3) costs three samples, and warp costs its own samples PLUS the -// field it then samples. On a large fixture that multiplies by pixel count — see the per-target +// field it then samples. On a large fixture that multiplies by pixel count: see the per-target // budget in the power-function docs before reaching for octaves on a 128x128 wall. /// Fractal Brownian motion: `octaves` samples at doubling frequency, halving amplitude, returned -/// normalised to 0..255. octaves=1 is plain noise; 3-4 is the usual cloud look. +/// normalized to 0..255. octaves=1 is plain noise; 3-4 is the usual cloud look. inline uint8_t fbm8(uint32_t x, uint32_t y, uint8_t octaves) { if (octaves == 0) return 128; // no octaves: flat mid-field uint32_t sum = 0, norm = 0, amp = 128; @@ -202,10 +299,12 @@ inline uint8_t fbm8(uint32_t x, uint32_t y, uint8_t octaves) { x <<= 1; y <<= 1; // double the frequency amp >>= 1; // halve the contribution } - return static_cast(norm ? sum / norm : 128); + if (!norm) return 128; + const int32_t widened = noise::fbmWiden(static_cast(sum / norm), 128, octaves); + return static_cast(widened < 0 ? 0 : (widened > 255 ? 255 : widened)); } -/// 3D fbm — the same sum with a z axis, so a 2D effect can use z as time for a field that evolves +/// 3D fbm: the same sum with a z axis, so a 2D effect can use z as time for a field that evolves /// in place rather than scrolling past. inline uint8_t fbm8(uint32_t x, uint32_t y, uint32_t z, uint8_t octaves) { if (octaves == 0) return 128; @@ -216,7 +315,9 @@ inline uint8_t fbm8(uint32_t x, uint32_t y, uint32_t z, uint8_t octaves) { x <<= 1; y <<= 1; z <<= 1; amp >>= 1; } - return static_cast(norm ? sum / norm : 128); + if (!norm) return 128; + const int32_t widened = noise::fbmWiden(static_cast(sum / norm), 128, octaves); + return static_cast(widened < 0 ? 0 : (widened > 255 ? 255 : widened)); } /// Turbulence: fbm over |noise - 128|, which creases the field where it crosses the midpoint. The @@ -235,18 +336,123 @@ inline uint8_t turbulence8(uint32_t x, uint32_t y, uint8_t octaves) { return static_cast(r > 255 ? 255 : r); } +/// 3D turbulence: the same creased sum with a z axis. +inline uint8_t turbulence8(uint32_t x, uint32_t y, uint32_t z, uint8_t octaves) { + if (octaves == 0) return 0; + uint32_t sum = 0, norm = 0, amp = 128; + for (uint8_t o = 0; o < octaves && amp > 0; o++) { + const int16_t v = static_cast(inoise8(x, y, z)) - 128; + sum += static_cast(v < 0 ? -v : v) * 2u * amp; + norm += amp; + x <<= 1; y <<= 1; z <<= 1; + amp >>= 1; + } + const uint32_t r = norm ? sum / norm : 0; + return static_cast(r > 255 ? 255 : r); +} + /// Domain warp: displace the sample coordinate by a noise field, then sample there. `strength` is /// how far the displacement reaches, in the same fixed-point units as the coordinates. /// /// This is the primitive behind the flowing/marbled look: the field stops looking like a texture /// laid on the grid and starts looking like something moving through it. Two extra samples. + +/// 3D domain warp: the same displacement with a z axis, so the field flows through a volume rather +/// than through a plane. Three probes rather than two, each offset by its own constant so the axes +/// displace independently: sampling one field three times would move everything along a diagonal. +/// +/// `octaves` has NO default here, unlike the 2D form. With one, `warp8(x, y, strength, octaves)` and +/// `warp8(x, y, z, strength)` are both viable at four arguments and every existing call becomes +/// ambiguous. Requiring it makes the arity say which field the caller means. +/// +/// Shares one body with the 2D form through `Dims`, so the two cannot drift apart, and each still +/// compiles to exactly its own arity: the 2D instantiation samples 2D noise over four corners and +/// never touches z. Calling the 3D body with z = 0 would have been simpler and measured 1.73x +/// slower, because every probe and the inner fbm then walk eight corners to reach the same answer. +template +inline uint8_t warpImpl(uint32_t x, uint32_t y, uint32_t z, uint16_t strength, uint8_t octaves) { + // Offset the probe fields so the axes displace independently: sampling one field twice would + // move everything along a diagonal. + int32_t dx, dy, dz = 0; + if constexpr (Dims > 2) { + dx = (static_cast(inoise8(x, y, z)) - 128) * strength / 128; + dy = (static_cast(inoise8(x + 0x9E37u, y + 0x7C15u, z)) - 128) * strength / 128; + // The z probe rides z ITSELF rather than a constant offset, so a fixture with no depth + // displaces nothing along an axis it does not have. That is what makes the 2D instantiation + // below the same field as this one at z = 0, rather than merely a similar one. + if (z) dz = (static_cast(inoise8(x + 0x6A09u, y + 0xBB67u, z)) - 128) * strength / 128; + } else { + dx = (static_cast(inoise8(x, y)) - 128) * strength / 128; + dy = (static_cast(inoise8(x + 0x9E37u, y + 0x7C15u)) - 128) * strength / 128; + } + // The displacement is added in UNSIGNED arithmetic. Casting the coordinate to int32_t first + // was signed overflow (undefined behavior) for any coordinate past 2^31, which a scaled field + // reaches easily: `r * zoom + drift` on a large fixture is already past it. Unsigned wrapping is + // defined, and wrapping is what a noise coordinate wants anyway. + const uint32_t sx = x + static_cast(dx); + const uint32_t sy = y + static_cast(dy); + if constexpr (Dims > 2) { + return fbm8(sx, sy, z + static_cast(dz), octaves); + } else { + return fbm8(sx, sy, octaves); + } +} + +/// 3D domain warp: the field flows through a volume rather than through a plane. +inline uint8_t warp8(uint32_t x, uint32_t y, uint32_t z, uint16_t strength, uint8_t octaves) { + return warpImpl<3>(x, y, z, strength, octaves); +} + +/// 2D domain warp: displace the sample coordinate by a noise field, then sample there. `strength` is +/// how far the displacement reaches, in the same fixed-point units as the coordinates. +/// +/// This is the primitive behind the flowing/marbled look: the field stops looking like a texture +/// laid on the grid and starts looking like something moving through it. Two extra samples. inline uint8_t warp8(uint32_t x, uint32_t y, uint16_t strength, uint8_t octaves = 1) { - // Offset the two probe fields so the x and y displacements are independent rather than equal - // (sampling the same field twice would displace everything along one diagonal). - const int32_t dx = (static_cast(inoise8(x, y)) - 128) * strength / 128; - const int32_t dy = (static_cast(inoise8(x + 0x9E37u, y + 0x7C15u)) - 128) * strength / 128; - return fbm8(static_cast(static_cast(x) + dx), - static_cast(static_cast(y) + dy), octaves); + return warpImpl<2>(x, y, 0u, strength, octaves); +} + +/// Curl of a noise potential: a velocity field that cannot pile up or thin out. +/// +/// The perpendicular gradient of a scalar field (Bridson, "Curl-Noise for Procedural Fluid Flow", +/// SIGGRAPH 2007). Taking the gradient of a potential and turning it 90 degrees gives a field whose +/// divergence is zero BY CONSTRUCTION: whatever flows into a region flows out again. That is what +/// separates it from sampling noise straight into a velocity, where the field has sources and sinks +/// and anything carried by it collects in the sinks and drains from the sources, which looks like +/// clumping rather than flow. +/// +/// The output is scaled by `strength`, in the caller's own units (sub-pixels per frame for a +/// transport). `eps` is the sampling distance for the central difference, in the same 16.16 +/// coordinates as the field: too small and the difference is quantization noise, too large and the +/// curl is of a blurrier field than the one being sampled. The default is a sixteenth of a cell. +inline void curl16(uint32_t x, uint32_t y, uint32_t z, int32_t strength, + int32_t& vx, int32_t& vy, uint32_t eps = 4096) { + // Two central differences of the potential. dP/dy becomes the x component and -dP/dx the y, + // which is the 90-degree turn: the flow runs ALONG the potential's contours rather than up them. + const int32_t dy = static_cast(inoise16(x, y + eps, z)) + - static_cast(inoise16(x, y - eps, z)); + const int32_t dx = static_cast(inoise16(x + eps, y, z)) + - static_cast(inoise16(x - eps, y, z)); + // The differences span roughly a quarter of the range at the default eps, so >>15 keeps the + // result near `strength` rather than swamping it. A caller wanting a wilder field raises eps. + // + // Widened to 64 bits for the product: a difference reaches +-65535 and `strength` is the + // caller's own number, so `dy * strength` overflows a signed 32-bit at a strength above 32768, + // which a large panel already approaches (a 768-wide fixture at full speed passes 12240, and a + // stalled frame scales that further). Signed overflow is undefined, so this is a real fault + // rather than a wrap; clamped on the way back down so a wild strength saturates instead. + const auto scaled = [](int32_t d, int32_t s) -> int32_t { + const int64_t v = (static_cast(d) * s) >> 15; + return static_cast(v > INT32_MAX ? INT32_MAX : (v < INT32_MIN ? INT32_MIN : v)); + }; + vx = scaled(dy, strength); + vy = scaled(-dx, strength); +} + +/// The 2D form: the same field at a fixed z, which is what a panel wants. +inline void curl16(uint32_t x, uint32_t y, int32_t strength, int32_t& vx, int32_t& vy, + uint32_t eps = 4096) { + curl16(x, y, 0u, strength, vx, vy, eps); } } // namespace mm diff --git a/src/core/oscillators.h b/src/core/oscillators.h new file mode 100644 index 00000000..00fbd650 --- /dev/null +++ b/src/core/oscillators.h @@ -0,0 +1,133 @@ +#pragma once + +#include "core/math16.h" // BeatPhase, sin16, triwave16, angle16 + +#include + +// An oscillator bank: N independent low-frequency oscillators advanced once per frame and read as +// often as an effect likes. +// +// Every animated quantity in a generative field is an oscillator: where a shape sits, how far a +// coordinate is displaced, how fast a layer rotates, how bright it is this instant. Written by +// hand that is a BeatPhase member, a waveform call and a range map per quantity, which is why +// effects that animate several things at once grow a row of near-identical members and a block of +// arithmetic in tick(). The bank is that pattern owned once: declare how many oscillators, set each +// one's rate, waveform and output range, advance the bank, then read. +// +// Two properties make a bank different from N loose oscillators, and both matter for the shaders +// this exists for. Advancing is ONE pass, so cost is per frame and not per pixel: a pixel loop +// reads values that are already computed, which is the whole point when a field samples the same +// oscillator 16,384 times. And phases are held together, so oscillators keep their relationships: +// two layers at the same rate a quarter-cycle apart stay a quarter-cycle apart for as long as the +// device runs, which is what makes a composition read as deliberate rather than as drift. +// +// Standard shapes only, all from math16: sine, triangle, sawtooth, square. Anything richer is a +// COMBINATION of oscillators (that is the vocabulary Stefan Petrick's shaders are written in), not +// a new waveform here. + +namespace mm { + +/// The shape an oscillator traces over its cycle. +enum class Wave : uint8_t { + Sine, ///< the default: smooth, no corners, the shape most motion wants + Triangle, ///< linear out and back; sharper turns than a sine at the same rate + Saw, ///< ramp up then jump back: rotation, scrolling, anything that only goes one way + Square, ///< low half the cycle, high the other: switching, strobing, hard alternation +}; + +/// One oscillator's settings. `rate` is BPM-like (cycles per minute), so 60 is one cycle a second +/// and 0 holds the phase still. `low`/`high` are the output range, inclusive, and may be inverted +/// (low > high) to run the shape backwards. `phaseOffset` is an angle16 added at read time, so two +/// oscillators can share a rate and sit a fixed fraction of a cycle apart: 16384 is a quarter turn. +struct Oscillator { + uint16_t rate = 0; + int32_t low = 0; + int32_t high = 65535; + angle16 phaseOffset = 0; + Wave wave = Wave::Sine; +}; + +/// A fixed-size bank of oscillators. `N` is a compile-time count because an effect knows how many +/// quantities it animates, and a fixed member array costs no allocation and no indirection. +/// +/// Usage, once per frame: `bank.advance(elapsed())`, then `bank.value(i)` wherever the value is +/// needed, including inside a pixel loop. Configure with `set(i, {...})` in prepare() or whenever a +/// control changes; a rate change takes effect from that frame without jumping the phase, which is +/// what live reconfiguration requires. +template +class OscillatorBank { +public: + static constexpr uint8_t kCount = N; + + /// Configure oscillator `i`. Out-of-range indices are ignored rather than trapping: an effect + /// driving the bank from a control must not be able to crash the device with a bad index. + void set(uint8_t i, const Oscillator& osc) { + if (i < N) osc_[i] = osc; + } + + /// Read one oscillator's settings, for a caller that adjusts a single field. + const Oscillator& get(uint8_t i) const { return osc_[i < N ? i : N - 1]; } + + /// Advance every oscillator by this frame's elapsed time. Call once per frame, before reading. + /// Each phase accumulates its own dt*rate numerator, so a rate of 0 holds and a rate changed + /// mid-run continues from where the phase stands (BeatPhase owns the why). + void advance(uint32_t elapsedMs) { + for (uint8_t i = 0; i < N; i++) phase_[i].advance(elapsedMs, osc_[i].rate); + } + + /// Oscillator `i`'s current phase as an angle16, offset included. The raw cycle position, for a + /// caller that wants to drive something other than the configured range. + angle16 phase(uint8_t i) const { + if (i >= N) return 0; + return static_cast(phase_[i].phase(65536) + osc_[i].phaseOffset); + } + + /// Oscillator `i`'s current value, mapped into its configured range. Cheap enough to call per + /// pixel, though an effect whose value is constant across the frame should hoist it. + int32_t value(uint8_t i) const { + if (i >= N) return 0; + return map(osc_[i], unit(osc_[i].wave, phase(i))); + } + + /// Oscillator `i`'s value as a 0..65535 unit position, ignoring the configured range. For a + /// caller doing its own mapping, and the form `value` is built on. + uint16_t unitValue(uint8_t i) const { + if (i >= N) return 0; + return unit(osc_[i].wave, phase(i)); + } + + /// Restart every phase at zero, keeping the configuration. For an effect whose composition must + /// begin from a known state. + void reset() { + for (uint8_t i = 0; i < N; i++) phase_[i].reset(); + } + +private: + /// The waveform, as a 0..65535 position through the cycle. + static uint16_t unit(Wave wave, angle16 theta) { + switch (wave) { + // sin16 is signed around zero; shift it into the unsigned range the mapping expects. + case Wave::Sine: return static_cast(sin16(theta) + 32768); + case Wave::Triangle: return triwave16(theta); + case Wave::Saw: return theta; + case Wave::Square: return theta < 32768 ? 0 : 65535; + } + return 0; + } + + /// Map a unit position onto [low, high], inclusive at BOTH ends. Dividing by 65535 rather than + /// shifting by 16 is the difference between an oscillator that reaches its stated maximum and + /// one that stops one step short of it forever: an effect sweeping a hue to 255 would never + /// arrive. 64-bit intermediate, because a range spanning the full int32 overflows a 32-bit + /// product, and a caller mapping onto pixel coordinates times a scale gets there sooner than it + /// looks. + static int32_t map(const Oscillator& osc, uint16_t u) { + const int64_t span = static_cast(osc.high) - osc.low; + return static_cast(osc.low + (span * u) / 65535); + } + + Oscillator osc_[N]; + BeatPhase phase_[N]; +}; + +} // namespace mm diff --git a/src/light/draw.h b/src/light/draw.h index a369e735..403903ed 100644 --- a/src/light/draw.h +++ b/src/light/draw.h @@ -4,25 +4,25 @@ #include "light/layers/Buffer.h" // Buffer (flat light array) #include "core/color.h" // RGB, scale8 #include "core/math8.h" -#include "core/math16.h" // isqrt — true-distance SDFs; the squared forms avoid it // qadd8 — saturating add for blur's seep accumulation -#include "light/Palette.h" // blend(RGB,RGB,amt) — for blendPixel -#include "light/fonts.h" // fonts::Font — bitmap glyph tables for draw::text +#include "core/math16.h" // isqrt: true-distance SDFs; halfLifeKeep: the decay weight +#include "light/Palette.h" // blend(RGB,RGB,amt): for blendPixel +#include "light/fonts.h" // fonts::Font: bitmap glyph tables for draw::text -#include // std::reverse — in-place rotation for a wrapping scroll -#include // std::memmove/memcpy/memset — the scroll's run moves +#include // std::reverse: in-place rotation for a wrapping scroll +#include // std::memmove/memcpy/memset: the scroll's run moves -// Geometry draw primitives for effects/modifiers: set a pixel, draw a line — bounds-clipped, +// Geometry draw primitives for effects/modifiers: set a pixel, draw a line: bounds-clipped, // integer-only, working 1D→3D against the flat light Buffer. The "core absorbs the hard part" // rule applied to drawing: the Bresenham + clipping lives here once, so an effect calls // drawLine() instead of re-rolling it. Light-domain (it touches the light Buffer), not core. // // Prior art: the line algorithm is Bresenham (1962) generalised to 3D (the textbook DDA-error -// form). FastLED keeps draw in its 2D/matrix add-ons, not core — same split here. +// form). FastLED keeps draw in its 2D/matrix add-ons, not core: same split here. // // The Buffer is a flat array of `count` lights × `cpl` channels; the grid SHAPE (w,h,d) lives on // the Layer/Layout, so the caller passes `dims` (the Coord3D extent). Index order matches the // engine: off = (z·h·w + y·w + x)·cpl. A pixel outside [0,w)×[0,h)×[0,d) is silently clipped, so -// a line that runs off the grid just stops drawing — no out-of-bounds write (the robustness rule). +// a line that runs off the grid just stops drawing: no out-of-bounds write (the robustness rule). namespace mm::draw { @@ -30,7 +30,7 @@ namespace mm::draw { /// The surface a draw call writes to: a buffer plus the grid dimensions that address it. /// /// Today every draw call takes `(Buffer&, Coord3D dims)` as two independent arguments that nothing -/// checks for agreement — pass dims from one layer with a buffer from another, or (far likelier) a +/// checks for agreement: pass dims from one layer with a buffer from another, or (far likelier) a /// dims computed with the depth guard and one without, and the result is silent misaddressing. /// Binding them into one value makes the mismatch unrepresentable rather than merely detected. /// @@ -39,7 +39,7 @@ namespace mm::draw { /// /// **Passed BY VALUE, deliberately.** It is a small POD (pointer + 3 int16 + 2 small ints) and /// measured 62 instructions in a per-pixel fill loop against 67 for today's separate arguments and -/// 69 for a `const Canvas&` — a reference member forces the extents to be re-read from memory +/// 69 for a `const Canvas&`: a reference member forces the extents to be re-read from memory /// because the compiler must assume they alias the buffer being written, while a by-value POD stays /// in registers. So the abstraction is not a cost here; it is a small win. struct Canvas { @@ -55,7 +55,7 @@ struct Canvas { buf.channelsPerLight()}; } - /// Byte offset of a coordinate, or `bytes` when it is outside the grid — the one address + /// Byte offset of a coordinate, or `bytes` when it is outside the grid: the one address /// computation every draw call shares, so the addressing rule (x fastest, then y, then z) has a /// single home. size_t offsetOf(Coord3D p) const { @@ -65,7 +65,7 @@ struct Canvas { } }; -/// One pixel, clipped to the grid — the Canvas form. Same semantics as the (Buffer&, dims) overload +/// One pixel, clipped to the grid: the Canvas form. Same semantics as the (Buffer&, dims) overload /// below, which remains until the migration completes and is then removed (a permanent two-API /// window would be worse than either shape alone). inline void pixel(const Canvas& cv, Coord3D p, RGB c) { @@ -90,7 +90,7 @@ namespace detail { /// Buffer and Canvas forms of `line` share one error-carry loop instead of drifting apart. /// /// `shorten` (0..255) pulls b back toward a by that fraction, with the *2 rounding the original -/// used — the perspective/length lever effects animate. +/// used: the perspective/length lever effects animate. template inline void walkLine(Coord3D a, Coord3D b, uint8_t shorten, PlotFn plot) { if (shorten == 0) return; @@ -137,7 +137,7 @@ inline void walkLine(Coord3D a, Coord3D b, uint8_t shorten, PlotFn plot) { } // namespace detail // One pixel, clipped to the grid. Writes R/G/B where channels fit (cpl may be 1..N); extra -// channels (e.g. a W in RGBW) are left as-is — the driver derives white, same as effects do. +// channels (e.g. a W in RGBW) are left as-is: the driver derives white, same as effects do. inline void pixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c) { if (p.x < 0 || p.y < 0 || p.z < 0 || p.x >= dims.x || p.y >= dims.y || p.z >= dims.z) return; const uint8_t cpl = buf.channelsPerLight(); @@ -152,11 +152,11 @@ inline void pixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c) { // A straight line a→b, clipped to the grid. 3D Bresenham: step along the dominant axis and carry // an integer error term per other axis (the textbook generalisation of the 2D line). Works for -// 1D (a row), 2D (a plane), and 3D (a volume) without special-casing — a degenerate axis just +// 1D (a row), 2D (a plane), and 3D (a volume) without special-casing: a degenerate axis just // never steps. Endpoints are inclusive. // // `shorten` (0..255, default 255 = full line) draws only the first shorten/255 of the way from a -// toward b — the far endpoint is pulled back toward `a`. 255 = whole line, 128 ≈ half, 1 = the +// toward b: the far endpoint is pulled back toward `a`. 255 = whole line, 128 ≈ half, 1 = the // start pixel, 0 = nothing. This is the perspective/length lever (MoonLight's `depth` param): // effects animate the drawn tip by varying `shorten`, so a fixed pair of endpoints traces a // sweeping partial segment over successive frames. (WLEDMM's *2-rounding shorten, generalised 3D.) @@ -192,8 +192,8 @@ inline void blendPixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c, uint8_t amt) d[off + 0] = out.r; d[off + 1] = out.g; d[off + 2] = out.b; } -// Add a color into a pixel, saturating (a bright pixel can't wrap to dark) — WLED's addRGB / additive -// setPixelColor. Used to re-stamp a light on top of a blur so its centre stays bright. Clipped like pixel(). +// Add a color into a pixel, saturating (a bright pixel can't wrap to dark): WLED's addRGB / additive +// setPixelColor. Used to re-stamp a light on top of a blur so its center stays bright. Clipped like pixel(). inline void addPixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c) { const size_t off = offsetOf(buf, dims, p); if (off + 2 >= buf.bytes()) return; @@ -203,7 +203,7 @@ inline void addPixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c) { d[off + 2] = qadd8(d[off + 2], c.b); } -// Fade the whole buffer toward black by amt/255 — one pass over the bytes. This is the primitive the +// Fade the whole buffer toward black by amt/255: one pass over the bytes. This is the primitive the // Layer's once-per-frame collected fade (Layer::fadeToBlackBy) applies; effects request a fade through // the Layer (which MINs the amount across effects and calls this once) rather than calling it directly. inline void fade(Buffer& buf, uint8_t amt) { @@ -213,17 +213,17 @@ inline void fade(Buffer& buf, uint8_t amt) { for (size_t i = 0; i < n; i++) d[i] = scale8(d[i], keep); } -// Box blur, working 1D→3D against the flat Buffer — one unified primitive, not a blur1d/blur2d/blur3d +// Box blur, working 1D→3D against the flat Buffer: one unified primitive, not a blur1d/blur2d/blur3d // trio (the *common patterns first* / "primitives are 3D-aware" rule, same as draw::line). It runs a // separable seep pass along each axis whose extent is >1: a 1×N 1D layer blurs along y (its only // axis with extent>1); 2D along x then y; 3D along x, y, z. `amt` (0 = none, 255 = max) is split // keep=255-amt / seep=amt>>1 per pixel. // -// Algorithm: the canonical FastLED blur1d single-forward-pass with carryover — each pixel keeps +// Algorithm: the canonical FastLED blur1d single-forward-pass with carryover: each pixel keeps // `keep` of itself, seeps `seep` forward to the next pixel and `seep` back to the previous one, so -// one O(N) pass per axis approximates a symmetric box blur. Behaviour is identical to MoonLight's +// one O(N) pass per axis approximates a symmetric box blur. Behavior is identical to MoonLight's // blur1d/blurRows/blurColumns (verified against VirtualLayer.cpp); the speed comes from doing it on -// the raw bytes — a stride walk with three uint8 carried in registers, no per-pixel getRGB/setRGB/ +// the raw bytes: a stride walk with three uint8 carried in registers, no per-pixel getRGB/setRGB/ // Coord3D construction (the overhead that makes a generic-layer blur an FPS killer). Prior art: // FastLED's blur1d (Mark Kriegsman), the recognisable carryover-seep; our byte-level implementation. // @@ -256,7 +256,7 @@ inline void blurAxis(uint8_t* d, size_t cpl, size_t len, size_t stride, } } -// Blur the whole buffer by `amt`, separably along every axis with extent >1 (x, then y, then z — +// Blur the whole buffer by `amt`, separably along every axis with extent >1 (x, then y, then z): // MoonLight's blur2d order, extended to z). One call covers 1D/2D/3D. Off the per-pixel-effect path. inline void blur(Buffer& buf, Coord3D dims, uint8_t amt) { if (amt == 0) return; @@ -269,7 +269,7 @@ inline void blur(Buffer& buf, Coord3D dims, uint8_t amt) { if (static_cast(w * h * z) * cpl > buf.bytes()) return; // dims/buffer mismatch guard // x-pass: each (y,z) line is `w` pixels, stride cpl; lines start every w·cpl bytes, h·z of them. blurAxis(d, cpl, w, cpl, h * z, w * cpl, amt); - // y-pass: each (x,z) line is `h` pixels, stride w·cpl. Lines: for each z, the w columns — start + // y-pass: each (x,z) line is `h` pixels, stride w·cpl. Lines: for each z, the w columns: start // offsets are z·(h·w·cpl) + x·cpl. Walk them as one run of (w·z) lines stepping by cpl, but the // z blocks aren't contiguous in column-start, so loop z outside. for (size_t zz = 0; zz < z; zz++) @@ -303,7 +303,7 @@ inline void glyph(Buffer& buf, Coord3D dims, const fonts::Font& font, char ch, l const uint8_t bits = rows[ry]; // Columns are MSB-first: the LEFTMOST glyph column (rx=0) is bit 7, the next bit 6, … so // read column rx from bit (7 - rx). (Reading (rx + 8-width) instead mirrors each glyph - // left-to-right — a 'b' renders as a 'd'.) + // left-to-right: a 'b' renders as a 'd'.) for (uint8_t rx = 0; rx < font.width; rx++) if ((bits >> (7 - rx)) & 0x01) pixel(buf, dims, {static_cast(x + rx), static_cast(y + ry), 0}, c); @@ -333,7 +333,7 @@ inline lengthType text(Buffer& buf, Coord3D dims, const fonts::Font& font, const // ---- Canvas overloads -------------------------------------------------------------------------- // The Canvas forms of the primitives above. Most carry their OWN implementation rather than // forwarding to the (Buffer&, dims) form, because `Canvas` holds a raw pointer where the older -// signatures take a Buffer&. That means a pair CAN drift, and one already did — the Canvas `blur` +// signatures take a Buffer&. That means a pair CAN drift, and one already did: the Canvas `blur` // looped its y-pass differently from the Buffer form until a reviewer caught it. `line` is the // exception: both forms share `detail::walkLine`, so its error-carry loop has exactly one home. // @@ -347,6 +347,57 @@ inline void fade(const Canvas& cv, uint8_t amt) { for (size_t i = 0; i < cv.bytes; i++) cv.data[i] = scale8(cv.data[i], keep); } +/// Decay every sample toward black by a HALF-LIFE: after `halfLifeMs`, half of it is gone. +/// +/// The framerate-independent form of `fade`, and the one to reach for on state that persists across +/// frames (a trail plane, an advected field). `fade` takes "how much to lose this frame", which +/// means the same setting is a long tail at 60 fps and an instant clear at 1200; this takes a +/// duration, so the picture is identical on any device and `dt` does the work. +/// +/// The weight is computed ONCE per call (`mm::halfLifeKeep`) and the loop is a multiply and a shift +/// per byte, so this costs what `fade` costs. +/// +/// **The 8-bit limit, measured (2026-09-04).** An 8-BIT buffer cannot hold this decay at a high +/// framerate, and no rounding rule fixes it. Decaying 200 over a 500 ms half-life in 500 ms of +/// frames, where the exact answer is 100: +/// +/// | frame | truncating | rounding | a 16-bit accumulator | +/// |---|---|---|---| +/// | 50 ms | 96 | 100 | 100 | +/// | 5 ms | 73 | 100 | 101 | +/// | 1 ms | **0** | **200** | 102 | +/// +/// Truncating loses a fraction every frame until a fast device erases the trail outright; rounding +/// puts it back every frame until the trail never decays and the effect turns solid, which is the +/// exact symptom `fade` already has. Both failures are the QUANTIZATION, not the weight: the value +/// is re-rounded to a byte hundreds of times a second. So an effect whose trail must survive at any +/// framerate keeps its plane WIDER than the layer (16 bits per channel in its own ScratchBuffer) +/// and narrows once on the way out, which is where the precision belongs. `decay` on a byte plane +/// is honest for a slow cadence (a 50 ms tick, or a half-life short enough that a frame's loss is +/// several counts) and is what the Layer's collected `fadeToBlackBy` already does. +inline void decay(const Canvas& cv, uint32_t halfLifeMs, uint32_t dtMs) { + const uint32_t keep = mm::halfLifeKeep(dtMs, halfLifeMs); + if (keep >= 65536) return; // nothing elapsed, or no half-life asked for + if (keep == 0) { std::memset(cv.data, 0, cv.bytes); return; } + for (size_t i = 0; i < cv.bytes; i++) + cv.data[i] = static_cast((static_cast(cv.data[i]) * keep) >> 16); +} + +/// The same decay over a 16-bit plane: the form a trail uses, and the one that holds at any +/// framerate (the table above). `n` is the number of 16-bit samples, not bytes. +/// +/// Separate from the Canvas form rather than a template over it, because the two are different +/// enough to be worth reading apart: this one owns no geometry, since a scratch plane is a flat +/// array its effect already knows the shape of. +inline void decay16(uint16_t* data, size_t n, uint32_t halfLifeMs, uint32_t dtMs) { + if (!data) return; + const uint32_t keep = mm::halfLifeKeep(dtMs, halfLifeMs); + if (keep >= 65536) return; + if (keep == 0) { std::memset(data, 0, n * sizeof(uint16_t)); return; } + for (size_t i = 0; i < n; i++) + data[i] = static_cast((static_cast(data[i]) * keep) >> 16); +} + /// Fill every light with one color, leaving any channel beyond RGB untouched. inline void fill(const Canvas& cv, RGB c) { if (cv.cpl == 0) return; // a 0-channel buffer has no color to write @@ -363,13 +414,13 @@ inline void blendPixel(const Canvas& cv, Coord3D p, RGB c, uint8_t amt) { pixel(cv, p, blend(cur, c, amt)); } -/// Saturating additive pixel — light adds, so this never wraps to black. +/// Saturating additive pixel: light adds, so this never wraps to black. inline void addPixel(const Canvas& cv, Coord3D p, RGB c) { const RGB cur = get(cv, p); pixel(cv, p, RGB{qadd8(cur.r, c.r), qadd8(cur.g, c.g), qadd8(cur.b, c.b)}); } -/// Separable box blur over every axis with extent > 1 — one call covers 1D/2D/3D. The axis passes +/// Separable box blur over every axis with extent > 1: one call covers 1D/2D/3D. The axis passes /// are the Buffer form's: the y-pass loops z on the OUTSIDE because a z-slice's column starts are /// not contiguous, which a single call cannot express. inline void blur(const Canvas& cv, uint8_t amt) { @@ -386,7 +437,7 @@ inline void blur(const Canvas& cv, uint8_t amt) { if (z > 1) blurAxis(cv.data, cpl, z, w * h * cpl, w * h, cpl, amt); // z } -/// A straight line a→b on a Canvas — the same 3D Bresenham the Buffer form runs, reached through +/// A straight line a→b on a Canvas: the same 3D Bresenham the Buffer form runs, reached through /// the shared walker below so the error-carry loop has exactly one home. inline void line(const Canvas& cv, Coord3D a, Coord3D b, RGB c, uint8_t shorten = 255) { detail::walkLine(a, b, shorten, [&](Coord3D p) { pixel(cv, p, c); }); @@ -396,13 +447,13 @@ inline void line(const Canvas& cv, Coord3D a, Coord3D b, RGB c, uint8_t shorten // // A field of N sources orbiting on sine paths, summed per pixel with an inverse-square falloff. // This is the standard implicit-surface primitive: anything that reads as fluid, merging, molten or -// organic is this field under a different coloring — blobs, plasma cores, glowing orbs, an audio +// organic is this field under a different coloring: blobs, plasma cores, glowing orbs, an audio // band driving a source's radius. Two effects use it today (they computed byte-identical fields // from two private copies of the loop) and differ ONLY in the coloring they apply, which is the // evidence for the seam: the field is shared, the coloring is the effect's own. // -// The oscillator tables stay with the CALLER, so a caller is free to drive sources from anything — -// a control, audio, a particle position — rather than from the sine paths the current callers use. +// The oscillator tables stay with the CALLER, so a caller is free to drive sources from anything: +// a control, audio, a particle position: rather than from the sine paths the current callers use. // A kernel that hard-coded one set of constants would have made every future effect look the same. // // Prior art: Jim Blinn's 1982 blobby model; the integer inverse-square form follows WLED's metaball @@ -415,8 +466,8 @@ struct BlobPath { uint8_t phaseY; }; -/// Evaluate blob centres for this frame into `outX`/`outY` (caller-sized to `count`). -inline void blobCentres(const BlobPath* paths, uint8_t count, uint8_t t, lengthType w, lengthType h, +/// Evaluate blob centers for this frame into `outX`/`outY` (caller-sized to `count`). +inline void blobCenters(const BlobPath* paths, uint8_t count, uint8_t t, lengthType w, lengthType h, int16_t* outX, int16_t* outY) { for (uint8_t b = 0; b < count; b++) { const uint8_t tb = static_cast(t * paths[b].speedMul); @@ -430,7 +481,7 @@ inline void blobCentres(const BlobPath* paths, uint8_t count, uint8_t t, lengthT /// caller passing a 24.8 sub-pixel radius squared would overflow. /// /// The field at one pixel: the sum over blobs of r²·64 / (d² + 1). The `+1` keeps a pixel sitting -/// exactly on a centre from dividing by zero, and the ·64 holds precision in the integer divide. +/// exactly on a center from dividing by zero, and the ·64 holds precision in the integer divide. /// Returns the raw sum; the caller decides how it maps to color, and how it clamps. inline uint32_t blobField(lengthType x, lengthType y, const int16_t* bx, const int16_t* by, uint8_t count, int32_t r2) { @@ -448,12 +499,12 @@ inline uint32_t blobField(lengthType x, lengthType y, const int16_t* bx, const i // // A shift register over the whole grid: every light moves `delta` steps along one axis, and the // vacated edge is left dark (the caller paints the new content into it). FreqMatrix hand-rolled this -// as a per-pixel copy loop reading each neighbour through get()/pixel(); the same move is one +// as a per-pixel copy loop reading each neighbor through get()/pixel(); the same move is one // `memmove` per contiguous run, because the addressing rule puts x adjacent in memory and a whole // row adjacent along y. // -// `wrap` chooses between the two useful behaviours: a shift register (false — content falls off the -// end and the far edge goes dark) and a loop (true — content that leaves one edge re-enters the +// `wrap` chooses between the two useful behaviors: a shift register (false: content falls off the +// end and the far edge goes dark) and a loop (true: content that leaves one edge re-enters the // other), which is the marquee/tunnel idiom. Prior art: WLED's `move()` and the classic shift // register; the wrapping form is the standard scrolling-texture primitive. @@ -468,9 +519,9 @@ inline void scroll(const Canvas& cv, uint8_t axis, int delta, bool wrap = false) // this BEFORE reducing modulo the extent, which would turn it into a no-op.) if (!wrap && (delta >= extent || delta <= -extent)) { fill(cv, RGB{0, 0, 0}); return; } - // Wrapping reduces into the axis and normalises to a positive rotation, since rotating left by n + // Wrapping reduces into the axis and normalizes to a positive rotation, since rotating left by n // equals rotating right by extent-n. A shift keeps its sign: the direction decides which end - // goes dark, so it cannot be normalised away. + // goes dark, so it cannot be normalized away. int shift = delta % extent; if (shift == 0) return; // a full turn when wrapping; nothing to do if (wrap && shift < 0) shift += extent; @@ -480,18 +531,27 @@ inline void scroll(const Canvas& cv, uint8_t axis, int delta, bool wrap = false) const size_t cpl = cv.cpl; const size_t rowBytes = static_cast(cv.dims.x) * cpl; const size_t sliceBytes = static_cast(cv.dims.y) * rowBytes; - size_t step = 0, lineCount = 0, lineStride = 0; + // + // A line's base is `outer * outerStride + inner * innerStride`, two nested counts rather than + // one: along Y the lines are the (z, x) columns, and those are NOT evenly spaced by a single + // stride (x steps by cpl within a slice, z steps by a whole slice). Collapsing them to one + // counter scrolled the first column of each slice and left every other column standing. + size_t step = 0, outer = 0, outerStride = 0, inner = 1, innerStride = 0; switch (axis) { - case 0: step = cpl; lineCount = static_cast(cv.dims.y) * cv.dims.z; lineStride = rowBytes; break; - case 1: step = rowBytes; lineCount = cv.dims.z; lineStride = sliceBytes; break; - default: step = sliceBytes; lineCount = 1; lineStride = 0; break; + case 0: step = cpl; outer = static_cast(cv.dims.y) * cv.dims.z; outerStride = rowBytes; + inner = 1; innerStride = 0; break; + case 1: step = rowBytes; outer = cv.dims.z; outerStride = sliceBytes; + inner = cv.dims.x; innerStride = cpl; break; + default: step = sliceBytes; outer = cv.dims.y; outerStride = rowBytes; + inner = cv.dims.x; innerStride = cpl; break; } + const size_t lineCount = outer * inner; // One cell of scratch is enough for the wrapping case if we rotate in place, but a rotation by // an arbitrary amount needs somewhere to hold the part that wraps around. The largest run we // ever hold is one line, and a line is at most the grid's longest axis. for (size_t line = 0; line < lineCount; line++) { - uint8_t* base = cv.data + line * lineStride; + uint8_t* base = cv.data + (line / inner) * outerStride + (line % inner) * innerStride; // Skip a line that would read past the buffer rather than abandoning the whole scroll: a // dims/buffer mismatch should cost that line, not leave every later line unscrolled. if (base + static_cast(extent - 1) * step + cpl > cv.data + cv.bytes) continue; @@ -520,7 +580,7 @@ inline void scroll(const Canvas& cv, uint8_t axis, int delta, bool wrap = false) // shift does not overwrite a source it has yet to read. if (wrap) { // Rotate by three reversals, the same trick the contiguous path above uses. No - // scratch buffer, so a light of ANY channel count rotates whole — a fixed-size + // scratch buffer, so a light of ANY channel count rotates whole: a fixed-size // temporary silently truncated the extra channels of a wide fixture, and there is // no ceiling worth guessing at here. It is also O(extent) instead of // O(shift x extent): a 64-row column scrolled by 30 was doing 1920 cell copies. @@ -559,7 +619,7 @@ inline void scroll(const Canvas& cv, uint8_t axis, int delta, bool wrap = false) // --- Rectangles and bars ----------------------------------------------------------------------- // // `bar` is the audio-meter staple: a run of `len` cells growing from an origin along one axis. Four -// effects hand-rolled it, and their loops disagreed on everything that matters — which end is the +// effects hand-rolled it, and their loops disagreed on everything that matters: which end is the // floor, whether the color varies along the run, and what happens when the run overshoots the grid. // // The color is a CALLBACK rather than a single RGB because that is what the call sites actually do: @@ -571,11 +631,11 @@ inline void scroll(const Canvas& cv, uint8_t axis, int delta, bool wrap = false) // // **What a MoonLive script gets, stated because the two forms are NOT equivalent.** A script calls // builtins through a table that carries plain scalars (`core/moonlive/MoonLiveBuiltins.h`), and the -// grammar is a function-call statement with expression arguments — no loops, no closures. So a +// grammar is a function-call statement with expression arguments: no loops, no closures. So a // script can reach the flat form, `bar(x, y, len, dir, r, g, b)`, and gets a SOLID bar. The -// per-cell gradient GEQ draws is not expressible that way, and a colour-callback builtin cannot be +// per-cell gradient GEQ draws is not expressible that way, and a color-callback builtin cannot be // registered in that table at all. Closing that gap needs a scalar-shaped primitive a script can -// call — a gradient bar taking two endpoint colours, or a palette-ramp variant — which is a +// call: a gradient bar taking two endpoint colors, or a palette-ramp variant: which is a // deliberate addition, not something the callback form provides for free. Tracked in the // power-function plan rather than assumed away here. @@ -637,7 +697,7 @@ inline void rect(const Canvas& cv, lengthType x, lengthType y, lengthType w, len // --- Circles --------------------------------------------------------------------------------- // // Two implementations, deliberately, because they answer different questions. `circle`/`fillCircle` -// take INTEGER pixel coordinates and use Bresenham's midpoint algorithm — no multiply per pixel, no +// take INTEGER pixel coordinates and use Bresenham's midpoint algorithm: no multiply per pixel, no // distance, exactly the cells on the rim. That is the right tool when a shape sits on the grid. // // The SDF forms above (`sdCircle` + `coverage`) are the right tool when the circle MOVES: they give @@ -670,7 +730,7 @@ inline void circle(const Canvas& cv, lengthType cx, lengthType cy, lengthType r, } /// A filled disc: the same midpoint walk, drawing a horizontal span per scanline instead of points, -/// colored per row by `colorAt(dyFromCenter)` — the signed row offset, so a caller can ramp a +/// colored per row by `colorAt(dyFromCenter)`: the signed row offset, so a caller can ramp a /// gradient across the disc. template inline void fillCircle(const Canvas& cv, lengthType cx, lengthType cy, lengthType r, ColorFn colorAt) { @@ -702,7 +762,7 @@ inline void fillCircle(const Canvas& cv, lengthType cx, lengthType cy, lengthTyp // // Wu's 1991 algorithm: where Bresenham picks ONE cell per step, Wu lights the two cells straddling // the true line and splits the intensity between them by distance. The result is a line without -// staircase edges, at roughly twice the writes. `line` (Bresenham, above) stays the default — this +// staircase edges, at roughly twice the writes. `line` (Bresenham, above) stays the default: this // is for the cases where the stair-stepping is the thing you notice. // // The blend is additive so a line crossing existing content brightens rather than replaces it, @@ -787,7 +847,7 @@ inline void sprite(const Canvas& cv, const sprites::Sprite& s, uint8_t frame, } } -/// Blit one glyph on a Canvas — the Canvas form of `glyph`. Same MSB-first column order and the +/// Blit one glyph on a Canvas: the Canvas form of `glyph`. Same MSB-first column order and the /// same clipping; only the pixel writer differs. inline void glyph(const Canvas& cv, const fonts::Font& font, char ch, lengthType x, lengthType y, RGB c) { if (ch < 32 || ch > 126) return; @@ -801,7 +861,7 @@ inline void glyph(const Canvas& cv, const fonts::Font& font, char ch, lengthType } } -/// Draw a NUL-terminated string on a Canvas — the Canvas form of `text`. Returns the first line's +/// Draw a NUL-terminated string on a Canvas: the Canvas form of `text`. Returns the first line's /// pixel width, as the Buffer form does. inline lengthType text(const Canvas& cv, const fonts::Font& font, const char* str, lengthType x, lengthType y, RGB c) { @@ -821,14 +881,14 @@ inline lengthType text(const Canvas& cv, const fonts::Font& font, const char* st } /// Byte offset of a coordinate on a Canvas, or `bytes` when it is outside the grid. The Canvas form -/// of `offsetOf` — for effects that need the raw index (e.g. to touch a W channel `pixel` leaves +/// of `offsetOf`: for effects that need the raw index (e.g. to touch a W channel `pixel` leaves /// alone). Delegates to Canvas::offsetOf so the addressing rule keeps one home. inline size_t offsetOf(const Canvas& cv, Coord3D p) { return cv.offsetOf(p); } // ---- Sub-pixel positioning ---------------------------------------------------------------------- /// A position in **24.8 fixed point**: one pixel = 256 sub-units, so `x >> 8` is the pixel and the -/// low byte is the fraction within it. int32 covers ±8 million pixels — a 16K-light strip has room +/// low byte is the fraction within it. int32 covers ±8 million pixels: a 16K-light strip has room /// to spare, where an int16 sub-pixel type (WLED-PS uses 10.6) runs out at ±512. using pos_t = int32_t; @@ -840,29 +900,29 @@ inline constexpr int32_t kSubOne = 1 << kSubShift; inline constexpr pos_t toSub(lengthType px) { return static_cast(px) << kSubShift; } /// Decode a sub-pixel coordinate to the pixel that contains it. Uses an arithmetic shift, which -/// floors toward negative infinity — the behaviour a grid wants, so -0.5 lands in pixel -1 rather +/// floors toward negative infinity: the behavior a grid wants, so -0.5 lands in pixel -1 rather /// than being pulled to 0 and doubling up on the boundary. inline constexpr lengthType toPixel(pos_t sub) { return static_cast(sub >> kSubShift); } -/// Draw a point at a FRACTIONAL position, spreading its light across the neighbouring pixels by +/// Draw a point at a FRACTIONAL position, spreading its light across the neighboring pixels by /// how much of each it covers (Xiaolin Wu, SIGGRAPH 1991; the same weighting WLED's `wu_pixel` and /// its particle renderer use). /// /// Why it matters: a whole-pixel write makes a moving point jump from cell to cell, which on a -/// coarse matrix reads as stepping. Splitting the light between neighbours by coverage lets the eye +/// coarse matrix reads as stepping. Splitting the light between neighbors by coverage lets the eye /// see it *between* pixels, so a 16x16 panel gains apparent resolution. This is the difference the /// canon survey ranks as the single highest-leverage primitive for motion. /// /// Additive with saturation, because light adds: two points landing on one pixel brighten it rather -/// than one overwriting the other. Weights are computed once per axis and are exact — the four +/// than one overwriting the other. Weights are computed once per axis and are exact: the four /// corner weights sum to 256, so a point contributes exactly its own brightness, no more. /// -/// 1D/2D/3D: a degenerate axis (extent 1) contributes no second neighbour, so the same call is a +/// 1D/2D/3D: a degenerate axis (extent 1) contributes no second neighbor, so the same call is a /// 2-pixel blend on a strand, 4 on a matrix, 8 in a volume. inline void splat(const Canvas& cv, pos_t x, pos_t y, pos_t z, RGB c) { const lengthType px = toPixel(x), py = toPixel(y), pz = toPixel(z); // Fraction within the pixel, 0..255. Masking (rather than subtracting) is correct for negatives - // too: -1.25 px has fraction 0.75 relative to pixel -2, which is what the neighbour weighting + // too: -1.25 px has fraction 0.75 relative to pixel -2, which is what the neighbor weighting // needs. const uint16_t fx = static_cast(x & (kSubOne - 1)); const uint16_t fy = static_cast(y & (kSubOne - 1)); @@ -880,7 +940,7 @@ inline void splat(const Canvas& cv, pos_t x, pos_t y, pos_t z, RGB c) { const uint32_t wy = dy ? fy : (kSubOne - fy); const uint32_t wz = dz ? fz : (kSubOne - fz); // Weight is the covered fraction of this corner: the product of the per-axis coverages, - // normalised back to 0..255 (>>16 for the two extra 8-bit factors). + // normalized back to 0..255 (>>16 for the two extra 8-bit factors). const uint32_t w = (wx * wy * wz) >> (2 * kSubShift); if (w == 0) continue; @@ -900,7 +960,7 @@ inline void splat(const Canvas& cv, pos_t x, pos_t y, RGB c) { splat(cv, x, y, 0 // // Everything above WRITES. These read, which is the other half of the vocabulary and the one the // canon survey found missing: once a grid can be sampled at an arbitrary sub-pixel coordinate, a -// whole family follows from three lines each — feedback, tunnels, zoom, rotation, plasma warp, +// whole family follows from three lines each: feedback, tunnels, zoom, rotation, plasma warp, // motion trails. Without it, each of those needs its own bespoke loop. // // `sampleWrap` takes SUB-PIXEL coordinates (the same 24.8 `pos_t` as splat) and returns a bilinear @@ -932,7 +992,121 @@ inline RGB sampleWrap(const Canvas& cv, pos_t x, pos_t y, lengthType z = 0) { mix(mix(c00.b, c10.b, fx), mix(c01.b, c11.b, fx), fy)}; } -/// Combine two colors by taking the brighter channel — the "screen"/max operator feedback chains +/// How a sample outside the grid is read: wrapped around, or held at the edge. +enum class Edge : uint8_t { + Wrap, ///< the far side comes back around: seamless for a scroll, a tunnel, a torus flow + Clamp, ///< the edge pixel repeats: what a flow leaving the grid should smear against +}; + +/// Bilinear sample at a sub-pixel coordinate, holding the edge pixel outside the grid. +/// +/// The Clamp half of `sampleWrap`. A flow that carries pixels off one side must not have them +/// reappear on the other, which is what Wrap does and what makes it wrong for a wind: the trail +/// would loop the panel instead of leaving it. +inline RGB sampleClamp(const Canvas& cv, pos_t x, pos_t y, lengthType z = 0) { + if (cv.dims.x <= 0 || cv.dims.y <= 0) return RGB{0, 0, 0}; + const int32_t x0 = toPixel(x), y0 = toPixel(y); + const uint8_t fx = static_cast(x & (kSubOne - 1)); + const uint8_t fy = static_cast(y & (kSubOne - 1)); + const auto clamp = [](int32_t v, lengthType n) { + return static_cast(v < 0 ? 0 : (v >= n ? n - 1 : v)); + }; + const lengthType xa = clamp(x0, cv.dims.x), xb = clamp(x0 + 1, cv.dims.x); + const lengthType ya = clamp(y0, cv.dims.y), yb = clamp(y0 + 1, cv.dims.y); + const RGB c00 = get(cv, {xa, ya, z}), c10 = get(cv, {xb, ya, z}); + const RGB c01 = get(cv, {xa, yb, z}), c11 = get(cv, {xb, yb, z}); + const auto mix = [](uint8_t a, uint8_t b, uint8_t tt) { + return static_cast(a + (((static_cast(b) - a) * tt) >> 8)); + }; + return RGB{mix(mix(c00.r, c10.r, fx), mix(c01.r, c11.r, fx), fy), + mix(mix(c00.g, c10.g, fx), mix(c01.g, c11.g, fx), fy), + mix(mix(c00.b, c10.b, fx), mix(c01.b, c11.b, fx), fy)}; +} + +/// Sample under either edge rule, so a caller carries the choice as data rather than a branch. +inline RGB sampleEdge(const Canvas& cv, pos_t x, pos_t y, Edge edge, lengthType z = 0) { + return edge == Edge::Wrap ? sampleWrap(cv, x, y, z) : sampleClamp(cv, x, y, z); +} + +/// Move every pixel of `src` along a velocity field and write the result into `dst`. +/// +/// Advection: the transport half of a flow, and the primitive a trail is made of. `rule` answers, +/// for a pixel, which way and how fast the medium is moving there; this walks BACKWARD along that +/// velocity and samples where the pixel must have come from, which is the standard stable form +/// (Stam, "Stable Fluids", SIGGRAPH 1999). Going backward rather than forward is what keeps it from +/// tearing: every destination pixel is written exactly once, so no gaps open where the field +/// diverges and nothing is written twice where it converges. +/// +/// `dst` and `src` MUST be different planes. Reading and writing one buffer would sample pixels the +/// same pass had already moved, which smears along the walk order rather than along the flow: the +/// caller keeps a scratch plane and swaps, the ping-pong an effect owns. +/// +/// The rule is called once per pixel and returns sub-pixel units per frame, so a velocity already +/// carries the frame's dt: the caller scales it, since only the caller knows the cadence. +template +inline void advect(const Canvas& dst, const Canvas& src, Rule&& rule, Edge edge = Edge::Wrap) { + if (!dst.data || !src.data) return; + if (dst.dims.x != src.dims.x || dst.dims.y != src.dims.y) return; // two shapes, no meaning + for (lengthType z = 0; z < dst.dims.z; z++) { + for (lengthType y = 0; y < dst.dims.y; y++) { + for (lengthType x = 0; x < dst.dims.x; x++) { + pos_t vx = 0, vy = 0; + rule(x, y, z, vx, vy); + // BACKWARD: where did what is here now come from? Hence the subtraction. + const pos_t sx = toSub(x) - vx; + const pos_t sy = toSub(y) - vy; + pixel(dst, {x, y, z}, sampleEdge(src, sx, sy, edge, z)); + } + } + } +} + +/// Advect a 16-BIT plane: the form a trail uses, and the reason it survives. +/// +/// The Canvas vocabulary is 8-bit throughout (`get`, `pixel` and `sampleWrap` all speak `RGB`), so a +/// wide plane cannot borrow it: a Canvas over 16-bit data would read the high and low halves of one +/// channel as two different colors. This is the same walk against `uint16_t` samples, three per +/// light, with the bilinear blend done at full width so the transport does not quantize what the +/// decay is about to multiply. `n` is samples, `w`/`h`/`d` the geometry they are laid out in. +/// +/// Edge::Clamp holds the border sample; Edge::Wrap brings the far side around. +template +inline void advect16(uint16_t* dst, const uint16_t* src, lengthType w, lengthType h, lengthType d, + Rule&& rule, Edge edge = Edge::Wrap) { + if (!dst || !src || w <= 0 || h <= 0 || d <= 0) return; + const auto fold = [&](int32_t v, lengthType n) -> lengthType { + if (edge == Edge::Wrap) { const int32_t m = v % n; return static_cast(m < 0 ? m + n : m); } + return static_cast(v < 0 ? 0 : (v >= n ? n - 1 : v)); + }; + for (lengthType z = 0; z < d; z++) { + for (lengthType y = 0; y < h; y++) { + for (lengthType x = 0; x < w; x++) { + pos_t vx = 0, vy = 0; + rule(x, y, z, vx, vy); + const pos_t sx = toSub(x) - vx, sy = toSub(y) - vy; // BACKWARD, as advect() + const int32_t x0 = toPixel(sx), y0 = toPixel(sy); + const uint32_t fx = static_cast(sx & (kSubOne - 1)); + const uint32_t fy = static_cast(sy & (kSubOne - 1)); + const lengthType xa = fold(x0, w), xb = fold(x0 + 1, w); + const lengthType ya = fold(y0, h), yb = fold(y0 + 1, h); + const size_t slice = static_cast(z) * h * w; + const size_t i00 = (slice + static_cast(ya) * w + xa) * 3; + const size_t i10 = (slice + static_cast(ya) * w + xb) * 3; + const size_t i01 = (slice + static_cast(yb) * w + xa) * 3; + const size_t i11 = (slice + static_cast(yb) * w + xb) * 3; + const size_t o = (slice + static_cast(y) * w + x) * 3; + for (uint8_t c = 0; c < 3; c++) { + // Two lerps at full width: the top edge, the bottom edge, then between them. + const uint32_t a = src[i00 + c] + (((static_cast(src[i10 + c]) - src[i00 + c]) * static_cast(fx)) >> kSubShift); + const uint32_t b = src[i01 + c] + (((static_cast(src[i11 + c]) - src[i01 + c]) * static_cast(fx)) >> kSubShift); + dst[o + c] = static_cast(a + (((static_cast(b) - static_cast(a)) * static_cast(fy)) >> kSubShift)); + } + } + } + } +} + +/// Combine two colors by taking the brighter channel: the "screen"/max operator feedback chains /// use so a trail brightens rather than averaging away. inline RGB combineMax(RGB a, RGB b) { return RGB{a.r > b.r ? a.r : b.r, a.g > b.g ? a.g : b.g, a.b > b.b ? a.b : b.b}; @@ -941,13 +1115,13 @@ inline RGB combineMax(RGB a, RGB b) { // ---- Signed distance fields -------------------------------------------------------------------- // -// An SDF answers "how far is this point from the shape's edge?" — negative inside, zero on the edge, +// An SDF answers "how far is this point from the shape's edge?": negative inside, zero on the edge, // positive outside (Iñigo Quilez's 2D/3D distance-function catalog is the reference). One number // then gives a filled shape, an anti-aliased edge, an outline (`|d| - width`), a glow (a falloff of // d), and a smooth blend between shapes (`smin`), instead of a separate routine for each. // // It is also where dimension-generic stops being a slogan: `length(p) - r` is two points on a -// strand, a circle on a matrix and a sphere in a volume — the SAME code, because only the length +// strand, a circle on a matrix and a sphere in a volume: the SAME code, because only the length // changes. A rasteriser needs a different algorithm per dimension. // // **Distances are in sub-pixel units (pos_t, 24.8)**, so a shape can move and grow smoothly rather @@ -958,8 +1132,8 @@ inline RGB combineMax(RGB a, RGB b) { // shape therefore has a `*Sq` variant that answers "inside/outside and by how much, squared", and // effects should reach for it unless they need a real distance (outline width, linear falloff). -/// Squared distance from a point to a circle's centre, minus the squared radius. Negative inside, -/// zero on the rim, positive outside — the sign and the ordering match the true SDF, so a threshold +/// Squared distance from a point to a circle's center, minus the squared radius. Negative inside, +/// zero on the rim, positive outside: the sign and the ordering match the true SDF, so a threshold /// test behaves identically without paying for a square root. inline int32_t sdCircleSq(pos_t px, pos_t py, pos_t cx, pos_t cy, pos_t r) { // Work in whole sub-units squared; int64 because a 24.8 coordinate squared overflows int32 on a @@ -975,7 +1149,7 @@ inline int32_t sdCircleSq(pos_t px, pos_t py, pos_t cx, pos_t cy, pos_t r) { return static_cast(diff); } -/// True signed distance to a circle's edge, in sub-pixel units. Costs a square root — use +/// True signed distance to a circle's edge, in sub-pixel units. Costs a square root: use /// `sdCircleSq` unless the actual distance is needed (an outline of a given width, a linear glow). inline int32_t sdCircle(pos_t px, pos_t py, pos_t cx, pos_t cy, pos_t r) { const int64_t dx = px - cx, dy = py - cy; @@ -984,7 +1158,7 @@ inline int32_t sdCircle(pos_t px, pos_t py, pos_t cx, pos_t cy, pos_t r) { return static_cast(d) - r; } -/// Signed distance to an axis-aligned box centred at (cx, cy) with half-extents (bx, by). +/// Signed distance to an axis-aligned box centered at (cx, cy) with half-extents (bx, by). /// /// Outside, this is the Chebyshev distance (the larger axis overshoot) rather than the Euclidean /// one: it is exact along the faces, differs only near the corners, and costs no square root. For a @@ -1001,7 +1175,7 @@ inline int32_t sdBox(pos_t px, pos_t py, pos_t cx, pos_t cy, pos_t bx, pos_t by) return qx > qy ? qx : qy; } -/// Signed distance to a line segment a→b, minus `thickness` — a capsule, which is what a drawn line +/// Signed distance to a line segment a→b, minus `thickness`: a capsule, which is what a drawn line /// with soft edges actually is. Projects the point onto the segment, clamps to its ends, then /// measures. One square root; the projection itself is integer. inline int32_t sdSegment(pos_t px, pos_t py, pos_t ax, pos_t ay, pos_t bx, pos_t by, pos_t thickness) { @@ -1023,12 +1197,12 @@ inline int32_t sdSegment(pos_t px, pos_t py, pos_t ax, pos_t ay, pos_t bx, pos_t return static_cast(d) - thickness; } -/// Smooth minimum of two distances — the operator that makes two shapes flow into each other rather +/// Smooth minimum of two distances: the operator that makes two shapes flow into each other rather /// than simply overlapping (Quilez, "smooth minimum"). `k` is the blend radius in sub-pixel units: /// 0 gives a hard union (a plain `min`), larger values a longer merge. /// /// This is metaballs generalised: the classic inverse-square blob field is one look, whereas `smin` -/// blends ANY pair of shapes — a circle into a box, a segment into a circle. +/// blends ANY pair of shapes: a circle into a box, a segment into a circle. inline int32_t smin(int32_t a, int32_t b, int32_t k) { if (k <= 0) return a < b ? a : b; // Polynomial smooth min: h = clamp(0.5 + 0.5*(b-a)/k), mix(b, a, h) - k*h*(1-h). @@ -1047,7 +1221,7 @@ inline int32_t smin(int32_t a, int32_t b, int32_t k) { } /// Turn a signed distance into coverage: 255 well inside, 0 well outside, a ramp across the edge. -/// This is the anti-aliasing an SDF gives for free — the pixel is lit in proportion to how much of +/// This is the anti-aliasing an SDF gives for free: the pixel is lit in proportion to how much of /// it the shape covers, so an edge reads as smooth instead of stepped. /// /// The ramp is one pixel wide by default, which matches how much area a boundary actually crosses. @@ -1059,4 +1233,313 @@ inline uint8_t coverage(int32_t d, pos_t edge = kSubOne) { return static_cast(((edge - d) * 255) / (2 * edge)); } + +// --- Filled shapes with soft edges ------------------------------------------------------------- +// +// `circle` and `fillCircle` above are Bresenham: whole pixels, hard edges, the right answer for a +// ring outline. These are the SDF forms, which take sub-pixel centers and radii and shade the +// boundary by coverage, so a small disc can sit between pixels and a growing one does not jump a +// whole cell at a time. On a 16x16 panel that difference is the whole difference between a blob +// that moves and a blob that stutters. + +/// A filled disc with an anti-aliased edge, additive so overlapping discs brighten. +/// +/// Coverage per pixel from the signed distance, over the bounding box only: a disc of radius r +/// touches (2r+2)^2 pixels however large the grid is, so this costs the shape rather than the frame. +inline void disc(const Canvas& cv, pos_t cx, pos_t cy, pos_t r, RGB c, lengthType z = 0) { + if (r <= 0) return; + const lengthType x0 = toPixel(cx - r) - 1, x1 = toPixel(cx + r) + 1; + const lengthType y0 = toPixel(cy - r) - 1, y1 = toPixel(cy + r) + 1; + for (lengthType y = y0; y <= y1; y++) { + for (lengthType x = x0; x <= x1; x++) { + // The pixel's CENTER against the edge: sampling the corner biases the shape half a + // pixel up and left, which shows as a disc that drifts as it grows. + const int32_t d = sdCircle(toSub(x) + kSubOne / 2, toSub(y) + kSubOne / 2, cx, cy, r); + const uint8_t cov = coverage(d); + if (cov == 0) continue; + addPixel(cv, {x, y, z}, RGB{scale8(c.r, cov), scale8(c.g, cov), scale8(c.b, cov)}); + } + } +} + +/// The volumetric form: a filled sphere, shaded the same way. +/// +/// Separate rather than a defaulted depth on `disc`, because the cost differs by an order: a sphere +/// touches (2r+2)^3 pixels. A caller on a panel should get the cheap one without thinking about it. +inline void sphere(const Canvas& cv, pos_t cx, pos_t cy, pos_t cz, pos_t r, RGB c) { + if (r <= 0) return; + const lengthType x0 = toPixel(cx - r) - 1, x1 = toPixel(cx + r) + 1; + const lengthType y0 = toPixel(cy - r) - 1, y1 = toPixel(cy + r) + 1; + const lengthType z0 = toPixel(cz - r) - 1, z1 = toPixel(cz + r) + 1; + for (lengthType z = z0; z <= z1; z++) { + for (lengthType y = y0; y <= y1; y++) { + for (lengthType x = x0; x <= x1; x++) { + const int64_t dx = toSub(x) + kSubOne / 2 - cx; + const int64_t dy = toSub(y) + kSubOne / 2 - cy; + const int64_t dz = toSub(z) + kSubOne / 2 - cz; + const uint64_t d2 = static_cast(dx * dx + dy * dy + dz * dz); + const uint32_t dist = isqrt(static_cast(d2 > UINT32_MAX ? UINT32_MAX : d2)); + const uint8_t cov = coverage(static_cast(dist) - r); + if (cov == 0) continue; + addPixel(cv, {x, y, z}, RGB{scale8(c.r, cov), scale8(c.g, cov), scale8(c.b, cov)}); + } + } + } +} + +// --- Rendering below the output resolution ----------------------------------------------------- +// +// The cost of a field effect is per light, so the cheapest way to afford one on a large fixture is +// to compute FEWER lights and interpolate the rest. A field is smooth by construction, which is +// exactly the property that makes this nearly free visually: at half resolution a noise field +// carries a quarter of the samples and the eye cannot tell, because the values in between were +// always going to be close to their neighbors. +// +// This is the `fieldScale` lever. Its companion is `fieldRate`, which is not a primitive: an effect +// updates its field every N frames and lets the oscillators advance every frame, so the motion +// stays smooth while the expensive part runs less often. +// +// **What it is worth depends on the field's cost per sample, and only that.** The stretch itself is +// a fixed price per OUTPUT light (measured at 1.55 ns a sample, which is the bilinear arithmetic's +// own floor), so it saves nothing on a field that was already cheap. Measured on a 64x64 layer: +// +// | field | full | half | quarter | +// |---|---|---|---| +// | 2-octave fbm (1 noise sample) | 59 us | 34 us (1.7x) | 23 us (2.6x) | +// | curl (4 noise samples) | 212 us | 71 us (3.0x) | 32 us (6.6x) | +// +// So this is a lever for the expensive fields, the ones an S3 cannot otherwise afford, and a +// caller reaching for it on a cheap one is paying the stretch for almost nothing. + +/// One destination column's blend: the two source columns and the weight between them. +struct UpscaleTap { uint16_t a, b; uint16_t w; }; + +/// Bilinearly stretch a smaller 16-bit plane over a larger one. +/// +/// `src` is `sw x sh x sd` samples, three per light; `dst` is `dw x dh x dd`. Both are the effect's +/// own planes, so this takes raw pointers rather than a Canvas: a field at half resolution is not a +/// fixture and has no channel count of its own. +/// +/// The z axis is interpolated too when both planes have depth, so a volumetric field can be +/// computed on a coarse cube. A plane with `sd == 1` is stretched flat across the destination's +/// depth instead, which is what a 2D field on a 3D fixture wants and costs no z work at all. +/// +/// `taps` is the caller's scratch, `dw` entries: the per-column blend table, hoisted out of the +/// inner loop. It belongs to the caller because this runs from tick(), where neither the heap nor +/// a large frame is available. An earlier version held it as a 4096-entry local, which is 24 KB on +/// a stack the ESP32 gives 12 KB (CONFIG_ESP_MAIN_TASK_STACK_SIZE), so the first stretched frame +/// would have overflowed it. Pass fewer than `dw` entries and the call does nothing. +inline void upscale16(uint16_t* dst, lengthType dw, lengthType dh, lengthType dd, + const uint16_t* src, lengthType sw, lengthType sh, lengthType sd, + UpscaleTap* taps, size_t tapCount) { + if (!dst || !src || dw <= 0 || dh <= 0 || dd <= 0 || sw <= 0 || sh <= 0 || sd <= 0) return; + if (!taps || tapCount < static_cast(dw)) return; + // Map destination center to source in 16.16, so the edges land on the edges rather than + // drifting half a cell: (d + 0.5) * s / D - 0.5, the standard alignment. + // + // The mapping is computed ONCE per axis, not per pixel: it needs a 64-bit divide by a runtime + // extent, and doing that three times per output sample made the upscale cost more than the + // field it was meant to save (measured: a flat 22 us against a field that dropped from 59 to + // 15). The x row is a small table, y and z are stepped, so the inner loop is adds and shifts. + const auto axis = [](lengthType d, lengthType dn, lengthType sn) -> int32_t { + if (dn <= 1) return 0; + const int64_t num = (static_cast(d) * 2 + 1) * sn - dn; + return static_cast((num << 15) / dn); // 16.16, may be negative at the edge + }; + const auto clamp = [](int32_t v, lengthType n) -> size_t { + return static_cast(v < 0 ? 0 : (v >= n ? n - 1 : v)); + }; + // One entry per destination column: the two source columns it blends and the weight between. + UpscaleTap* row = taps; + for (lengthType x = 0; x < dw; x++) { + const int32_t fx = axis(x, dw, sw); + row[x] = {static_cast(clamp(fx >> 16, sw)), + static_cast(clamp((fx >> 16) + 1, sw)), + static_cast(fx & 0xFFFF)}; + } + const bool interpZ = sd > 1; + for (lengthType z = 0; z < dd; z++) { + const int32_t fz = interpZ ? axis(z, dd, sd) : 0; + const int32_t z0 = fz >> 16; + const uint32_t wz = static_cast(fz & 0xFFFF); + const size_t za = clamp(z0, sd), zb = interpZ ? clamp(z0 + 1, sd) : za; + for (lengthType y = 0; y < dh; y++) { + const int32_t fy = axis(y, dh, sh); + const int32_t y0 = fy >> 16; + const uint32_t wy = static_cast(fy & 0xFFFF); + const size_t ya = clamp(y0, sh), yb = clamp(y0 + 1, sh); + for (lengthType x = 0; x < dw; x++) { + const UpscaleTap tap = row[x]; + const uint32_t wx = tap.w; + const size_t xa = tap.a, xb = tap.b; + const size_t slA = za * sh * sw, slB = zb * sh * sw; + const size_t o = ((static_cast(z) * dh + y) * dw + x) * 3; + for (uint8_t c = 0; c < 3; c++) { + const auto at = [&](size_t sl, size_t yy, size_t xx) -> uint32_t { + return src[(sl + yy * sw + xx) * 3 + c]; + }; + // SIGNED difference: `b - a` on unsigned wraps whenever the field descends, + // and a wrapped row blended against an unwrapped one lands far outside the + // input range (a 2x2 saddle of 0 and 100 produced 98354). A noise field is a + // saddle almost everywhere, so this lit whole cells at full brightness. + const auto lerp = [](uint32_t a, uint32_t b, uint32_t w) -> uint32_t { + return static_cast(static_cast(a) + + (((static_cast(b) - static_cast(a)) + * static_cast(w)) >> 16)); + }; + // Two rows of the near slice, then the far one, then between them. + const uint32_t n0 = lerp(at(slA, ya, xa), at(slA, ya, xb), wx); + const uint32_t n1 = lerp(at(slA, yb, xa), at(slA, yb, xb), wx); + uint32_t v = lerp(n0, n1, wy); + if (interpZ) { + const uint32_t f0 = lerp(at(slB, ya, xa), at(slB, ya, xb), wx); + const uint32_t f1 = lerp(at(slB, yb, xa), at(slB, yb, xb), wx); + v = lerp(v, lerp(f0, f1, wy), wz); + } + dst[o + c] = static_cast(v); + } + } + } + } +} + +// --- Narrowing 16-bit state to the wire ------------------------------------------------------- +// +// Everything above computes wider than it writes. A field is 16-bit, a trail plane is 16-bit, and +// the wire is a byte, so somewhere the low half is thrown away. Doing that by `>> 8` is what makes +// a slow gradient band: measured on a dark 2%-wide ramp across 64 lights, the 16-bit values hold 64 +// distinct levels and the truncation leaves 6. +// +// Dithering does not add levels. It moves the error somewhere the eye integrates it away: +// +// - ORDERED (a 4x4 Bayer matrix, no state) spreads the error over SPACE. The mean lands right +// (28.14 against 28.01 ideal, versus truncation's 27.59) and the band edge dissolves into a +// texture. Free, and correct on a still image. +// - TEMPORAL (one byte of carry per channel) spreads it over TIME: the remainder of this frame's +// truncation is added to the next, so a pixel alternates between two levels in the proportion +// its true value asks for. Measured over 32 frames, eight neighbors a fraction of a byte apart +// resolve to 25.59, 25.75, 25.91, 26.06 ... where truncation reports 25, 25, 25, 26. This is +// what makes a slow fade smooth rather than stepped, and it is the reason a 16-bit pipeline is +// worth having at all on 8-bit LEDs. +// +// Temporal needs a byte of state per channel, which the caller owns: the state must persist across +// frames and belongs to whoever owns the plane. Ordered needs none, which is why it is the fallback +// for a caller with nowhere to keep it. + +/// How the low half of a 16-bit sample is disposed of on the way to a byte. +enum class Dither : uint8_t { + None, ///< truncate: the fastest, and what bands + Ordered, ///< a 4x4 Bayer threshold on the pixel's position: stateless, spatial + Temporal, ///< carry the error into the next frame: needs a byte per channel, and is smoothest +}; + +/// The 4x4 Bayer matrix, scaled to a 0..255 threshold. The standard recurrence, in its usual order. +inline constexpr uint8_t kBayer4[16] = { + 8, 136, 40, 168, + 200, 72, 232, 104, + 56, 184, 24, 152, + 248, 120, 216, 88, +}; + +/// Narrow one 16-bit sample to a byte, dithered. +/// +/// `carry` is the caller's per-channel state and is READ AND WRITTEN under Temporal; it is ignored +/// by the other modes, so a caller with no state passes a dummy. `x`/`y` position the Bayer +/// threshold under Ordered. +inline uint8_t quantize(uint16_t v, Dither mode, uint8_t& carry, + lengthType x = 0, lengthType y = 0, lengthType z = 0) { + switch (mode) { + case Dither::Ordered: { + // z ROTATES the matrix rather than indexing a third dimension. A 4x4x4 table would be + // the textbook answer and is the wrong trade here: it is 4x the constant data for a + // pattern the eye never resolves in depth, and a volume's slices only need to avoid + // sharing one threshold, not to be independently optimal. The rotation costs an add on + // an index that is already being computed, so a panel (z always 0) pays nothing. + const size_t cell = (static_cast(y & 3) * 4) + (x & 3) + (static_cast(z & 3) * 5); + const uint8_t thr = kBayer4[cell & 15]; + const uint8_t hi = static_cast(v >> 8); + // Round up when the discarded low byte beats this pixel's threshold. Saturating, so a + // sample already at full stays there rather than wrapping to black. + return (static_cast(v & 0xFF) > thr && hi < 255) ? static_cast(hi + 1) : hi; + } + case Dither::Temporal: { + // The remainder this frame could not express is added to the next, so the sequence of + // bytes averages to the true value. One byte of carry is enough: the error is always + // under one step, and letting it saturate rather than wrap keeps a bright pixel bright. + const uint32_t x16 = static_cast(v) + carry; + const uint32_t hi = x16 >> 8; + const uint8_t out = static_cast(hi > 255 ? 255 : hi); + const uint32_t used = static_cast(out) << 8; + carry = static_cast(x16 > used ? (x16 - used > 255 ? 255 : x16 - used) : 0); + return out; + } + case Dither::None: + default: + return static_cast(v >> 8); + } +} + +/// A 16-bit plane onto the canvas: the one narrowing step, dithered. +/// +/// Three effects held a copy of this loop and they disagreed, which is the reason it is here: two +/// dithered and one truncated, so the same slow fade banded in one effect and not the others. The +/// `carry` plane is the dither's per-channel error, `w*h*d*3` bytes sized by the caller's prepare(); +/// pass nullptr for none and the narrowing truncates, which is what a plane with no fade wants. +inline void blit16(const Canvas& cv, const uint16_t* p, lengthType w, lengthType h, lengthType d, + uint8_t* carry) { + if (!p) return; + std::size_t i = 0; + uint8_t dummy = 0; + for (lengthType z = 0; z < d; z++) + for (lengthType y = 0; y < h; y++) + for (lengthType x = 0; x < w; x++, i += 3) { + const auto q = [&](std::size_t c) { + uint8_t& st = carry ? carry[i + c] : dummy; + return quantize(p[i + c], carry ? Dither::Temporal : Dither::None, st, x, y, z); + }; + pixel(cv, {x, y, z}, RGB{q(0), q(1), q(2)}); + } +} + + +// --- Velocity rules ---------------------------------------------------------------------------- +// +// A velocity rule answers, for one point, which way the medium is moving there. They are plain +// functions rather than objects because none of them holds state: everything that varies over time +// arrives as an argument, so an effect can drive one from a control, an oscillator or audio without +// the rule knowing. `advect` takes any callable of this shape, so these are conveniences, not a +// closed set: an effect with its own idea writes a lambda. +// +// All return sub-pixels PER CALL, so the caller has already folded in the frame's dt. Prior art: +// the flow vocabulary of 4wheeljive's FlowFields (from a Stefan Petrick concept) and, for curl, +// Bridson 2007. + +/// A steady wind: everything moves the same way at the same speed. +inline void flowWind(angle16 direction, int32_t speed, pos_t& vx, pos_t& vy) { + vx = static_cast((static_cast(cos16(direction)) * speed) >> 15); + vy = static_cast((static_cast(sin16(direction)) * speed) >> 15); +} + +/// Out from a center, or into it when `speed` is negative. The fountain and the drain. +inline void flowRadial(lengthType x, lengthType y, lengthType cx, lengthType cy, + int32_t speed, pos_t& vx, pos_t& vy) { + const int32_t dx = static_cast(x) - cx, dy = static_cast(y) - cy; + if (dx == 0 && dy == 0) { vx = vy = 0; return; } // the center has no direction to go + const angle16 a = atan16(dy, dx); + flowWind(a, speed, vx, vy); +} + +/// Around a center and outward at once: the spiral, which is the two above added. +inline void flowSpiral(lengthType x, lengthType y, lengthType cx, lengthType cy, + int32_t angular, int32_t radial, pos_t& vx, pos_t& vy) { + const int32_t dx = static_cast(x) - cx, dy = static_cast(y) - cy; + if (dx == 0 && dy == 0) { vx = vy = 0; return; } + const angle16 a = atan16(dy, dx); + pos_t rx, ry, tx, ty; + flowWind(a, radial, rx, ry); // outward + flowWind(static_cast(a + 16384), angular, tx, ty); // and a quarter turn from it + vx = rx + tx; + vy = ry + ty; +} + } // namespace mm::draw diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index b202c927..5ace8294 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -451,24 +451,29 @@ class Drivers : public MoonModule { std::strcmp(controlName, "brightness") == 0) { rebuildAllCorrections(); } - // The relay follows `on`, and is written HERE rather than per frame: switching power is a - // state change, not a per-frame concern, and a mechanical relay would wear out doing it at - // frame rate (the Dig-2-Go's is solid-state, but the rule holds for the ones that are not). - // Also written when the pin itself changes, so entering it on a live device closes the relay - // straight away instead of at the next toggle. - if (std::strcmp(controlName, "on") == 0 || std::strcmp(controlName, "relayPins") == 0) { + // The relay follows `on` and brightness, and is written HERE rather than per frame: + // switching power is a state change, not a per-frame concern, and a mechanical relay would + // wear out doing it at frame rate (the Dig-2-Go's is solid-state, but the rule holds for the + // ones that are not). Also written when the pin itself changes, so entering it on a live + // device closes the relay straight away instead of at the next toggle. A brightness drag + // costs at most one edge, at the zero boundary: the level only changes there. + if (std::strcmp(controlName, "on") == 0 || std::strcmp(controlName, "brightness") == 0 || + std::strcmp(controlName, "relayPins") == 0) { applyRelay(); } } - /// Drive the power relay to match `on`. + /// Drive the power relay: closed while `on` and brightness is above zero, open otherwise. /// /// Some boards gate the LED supply behind a relay (the QuinLED Dig-2-Go's GPIO 12 is its "LED /// Relay enable pin"), so the data line can be perfectly correct and the strip stay dark. The /// relay belongs to `on` rather than to a driver or a service: it is the physical expression of /// master power, and `on` is the one control the UI, the WLED bridge, MQTT and OSC all already /// write. A second on/off would be a split brain, with nothing able to say which one meant off. + /// Brightness 0 opens it too: a strip at zero draws its idle current for nothing, and a slider + /// at 0 is how a WLED-style client says "off" without touching `on`. void applyRelay() { + const bool closed = on && brightness > 0; // Release a pin the user just cleared, or it stays asserted forever on a GPIO nothing owns. // The same reasoning InfraredService applies when its pin is unset. if (!relayPins[0]) { @@ -483,6 +488,13 @@ class Drivers : public MoonModule { // setStatus. Reporting it is the difference between a typo'd list and a working one. if (const char* err = parsePinList(relayPins, pins, kMaxRelays, n)) { setStatus(err, Severity::Warning); + // Release what the OLD list held before giving up. A typo mid-edit would otherwise leave + // the previous relays asserted on GPIOs no control names any more, so the strip stays + // powered after brightness reaches zero and nothing in the UI explains why. An + // unparseable list means no relays, which is the same state as an empty one. + for (uint8_t i = 0; i < lastRelayCount_; i++) + platform::gpioWrite(lastRelayPins_[i], false); + lastRelayCount_ = 0; return; } // Release the pins that are LEAVING the list before driving the new one. Clearing the list @@ -497,7 +509,7 @@ class Drivers : public MoonModule { for (uint8_t i = 0; i < n; i++) { // An input-only pin (classic ESP32 34-39) refuses the write, and the seam says so: a // relay wired to one would otherwise look configured and do nothing. - if (!platform::gpioWrite(static_cast(pins[i]), on)) + if (!platform::gpioWrite(static_cast(pins[i]), closed)) setStatus("relay pin cannot drive an output", Severity::Warning); lastRelayPins_[i] = static_cast(pins[i]); } diff --git a/src/light/drivers/RmtLedDriver.h b/src/light/drivers/RmtLedDriver.h index 66113236..10f9fc61 100644 --- a/src/light/drivers/RmtLedDriver.h +++ b/src/light/drivers/RmtLedDriver.h @@ -68,6 +68,33 @@ class RmtLedDriver : public DriverBase { /// not this safety cap. char ledsPerPin[48] = ""; + /// Wire timing. Named by SPEED rather than by chipset, because chipset names do not partition + /// the timings: SK6812 and WS2812B decode identically, while "WS2811" covers two different bit + /// rates. The chip names in each label are examples of what that timing drives, not a list. + /// + /// The default satisfies WS2812, WS2812B and SK6812 at once, which is why it has served every + /// strip so far. A 12V WS2811 strip in its low-speed mode needs twice the bit cell and reads + /// the default as noise past the first few lights (issue #94). + uint8_t timing = 0; + + /// The three numbers behind `timing`, shown only when it is `custom`. A strip whose datasheet + /// matches no preset is then a control change rather than a firmware release, and what a user + /// finds by trying is a number they can report back. + uint16_t t0hNs = 350; + uint16_t t1hNs = 700; + uint16_t periodNs = 1250; + + /// The index of `custom` in the table below: the one option that reads the three fields. + static constexpr uint8_t kTimingCustom = 3; + + /// The presets, in the order the select lists them. + static constexpr const char* kTimingOptions[] = { + "800kHz WS2812B/SK6812", // 350 / 700 / 1250: the default, and most strips + "400kHz WS2811", // 500 / 1200 / 2500: 12V WS2811 in low-speed mode + "800kHz WS2811 fast", // 250 / 600 / 1250: WS2811 strapped to high speed + "custom", // the three fields below + }; + /// On-device loopback self-test — RMT is a transceiver, so the driver verifies its own output /// on real silicon (replaces the old standalone test firmware). Tick to run a one-shot RMT /// TX→RX round-trip: jumper the first pin (TX) to `loopbackRxPin`, it transmits a known WS2812 @@ -110,6 +137,19 @@ class RmtLedDriver : public DriverBase { addWindowControls(); // start / count — the slice of the shared buffer this driver outputs controls_.addText("pins", pins, sizeof(pins)); controls_.addText("ledsPerPin", ledsPerPin, sizeof(ledsPerPin)); + controls_.addSelect("timing", timing, kTimingOptions, + static_cast(sizeof(kTimingOptions) / sizeof(kTimingOptions[0]))); + // The custom fields are always bound so persistence can load them whatever the mode, and + // shown only in custom mode: the same add-then-setHidden shape the loopback controls use. + // Ranges are the encodable window at the 40 MHz tick clock, not datasheet limits: a user + // reading their own datasheet should be able to type what it says. + const bool custom = timing == kTimingCustom; + controls_.addControl("t0hNs", t0hNs, 50, 4000); + controls_.setHidden(controls_.count() - 1, !custom); + controls_.addControl("t1hNs", t1hNs, 50, 4000); + controls_.setHidden(controls_.count() - 1, !custom); + controls_.addControl("periodNs", periodNs, 200, 8000); + controls_.setHidden(controls_.count() - 1, !custom); controls_.addControl("loopbackTest", loopbackTest); controls_.setAdvanced(controls_.count() - 1); // expert-mode: a bench self-test, not a normal-use control // loopbackTxPin / loopbackRxPin are always bound (so persistence can load @@ -131,6 +171,8 @@ class RmtLedDriver : public DriverBase { /// sweep runs and parseConfig()/reinit() pick up the new lists. bool affectsPrepare(const char* name) const override { return std::strcmp(name, "pins") == 0 || std::strcmp(name, "ledsPerPin") == 0 + || std::strcmp(name, "timing") == 0 || std::strcmp(name, "t0hNs") == 0 + || std::strcmp(name, "t1hNs") == 0 || std::strcmp(name, "periodNs") == 0 || isWindowControl(name); } @@ -316,6 +358,15 @@ class RmtLedDriver : public DriverBase { Buffer* sourceBuffer_ = nullptr; LedDriverConfig cfg_; + +public: + /// The wire timing the `timing` control resolved to. The tests assert against this rather than + /// against RMT ticks, so what is pinned is the contract a datasheet states (nanoseconds) rather + /// than the tick clock that happens to express it. Same injection-point role as + /// `correctionForTest()`; read-only, since the control is the way to change it. + const LedDriverConfig& wireTimingForTest() const { return cfg_; } + +private: platform::RmtWs2812Handle rmt_[kMaxPins]; uint16_t pinList_[kMaxPins] = {}; // parsed pins, list order nrOfLightsType pinCounts_[kMaxPins] = {}; // lights per pin (slice lengths) @@ -369,7 +420,33 @@ class RmtLedDriver : public DriverBase { // the current buffer/correction. On error: pinCount_ = 0 (tick() idles) and // the static error literal goes to the status slot; a later successful parse // clears it. Off the hot path. + /// Turn the `timing` selection into the wire timing the encoder reads. Called from + /// parseConfig, so every path that rebuilds the driver picks it up: the numbers are read per + /// frame from cfg_, so a change takes effect on the next frame with no channel reinit (the RMT + /// tick clock is unchanged, only how many ticks each bit lasts). + void applyTiming() { + switch (timing) { + // The 400 kHz WS2811 mode: T0H 0.5 us, T1H 1.2 us, 2.5 us cell. Twice the bit period, + // so a frame takes twice as long; at 18 lights that is ~1 ms, at 1000 it is ~60 ms. + case 1: cfg_.t0h_ns = 500; cfg_.t1h_ns = 1200; cfg_.period_ns = 2500; break; + // WS2811 strapped to its high-speed mode: the same 1.25 us cell as WS2812 with narrower + // pulses. Within the WS2812 decode window, so it is a refinement rather than a rescue. + case 2: cfg_.t0h_ns = 250; cfg_.t1h_ns = 600; cfg_.period_ns = 1250; break; + case kTimingCustom: + // Trusted as typed, but ordered: a t1h below t0h encodes 1 as a shorter pulse than + // 0, which no chip decodes, and a period below t1h cannot contain the pulse. + cfg_.t0h_ns = t0hNs; + cfg_.t1h_ns = t1hNs > t0hNs ? t1hNs : static_cast(t0hNs + 50); + cfg_.period_ns = periodNs > cfg_.t1h_ns ? periodNs + : static_cast(cfg_.t1h_ns + 50); + break; + // The default: satisfies WS2812, WS2812B and SK6812 at once. + default: cfg_.t0h_ns = 350; cfg_.t1h_ns = 700; cfg_.period_ns = 1250; break; + } + } + bool parseConfig() { + applyTiming(); pinCount_ = 0; uint8_t n = 0; const char* warn = nullptr; diff --git a/src/light/effects/AuroraEffect.h b/src/light/effects/AuroraEffect.h new file mode 100644 index 00000000..18d683ac --- /dev/null +++ b/src/light/effects/AuroraEffect.h @@ -0,0 +1,216 @@ +#pragma once + +#include "light/effects/EffectBase.h" + +namespace mm { + +// Aurora: several noise fields, each drifting on its own clock, read in polar coordinates and +// composited into light. +// +// This is the shader idiom rather than a picture of anything: no aurora is simulated. Layers of the +// same field, sampled at different scales and moved by different oscillators, are combined, and the +// interference between them is what reads as curtains of light folding through each other. Nothing +// here is specific to auroras, which is the point: it is the vocabulary this library exists to +// provide, and the effect is a composition in it. +// +// Four pieces, each a power function: +// +// - `PolarLut` gives every pixel its angle and radius once, so the field is addressed around the +// center rather than across the grid, and the address is not recomputed per frame. +// - `OscillatorBank` drives the motion. Each layer has its own drift, its own breathing scale and +// its own rotation, and because the bank holds their phases together the layers keep their +// relationships instead of sliding into each other over an evening. +// - `fbm8` over gradient noise is the field itself: octaves of noise, so each layer has both a +// broad shape and fine structure on it. +// - A contrast window then decides what is visible. This is what separates a shader from a blur: +// most of the field is pushed to black and only the top of it lights, so the result reads as +// distinct curtains rather than as an evenly cloudy panel. +// +// The layers are combined by taking the strongest at each pixel, and the pixel's color comes from +// WHICH layer won and how far it exceeded the window, read through the palette. So the palette +// controls the mood and the layers control the structure, which is the split that makes the effect +// worth handing someone: every palette gives a different aurora and none of them look wrong. +// +// Cost: one fbm per layer per pixel, so `layers` is the cost knob and `octaves` multiplies it. The +// polar address is a table read. Targets in performance.md. +// @card AuroraEffect.png +/// Effect: layered noise curtains in polar coordinates, each layer on its own oscillators. +class AuroraEffect : public EffectBase { +public: + const char* tags() const override { return "💫🖌️"; } // power-function showcase + Dim dimensions() const override { return Dim::D3; } // volumetric: the curtains have depth + + static constexpr uint8_t kMaxLayers = 4; + + uint8_t speed = 30; // master rate: every layer's motion scales from this + uint8_t scale = 40; // noise cells across the grid: low = broad curtains, high = fine detail + uint8_t layers = 3; // how many fields are composited, and the main cost knob + uint8_t warp = 60; // how far the field displaces its own sample angle + uint8_t twist = 40; // how much the radius shears the angle, giving the curtains their lean + uint8_t segments = 1; // kaleidoscope fold; 1 leaves the composition unfolded + uint8_t contrast = 140; // the visibility window: higher = fewer, sharper curtains + uint8_t octaves = 2; // detail within each layer, multiplying the cost knob + + /// The polar address: whether to read it from a table, at what precision, and how a + /// volumetric fixture's coordinates become an angle and a radius (light/polar.h). + PolarLut::Controls polar; + + void defineControls() override { + controls_.addControl("speed", speed, 0, 120); + controls_.addControl("scale", scale, 1, 255); + controls_.addControl("layers", layers, 1, kMaxLayers); + controls_.addControl("warp", warp, 0, 255); + controls_.addControl("twist", twist, 0, 255); + controls_.addControl("segments", segments, 1, 16); + controls_.addControl("contrast", contrast, 0, 255); + controls_.addControl("octaves", octaves, 1, 4); + PolarLut::addControls(controls_, polar); + } + + void prepare() override { + // The polar address is built here, not in tick(): prepare() is where a module builds state + // and where allocation is allowed, and it runs again on every resize and control change, so + // the table is always current without the render path ever allocating. + lut_.prepareFor(polar, width(), height(), EffectBase::depth()); + } + + void tick() MM_NONBLOCKING override { + const draw::Canvas cv = canvas(); + const lengthType w = width(), h = height(), dep = depth(); + const uint8_t n = layers < 1 ? 1 : (layers > kMaxLayers ? kMaxLayers : layers); + + // Each layer gets three oscillators: how far it has drifted, how its scale breathes, and how + // far it has rotated. Rates are deliberately unequal and not multiples of each other, so the + // layers drift in and out of alignment for a long time before repeating. + for (uint8_t i = 0; i < kMaxLayers; i++) { + const uint16_t rate = static_cast(speed) * (7 + i * 5) / 10; + bank_.set(static_cast(i * 3 + 0), + {.rate = rate, .low = 0, .high = 65535, .phaseOffset = 0, .wave = Wave::Saw}); + bank_.set(static_cast(i * 3 + 1), + {.rate = static_cast(rate / 3), .low = 192, .high = 320, + .phaseOffset = static_cast(i * 12000), .wave = Wave::Sine}); + bank_.set(static_cast(i * 3 + 2), + {.rate = static_cast(rate / 5), .low = -8192, .high = 8192, + .phaseOffset = static_cast(i * 20000), .wave = Wave::Sine}); + } + bank_.advance(elapsed()); + + const bool table = lut_.ready(); + const int32_t cx = w / 2, cy = h / 2, cz = dep / 2; + + // Hoist everything constant across the frame: the pixel loop should read, not compute. + uint32_t drift[kMaxLayers]; + uint32_t layerScale[kMaxLayers]; + int32_t rotate[kMaxLayers]; + for (uint8_t i = 0; i < n; i++) { + drift[i] = bank_.unitValue(static_cast(i * 3 + 0)); + layerScale[i] = static_cast(bank_.value(static_cast(i * 3 + 1))) * scale / 256u; + rotate[i] = bank_.value(static_cast(i * 3 + 2)); + if (layerScale[i] == 0) layerScale[i] = 1; + } + + // The window sits at `contrast` of the way up the field's own range, which the previous + // frame measured. The field drifts slowly, so last frame's range is this frame's to within a + // step, and the alternative (a pass to measure, then a pass to draw) doubles the cost. + // Anchored on the measured LOW, not the midpoint, so contrast 0 lights the whole field and + // contrast 255 leaves only its peaks. + const uint8_t lowEnd = floor_ < peak_ ? floor_ : static_cast(peak_ > 8 ? peak_ - 8 : 0); + const uint8_t threshold = static_cast( + lowEnd + (static_cast(peak_ - lowEnd) * contrast) / 272u); + uint8_t frameMax = 0, frameMin = 255; + + std::size_t idx = 0; + for (lengthType z = 0; z < dep; z++) + for (lengthType y = 0; y < h; y++) { + for (lengthType x = 0; x < w; x++, idx++) { + angle16 baseAngle; + uint32_t r; + // The depth coordinate the field samples along. Under cylindrical the address is + // the same at every height, so this is what separates the slices; under the other + // two the address already carries depth and this rides along with it. + int32_t along; + if (table) { + baseAngle = lut_.angle(idx); + r = lut_.radiusPixels(idx); + along = lut_.mapping() == PolarLut::Mapping::Spherical + ? static_cast(lut_.pitch(idx) >> 6) + : static_cast(z) - cz; + } else { + // The same address the table would have held, under the same mapping. + const auto m = PolarLut::mappingOf(polar); + const auto ad = PolarLut::addressOf(m, static_cast(x) - cx, + static_cast(y) - cy, + static_cast(z) - cz); + baseAngle = ad.angle; + r = ad.radius; + along = m == PolarLut::Mapping::Spherical ? static_cast(ad.pitch >> 6) + : static_cast(z) - cz; + } + + // The strongest layer at this pixel wins, and how far it exceeds the visibility + // window is the brightness. Taking the maximum rather than a sum is what keeps the + // curtains distinct: summing would average them into an even haze. + uint8_t best = 0; + uint8_t winner = 0; + for (uint8_t i = 0; i < n; i++) { + // Each layer leans its own way and turns at its own rate. + angle16 a = static_cast(baseAngle + (r * twist) + rotate[i]); + a = kaleido(a, segments); + + const uint32_t fx = (static_cast(a) >> 6) * layerScale[i] / 16u; + const uint32_t fy = (r * layerScale[i]) + (drift[i] >> 6) + i * 4096u; + // Depth is the field's third axis, so a volumetric fixture samples through the + // field rather than repeating one slice. On a panel `along` is 0 at every light + // and this reduces to the 2D sample exactly. + const uint32_t fz = static_cast(along * static_cast(layerScale[i])); + + // The field displaces its own sample angle, which is what makes a curtain fold + // over itself rather than merely sweep past. + const uint8_t v = warp > 0 ? warp8(fx, fy, fz, static_cast(warp) * 4, octaves) + : fbm8(fx, fy, fz, octaves); + if (v > best) { best = v; winner = i; } + } + + // The visibility window: everything below the threshold is dark, and what is above + // it is stretched back over the full range, so a small part of the field becomes the + // whole of the light. This is the control that decides curtains against cloud. + // + // The window is placed against the field's OWN range, measured on the previous + // frame, not against 0..255. A field of noise rarely reaches either end on a small + // grid, so a fixed threshold left the brightest curtain at two thirds of full and no + // setting could fix it. Tracking the range means `contrast` says what fraction of + // the field lights, on any grid, at any octave count, in any palette. + uint8_t bri = 0; + if (best > threshold) { + const uint32_t span = peak_ > threshold ? peak_ - threshold : 1u; + const uint32_t over = static_cast(best - threshold) * 255u / span; + bri = static_cast(over > 255u ? 255u : over); + } + if (best > frameMax) frameMax = best; + if (best < frameMin) frameMin = best; + + // Which layer won picks the region of the palette: the layers stay separable + // colors rather than one averaged hue. Nothing else enters the index. An earlier + // version added a term from the field's own value, which moved the hue as the + // brightness moved and wrapped past the end of the palette, so a brightening + // curtain jumped from one end of it to the other. Brightness belongs in the + // brightness. + const uint8_t index = static_cast((winner * 255u) / n); + draw::pixel(cv, {x, y, z}, colorFromPalette(*Palettes::active(), index, bri)); + } + } + + // Carry this frame's range into the next one's window. Eased rather than assigned, so a + // single bright frame cannot make the whole composition flinch. + peak_ = static_cast((peak_ * 7u + (frameMax < 32 ? 32 : frameMax)) / 8u); + floor_ = static_cast((floor_ * 7u + frameMin) / 8u); + } + +private: + PolarLut lut_{*this}; + OscillatorBank bank_; + uint8_t peak_ = 200; ///< the field's high water mark, eased per frame + uint8_t floor_ = 40; ///< and its low, so the window spans what is there +}; + +} // namespace mm diff --git a/src/light/effects/EffectBase.h b/src/light/effects/EffectBase.h index c6391788..b11fd668 100644 --- a/src/light/effects/EffectBase.h +++ b/src/light/effects/EffectBase.h @@ -144,13 +144,16 @@ class EffectBase : public MoonModule { #include "light/draw.h" // draw::pixel / fill / line / fade / blur — write pixels by coordinate #include "light/Palette.h" // colorFromPalette, Palettes::active — the palette system #include "core/math8.h" // beat8 / beatsin8 / sin8 / random8 — the integer animation helpers -#include "core/noise.h" // inoise8 — the shared value-noise field +#include "core/noise.h" // inoise8: the shared gradient-noise field #include "core/color.h" // RGB #include "core/crc.h" // crc16 — grid/state fingerprints (stasis detection) #include "core/ScratchBuffer.h" // ScratchBuffer — self-sizing scratch memory for stateful effects +#include "core/oscillators.h" // OscillatorBank / Wave: the motion kernel five effects share +#include "core/math16.h" // angle16, kaleido, halfLifeKeep: the fixed-point trig and decay +#include "light/polar.h" // PolarLut: the per-pixel angle and radius four radial effects read #include "core/AudioService.h" // AudioService::latestFrame() — the shared audio source #include "core/AudioFrame.h" // AudioFrame — level + 16-band spectrum an audio-reactive effect reads #include // memset / memcpy / strcmp — buffer + control-name handling -#include // sqrtf / sinf / log10f — per-frame float maths (never per-light) +#include // sqrtf / sinf / log10f: per-frame float math (never per-light) #include // std::array — fixed-size effect state tables (cube faces, LUTs) diff --git a/src/light/effects/FluidEffect.h b/src/light/effects/FluidEffect.h new file mode 100644 index 00000000..00acc615 --- /dev/null +++ b/src/light/effects/FluidEffect.h @@ -0,0 +1,282 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "light/fluid.h" // the Stam solver: the medium itself + +namespace mm { + +// Fluid: light poured into a simulated medium, and carried by it. +// +// Why a solver rather than another field: `light/fluid.h`. What it looks like and what the controls +// do: the catalog card. Here: how the three parts are wired. +// +// Three parts, and only the first is new: +// +// - `Fluid` solves the medium (Stam 1999: diffuse, project, advect, project). Its own header +// explains why that algorithm and why Q16.16. +// - Jets pour velocity AND dye in together, so light enters where the medium is being pushed. +// - `draw::advect16` carries the dye along the finished field, and `draw::decay16` fades it, which +// is exactly what Trails does. The dye plane is 16-bit for the same reason: a value multiplied +// by slightly less than one, many times a second, has nowhere to go at 8 bits. +// +// **Cost is the honest problem.** The solver is several passes over the grid per frame and the +// pressure solve is `iterations` of them, so this is a desktop and P4 effect. An S3 runs it on a +// small grid; the numbers per target are in performance.md rather than promised here. +// @card FluidEffect.png +/// Effect: dye poured into a simulated fluid, carried by the flow the medium itself works out. +class FluidEffect : public EffectBase { +public: + const char* tags() const override { return "💫🖌️"; } // power-function showcase + Dim dimensions() const override { return Dim::D3; } // a medium per slice, jets wander in z + + static constexpr uint8_t kMaxJets = 4; + static constexpr uint8_t kClocks = 4; ///< per jet: position, reach, aim, depth + + uint8_t jets = 2; // how many places light is poured in + uint8_t force = 120; // how hard each one pushes + uint8_t swirl = 90; // how fast the jets sweep, which is what makes vortices + uint8_t viscosity = 20; // how much the medium drags on itself + uint8_t persistence = 150; // how long dye survives, as a half-life + uint8_t iterations = 5; // pressure-solve effort, and the cost knob + + void defineControls() override { + controls_.addControl("jets", jets, 1, kMaxJets); + controls_.addControl("force", force, 0, 255); + controls_.addControl("swirl", swirl, 0, 255); + controls_.addControl("viscosity", viscosity, 0, 255); + controls_.addControl("persistence", persistence, 0, 255); + controls_.addControl("iterations", iterations, 1, 20); + } + + void prepare() override { + const lengthType w = width(), h = height(), d = depth(); + // On a cube every slice is its own medium and the jets wander through them, the same + // per-slice shape Trails has: nothing is carried between slices, which a volumetric solve + // would do and is a different solver. A panel is depth 1 and pays nothing for it. + const bool medium = fluid_.resize(w, h, d); + const size_t n = medium ? static_cast(w) * h * d * 3 : 0; + const size_t had = dyeA_.count(); + dyeA_.resize(n); + dyeB_.resize(n); + carry_.resize(n); // the dither's error, sized here: tick() is MM_NONBLOCKING + // Same sample count but a different shape (8x16 to 16x8, or a cube reshaped): resize() kept + // the samples and they are laid out for the old geometry, so they would smear. BOTH planes, + // because the ping-pong swaps the spare one in on the very next frame and clearing only the + // live one leaves the stale picture one frame away. (A changed count needs no clear: + // resize() zero-fills when it reallocates.) The guard Trails and Nebula carry. + if (n > 0 && n == had && (w != planeW_ || h != planeH_ || d != planeD_)) { + std::memset(dyeA_.data(), 0, dyeA_.bytes()); + std::memset(dyeB_.data(), 0, dyeB_.bytes()); + // The dither's error is per LIGHT, so it is laid out for the old geometry as much as + // the dye is: carrying it into the new shape seeds the first frames with another + // picture's rounding. + if (carry_) std::memset(carry_.data(), 0, carry_.bytes()); + } + planeW_ = w; planeH_ = h; planeD_ = d; + started_ = false; + pourCarry_ = 0; + poured_ = false; + } + + void tick() MM_NONBLOCKING override { + // Readiness is READ from the buffers every frame, never cached: MoonModule::release() + // frees them when this module or an ancestor is disabled, and the re-enable only requests + // a prepare for the next loop, so one frame can tick between the two. A cached flag said + // "ready" on that frame and pour() wrote to a null plane (SIGSEGV on the desktop). + if (!fluid_.valid() || !dyeA_ || !dyeB_) return; + const lengthType w = width(), h = height(), d = depth(); + const uint32_t now = elapsed(); + // A zero delta on the first tick: the whole uptime would push the solver in one step. + const uint32_t dt = started_ ? now - lastMs_ : 0u; + lastMs_ = now; + started_ = true; + + // Each jet gets its own clocks: where it sits, how far out it wanders, which way it aims, + // and on a cube which slice it is in. They are separate for the reason pour() documents. + // Rates are deliberately not multiples of each other. + for (uint8_t i = 0; i < kMaxJets; i++) { + const uint16_t rate = static_cast(3 + swirl / 12 + i * 2); + bank_.set(static_cast(i * kClocks + 0), + {.rate = rate, .low = 0, .high = 65535, + .phaseOffset = static_cast(i * 16384), .wave = Wave::Saw}); + // The radius breathes from the middle out to near the wall, so the forcing covers the + // panel over time instead of a band. + bank_.set(static_cast(i * kClocks + 1), + {.rate = static_cast(rate / 3 + 1), .low = 0, .high = 255, + .phaseOffset = static_cast(i * 9000), .wave = Wave::Sine}); + // And the aim swings around the tangent, so a jet drives across the middle rather than + // only along its own orbit. + bank_.set(static_cast(i * kClocks + 2), + {.rate = static_cast(rate / 2 + 1), .low = 0, .high = 65535, + .phaseOffset = static_cast(i * 21000), .wave = Wave::Sine}); + // And on a cube the jet drifts through the slices, so each slice is visited in turn. + bank_.set(static_cast(i * kClocks + 3), + {.rate = static_cast(rate / 4 + 1), .low = 0, .high = 65535, + .phaseOffset = static_cast(i * 30000), .wave = Wave::Sine}); + } + bank_.advance(dt); + + uint16_t* live = front_ ? dyeA_.data() : dyeB_.data(); + uint16_t* spare = front_ ? dyeB_.data() : dyeA_.data(); + + // Paced by TIME, not by frames: a jet firing every frame pours at the framerate, so a fast + // device fills the panel while a slow one trickles (measured 1.58 at 1200 fps against 60). + // The dye is SET rather than accumulated, so scaling its brightness by dt does not work + // either, which is the trap Trails' emitter documents. A budget with a carry is the fix. + constexpr uint32_t kPourMs = 20; + pourCarry_ += dt; + // The first pour is unconditional: dt is deliberately 0 on the opening tick, so a purely + // time-paced jet never fires on it, and on a fast device it then takes many frames to owe + // a whole period. The panel stays black in the meantime, which a resize repeats every + // time. One jet at startup is what the medium needs anyway. + if (!poured_) { + poured_ = true; // the opening pour, see above + pour(live, w, h, d, now); + } + // Subtracting the period rather than taking a modulo of it: the remainder IS the time + // already owed toward the next pour, and discarding it drops a different fraction at every + // framerate. The debt is CAPPED, though: after a long stall (a WiFi scan, a filesystem + // write) the owed time can be seconds, and pouring all of it in one frame is a burst that + // both blows the frame budget and floods the medium. Beyond the cap the missed pours are + // dropped, which is what "degrade visibly, never crash" means here. + constexpr uint32_t kMaxCatchUp = 4; // 80 ms of jets in one frame, at most + if (pourCarry_ > kPourMs * kMaxCatchUp) pourCarry_ = kPourMs * kMaxCatchUp; + while (pourCarry_ >= kPourMs) { + pourCarry_ -= kPourMs; + pour(live, w, h, d, now); + } + + // The medium first, then the dye it carries: the field must be solved before anything is + // moved along it, or the dye would follow the PREVIOUS frame's flow. + const int32_t dtQ = static_cast((static_cast(dt) * Fluid::kOne) / 1000u); + fluid_.step(static_cast(viscosity) * (Fluid::kOne / 4096), dtQ, iterations); + + const int32_t* vx = fluid_.velocityX(); + const int32_t* vy = fluid_.velocityY(); + const size_t plane = fluid_.plane(); + draw::advect16(spare, live, w, h, d, + [&](lengthType x, lengthType y, lengthType z, draw::pos_t& ox, draw::pos_t& oy) { + const size_t i = static_cast(z) * plane + static_cast(y) * w + x; + // Q16.16 cells per second into sub-pixels this frame: one cell is one + // light, and draw::pos_t counts 256 to the light. + ox = static_cast((static_cast(vx[i]) * dtQ) >> 24); + oy = static_cast((static_cast(vy[i]) * dtQ) >> 24); + }, draw::Edge::Clamp); + front_ = !front_; + uint16_t* dye = front_ ? dyeA_.data() : dyeB_.data(); + + draw::decay16(dye, dyeA_.count(), 40u + static_cast(persistence) * persistence / 10u, dt); + draw::blit16(canvas(), dye, w, h, d, carry_ ? carry_.data() : nullptr); + } + + /// Read-only access to the dye planes, for the reshape test: a rendered frame pours fresh dye + /// over a stale plane, so the picture cannot tell a cleared plane from an uncleared one. + std::size_t dyeSamples() const { return dyeA_.count() + dyeB_.count(); } + uint16_t dyeAt(std::size_t i) const { + const std::size_t n = dyeA_.count(); + if (i < n) return dyeA_.data()[i]; + const std::size_t j = i - n; + return j < dyeB_.count() ? dyeB_.data()[j] : 0; // past the end reads 0, never off it + } + +private: + /// The jets: velocity and dye together, so light enters where the medium is pushed. + void pour(uint16_t* dye, lengthType w, lengthType h, lengthType d, uint32_t now) { + const uint8_t n = jets < 1 ? 1 : (jets > kMaxJets ? kMaxJets : jets); + for (uint8_t i = 0; i < n; i++) { + const angle16 a = static_cast(bank_.unitValue(static_cast(i * kClocks + 0))); + // The radius wanders between the center and the wall rather than sitting on one circle. + const uint32_t reach = bank_.value(static_cast(i * kClocks + 1)); + const int32_t rx = static_cast((w / 2 - 1) * reach / 320u); + const int32_t ry = static_cast((h / 2 - 1) * reach / 320u); + const lengthType jx = static_cast(w / 2 + (static_cast(cos16(a)) * rx) / 32768); + const lengthType jy = static_cast(h / 2 + (static_cast(sin16(a)) * ry) / 32768); + // The aim swings either side of the tangent, so the jet has a radial component and + // drives dye through the middle. Pure tangent is what produced a hollow ring. + const int32_t lean = static_cast(bank_.value(static_cast(i * kClocks + 2))) - 32768; + const lengthType jz = d > 1 + ? static_cast((bank_.value(static_cast(i * kClocks + 3)) * (d - 1)) / 65535u) + : 0; + // Odd jets sweep the other way, so they meet the even ones head-on: colliding jets are + // what roll up vortex pairs, while jets all turning together sum into one rotation. + const int32_t sense = (i & 1) ? -1 : 1; + const angle16 dir = static_cast(a + static_cast(sense * (16384 + lean / 3))); + // The push covers the same disc as the dye and scales with the fixture, in cells per + // second. A single-cell push is mostly divergence, which the projection removes, and + // `force / 64` cells/s on a 64-wide panel measured a mean of 0.45 cells/s: the dye sat + // where it was poured and decayed in place. At this scale the nozzle peaks near 1.5 + // lights per frame, which is what reads as paint being jetted rather than dabbed. + const lengthType span = w > h ? w : h; + const lengthType rad = span / 32 + 1; + const lengthType rz = d > 1 ? rad : 0; // a sphere on a cube, a disc on a panel + const int32_t mag = static_cast( + (static_cast(force) * span * Fluid::kOne) / (255 * 2)); + const int32_t dvx = static_cast((static_cast(cos16(dir)) * mag) >> 15); + const int32_t dvy = static_cast((static_cast(sin16(dir)) * mag) >> 15); + for (lengthType dz = -rz; dz <= rz; dz++) + for (lengthType dy = -rad; dy <= rad; dy++) + for (lengthType dx = -rad; dx <= rad; dx++) + if (dx * dx + dy * dy + dz * dz <= rad * rad) + fluid_.addVelocity(jx + dx, jy + dy, dvx, dvy, jz + dz); + + const RGB c = colorFromPalette(*Palettes::active(), + static_cast(i * (255u / n) + (now >> 6))); + splatDye(dye, w, h, d, jx, jy, jz, c); + } + } + + /// Dye into a soft disc, so a jet has a body rather than a single pixel the flow immediately + /// spreads to nothing. Written at the plane's full width, which is where the decay lives. + void splatDye(uint16_t* dye, lengthType w, lengthType h, lengthType d, + lengthType cx, lengthType cy, lengthType cz, RGB c) { + // The jet's body scales with the fixture. A fixed radius is a fixed NUMBER of lights, and + // advection spreads them over an area that grows with the grid, so on a large panel the dye + // arrives at a fraction of a count and the whole effect reads as a faint smear. This is the + // same arithmetic Trails' emitter head carries, for the same reason. + const lengthType span = w > h ? w : h; + const lengthType rad = span / 32 + 1; + const lengthType rz = d > 1 ? rad : 0; // a sphere on a cube, a disc on a panel + const int32_t r2 = static_cast(rad) * rad; + const uint16_t wide[3] = {static_cast((c.r << 8) | c.r), + static_cast((c.g << 8) | c.g), + static_cast((c.b << 8) | c.b)}; + for (lengthType dz = -rz; dz <= rz; dz++) + for (lengthType dy = -rad; dy <= rad; dy++) + for (lengthType dx = -rad; dx <= rad; dx++) { + // A DISC, not a square. A square block is an axis-aligned slab, and advection + // carries its corners into the blocky patches the panel shows as squares. + const int32_t d2 = static_cast(dx) * dx + static_cast(dy) * dy + + static_cast(dz) * dz; + if (d2 > r2) continue; + const lengthType x = cx + dx, y = cy + dy, z = cz + dz; + if (x < 0 || y < 0 || z < 0 || x >= w || y >= h || z >= d) continue; + // Full at the center, tapering to nothing at the rim: a flat slab has a hard edge + // that stays hard however far the flow carries it. + const uint32_t fall = static_cast(((r2 - d2) * 255) / (r2 > 0 ? r2 : 1)); + const size_t o = ((static_cast(z) * h + y) * w + x) * 3; + for (size_t k = 0; k < 3; k++) { + // ADDED, not assigned. Overwriting stamps a solid block of one color, which is + // why the jets read as flat patches rather than as dye entering a medium; + // accumulating lets a jet build up where it lingers and blend where two meet. + const uint32_t add = (static_cast(wide[k]) * fall) / 255u; + const uint32_t sum = static_cast(dye[o + k]) + add; + dye[o + k] = static_cast(sum > 65535u ? 65535u : sum); + } + } + } + + + Fluid fluid_{*this}; + ScratchBuffer dyeA_{*this}; ///< the dye; the two alternate roles + ScratchBuffer dyeB_{*this}; + ScratchBuffer carry_{*this}; ///< the dither's per-channel error + OscillatorBank bank_; + bool front_ = true; + bool started_ = false; + uint32_t lastMs_ = 0; + uint32_t pourCarry_ = 0; ///< time owed to the jets, in ms + lengthType planeW_ = 0, planeH_ = 0, planeD_ = 0; ///< the shape the planes hold + bool poured_ = false; ///< has the opening pour happened yet +}; + +} // namespace mm diff --git a/src/light/effects/LavaLampEffect.h b/src/light/effects/LavaLampEffect.h index 7b484563..9c566030 100644 --- a/src/light/effects/LavaLampEffect.h +++ b/src/light/effects/LavaLampEffect.h @@ -53,7 +53,7 @@ class LavaLampEffect : public EffectBase { int16_t bx[NUM_BLOBS] = {}; int16_t by[NUM_BLOBS] = {}; - draw::blobCentres(BLOB_PATHS, NUM_BLOBS, t, w, h, bx, by); + draw::blobCenters(BLOB_PATHS, NUM_BLOBS, t, w, h, bx, by); int32_t r2 = static_cast(radius) * radius; for (lengthType y = 0; y < h; y++) { diff --git a/src/light/effects/MetaballsEffect.h b/src/light/effects/MetaballsEffect.h index 8be5218e..8be49ac3 100644 --- a/src/light/effects/MetaballsEffect.h +++ b/src/light/effects/MetaballsEffect.h @@ -52,7 +52,7 @@ class MetaballsEffect : public EffectBase { const uint8_t n = count < MAX_BALLS ? count : MAX_BALLS; int16_t bx[MAX_BALLS]; int16_t by[MAX_BALLS]; - draw::blobCentres(BLOB_PATHS, n, t, w, h, bx, by); + draw::blobCenters(BLOB_PATHS, n, t, w, h, bx, by); // Field strength: sum of r^2 / (d^2 + 1) int32_t r2 = static_cast(radius) * radius; diff --git a/src/light/effects/NebulaEffect.h b/src/light/effects/NebulaEffect.h new file mode 100644 index 00000000..7416564f --- /dev/null +++ b/src/light/effects/NebulaEffect.h @@ -0,0 +1,271 @@ +#pragma once + +#include "light/effects/EffectBase.h" // and core/noise.h with it: fbm8, curl16 + + +namespace mm { + +// Nebula: a cloud that is generated in one place and carried in another. +// +// The composition phase 4 exists to demonstrate, and the first effect built from two power +// functions rather than one. A noise field decides WHERE light is born: thresholded hard, so only +// the top of the field survives and the rest is black. A curl flow decides where it GOES. Neither +// half is new; what is new is that the emitter is a field rather than a handful of dots, so the +// light enters everywhere at once and the flow shapes a whole cloud instead of drawing trails. +// +// That is why it reads as a nebula rather than as either of its parts: the birth is structured (the +// field's own shape) and the transport is divergence-free (curl noise, so nothing piles up), and +// between them the cloud keeps folding into itself without ever collapsing or thinning out. +// +// **Two cost levers, because this is the effect that needs them.** The field is the expensive half, +// so `fieldScale` computes it at half or quarter resolution and stretches it (measured 3x and 6.6x +// on a curl field; `draw::upscale16` carries the numbers), and `fieldRate` recomputes it only every +// N frames. Both leave the MOTION alone: the flow still carries the plane every frame and the +// oscillators still advance, so a cheaper field costs detail rather than smoothness. +// +// Cost: the flow is one curl per light per frame (4 noise samples), the field is one fbm per light +// at whatever scale and rate the levers ask for. Targets in performance.md. +// @card NebulaEffect.png +/// Effect: a noise field births light, a curl flow carries it, and the two make a folding cloud. +class NebulaEffect : public EffectBase { +public: + const char* tags() const override { return "💫🖌️"; } // power-function showcase + Dim dimensions() const override { return Dim::D3; } // the flow and the field both carry z + + uint8_t speed = 40; // how fast the medium moves, and with it the whole cloud + uint8_t scale = 40; // the field's cell size: low = broad clouds, high = wisps + /// The window: only the top of the field is born, so the cloud is mostly dark with structure + /// in it. Placed against the field's OWN measured range, so this means the same thing on a + /// panel and on a cube. Measured across the slider, 64x64 against a 20-cube: 0 floods both + /// (99% and 97%), 128 is a haze (79% and 66%), 192 is a cloud (35% and 31%), 255 leaves a few + /// wisps (7% and 6%). The two track each other at every setting, where an absolute threshold + /// lit a quarter of the panel and almost none of the cube. + uint8_t contrast = 192; + uint8_t persistence = 140; // how long light survives once it is in the flow + uint8_t octaves = 2; // detail in the field, and its cost knob + uint8_t fieldScale = 1; // 1 = full, 2 = half, 4 = quarter: the field's resolution + uint8_t fieldRate = 1; // recompute the field every N frames + + void defineControls() override { + controls_.addControl("speed", speed, 0, 255); + controls_.addControl("scale", scale, 1, 255); + controls_.addControl("contrast", contrast, 0, 255); + controls_.addControl("persistence", persistence, 0, 255); + controls_.addControl("octaves", octaves, 1, 4); + controls_.addControl("fieldScale", fieldScale, 1, 4); + controls_.addControl("fieldRate", fieldRate, 1, 8); + } + + bool affectsPrepare(const char* name) const override { + // `fieldScale` alone resizes the field plane. `fieldRate` only skips frames and every other + // control is read per frame, so none of them needs a rebuild. + return std::strcmp(name, "fieldScale") == 0; + } + + /// The lever, clamped once: prepare() sizes the field from it and tick() picks its path by it. + uint8_t fieldScaleClamped() const { return fieldScale < 1 ? 1 : (fieldScale > 4 ? 4 : fieldScale); } + + void prepare() override { + const lengthType w = width(), h = height(), d = depth(); + const size_t n = static_cast(w) * h * d * 3; + const size_t had = planeA_.count(); + // The cloud itself: two planes, because advection reads one and writes the other. + planeA_.resize(n); + planeB_.resize(n); + // Everything the render path writes into is sized HERE: tick() is MM_NONBLOCKING, and a + // resize() on a changed size reallocates and zero-fills, which is heap work in the hot path. + carry_.resize(n); + scratch_.resize(fieldScaleClamped() > 1 ? n : 0); + taps_.resize(static_cast(w)); + // Same sample count but a different shape (8x16 -> 16x8, or a cube reshaped): both planes + // hold samples laid out for the old geometry and the ping-pong swaps the spare one in on + // the next frame, so clearing only the live one leaves the stale picture one frame away. + // (A changed count needs no clear: resize() zero-fills when it reallocates.) The same guard + // Trails carries, for the same reason. + if (n > 0 && n == had && (w != planeW_ || h != planeH_ || d != planeD_)) { + std::memset(planeA_.data(), 0, planeA_.bytes()); + std::memset(planeB_.data(), 0, planeB_.bytes()); + } + // The field is computed at its own resolution and stretched. At fieldScale 1 it IS the + // fixture, and no intermediate plane is allocated: the lever costs nothing when it is off. + const uint8_t s = fieldScaleClamped(); + fw_ = w / s > 0 ? w / s : 1; + fh_ = h / s > 0 ? h / s : 1; + fd_ = d / s > 0 ? d / s : 1; + field_.resize(s > 1 ? static_cast(fw_) * fh_ * fd_ * 3 : 0); + planeW_ = w; planeH_ = h; planeD_ = d; + frame_ = 0; + started_ = false; + } + + void tick() MM_NONBLOCKING override { + if (!planeA_ || !planeB_) return; + const lengthType w = width(), h = height(), d = depth(); + // One clock reading, and a zero delta on the first tick: without it `now - 0` hands the + // flow the whole uptime, which teleports the cloud and decays it away on the frame it + // starts. Trails carries the same guard for the same reason. + const uint32_t now = elapsed(); + const uint32_t t = now; // the flow field's third axis, read inside the lambda + const uint32_t dt = started_ ? now - lastMs_ : 0u; + lastMs_ = now; + started_ = true; + + bank_.set(0, {.rate = static_cast(4 + speed / 6), .low = 0, .high = 65535, + .phaseOffset = 0, .wave = Wave::Saw}); + bank_.advance(dt); + + uint16_t* live = front_ ? planeA_.data() : planeB_.data(); + uint16_t* spare = front_ ? planeB_.data() : planeA_.data(); + + // 1. Transport, every frame: this is the motion, and it is what fieldRate must not skip. + const uint32_t cells = static_cast(scale) * 256u; + const int32_t push = static_cast(speed) * static_cast(dt) / 4; + draw::advect16(spare, live, w, h, d, + [&](lengthType x, lengthType y, lengthType z, + draw::pos_t& vx, draw::pos_t& vy) { + int32_t cx = 0, cy = 0; + // eps MUST be at least the distance between neighboring samples. + // Left at its default (4096) while a pixel step is `cells` (10240 at + // scale 40), the central difference measures inside the gap BETWEEN two + // pixels, so neighbors get uncorrelated flow directions and the cloud + // comes out striped rather than smooth. Half a pixel step is the + // smallest value that still sees the field the pixels sample. + curl16(static_cast(x) * cells, + static_cast(y) * cells, + static_cast(z) * cells + t / 8u, push, cx, cy, + cells / 2u); + vx = static_cast(cx); + vy = static_cast(cy); + }, draw::Edge::Clamp); + front_ = !front_; + uint16_t* cloud = front_ ? planeA_.data() : planeB_.data(); + + // 2. Decay, every frame: the half-life is what makes the cloud a cloud rather than a smear. + draw::decay16(cloud, planeA_.count(), 40u + static_cast(persistence) * persistence / 12u, dt); + + // 3. Birth, every fieldRate frames: the expensive half, and the one the levers exist for. + const uint8_t rate = fieldRate < 1 ? 1 : fieldRate; + if (frame_ % rate == 0) birth(cloud, w, h, d, now); + frame_++; + + // 4. Onto the layer, narrowing once. Dithered temporally: the cloud's dark half is where + // banding lives, and carrying the error is what keeps a slow fade smooth. + draw::blit16(canvas(), cloud, w, h, d, carry_ ? carry_.data() : nullptr); + } + +private: + /// Where light is born: the field, thresholded so only its top survives, added to the cloud. + void birth(uint16_t* cloud, lengthType w, lengthType h, lengthType d, uint32_t now) { + const uint32_t drift = bank_.unitValue(0) >> 6; + const uint32_t cells = static_cast(scale) * 192u; + const uint8_t oct = octaves < 1 ? 1 : (octaves > 4 ? 4 : octaves); + // At fieldScale 1 the field is written straight into the cloud; above it, into the small + // plane and stretched. One code path either way: `dst` and its extents are what differ. + // The window sits at `contrast` of the way up the field's OWN range, measured on the + // previous frame, rather than at an absolute value. A fixed threshold is tuned for one + // fixture and wrong on every other: measured, fbm8 over an 8-cube's 512 samples peaks near + // 223 while a 64x64 panel's 4096 reach much higher, so a threshold of 200 lit a quarter of + // the panel and almost nothing of the cube. Tracking the range means `contrast` says what + // FRACTION of the field is born, on any fixture. (Aurora places its window the same way.) + // The control spans the TOP HALF of the range, not all of it. Nebula's field is an emitter + // that runs every frame and accumulates in the cloud, so anything below about 60% of the + // range floods the fixture: measured at 64x64, a window at 73% lights 96% of the panel and + // one at 93% lights 24%. Mapping 0..255 onto 60%..100% puts the whole useful span on the + // slider instead of compressing it into its last few values. (Aurora draws its field + // directly rather than accumulating, so its own mapping starts at the bottom.) + const uint8_t lowEnd = floor_ < peak_ ? floor_ : static_cast(peak_ > 8 ? peak_ - 8 : 0); + const uint32_t span16 = static_cast(peak_ - lowEnd); + const uint32_t frac = 154u + (static_cast(contrast) * 101u) / 255u; // 60%..100% + const uint8_t threshold = static_cast(lowEnd + (span16 * frac) / 255u); + uint8_t frameMax = 0, frameMin = 255; + + const bool small = field_.count() > 0; + uint16_t* dst = small ? field_.data() : cloud; + const lengthType dw = small ? fw_ : w, dh = small ? fh_ : h, dd = small ? fd_ : d; + const lengthType sx = w / (dw > 0 ? dw : 1), sy = h / (dh > 0 ? dh : 1), sz = d / (dd > 0 ? dd : 1); + std::size_t i = 0; + for (lengthType z = 0; z < dd; z++) + for (lengthType y = 0; y < dh; y++) + for (lengthType x = 0; x < dw; x++, i += 3) { + const uint8_t v = fbm8(static_cast(x * sx) * cells / 256u + drift, + static_cast(y * sy) * cells / 256u, + static_cast(z * sz) * cells / 256u + now / 32u, oct); + // The window: below `contrast` nothing is born at all, and what is above it is + // stretched back over the full range, so a small part of the field becomes all + // of the light. This is what makes a cloud rather than an even haze. + if (v > frameMax) frameMax = v; + if (v < frameMin) frameMin = v; + if (v <= threshold) { + if (small) { dst[i] = dst[i + 1] = dst[i + 2] = 0; } + continue; + } + const uint32_t span = peak_ > threshold ? peak_ - threshold : 1u; + const uint32_t over = static_cast(v - threshold) * 255u / span; + const uint8_t bri = static_cast(over > 255u ? 255u : over); + // The palette index is the field's position WITHIN THE WINDOW, stretched back + // over the full range, not the raw field value. Two reasons, and the second is + // what makes the effect look like its palette at all: + // + // - no time term: adding one walks every born pixel through the palette + // together, which reads as the cloud changing color rather than as a cloud. + // A wisp should keep the color it was born with as the flow carries it. + // - stretched: the window admits only the top of the field (values above + // `contrast`), so the raw value spans about a fifth of the palette and every + // wisp comes out the same hue. Rescaling that slice to 0..255 gives the same + // cloud in every color the palette has. + // + // `over` is already that position, 0 at the window's edge and 255 at the + // field's peak, so the index and the brightness come from one computation. + const RGB c = colorFromPalette(*Palettes::active(), bri, bri); + // Written at full width, so the decay has somewhere to go. + if (small) { + dst[i + 0] = static_cast((c.r << 8) | c.r); + dst[i + 1] = static_cast((c.g << 8) | c.g); + dst[i + 2] = static_cast((c.b << 8) | c.b); + } else { + // Straight into the cloud: ADD, so new light joins what is already flowing + // rather than erasing it. + addWide(dst, i + 0, static_cast((c.r << 8) | c.r)); + addWide(dst, i + 1, static_cast((c.g << 8) | c.g)); + addWide(dst, i + 2, static_cast((c.b << 8) | c.b)); + } + } + // Carry this frame's range into the next one's window, eased so a single bright frame + // cannot make the whole cloud flinch. + peak_ = static_cast((peak_ * 7u + (frameMax < 32 ? 32 : frameMax)) / 8u); + floor_ = static_cast((floor_ * 7u + frameMin) / 8u); + + if (!small) return; + // Stretch the small field over the cloud, adding as it goes. + if (!scratch_ || !taps_) return; // sized in prepare(); the render path never allocates + draw::upscale16(scratch_.data(), w, h, d, field_.data(), fw_, fh_, fd_, + taps_.data(), taps_.count()); + const size_t n = static_cast(w) * h * d * 3; + for (size_t k = 0; k < n; k++) addWide(cloud, k, scratch_[k]); + } + + /// Saturating add at 16 bits: light adds, and a bright cloud must not wrap to black. + static void addWide(uint16_t* p, size_t i, uint16_t v) { + const uint32_t s = static_cast(p[i]) + v; + p[i] = static_cast(s > 65535u ? 65535u : s); + } + + + ScratchBuffer planeA_{*this}; ///< the cloud; the two alternate roles + ScratchBuffer planeB_{*this}; + ScratchBuffer field_{*this}; ///< the field at its own resolution (empty at 1:1) + ScratchBuffer scratch_{*this}; ///< the stretched field, when one is used + ScratchBuffer taps_{*this}; ///< upscale16's per-column blend table + ScratchBuffer carry_{*this}; ///< the dither's per-channel error + OscillatorBank<1> bank_; + lengthType planeW_ = 0, planeH_ = 0, planeD_ = 0; + lengthType fw_ = 0, fh_ = 0, fd_ = 0; + bool front_ = true; + uint32_t lastMs_ = 0; + uint32_t frame_ = 0; + uint8_t peak_ = 200; ///< the field's high water mark, eased per frame + uint8_t floor_ = 40; ///< and its low, so the window spans what is there + bool started_ = false; ///< false until a frame has been timed +}; + +} // namespace mm diff --git a/src/light/effects/Noise2DEffect.h b/src/light/effects/Noise2DEffect.h deleted file mode 100644 index 3ab0071a..00000000 --- a/src/light/effects/Noise2DEffect.h +++ /dev/null @@ -1,62 +0,0 @@ -#pragma once - -#include "light/effects/EffectBase.h" - -namespace mm { - -// Noise 2D: a smoothly drifting value-noise field. Each (x,y) pixel reads a 3D noise sample whose -// X/Y coordinates are the grid position scaled by `scale` (larger scale = finer, more detailed -// noise; smaller = broad, smooth blobs) and whose Z coordinate is time, so the whole field flows / -// morphs over the frames. The 0..255 noise value indexes the active palette directly, giving the -// classic organic, plasma-like color wash. -// -// Source math (MoonLight's Noise2D): for every (x,y), -// pixelHue8 = inoise8(x*scale, y*scale, millis()/(16-speed)); -// setRGB(x,y, ColorFromPalette(pal, pixelHue8)); -// `16-speed` is the time divisor: a higher `speed` (max 15) shrinks the divisor, so time advances -// faster through the noise and the field morphs quicker. speed maxes at 15, so 16-speed is at least -// 1 — the division can never be by zero. -// -// Prior art: MoonLight's Noise2D effect (E_MoonModules / MoonModules), itself in the WLED -// noise-effect lineage (FastLED inoise8 — Perlin/value noise, Mark Kriegsman / Ken Perlin). The -// per-pixel coordinate-scale + time-on-Z animation and the direct palette indexing are reproduced -// exactly here, written fresh on EffectBase + the shared draw / noise primitives. -// Author: WLED (Noise 2D) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h -/// 2D value-noise effect. -class Noise2DEffect : public EffectBase { -public: - const char* tags() const override { return "💫🌙🐙"; } // MoonLight origin · MoonModules · WLED - Dim dimensions() const override { return Dim::D2; } - - uint8_t speed = 8; // time-flow rate (0..15); higher = faster morph (divisor is 16-speed) - uint8_t scale = 64; // noise zoom (2..255); higher = finer/more-detailed field - - void defineControls() override { - controls_.addControl("speed", speed, 0, 15); - controls_.addControl("scale", scale, 2, 255); - } - - void tick() MM_NONBLOCKING override { - const int cols = width(); - const int rows = height(); - - const draw::Canvas cv = canvas(); - - // Time coordinate on the noise Z axis: millis() / (16 - speed). speed <= 15 keeps the - // divisor >= 1 (no divide-by-zero). uint32_t throughout — matches inoise8's coordinate type. - const uint32_t t = elapsed() / static_cast(16 - speed); - - for (int y = 0; y < rows; y++) { - for (int x = 0; x < cols; x++) { - const uint8_t pixelHue8 = inoise8(static_cast(x) * scale, - static_cast(y) * scale, t); - const RGB c = colorFromPalette(*Palettes::active(), pixelHue8); - draw::pixel(cv, {static_cast(x), static_cast(y), 0}, c); - } - } - } - -private: -}; - -} // namespace mm \ No newline at end of file diff --git a/src/light/effects/NoiseEffect.h b/src/light/effects/NoiseEffect.h index 90d3d0ee..d39c5f0e 100644 --- a/src/light/effects/NoiseEffect.h +++ b/src/light/effects/NoiseEffect.h @@ -1,66 +1,87 @@ #pragma once -#include "core/math16.h" // BeatPhase — the shared BPM accumulator +#include "core/math16.h" // BeatPhase: the shared BPM accumulator #include "light/effects/EffectBase.h" namespace mm { -// Author: FastLED inoise field (Mark Kriegsman) -/// Smooth animated value-noise effect (3D on volumetric layouts). +// Noise: a gradient-noise field indexed straight into the palette, the plainest way to turn the +// field into light and the reference every other noise effect is a variation on. +// +// One control decides what MOVES, which is the whole character of the effect and was previously the +// difference between two separate effects: +// +// drift - the sample coordinates scroll, so the field slides across the fixture like weather. +// Each axis scrolls at a slightly different rate, so it flows rather than translating +// rigidly. On a volumetric fixture the third axis is the light's own z, so the slices +// differ and the field has real depth. +// morph - the coordinates hold still and TIME is the third axis, so the field changes in place +// without going anywhere. On a panel this is the classic plasma-like wash. On a +// volumetric fixture there is no axis left for depth, so every slice is the same. +// +// Author: FastLED inoise field (Mark Kriegsman), and MoonLight's Noise2D for the morph form. +/// Effect: a gradient-noise field through the palette, drifting across the fixture or morphing in place. /// @card NoiseEffect.gif class NoiseEffect : public EffectBase { public: - const char* tags() const override { return "⚡️"; } // FastLED-style noise + const char* tags() const override { return "⚡️💫🌙🐙"; } // FastLED + MoonLight lineage Dim dimensions() const override { return Dim::D3; } - uint8_t scale = 4; // spatial frequency (1-32) - uint8_t bpm = 60; // beats per minute — scrolls 8 noise cells per beat + static constexpr const char* kMotionOptions[] = {"drift", "morph"}; + + uint8_t motion = 0; // 0 = drift (the field moves), 1 = morph (the field changes in place) + uint8_t scale = 4; // spatial frequency: lower is broader, higher is finer + uint8_t bpm = 60; // how fast it moves, in beats per minute void defineControls() override { - controls_.addControl("scale", scale, 1, 32); + controls_.addSelect("motion", motion, kMotionOptions, 2); + controls_.addControl("scale", scale, 1, 255); controls_.addControl("bpm", bpm, 1, 255); } void tick() MM_NONBLOCKING override { uint8_t* buf = buffer(); - lengthType w = width(); - lengthType h = height(); - lengthType d = depth(); - uint8_t cpl = channelsPerLight(); - nrOfLightsType count = nrOfLights(); - nrOfLightsType wh = static_cast(w) * h; + const lengthType w = width(), h = height(), d = depth(); + const uint8_t cpl = channelsPerLight(); + const nrOfLightsType count = nrOfLights(); + const nrOfLightsType wh = static_cast(w) * h; + if (cpl == 0 || w == 0 || h == 0) return; - // Accumulate phase incrementally — changing BPM doesn't cause a jump. phase_ holds the RAW - // numerator (dt·bpm·w·64) and the /60000 divide runs only at the sampling point below; dividing - // per tick would truncate sub-unit progress to zero on a fast board (short dt) or small grid, - // stalling the field. Factor 64 tuned so 60 BPM at 128-wide gives smooth motion. - // The rate carries the grid width, so it is fed pre-scaled: advanceScaled takes the whole - // dt multiplicand rather than a plain BPM. BeatPhase owns the first-tick seed (the field - // starts at phase 0 rather than jumping by the device uptime) and the divide-late rule. + // Accumulate the phase incrementally, so changing bpm does not jump the field. phase_ holds + // the RAW numerator and the divide runs at the sampling point: dividing per tick truncates + // sub-unit progress to zero on a fast board or a small grid, stalling the field. The rate + // carries the grid width, so it is fed pre-scaled. BeatPhase owns the first-tick seed and + // the divide-late rule. phase_.advanceScaled(elapsed(), static_cast(bpm) * w * 64); - uint32_t t = phase_.phase(1); + const uint32_t t = phase_.phase(1); + const uint8_t sc = scale ? scale : 1; - // Buffer layout is (z * h * w + y * w + x). For a 2D grid (d == 1) z - // is always 0 and we sample 2D noise — no perf cost vs. the old 2D-only - // path. For d > 1 we sample 3D noise so each z-slice differs. for (nrOfLightsType i = 0; i < count; i++) { - nrOfLightsType rem = i % wh; - lengthType x = static_cast(rem % w); - lengthType y = static_cast(rem / w); - lengthType z = static_cast(i / wh); + const nrOfLightsType rem = i % wh; + const lengthType x = static_cast(rem % w); + const lengthType y = static_cast(rem / w); + const lengthType z = static_cast(i / wh); - // Scale coords into noise space: a finer `scale` packs more cells across the grid, - // and the time offset scrolls each axis at a slightly different rate so the field - // flows rather than slides flat. inoise8's high byte selects the cell, low byte the - // position within it. - const uint32_t nx = (static_cast(x) * 256u + t) / scale; - const uint32_t ny = (static_cast(y) * 256u + t / 3u) / scale; - const uint8_t n = (d > 1) - ? inoise8(nx, ny, (static_cast(z) * 256u + t / 5u) / scale) - : inoise8(nx, ny); - RGB c = colorFromPalette(*Palettes::active(), n); + uint8_t n; + if (motion == 1) { + // Morph: space is still and TIME is the third axis, with no depth term, so the + // field changes without traveling and a volumetric fixture shows the same field in + // every slice. On a panel it is the classic plasma wash. Drift spends that axis on + // depth instead, which is what separates the two. + n = inoise8(static_cast(x) * 256u / sc, + static_cast(y) * 256u / sc, t); + } else { + // Drift: the coordinates scroll, each axis at its own rate so the field flows + // instead of sliding rigidly. On a volumetric fixture z is the light's own depth, + // so the slices differ; on a panel that term is constant and costs nothing. + const uint32_t nx = (static_cast(x) * 256u + t) / sc; + const uint32_t ny = (static_cast(y) * 256u + t / 3u) / sc; + n = d > 1 ? inoise8(nx, ny, (static_cast(z) * 256u + t / 5u) / sc) + : inoise8(nx, ny); + } - size_t offset = static_cast(i) * cpl; + const RGB c = colorFromPalette(*Palettes::active(), n); + const size_t offset = static_cast(i) * cpl; if (cpl >= 1) buf[offset + 0] = c.r; if (cpl >= 2) buf[offset + 1] = c.g; if (cpl >= 3) buf[offset + 2] = c.b; @@ -69,8 +90,8 @@ class NoiseEffect : public EffectBase { private: BeatPhase phase_; - // The value-noise field itself (hash + smoothstep + bi/trilinear interp) is the shared - // inoise8 in core/noise.h — this effect just scales coordinates into it and colors the + // The field itself (lattice hash, gradient dot products, quintic fade, bi- or trilinear blend) + // is the shared inoise8 in core/noise.h: this effect scales coordinates into it and colors the // result through the palette. }; diff --git a/src/light/effects/NoiseMeterEffect.h b/src/light/effects/NoiseMeterEffect.h index cdcd4c36..5d79b645 100644 --- a/src/light/effects/NoiseMeterEffect.h +++ b/src/light/effects/NoiseMeterEffect.h @@ -5,7 +5,7 @@ namespace mm { // Noise Meter: a vertical VU column whose height tracks the overall sound level and whose color is a -// scrolling 2D value-noise field, so a loud moment fills the panel from the bottom up with a drifting, +// scrolling 2D gradient-noise field, so a loud moment fills the panel from the bottom up with a drifting, // organic gradient instead of a flat bar. Each frame the buffer fades a little (motion trail), the // audio level (scaled by `width`) sets how many rows light up from the bottom, and for each lit row a // noise sample — taken from a field that both scrolls (the aux0/aux1 phase accumulators) and is diff --git a/src/light/effects/PolarNoiseEffect.h b/src/light/effects/PolarNoiseEffect.h index 310d0623..96bb8ffd 100644 --- a/src/light/effects/PolarNoiseEffect.h +++ b/src/light/effects/PolarNoiseEffect.h @@ -1,7 +1,5 @@ #pragma once -#include "core/math16.h" // atan16, dist16, kaleido, BeatPhase -#include "core/noise.h" // fbm8, warp8 #include "light/effects/EffectBase.h" namespace mm { @@ -12,14 +10,17 @@ namespace mm { // texture scrolling past the panel, but a field that seems to turn and breathe inside it. Three // power functions compose to get there, and the effect itself is mostly parameter choices: // -// - `atan16`/`dist16` address the grid by ANGLE and RADIUS instead of x and y, which is what -// makes the motion rotate around the centre rather than slide across it. +// - `PolarLut` addresses the grid by ANGLE and RADIUS instead of x and y, which is what makes +// the motion rotate around the center rather than slide across it. The address is the same one +// `atan16` and `dist16` compute, read from a table: it does not change between frames, and +// computing it per pixel measured 39% of this effect's frame on an ESP32-S3. // - `warp8` displaces the sample coordinate by another noise field, so the field flows and // marbles instead of merely drifting (Quilez's domain warping). // - `kaleido` folds the angle into n mirrored wedges, turning the field into a mandala with a // single modulo — the symmetry is free because it happens before the field is ever sampled. // // Cost: `warp` is 2 noise samples plus its inner fbm, so at octaves=2 this is ~4 samples/pixel. +// The polar address is a table read rather than an `atan16` plus a `dist16`. // That is a rich-field effect, appropriate on small and medium fixtures and on desktop; on a large // wall drop `octaves` to 1 (or `warp` to 0) and it degrades to a plain polar noise that still // reads well. The controls are deliberately the cost knobs, not just the look knobs. @@ -31,7 +32,7 @@ namespace mm { class PolarNoiseEffect : public EffectBase { public: const char* tags() const override { return "💫🖌️"; } // power-function showcase - Dim dimensions() const override { return Dim::D2; } // writes the z=0 slice; extrude fills z + Dim dimensions() const override { return Dim::D3; } // volumetric: the field turns through depth uint8_t bpm = 8; // how fast the field drifts uint8_t scale = 40; // noise cells across the grid: low = broad shapes, high = fine detail @@ -40,6 +41,10 @@ class PolarNoiseEffect : public EffectBase { uint8_t octaves = 2; // fbm octaves — the main cost knob uint8_t twist = 30; // how much the radius shears the angle, giving the field a spiral set + /// The polar address: whether to read it from a table, at what precision, and how a + /// volumetric fixture's coordinates become an angle and a radius (light/polar.h). + PolarLut::Controls polar; + void defineControls() override { controls_.addControl("bpm", bpm, 0, 60); controls_.addControl("scale", scale, 1, 255); @@ -47,27 +52,64 @@ class PolarNoiseEffect : public EffectBase { controls_.addControl("warp", warp, 0, 255); controls_.addControl("octaves", octaves, 1, 4); controls_.addControl("twist", twist, 0, 255); + // The address tradeoff, as controls because it is the user's to make: the table costs 2 + // bytes per pixel (4 when wide) and buys back the per-pixel atan16 and dist16. + PolarLut::addControls(controls_, polar); + } + void prepare() override { + // The polar address is built here, not in tick(): prepare() is where a module builds state + // and where allocation is allowed, and it runs again on every resize and control change, so + // the table is always current without the render path ever allocating. + lut_.prepareFor(polar, width(), height(), EffectBase::depth()); } + void tick() MM_NONBLOCKING override { const draw::Canvas cv = canvas(); - const lengthType w = width(), h = height(); + const lengthType w = width(), h = height(), dep = depth(); - phase_.advance(elapsed(), bpm); - const uint32_t t = phase_.phase(65536); + // The drift is an oscillator: a sawtooth that only ever moves forward, which is what makes + // the field breathe outward rather than rock back and forth. + drift_.set(0, {.rate = bpm, .low = 0, .high = 65535, .phaseOffset = 0, .wave = Wave::Saw}); + drift_.advance(elapsed()); + const uint32_t t = drift_.unitValue(0); - // Centre in whole pixels; the field is smooth so sub-pixel centring buys nothing here. - const int32_t cx = w / 2, cy = h / 2; + // Build the address table if the grid changed; a rebuild is the only frame that pays for it. + // If it cannot be allocated the effect still renders, computing the address per pixel. + const bool table = lut_.ready(); + const int32_t cx = w / 2, cy = h / 2, cz = dep / 2; + std::size_t i = 0; + for (lengthType z = 0; z < dep; z++) for (lengthType y = 0; y < h; y++) { - for (lengthType x = 0; x < w; x++) { - const int32_t dx = static_cast(x) - cx; - const int32_t dy = static_cast(y) - cy; - - // Polar address. The twist term shears the angle by the radius, which is what turns - // concentric rings into spiral arms. - const uint32_t r = dist16(dx, dy); - angle16 a = atan16(dy, dx); + for (lengthType x = 0; x < w; x++, i++) { + // The polar address, and the radius in the pixel units the field is scaled in. + angle16 a; + uint32_t r; + // The axis the field is sampled along through the fixture. Zero at every light on a + // panel, so the volumetric form reduces exactly to the flat one there. + int32_t along; + if (table) { + a = lut_.angle(i); + r = lut_.radiusPixels(i); + along = lut_.mapping() == PolarLut::Mapping::Spherical + ? static_cast(lut_.pitch(i) >> 6) + : static_cast(z) - cz; + } else { + // The same address the table would have held, under the same mapping: a device + // too tight for the table must show the same composition, not a different one. + const auto m = PolarLut::mappingOf(polar); + const auto ad = PolarLut::addressOf(m, static_cast(x) - cx, + static_cast(y) - cy, + static_cast(z) - cz); + a = ad.angle; + r = ad.radius; + along = m == PolarLut::Mapping::Spherical ? static_cast(ad.pitch >> 6) + : static_cast(z) - cz; + } + + // The twist shears the angle by the radius, which is what turns concentric rings + // into spiral arms. a = static_cast(a + (r * twist)); // Fold into wedges BEFORE sampling, so the symmetry costs one modulo rather than a @@ -75,22 +117,24 @@ class PolarNoiseEffect : public EffectBase { a = kaleido(a, segments); // Sample the field in (angle, radius) space: the angle drives one axis and the - // radius the other, so the field wraps around the centre. Time moves the radius + // radius the other, so the field wraps around the center. Time moves the radius // axis, which reads as the pattern breathing outward. const uint32_t fx = (static_cast(a) >> 6) * scale / 16u; const uint32_t fy = (r * scale) + (t >> 6); + const uint32_t fz = static_cast(along * static_cast(scale)); - const uint8_t v = warp > 0 ? warp8(fx, fy, static_cast(warp) * 4, octaves) - : fbm8(fx, fy, octaves); + const uint8_t v = warp > 0 ? warp8(fx, fy, fz, static_cast(warp) * 4, octaves) + : fbm8(fx, fy, fz, octaves); const RGB c = colorFromPalette(*Palettes::active(), v, 255); - draw::pixel(cv, {x, y, 0}, c); + draw::pixel(cv, {x, y, z}, c); } } } private: - BeatPhase phase_; + PolarLut lut_{*this}; + OscillatorBank<1> drift_; }; } // namespace mm diff --git a/src/light/effects/RingsEffect.h b/src/light/effects/RingsEffect.h index 5dc61c9c..060ff166 100644 --- a/src/light/effects/RingsEffect.h +++ b/src/light/effects/RingsEffect.h @@ -82,8 +82,13 @@ class RingsEffect : public EffectBase { const int32_t dx = static_cast(x) - cx_[i]; const int32_t dy = static_cast(y) - cy_[i]; // Kept wide for the same reason as maxR: clamping the per-pixel distance to a - // byte made every light past 255 from a ripple's centre read as exactly 255, so + // byte made every light past 255 from a ripple's center read as exactly 255, so // the ring never appeared out there at all. + // + // Computed rather than read from a PolarLut, unlike the other radial effects: + // this distance is from a RIPPLE's center, not the grid's, and each ripple moves + // and respawns. A table per ripple would cost N times the memory and a rebuild + // whenever one respawns, which is more than the dist16 it would save. const uint32_t d = dist16(dx, dy); int32_t diff = static_cast(d) - static_cast(radius_[i]); if (diff < 0) diff = -diff; // stays int32: narrowing truncated large distances diff --git a/src/light/effects/SpiralEffect.h b/src/light/effects/SpiralEffect.h index 13f284d6..d33b9963 100644 --- a/src/light/effects/SpiralEffect.h +++ b/src/light/effects/SpiralEffect.h @@ -1,6 +1,5 @@ #pragma once -#include "core/math16.h" // BeatPhase — the shared BPM accumulator #include "light/effects/EffectBase.h" namespace mm { @@ -18,12 +17,24 @@ class SpiralEffect : public EffectBase { uint8_t twist = 4; uint8_t hue_shift = 0; + /// The polar address: whether to read it from a table, at what precision, and how a + /// volumetric fixture's coordinates become an angle and a radius (light/polar.h). + PolarLut::Controls polar; + void defineControls() override { controls_.addControl("bpm", bpm, 1, 255); controls_.addControl("twist", twist, 1, 255); controls_.addControl("hue_shift", hue_shift, 0, 255); + PolarLut::addControls(controls_, polar); + } + void prepare() override { + // The polar address is built here, not in tick(): prepare() is where a module builds state + // and where allocation is allowed, and it runs again on every resize and control change, so + // the table is always current without the render path ever allocating. + lut_.prepareFor(polar, width(), height(), depth()); } + void tick() MM_NONBLOCKING override { uint8_t* buf = buffer(); lengthType w = width(); @@ -42,16 +53,27 @@ class SpiralEffect : public EffectBase { int16_t cx = static_cast(w >> 1); int16_t cy = static_cast(h >> 1); + // The polar address is the same every frame, so it is read from a table; if the device + // cannot spare the memory the effect computes it per pixel and looks the same. + const bool table = lut_.ready(); + + std::size_t i = 0; for (lengthType y = 0; y < h; y++) { int16_t dy = static_cast(y) - cy; uint8_t* row = buf + static_cast(y) * static_cast(w) * cpl; - for (lengthType x = 0; x < w; x++) { + for (lengthType x = 0; x < w; x++, i++) { int16_t dx = static_cast(x) - cx; - // 16-bit polar: atan16 resolves the sweep smoothly where the 8-bit form stepped, and - // dist16 is a TRUE radius where dist8 approximated an octagon (visible corners on a - // large panel). Both are taken down to 8 bits here because hue is mod-256 by design. - const uint8_t angle = static_cast(atan16(dy, dx) >> 8); - const uint8_t dist = static_cast(dist16(dx, dy)); + // Angle and radius, both taken down to 8 bits here because hue is mod-256 by design. + // A true radius rather than the octagon an 8-bit distance approximates, which showed + // as visible corners on a large panel. + uint8_t angle, dist; + if (table) { + angle = static_cast(lut_.angle(i) >> 8); + dist = static_cast(lut_.radiusPixels(i)); + } else { + angle = static_cast(atan16(dy, dx) >> 8); + dist = static_cast(dist16(dx, dy)); + } uint8_t hue = static_cast( angle + static_cast(dist * twist) - t + hue_shift); RGB c = colorFromPalette(*Palettes::active(), hue); @@ -65,6 +87,7 @@ class SpiralEffect : public EffectBase { } private: + PolarLut lut_{*this}; // Numerator-only accumulator (units of dt*bpm). See tick() for why. BeatPhase phase_; }; diff --git a/src/light/effects/TrailsEffect.h b/src/light/effects/TrailsEffect.h new file mode 100644 index 00000000..5cee44f3 --- /dev/null +++ b/src/light/effects/TrailsEffect.h @@ -0,0 +1,261 @@ +#pragma once + +#include "light/effects/EffectBase.h" + +namespace mm { + +// Trails: dots thrown into a moving medium, leaving tails that the flow carries and bends. +// +// The composition is three power functions and nothing else: an EMITTER draws a few bright points, +// `draw::advect` carries the whole plane along a velocity field, and `draw::decay16` dims it by a +// half-life. Run every frame, that loop is what a trail IS. Nothing here paints a tail: the tail is +// last frame's dots, moved and dimmed, which is why the shape of the flow is visible in it. +// +// This is the advection idiom the way Aurora is the shader idiom. The vocabulary it demonstrates is +// transport rather than sampling: a field that says where the medium is going, applied to whatever +// happens to be there. Prior art: the flow-field family (4wheeljive's FlowFields, from a Stefan +// Petrick concept), and Stam's backward advection for the transport step itself. +// +// **The plane is 16-BIT, and that is the point.** A trail is a value multiplied by slightly less +// than one, hundreds of times a second. At 8 bits that either truncates to nothing (the tail dies +// early: measured, 48 of 64 cells) or rounds back up to where it started (the trail never fades and +// the effect turns solid). Both were measured; see `draw::decay`. So the plane the effect owns is +// wider than the layer it writes to, and narrows once on the way out. The precision belongs in the +// ACCUMULATOR, not in the frame buffer. +// +// Cost: one advect (a bilinear sample per light) plus one noise sample per light for the flow, so +// the flow rule dominates. Volumetric: on a cube every slice gets its OWN flow, sampled at that +// slice's depth, so the slices differ rather than one plane repeating. Light is carried WITHIN a +// slice and not yet between them: `advect16`'s rule yields vx and vy only, so a trail does not +// travel through the volume. 3D transport needs a trilinear sampler and a vz, which is its own +// change. +// @card TrailsEffect.png +/// Effect: bright dots thrown into a flowing medium, leaving tails the flow carries and bends. +class TrailsEffect : public EffectBase { +public: + const char* tags() const override { return "💫🖌️"; } // power-function showcase + Dim dimensions() const override { return Dim::D3; } // per-slice: each slice gets its own flow + + static constexpr uint8_t kMaxDots = 8; + static constexpr uint32_t kMaxEmitters = 64; ///< the ceiling once `dots` is scaled by the grid + + /// The head's radius for a fixture: one light on a panel, larger as the grid grows, capped so a + /// wall does not spend the frame drawing one head. + static lengthType spanRadius(lengthType span) { + const lengthType r = static_cast(span / 256 + 1); + return r > 4 ? 4 : r; + } + + uint8_t speed = 40; // how fast the medium moves, and with it every tail + uint8_t dots = 3; // emitter DENSITY: the count scales with the grid (see emitDots) + uint8_t scale = 30; // the flow field's cell size: low = broad sweeps, high = eddies + uint8_t persistence = 90; // how long a tail survives, as a half-life (see halfLifeMs) + uint8_t breathe = 40; // how much the flow's strength rises and falls + + void defineControls() override { + controls_.addControl("speed", speed, 0, 255); + controls_.addControl("dots", dots, 1, kMaxDots); + controls_.addControl("scale", scale, 1, 255); + controls_.addControl("persistence", persistence, 0, 255); + controls_.addControl("breathe", breathe, 0, 255); + } + + void prepare() override { + // The plane is the effect's own state and holds THREE channels per light whatever the + // layer's width is: a trail is about its own history, not about the fixture's wiring. + const lengthType w = width(), h = height(), d = depth(); + const size_t needed = static_cast(w) * h * d * 3; + const size_t had = plane_.count(); + plane_.resize(needed); + // Same sample count but a different shape (8x16 -> 16x8, or a cube reshaped): resize() kept + // the old samples, and they are laid out for the old geometry, so they would smear. Clear. + scratch_.resize(needed); + carry_.resize(needed); // the dither's error, one byte per sample + // Same sample count but a different shape: BOTH planes hold samples laid out for the old + // geometry, and the ping-pong swaps the spare one in on the very next frame, so clearing + // only the live one leaves the stale picture one frame away. (A changed sample count needs + // no clear: resize() zero-fills when it reallocates.) + if (needed > 0 && needed == had && (w != planeW_ || h != planeH_ || d != planeD_)) { + std::memset(plane_.data(), 0, plane_.bytes()); + std::memset(scratch_.data(), 0, scratch_.bytes()); + } + planeW_ = w; planeH_ = h; planeD_ = d; + started_ = false; // the next tick is the first: it has no previous frame to measure + } + + void tick() MM_NONBLOCKING override { + if (!plane_ || !scratch_) return; // a zero grid, or an allocation that failed + const lengthType w = width(), h = height(), d = depth(); + // One reading of the clock, and a zero delta on the first tick: `lastMs_` has no previous + // frame to measure against, so `elapsed() - 0` would hand the flow the whole uptime and + // teleport the trail (and decay it to nothing) on the frame it starts. + const uint32_t now = elapsed(); + const uint32_t dt = started_ ? now - lastMs_ : 0u; + lastMs_ = now; + started_ = true; + + // Two oscillators: one walks the emitters around, one breathes the flow's strength so the + // composition swells and settles instead of running at one rate forever. + bank_.set(0, {.rate = static_cast(8 + speed / 8), .low = 0, .high = 65535, + .phaseOffset = 0, .wave = Wave::Saw}); + bank_.set(1, {.rate = 7, .low = static_cast(256 - breathe), + .high = static_cast(256 + breathe), + .phaseOffset = 0, .wave = Wave::Sine}); + bank_.advance(dt); + + // Which buffer currently HOLDS the trail alternates: a ScratchBuffer is deliberately fixed + // to its module (non-movable, it owns a slot in the module's free list), so the ping-pong + // swaps a flag rather than the buffers. + ScratchBuffer& src = front_ ? plane_ : scratch_; + ScratchBuffer& dst = front_ ? scratch_ : plane_; + + // 1. Transport: carry what is already there along the flow. Backward-sampled, so this both + // moves the trail and is what bends it, since neighboring pixels take different paths. + const uint32_t t = now; // the same reading the delta came from + const uint32_t cells = static_cast(scale) * 256u; + const int32_t strength = static_cast(bank_.value(1)); // the breathing multiplier + const uint32_t step = (static_cast(speed) * dt) / 8u; // sub-pixels this frame + draw::advect16(dst.data(), src.data(), w, h, d, + [&](lengthType x, lengthType y, lengthType z, + draw::pos_t& vx, draw::pos_t& vy) { + flowAt(x, y, z, t, cells, strength, step, vx, vy); + }, draw::Edge::Clamp); + front_ = !front_; // the destination now holds the trail + ScratchBuffer& moved = front_ ? plane_ : scratch_; + + // 2. Decay: a half-life, so the tail is the same length in SECONDS on any device. This is + // the step that needs the wide plane (draw::decay's own note has the measurements). + draw::decay16(moved.data(), moved.count(), halfLifeMs(), dt); + + // 3. Emit: the bright heads, drawn after the transport so this frame's dots are sharp and + // only the previous ones have been carried. + emitDots(moved.data(), w, h, d, dt); + + // 4. Narrow onto the layer: the one place the wide plane meets the fixture's width. + // Dithered, like the other wide-plane effects: a trail's slow fade is exactly where an + // 8-bit truncation bands, and the carry is what removes it. + draw::blit16(canvas(), moved.data(), w, h, d, carry_ ? carry_.data() : nullptr); + } + +private: + /// The persistence control as a half-life in milliseconds. Deliberately not linear: the + /// interesting range is short, and a slider that spends half its travel between four and eight + /// seconds would waste it. 0 is a bare head with no tail at all. + uint32_t halfLifeMs() const { + return 20u + static_cast(persistence) * static_cast(persistence) / 16u; + } + + /// The flow: where the medium is going at this point, in sub-pixels this frame. + /// + /// A noise field read at two offsets, one per axis, which is the decoupled form: reading ONE + /// field for both axes moves everything along a diagonal, since the two components would rise + /// and fall together. The third axis is sampled too, so a cube's slices flow differently rather + /// than the same plane repeating through the volume. + void flowAt(lengthType x, lengthType y, lengthType z, uint32_t t, uint32_t cells, + int32_t strength, uint32_t step, draw::pos_t& vx, draw::pos_t& vy) const { + const uint32_t fx = static_cast(x) * cells; + const uint32_t fy = static_cast(y) * cells; + const uint32_t fz = static_cast(z) * cells + t / 4u; + // Centered on zero, so the field pushes both ways rather than only along the axes. + const int32_t nx = static_cast(inoise16(fx, fy, fz)) - 32768; + const int32_t ny = static_cast(inoise16(fx + 0x9E37u, fy + 0x7C15u, fz)) - 32768; + // strength is the breathing multiplier in 1/256ths; step is the frame's travel budget. + const int32_t amp = static_cast(step) * strength / 256; + // 64-bit for the product, as curl16 carries for the same reason: `amp` grows with a + // stalled dt, and a signed 32-bit multiply wraps rather than saturating, which reverses the + // flow instead of merely overdriving it. + vx = static_cast((static_cast(nx) * amp) >> 15); + vy = static_cast((static_cast(ny) * amp) >> 15); + } + + /// The heads: a few bright points walking their own paths, each on the palette. + /// + /// Emission is paced by TIME, not by frames, and the heads stay at full brightness. + /// + /// Writing a head every frame injects light at the framerate: measured, a 1200 fps device lit a + /// third more of the panel than a 60 fps one for the same settings. Scaling the brightness by + /// dt instead is the obvious fix and is WRONG here, because `writeWide` SETS the pixel rather + /// than accumulating into it: twenty dim writes do not add up to one bright one, and the fast + /// device came out twice as dark instead (1.96 the other way). So the head keeps its full + /// value and fires on a budget, with the remainder carried, which is the pattern + /// ParticlesEffect already uses for its fade. + void emitDots(uint16_t* plane, lengthType w, lengthType h, lengthType d, uint32_t dt) { + // `dots` is a DENSITY, not a count: the emitters scale with the fixture, because a handful + // of heads that fill a 64x64 panel are lost on a wall. Measured against the script, which + // already did this: three fixed dots put 1/567th of the light on a 768x348 panel. + const lengthType span = w > h ? w : h; + const lengthType rad = spanRadius(span); + const uint32_t area = static_cast(rad * 2 + 1) * (rad * 2 + 1); + const uint8_t want = dots < 1 ? 1 : (dots > kMaxDots ? kMaxDots : dots); + uint32_t scaled = static_cast(want) * w * h / (4096u * (area > 8u ? area / 9u : 1u)); + if (scaled < 1) scaled = 1; + if (scaled > kMaxEmitters) scaled = kMaxEmitters; + const uint8_t n = static_cast(scaled); + constexpr uint32_t kReferenceMs = 20; + emitCarry_ += dt; + if (emitCarry_ < kReferenceMs) return; // not enough time has passed for a head yet + // One head per frame at most, and the remainder carries: a slow frame must not emit the + // burst it owes, because every head is a full disc and a stall would flood the plane. Below + // 50 fps this does cost heads (30/s at 30 fps against 50/s above 50), which is the trade + // taken deliberately; Fluid's jets cap the same debt rather than dropping it. + emitCarry_ %= kReferenceMs; + const uint32_t walk = bank_.unitValue(0); + for (uint8_t i = 0; i < n; i++) { + // Each dot rides the same clock at its own offset and its own ratio, so they never + // bunch: a Lissajous walk, which visits the whole grid rather than circling one spot. + const angle16 a = static_cast(walk + i * (65536u / n)); + const angle16 b = static_cast(walk * 3u + i * 9973u); + const lengthType px = mapAxis(sin16(a), w); + const lengthType py = mapAxis(cos16(b), h); + const lengthType pz = d > 1 ? mapAxis(sin16(static_cast(b * 2u)), d) : 0; + const RGB c = colorFromPalette(*Palettes::active(), + static_cast(i * (255u / n) + (walk >> 9))); + // Written at the plane's width, so a head starts at full precision and the decay has + // somewhere to go: writing a byte would put the whole tail in the top 8 bits. + writeWide(plane, w, h, d, px, py, pz, c, rad); + } + } + + /// A signed 16-bit sine mapped onto an axis, centered, with the ends reachable. + static lengthType mapAxis(int16_t s, lengthType extent) { + if (extent <= 1) return 0; + const int32_t v = (static_cast(s) + 32768) * (extent - 1) / 65535; + return static_cast(v); + } + + /// One light at the plane's full width: an 8-bit color widened by repeating the byte, so 255 + /// becomes 65535 rather than 65280 and a full-brightness head is genuinely full. + static void writeWide(uint16_t* plane, lengthType w, lengthType h, lengthType d, + lengthType x, lengthType y, lengthType z, RGB c, lengthType rad) { + // A DISC, not a pixel. Advection spreads a head bilinearly, so after N frames one light's + // worth of brightness covers roughly N pixels: a single-pixel head on a multi-second tail + // arrives at about 1 part in 255 and the panel reads as a faint smear. The radius grows + // with the fixture, because the area a tail is spread over does too. + const lengthType rz = d > 1 ? rad : 0; + for (lengthType dz = -rz; dz <= rz; dz++) + for (lengthType dy = -rad; dy <= rad; dy++) + for (lengthType dx = -rad; dx <= rad; dx++) { + if (dx * dx + dy * dy + dz * dz > rad * rad) continue; + const lengthType px = x + dx, py = y + dy, pz = z + dz; + if (px < 0 || py < 0 || pz < 0 || px >= w || py >= h || pz >= d) continue; + const size_t off = (static_cast(pz) * h * w + + static_cast(py) * w + px) * 3; + plane[off + 0] = static_cast((c.r << 8) | c.r); + plane[off + 1] = static_cast((c.g << 8) | c.g); + plane[off + 2] = static_cast((c.b << 8) | c.b); + } + } + + + ScratchBuffer plane_{*this}; ///< the trail itself, three samples per light + ScratchBuffer scratch_{*this}; ///< advect's destination; the two alternate roles + ScratchBuffer carry_{*this}; ///< the dither's per-channel error + bool front_ = true; ///< which of the two currently holds the trail + OscillatorBank<2> bank_; + lengthType planeW_ = 0, planeH_ = 0, planeD_ = 0; + uint32_t lastMs_ = 0; + bool started_ = false; ///< false until a frame has been timed + uint32_t emitCarry_ = 0; ///< time owed to the emitters, in ms +}; + +} // namespace mm diff --git a/src/light/effects/TunnelEffect.h b/src/light/effects/TunnelEffect.h index c80fc806..edf8f37e 100644 --- a/src/light/effects/TunnelEffect.h +++ b/src/light/effects/TunnelEffect.h @@ -1,7 +1,5 @@ #pragma once -#include "core/math16.h" // atan16, dist16, kaleido, BeatPhase -#include "core/noise.h" // fbm8 — the tunnel wall texture #include "light/effects/EffectBase.h" namespace mm { @@ -9,9 +7,9 @@ namespace mm { // Tunnel: the demoscene classic — a texture mapped onto the inside of an infinite tube, so the // viewer appears to fly down it forever. // -// The trick is that nothing is 3D. For each pixel, its ANGLE around the centre becomes one texture +// The trick is that nothing is 3D. For each pixel, its ANGLE around the center becomes one texture // coordinate and the RECIPROCAL of its distance becomes the other. Because 1/r grows without bound -// as you approach the centre, the texture compresses toward a vanishing point, and adding time to +// as you approach the center, the texture compresses toward a vanishing point, and adding time to // that coordinate pulls it toward the viewer. Perspective for the price of a divide. // // It is the polar vocabulary carried to its conclusion: `atan16` and `dist16` give the angle and @@ -29,15 +27,21 @@ namespace mm { class TunnelEffect : public EffectBase { public: const char* tags() const override { return "💫🖌️"; } // power-function showcase - Dim dimensions() const override { return Dim::D2; } // writes the z=0 slice; extrude fills z + Dim dimensions() const override { return Dim::D3; } // volumetric: the wall recedes through depth uint8_t bpm = 20; // how fast the tunnel flies past - uint8_t depth = 60; // texture scale along the tunnel: higher = finer rings + uint8_t depth = 60; // texture scale along the tunnel: higher = finer rings. + // Shadows EffectBase::depth() (the fixture's), which is why the few + // uses of that one below are qualified. uint8_t twist = 40; // rotation per unit depth, so the tunnel corkscrews uint8_t segments = 1; // kaleidoscope the wall; 1 leaves it plain uint8_t octaves = 2; // wall texture detail, and the cost knob bool vignette = true; // darken toward the vanishing point so it reads as receding + /// The polar address: whether to read it from a table, at what precision, and how a + /// volumetric fixture's coordinates become an angle and a radius (light/polar.h). + PolarLut::Controls polar; + void defineControls() override { controls_.addControl("bpm", bpm, 0, 120); controls_.addControl("depth", depth, 1, 255); @@ -45,28 +49,60 @@ class TunnelEffect : public EffectBase { controls_.addControl("segments", segments, 1, 16); controls_.addControl("octaves", octaves, 1, 4); controls_.addControl("vignette", vignette); + PolarLut::addControls(controls_, polar); + } + void prepare() override { + // The polar address is built here, not in tick(): prepare() is where a module builds state + // and where allocation is allowed, and it runs again on every resize and control change, so + // the table is always current without the render path ever allocating. + lut_.prepareFor(polar, width(), height(), EffectBase::depth()); } + void tick() MM_NONBLOCKING override { const draw::Canvas cv = canvas(); - const lengthType w = width(), h = height(); + const lengthType w = width(), h = height(), dep = EffectBase::depth(); phase_.advance(elapsed(), bpm); const uint32_t t = phase_.phase(65536); - const int32_t cx = w / 2, cy = h / 2; + const int32_t cx = w / 2, cy = h / 2, cz = dep / 2; - for (lengthType y = 0; y < h; y++) { - for (lengthType x = 0; x < w; x++) { - const int32_t dx = static_cast(x) - cx; - const int32_t dy = static_cast(y) - cy; + // The polar address does not change between frames, so it is read from a table; if the + // device cannot spare the memory the effect computes it per pixel and looks the same. + const bool table = lut_.ready(); - const uint32_t r = dist16(dx, dy); - angle16 a = atan16(dy, dx); + std::size_t i = 0; + for (lengthType z = 0; z < dep; z++) + for (lengthType y = 0; y < h; y++) { + for (lengthType x = 0; x < w; x++, i++) { + uint32_t r; + angle16 a; + // How far along the tube this light sits. A volumetric fixture is a real tube, so + // depth is literally distance down it: the wall texture scrolls past each slice at + // its own offset rather than every slice showing the same ring. + int32_t along; + if (table) { + a = lut_.angle(i); + r = lut_.radiusPixels(i); + along = lut_.mapping() == PolarLut::Mapping::Spherical + ? static_cast(lut_.pitch(i) >> 6) + : static_cast(z) - cz; + } else { + // The same address the table would have held, under the same mapping. + const auto m = PolarLut::mappingOf(polar); + const auto ad = PolarLut::addressOf(m, static_cast(x) - cx, + static_cast(y) - cy, + static_cast(z) - cz); + a = ad.angle; + r = ad.radius; + along = m == PolarLut::Mapping::Spherical ? static_cast(ad.pitch >> 6) + : static_cast(z) - cz; + } - // 1/r is the depth coordinate: distant wall (small r) compresses toward the centre, + // 1/r is the depth coordinate: distant wall (small r) compresses toward the center, // which is exactly the perspective foreshortening a real tunnel has. The +1 keeps - // the pixel at the very centre from dividing by zero. + // the pixel at the very center from dividing by zero. const uint32_t depthCoord = (static_cast(depth) * 4096u) / (r + 1); // The wall corkscrews: rotating by depth means each ring is turned a little more @@ -77,10 +113,13 @@ class TunnelEffect : public EffectBase { // Sample the wall texture in (angle, depth) space, with time pulling the depth // coordinate toward the viewer. const uint32_t u = (static_cast(a) >> 5); - const uint32_t v = depthCoord + (t >> 5); + // Depth down the tube adds to the texture's own depth coordinate, so a light further + // in shows wall that is further away. On a panel `along` is 0 and this is the flat + // tunnel exactly. + const uint32_t v = depthCoord + (t >> 5) + static_cast(along * 256); const uint8_t tex = fbm8(u, v, octaves); - // Vignette by distance so the centre reads as far away rather than merely small. + // Vignette by distance so the center reads as far away rather than merely small. uint8_t bri = 255; if (vignette) { const uint32_t maxR = static_cast(cx > cy ? cx : cy) + 1; @@ -88,12 +127,13 @@ class TunnelEffect : public EffectBase { bri = static_cast(rel < 20 ? 20 : rel); } - draw::pixel(cv, {x, y, 0}, colorFromPalette(*Palettes::active(), tex, bri)); + draw::pixel(cv, {x, y, z}, colorFromPalette(*Palettes::active(), tex, bri)); } } } private: + PolarLut lut_{*this}; BeatPhase phase_; }; diff --git a/src/light/effects/WaveEffect.h b/src/light/effects/WaveEffect.h index e1bbd6f3..c3bf0ca5 100644 --- a/src/light/effects/WaveEffect.h +++ b/src/light/effects/WaveEffect.h @@ -29,7 +29,7 @@ class WaveEffect : public EffectBase { Dim dimensions() const override { return Dim::D2; } // Waveform shapes, index-aligned with waveY()'s switch. "Sin3" is a composite of three sines - // (a richer, rolling wave); "Noise" plots value noise for a jittered band. + // (a richer, rolling wave); "Noise" plots gradient noise for a jittered band. static constexpr const char* kTypeOptions[] = {"Sawtooth", "Triangle", "Sine", "Square", "Sin3", "Noise"}; static constexpr uint8_t kTypeCount = 6; @@ -136,7 +136,7 @@ class WaveEffect : public EffectBase { case 4: v = static_cast( // Sin3: three summed sines (sin8(phase) + sin8(static_cast(phase * 2)) + sin8(static_cast(phase * 3))) / 3); break; - default: v = inoise8(phase); break; // Noise (type 5) — shared 1D value noise + default: v = inoise8(phase); break; // Noise (type 5): shared 1D gradient noise } const lengthType y = static_cast((static_cast(v) * h) / 256); return y < h ? y : static_cast(h - 1); diff --git a/src/light/fluid.h b/src/light/fluid.h new file mode 100644 index 00000000..6a98a1d8 --- /dev/null +++ b/src/light/fluid.h @@ -0,0 +1,241 @@ +#pragma once + +#include "core/ScratchBuffer.h" // the grids an effect owns +#include "light/light_types.h" + +#include + +namespace mm { + +// A stable-fluid velocity field: the medium itself, simulated rather than sampled. +// +// Every flow so far has been a FUNCTION of position and time: noise, curl, a wind. This is the +// other kind. The velocity here is state that evolves from its own past, so pushing the medium in +// one place changes where everything downstream goes, and a vortex forms because the math says it +// must rather than because a rule drew one. That is the difference a viewer sees: a curl field is +// beautiful and unchanging in character, while a fluid REACTS. +// +// The algorithm is Stam's (Jos Stam, "Stable Fluids", SIGGRAPH 1999), which is the standard choice +// for exactly one reason: it cannot blow up. An explicit solver has a timestep small enough to stay +// stable, and a frame that runs long breaks it; Stam's is unconditionally stable, so a device that +// stalls for a second resumes with a plausible field instead of a screenful of infinities. On a +// fixture that must never look broken, that property is worth more than accuracy. +// +// Four steps a frame, and the order is the algorithm: +// +// 1. `diffuse` viscosity: each cell relaxes toward its neighbors' average. +// 2. `project` make it divergence-free: the step that turns a set of arrows into a FLOW. +// 3. `advect` the velocity carries itself, which is what makes a vortex persist and travel. +// 4. `project` again, because advection reintroduces divergence. +// +// Then the caller advects its own dye (the light) along the finished field with `draw::advect16`. +// +// **Q16.16 throughout, not float.** The render path is integer by contract, and a fluid is the +// hardest case for that: `project` solves a linear system by relaxation, so an error that a float +// would absorb accumulates over iterations. 16 fraction bits is what makes the pressure solve +// converge at all; 8 would quantize the gradient to nothing on a slow flow. +// +// Sized for panels. The cost is per cell per iteration and there are several passes, so this is a +// desktop and P4 effect; an S3 runs it on a small grid or not at all. +class Fluid { +public: + explicit Fluid(MoonModule& owner) + : vx_(owner), vy_(owner), vx0_(owner), vy0_(owner), p_(owner), div_(owner) {} + + /// Q16.16: the fixed-point format the whole solver works in. + static constexpr int32_t kOne = 1 << 16; + + /// Size (or free) the grids. Returns whether a field is available, which is what an effect + /// reports: a device too small says so rather than rendering nothing in silence. + /// + /// Depth is a stack of INDEPENDENT slices, each its own 2D medium: the solve is per slice and + /// nothing is carried between them. A panel is depth 1 and pays nothing for the stack. A true + /// volumetric solve (pressure and advection across z) is a different solver, not a flag. + bool resize(lengthType w, lengthType h, lengthType d = 1) { + if (w <= 2 || h <= 2 || d < 1) { release(); return false; } // a grid with no interior + const size_t n = static_cast(w) * h * d; + // Already this shape AND still allocated: MoonModule::release() frees every registered + // buffer behind this object's back (a disabled module, or a disabled ancestor), and the + // shape alone would then report a grid that is no longer there. + if (w == w_ && h == h_ && d == d_ && vx_) return true; + // The velocity is per cell, but the four working grids are per SLICE: the solve walks one + // slice at a time and never addresses another's, so sizing them to the volume allocated + // 19 unused copies on a 20-cube (121 KB for nothing). + const size_t slice = static_cast(w) * h; + const bool ok = vx_.resize(n) && vy_.resize(n) && vx0_.resize(slice) + && vy0_.resize(slice) && p_.resize(slice) && div_.resize(slice); + if (!ok) { release(); return false; } + w_ = w; h_ = h; d_ = d; cells_ = n; + reset(); + return true; + } + + void release() { + vx_.resize(0); vy_.resize(0); vx0_.resize(0); vy0_.resize(0); p_.resize(0); div_.resize(0); + w_ = h_ = d_ = 0; cells_ = 0; + } + + /// Re-seed to rest: every velocity zero. The resync point, for a fixture that has been + /// reconfigured under a running simulation. + void reset() { + if (!valid()) return; + std::memset(vx_.data(), 0, vx_.bytes()); + std::memset(vy_.data(), 0, vy_.bytes()); + } + + /// Ready to step. Read from the buffers, not from a cached shape: the owner's release() can + /// free them between two frames, and that is exactly the frame a stale flag crashes on. + bool valid() const { return cells_ > 0 && vx_ && vy_ && vx0_ && vy0_ && p_ && div_; } + lengthType width() const { return w_; } + lengthType height() const { return h_; } + lengthType depth() const { return d_; } + /// Cells per slice: a slice's fields start at `z * plane()` in velocityX()/velocityY(). + size_t plane() const { return static_cast(w_) * h_; } + const int32_t* velocityX() const { return vx_.data(); } + const int32_t* velocityY() const { return vy_.data(); } + + /// Push the medium at one cell. The source term: an emitter, a control, a beat. + void addVelocity(lengthType x, lengthType y, int32_t dvx, int32_t dvy, lengthType z = 0) { + if (!valid() || x < 0 || y < 0 || z < 0 || x >= w_ || y >= h_ || z >= d_) return; + const size_t i = static_cast(z) * plane() + idx(x, y); + vx_[i] += dvx; + vy_[i] += dvy; + } + + /// One frame of the simulation. `viscosity` and `dt` are Q16.16; `iterations` is the pressure + /// solve's effort, and the honest cost knob: 5 is the usual default, 1 is visibly springy. + void step(int32_t viscosity, int32_t dt, uint8_t iterations) { + if (!valid()) return; + const uint8_t iters = iterationsSanitized(iterations); + const size_t n = plane(); + for (lengthType z = 0; z < d_; z++) { + const size_t at = static_cast(z) * n; + int32_t* vx = vx_.data() + at; int32_t* vy = vy_.data() + at; + int32_t* vx0 = vx0_.data(); int32_t* vy0 = vy0_.data(); // per-slice scratch + if (viscosity > 0) { + diffuse(vx, vx0, viscosity, dt, iters, 1); + diffuse(vy, vy0, viscosity, dt, iters, 2); + } + project(vx, vy, iters); + std::memcpy(vx0, vx, n * sizeof(int32_t)); + std::memcpy(vy0, vy, n * sizeof(int32_t)); + advectSelf(vx, vy, vx0, vy0, dt); + project(vx, vy, iters); + } + } + +private: + static uint8_t iterationsSanitized(uint8_t iterations) { return iterations < 1 ? 1 : iterations; } + size_t idx(lengthType x, lengthType y) const { return static_cast(y) * w_ + x; } + + /// Walls: the boundary mirrors the interior, with the normal component negated, so the medium + /// slides along an edge rather than through it. `b` says which component (1 = x, 2 = y, 0 = a + /// scalar like pressure). + void setBoundary(int32_t* f, int b) { + for (lengthType x = 1; x < w_ - 1; x++) { + f[idx(x, 0)] = (b == 2) ? -f[idx(x, 1)] : f[idx(x, 1)]; + f[idx(x, h_ - 1)] = (b == 2) ? -f[idx(x, h_ - 2)] : f[idx(x, h_ - 2)]; + } + for (lengthType y = 1; y < h_ - 1; y++) { + f[idx(0, y)] = (b == 1) ? -f[idx(1, y)] : f[idx(1, y)]; + f[idx(w_ - 1, y)] = (b == 1) ? -f[idx(w_ - 2, y)] : f[idx(w_ - 2, y)]; + } + // The corners have no single neighbor to mirror, so they average the two beside them. + f[idx(0, 0)] = (f[idx(1, 0)] + f[idx(0, 1)]) / 2; + f[idx(w_ - 1, 0)] = (f[idx(w_ - 2, 0)] + f[idx(w_ - 1, 1)]) / 2; + f[idx(0, h_ - 1)] = (f[idx(1, h_ - 1)] + f[idx(0, h_ - 2)]) / 2; + f[idx(w_ - 1, h_ - 1)] = (f[idx(w_ - 2, h_ - 1)] + f[idx(w_ - 1, h_ - 2)]) / 2; + } + + /// Gauss-Seidel relaxation: the shared inner loop of both diffuse and project. Each cell + /// becomes a weighted average of itself and its four neighbors, repeated until it settles. + void relax(int32_t* f, const int32_t* f0, int32_t a, int32_t c, uint8_t iters, int b) { + if (c == 0) return; + for (uint8_t k = 0; k < iters; k++) { + for (lengthType y = 1; y < h_ - 1; y++) { + for (lengthType x = 1; x < w_ - 1; x++) { + const int64_t neigh = static_cast(f[idx(x - 1, y)]) + f[idx(x + 1, y)] + + f[idx(x, y - 1)] + f[idx(x, y + 1)]; + // 64-bit for the product: a Q16.16 velocity times a Q16.16 coefficient is a + // Q32.32 intermediate, and truncating it to 32 bits loses the whole integer + // part on any but the slowest flow. + const int64_t v = (static_cast(f0[idx(x, y)]) << 16) + + static_cast(a) * neigh; + f[idx(x, y)] = static_cast(v / c); + } + } + setBoundary(f, b); + } + } + + void diffuse(int32_t* f, int32_t* f0, int32_t visc, int32_t dt, uint8_t iters, int b) { + std::memcpy(f0, f, plane() * sizeof(int32_t)); + const int64_t a = (static_cast(visc) * dt) >> 16; + if (a <= 0) return; + relax(f, f0, static_cast(a), static_cast((kOne + 4 * a)), iters, b); + } + + /// Make the field divergence-free: compute how much each cell is gaining or losing, solve for + /// a pressure whose gradient cancels it, then subtract that gradient. + /// + /// This is the step that separates a fluid from a field of arrows. Without it the medium piles + /// up in some places and drains from others, and anything carried by it clumps and vanishes. + void project(int32_t* vx, int32_t* vy, uint8_t iters) { + int32_t* div = div_.data(); + int32_t* p = p_.data(); + for (lengthType y = 1; y < h_ - 1; y++) + for (lengthType x = 1; x < w_ - 1; x++) { + const int64_t d = static_cast(vx[idx(x + 1, y)]) - vx[idx(x - 1, y)] + + vy[idx(x, y + 1)] - vy[idx(x, y - 1)]; + div[idx(x, y)] = static_cast(-d / 2); + p[idx(x, y)] = 0; + } + setBoundary(div, 0); + setBoundary(p, 0); + relax(p, div, kOne, 4 * kOne, iters, 0); + for (lengthType y = 1; y < h_ - 1; y++) + for (lengthType x = 1; x < w_ - 1; x++) { + vx[idx(x, y)] -= (p[idx(x + 1, y)] - p[idx(x - 1, y)]) / 2; + vy[idx(x, y)] -= (p[idx(x, y + 1)] - p[idx(x, y - 1)]) / 2; + } + setBoundary(vx, 1); + setBoundary(vy, 2); + } + + /// The velocity carries itself: backward-sampled, like every other advection here, because + /// that is what stays stable when a cell would otherwise move further than one cell per step. + void advectSelf(int32_t* vx, int32_t* vy, const int32_t* vx0, const int32_t* vy0, int32_t dt) { + for (lengthType y = 1; y < h_ - 1; y++) { + for (lengthType x = 1; x < w_ - 1; x++) { + // Where this cell's contents came from, in Q16.16 cell coordinates. + int64_t sx = (static_cast(x) << 16) - ((static_cast(vx0[idx(x, y)]) * dt) >> 16); + int64_t sy = (static_cast(y) << 16) - ((static_cast(vy0[idx(x, y)]) * dt) >> 16); + // Clamped half a cell inside the wall, so the bilinear read below always has four + // real neighbors and the boundary handles the rest. + const int64_t lo = kOne / 2, hiX = (static_cast(w_) << 16) - kOne - kOne / 2; + const int64_t hiY = (static_cast(h_) << 16) - kOne - kOne / 2; + sx = sx < lo ? lo : (sx > hiX ? hiX : sx); + sy = sy < lo ? lo : (sy > hiY ? hiY : sy); + const lengthType x0 = static_cast(sx >> 16), y0 = static_cast(sy >> 16); + const int64_t fx = sx & 0xFFFF, fy = sy & 0xFFFF; + const auto blend = [&](const int32_t* f) -> int32_t { + const int64_t a = f[idx(x0, y0)] + (((static_cast(f[idx(x0 + 1, y0)]) - f[idx(x0, y0)]) * fx) >> 16); + const int64_t b = f[idx(x0, y0 + 1)] + (((static_cast(f[idx(x0 + 1, y0 + 1)]) - f[idx(x0, y0 + 1)]) * fx) >> 16); + return static_cast(a + (((b - a) * fy) >> 16)); + }; + vx[idx(x, y)] = blend(vx0); + vy[idx(x, y)] = blend(vy0); + } + } + setBoundary(vx, 1); + setBoundary(vy, 2); + } + + ScratchBuffer vx_, vy_; ///< the velocity field, Q16.16 + ScratchBuffer vx0_, vy0_; ///< the previous field, which advection reads + ScratchBuffer p_, div_; ///< the pressure solve's working grids + lengthType w_ = 0, h_ = 0, d_ = 0; + size_t cells_ = 0; ///< every slice together +}; + +} // namespace mm diff --git a/src/light/layouts/Rings241Layout.h b/src/light/layouts/Rings241Layout.h index b7f3ffde..e8909548 100644 --- a/src/light/layouts/Rings241Layout.h +++ b/src/light/layouts/Rings241Layout.h @@ -6,12 +6,12 @@ namespace mm { // The classic 241-LED concentric-ring disc: nine full circles sharing one -// centre, with ring LED counts 1, 8, 12, 16, 24, 32, 40, 48, 60 (sum 241). +// center, with ring LED counts 1, 8, 12, 16, 24, 32, 40, 48, 60 (sum 241). // // Prior art: MoonLight's Rings241Layout, which composes MoonLight's RingLayout // once per ring. RingLayout places `n` LEDs evenly on a circle of radius // n / (2π), starting at the bottom (angleRad = π at i=0) and stepping by 2π/n. -// This port reproduces that exact per-LED math but emits coordinates only — +// This port reproduces that exact per-LED math but emits coordinates only; // MoonLight's pin/wiring plumbing (doNextPin/nextPin, and RingLayout's // angleFirst/rotation/clockwise/nrOfLEDs UI controls) has no place here, since // a projectMM layout hands positions to the driver and the driver owns pins. @@ -19,6 +19,12 @@ namespace mm { // multiplier. Every ring is a full circle (MoonLight's rotation = 360), so every // LED is emitted; that makes lightCount() the fixed constant 241. // +// `outside in` is ours, not MoonLight's: it names which end of the wire is light +// 0. A disc is soldered either from the center LED outward (MoonLight's order, +// the default) or from the outer 60-LED ring inward, and a layout that only knew +// one of them would light the wrong ring for the other. Only the ring SEQUENCE +// flips; the direction around each ring stays as wired. +// // Precision is reproduced statement-for-statement, because the disc's integer // coordinates depend on it: MoonLight forms `angleRad` from the double macros // PI / TWO_PI, stores it in a *float*, then takes float sinf/cosf and multiplies @@ -33,12 +39,16 @@ class Rings241Layout : public LayoutBase { public: const char* tags() const override { return "💫"; } Dim dimensions() const override { return Dim::D2; } - // Spacing multiplier — scales both the ring radii and the shared centre. + // Spacing multiplier: scales both the ring radii and the shared center. // MoonLight default 2, range 1..10. uint8_t scale = 2; + // Wiring order: false = light 0 at the center, rings outward (MoonLight's + // order); true = light 0 on the outer ring, rings inward. + bool outside_in = false; // "outside in" void defineControls() override { controls_.addControl("scale", scale, 1, 10); + controls_.addControl("outside in", outside_in); } nrOfLightsType lightCount() const override { @@ -51,15 +61,19 @@ class Rings241Layout : public LayoutBase { } void placeLights(const CoordSink& sink) const override { - // Shared centre — MoonLight: leftMargin = 1.1 * getRadius(60), assigned to + // Shared center. MoonLight: leftMargin = 1.1 * getRadius(60), assigned to // a uint8_t (implicit truncation), stored as ringCenter's integer x/y, then // scaled per LED: x = scale * ringCenter.x. const uint8_t leftMargin = static_cast(1.1f * getRadius(60)); nrOfLightsType idx = 0; // Rings emitted smallest-to-largest, the same order MoonLight calls - // RingLayout::onLayout (nrOfLEDs = 1, 8, 12, … 60). - for (uint8_t n : kRingSizes) { + // RingLayout::onLayout (nrOfLEDs = 1, 8, 12, … 60), or largest-to-smallest + // when the disc is wired from the outer ring in. The per-LED math below + // depends only on n, so the ring order is the whole difference. + constexpr uint8_t kRings = sizeof(kRingSizes) / sizeof(kRingSizes[0]); + for (uint8_t r = 0; r < kRings; r++) { + const uint8_t n = kRingSizes[outside_in ? kRings - 1 - r : r]; const float radius = getRadius(n); for (uint8_t i = 0; i < n; i++) { float x = static_cast(scale * leftMargin); @@ -83,7 +97,7 @@ class Rings241Layout : public LayoutBase { } private: - // RingLayout::getRadius(n) = n / (2π) — the radius that spaces n LEDs one + // RingLayout::getRadius(n) = n / (2π), the radius that spaces n LEDs one // unit apart around the circle. Returns float, as MoonLight does (the // division is in double then narrowed, matching n / TWO_PI on the double macro). static float getRadius(uint8_t n) { return static_cast(n / kTwoPi); } diff --git a/src/light/moonlive/MoonLiveBuiltins_light.h b/src/light/moonlive/MoonLiveBuiltins_light.h index e864c272..0ed4468e 100644 --- a/src/light/moonlive/MoonLiveBuiltins_light.h +++ b/src/light/moonlive/MoonLiveBuiltins_light.h @@ -5,21 +5,21 @@ #include "core/moonlive/MoonLiveBuiltins.h" #include "core/moonlive/MoonLiveBuiltins_common.h" // the neutral half: math, waveforms, noise, print #include "core/moonlive/MoonLive.h" // runDefineControls drives the engine -#include "core/moonlive/MoonLiveIr.h" // kArg3 — the register `t` is passed in +#include "core/moonlive/MoonLiveIr.h" // kArg3: the register `t` is passed in #include #include -#include "core/math8.h" // beatsin16 — the shared time vocabulary -#include "core/math16.h" // beat16 / triwave16 — full-range waveforms +#include "core/math8.h" // beatsin16: the shared time vocabulary +#include "core/math16.h" // beat16 / triwave16: full-range waveforms #include "light/shader.h" // shader::smoothstep, the GLSL vocabulary, already in fixed point -#include "core/noise.h" // inoise8 — the shared value-noise field +#include "core/noise.h" // inoise8: the shared gradient-noise field #include #include "core/AudioService.h" // the audio vocabulary reads the latest frame #include "light/draw.h" // draw::line, the shared 3D Bresenham a script draws with #include "light/particles.h" // particles::Pool, the kernel a scripted particle effect drives -// MoonLive — the LIGHT-DOMAIN built-in registration. This is the only place the LED vocabulary +// MoonLive: the LIGHT-DOMAIN built-in registration. This is the only place the LED vocabulary // lives: the function NAMES (`setRGB`, `fill`, `random16`), their arg counts, and the meaning // of the inline opcodes (StoreElem = an RGB pixel write, FillElems = fill every light). The core // compiler sees only the neutral BuiltinTable / InlineOp tags this file hands it. A different @@ -31,7 +31,7 @@ namespace mm::moonlive { // random16(n) → a pseudo-random value in [0, n). A simple LCG, deterministic enough that the // runtime Bounds guard always sees an in-range index; the same implementation on every target // so a script behaves identically. The one host helper exposed as a Call so far. -// The palette, as THREE builtins — `paletteR(i, bri)`, `paletteG(i, bri)`, `paletteB(i, bri)`. +// The palette, as THREE builtins: `paletteR(i, bri)`, `paletteG(i, bri)`, `paletteB(i, bri)`. // `bri` is the brightness colorFromPalette already takes, and it is what gives a shape a // radial falloff instead of a flat fill. A builtin returns // one uint32_t, so a packed 0xRRGGBB would need the script to unpack it, and the language has no @@ -61,11 +61,11 @@ inline uint8_t byteArg(uintptr_t a) { } // The ACTIVE palette, so a script follows the device's palette control exactly as a compiled -// effect does — which is the whole point: before this, a script could only hard-code colour. +// effect does, which is the whole point: before this, a script could only hard-code color. // -// Deliberately NO hsv() alongside it. A hue wheel is how an effect picks colour while IGNORING +// Deliberately NO hsv() alongside it. A hue wheel is how an effect picks color while IGNORING // the user's palette, which is the habit the compiled effects were moved off (47 of 52 read the -// palette; the exceptions are effects where colour carries meaning, like the axis-identifying +// palette; the exceptions are effects where color carries meaning, like the axis-identifying // red/green/blue in LinesEffect). Giving scripts hsv() would reintroduce it as the easy default. extern "C" inline uint32_t mm_light_paletteR(const uintptr_t* args, uint32_t, const uint8_t*) { return colorFromPalette(*Palettes::active(), byteArg(args[0]), byteArg(args[1])).r; @@ -170,7 +170,7 @@ extern "C" inline uint32_t mm_light_uvY(const uintptr_t* args, uint32_t, const u // no rescaling anywhere. // // The products are int64 and have to be. z*z at the escape radius is 4.0, whose Q32 square is -// about 7.4e10 — an int32 overflows there and the point reads as escaped when it has not, which +// about 7.4e10: an int32 overflows there and the point reads as escaped when it has not, which // draws holes in the middle of the set. // // `iters` is the detail dial and the cost: the loop is bounded by it, so a script trades @@ -220,17 +220,17 @@ extern "C" inline uint32_t mm_light_escape(const uintptr_t* args, uint32_t, cons // scale(value, n) → map a 0..65535 value onto 0..n-1. The other half of `beat`: a beat is full-scale // by design so it is fixture-independent, and this is what lands it on an actual axis. `beat(30, t)` // then `scale(…, width)` is the sweep position, which is exactly what LinesEffect computes -// (`beat * n / 65536`) — including the detail that it REACHES n-1, where the naive `/ 65535` form +// (`beat * n / 65536`): including the detail that it REACHES n-1, where the naive `/ 65535` form // truncates one short and the last column never lights. -// sin(angle) / cos(angle) — the full-turn wave, angle 0..65535 for one revolution. +// sin(angle) / cos(angle): the full-turn wave, angle 0..65535 for one revolution. // // math16's sin16/cos16 return SIGNED -32768..32767; a script's values are unsigned, so the result // is biased into 0..65535 with the zero line at 32768. A script that wants a coordinate scales the // result: `scale(sin(a), width)` sweeps the whole axis, which is the same `scale` a beat uses. -// polarA(dx, dy) / polarR(dx, dy) — the POLAR pair (Angle, Radius), for an effect written around distance and -// bearing from a centre rather than around x/y. Both take offsets that a script computes as -// `x - cx`, which is unsigned and therefore wraps for a point left of centre: the builtins -// re-centre it themselves (see below), so a script does not have to reason about the wrap. +// polarA(dx, dy) / polarR(dx, dy): the POLAR pair (Angle, Radius), for an effect written around distance and +// bearing from a center rather than around x/y. Both take offsets that a script computes as +// `x - cx`, which is unsigned and therefore wraps for a point left of center: the builtins +// re-center it themselves (see below), so a script does not have to reason about the wrap. // // polarA() returns an angle16 (65536 = one turn), so it feeds straight into sin()/cos(). polarR() // returns the true distance, not the octagonal approximation, because a visibly non-circular @@ -244,6 +244,58 @@ extern "C" inline uint32_t mm_light_polarR(const uintptr_t* args, uint32_t, cons +// fbm(x, y, octaves) → octaves of noise summed at doubling frequency and halving amplitude, 0..255. +// One noise() sample is a smooth blur; this is what turns it into cloud, smoke and terrain, with a +// broad shape and finer structure on it. Coordinates are the same 16.0 fixed point noise() takes. +// `octaves` is the cost knob: each one is another noise sample per pixel. +extern "C" inline uint32_t mm_light_fbm(const uintptr_t* args, uint32_t, const uint8_t*) { + return fbm8(uint32_t(args[0]), uint32_t(args[1]), static_cast(uint32_t(args[2]))); +} + +// warp(x, y, strength) → the field sampled at a point that the field itself displaced, 0..255. +// This is the primitive behind the flowing, marbled look: the field stops reading as a texture laid +// over the grid and starts reading as something moving through it. Three noise samples per call. +extern "C" inline uint32_t mm_light_warp(const uintptr_t* args, uint32_t, const uint8_t*) { + return warp8(uint32_t(args[0]), uint32_t(args[1]), static_cast(uint32_t(args[2])), 1); +} + +// fbm3(x, y, z, octaves) / warp3(x, y, z, strength) → the volumetric forms of the two field +// compositions. The kernels underneath are the same ones the compiled effects call, and each is its +// 2D form when z is 0, so a script can pass a light's depth unconditionally. +extern "C" inline uint32_t mm_light_fbm3(const uintptr_t* args, uint32_t, const uint8_t*) { + return fbm8(uint32_t(args[0]), uint32_t(args[1]), uint32_t(args[2]), + static_cast(uint32_t(args[3]))); +} +extern "C" inline uint32_t mm_light_warp3(const uintptr_t* args, uint32_t, const uint8_t*) { + return warp8(uint32_t(args[0]), uint32_t(args[1]), uint32_t(args[2]), + static_cast(uint32_t(args[3])), 1); +} + +// osc(rate, ms, shape) → a low-frequency oscillator, 0..65535, `rate` cycles per minute at time +// `ms`. Shapes: 0 sine, 1 triangle, 2 sawtooth, 3 square (core/oscillators.h names them). +// +// Every animated quantity in a generative field is one of these, and a composition is several at +// different rates: where a shape sits, how far a coordinate is displaced, how fast a layer turns. +// A pure function of time rather than a stateful bank, because a script has no state between +// frames: two calls with the same rate stay locked together for as long as the device runs, which +// is what the compiled OscillatorBank gives an effect and what a composition needs. +extern "C" inline uint32_t mm_light_osc(const uintptr_t* args, uint32_t, const uint8_t*) { + const uint32_t rate = uint32_t(args[0]), ms = uint32_t(args[1]), shape = uint32_t(args[2]); + // No special case for rate 0: the phase is then 0 and each shape's own value at phase 0 is the + // right answer (a sine sits at its midpoint, a triangle and a sawtooth at their start, a square + // low). An earlier version returned the midpoint for all four, which held a triangle and a saw + // at 32768 rather than at 0. + // The phase, as an angle16: rate cycles per minute means rate * ms / 60000 turns. + const uint32_t phase = static_cast((static_cast(ms) * rate * 65536u) / 60000u); + const angle16 a = static_cast(phase); + switch (shape) { + case 1: return triwave16(a); + case 2: return a; + case 3: return a < 32768 ? 0u : 65535u; + default: return static_cast(sin16(a) + 32768); + } +} + // print(v) → write one value to the serial log, and return it so `print` can be dropped into an // expression without changing what it computes (`setXYZ(0, print(x), y, z)` still stores x). @@ -253,7 +305,7 @@ extern "C" inline uint32_t mm_light_polarR(const uintptr_t* args, uint32_t, cons // That case cost a long debugging session before this existed. // // **Rate-limited, because the call sites are per-light.** A modifier's script runs once per light -// per mapping rebuild — 16,384 times on a 128x128 wall. Printing all of them would flood the serial +// per mapping rebuild: 16,384 times on a 128x128 wall. Printing all of them would flood the serial // line, stall the render (a UART write blocks) and bury the first values, which are the useful // ones. So a burst is capped and the rest are counted, not printed: the tail of a flood tells you // nothing the head did not. @@ -263,25 +315,25 @@ extern "C" inline uint32_t mm_light_polarR(const uintptr_t* args, uint32_t, cons // // A layout cannot write into a buffer the way an effect does: it does not know how many lights it // will place until it has placed them, and on a classic ESP32 a 16k-light fixture would need 48 KB -// of coordinate staging — memory that board does not have. So the script CALLS OUT instead, once +// of coordinate staging: memory that board does not have. So the script CALLS OUT instead, once // per light, and the host decides what to do with each: count it on the sizing pass, emit it into // the consumer's sink on the walk. Nothing is stored. // // The active sink is set by the binding around each run. Outside a run it is null and a call is -// ignored — a script that reaches addLight from an effect places nothing rather than corrupting +// ignored: a script that reaches addLight from an effect places nothing rather than corrupting // something. using AddLightFn = void (*)(void* ctx, uint16_t x, uint16_t y, uint16_t z); /// PER-THREAD, not one global: the sink belongs to whichever thread is running a script, and more /// than one does. A layout is asked for its light count and its coordinates from the HTTP task when a -/// control is edited, while the render task walks the same layout for the frame — as one global, one +/// control is edited, while the render task walks the same layout for the frame: as one global, one /// thread cleared the sink while the other was mid-run and the built-in called through a live /// function pointer with a null context. That is a null dereference on the render core, seen as an /// intermittent crash while resizing a scripted layout. /// /// Keyed on platform::currentThreadId() rather than C++ `thread_local`, which is UNUSABLE on the /// ESP32: the compiler reaches TLS through the THREADPTR special register, and a FreeRTOS task -/// created without TLS has THREADPTR = 0 — so the access dereferences a small offset from null and +/// created without TLS has THREADPTR = 0: so the access dereferences a small offset from null and /// dies inside the exception handler. Measured: EXCVADDR 0xfffffff0, `Double exception` in /// _xt_context_save, on every scripted LAYOUT (the only binding whose script calls a host function). /// Reading the task handle costs one load and needs no per-task setup. @@ -343,13 +395,36 @@ struct MotionSink { MotionFn fn = nullptr; void* ctx = nullptr; }; /// read-only handle installed around each tick. A script calling pool(400) from tick() therefore /// reaches no sizing sink and gets a no-op returning the live count, which is what keeps allocation /// off the render path entirely. +using TrailSizeFn = bool (*)(void* ctx, bool want); +struct TrailSizeSink { TrailSizeFn fn = nullptr; void* ctx = nullptr; }; + using PoolSizeFn = uint16_t (*)(void* ctx, uint16_t count); struct PoolSizeSink { PoolSizeFn fn = nullptr; void* ctx = nullptr; }; struct PoolSink { particles::Pool* pool = nullptr; uint32_t scale = particles::FrameTime::kOne; }; +/// The trail plane a script advects and decays, and the frame's dt. +/// +/// A data handle rather than a function sink, for PoolSink's reason: the script names a flow and a +/// persistence, and the BINDING owns the two planes, their geometry and the ping-pong between them. +/// Framerate independence is the system's property too: `dtMs` arrives here rather than being asked +/// of the script, so a script author cannot get it wrong and a slow frame cannot skip the decay. +struct FlowSink { + uint16_t* a = nullptr; ///< one of the two planes, three uint16 per light + uint16_t* b = nullptr; ///< the other; which one holds the trail is `front` + /// Points at the BINDING's own flag, so a script's advect calls flip the owner's state + /// directly. Copying the flag into the sink and reading it back afterwards would work only for + /// an even number of swaps, and a script may advect once, twice or not at all. + bool* front = nullptr; ///< true: `a` holds the trail; false: `b` does + const uint32_t* frame = nullptr; ///< the binding's frame counter, for fieldRate + lengthType w = 0, h = 0, d = 0; + uint32_t dtMs = 0; + uint16_t* live() const { return !front ? nullptr : (*front ? a : b); } + uint16_t* spare() const { return !front ? nullptr : (*front ? b : a); } +}; + namespace detail { // `owner` is ATOMIC and claimed with compare_exchange: the claim used to be a load then a store, -// so two threads could both see the same slot free and both take it — leaving them sharing one +// so two threads could both see the same slot free and both take it: leaving them sharing one // sink, which is the very aliasing this table exists to prevent. // // The slot is the ONE per-thread home for everything a running script's built-ins reach: the @@ -357,9 +432,10 @@ namespace detail { // second table would repeat the claim/release machinery for the same lifetime. struct SinkSlot { std::atomic owner{0}; AddLightSink sink; draw::Canvas canvas; AddControlSink controls; FadeSink fade; MotionSink motion; CoordSink coord; - PalSink pal; PoolSizeSink poolSize; PoolSink pool; }; + PalSink pal; PoolSizeSink poolSize; PoolSink pool; FlowSink flow; + TrailSizeSink trailSize; }; /// Two slots: the render task and whichever task edits a control are the two that ever run a script -/// at once. A third concurrent runner gets the overflow slot, which holds no sink — so its addLight +/// at once. A third concurrent runner gets the overflow slot, which holds no sink: so its addLight /// calls no-op instead of writing through someone else's context. // constinit at namespace scope, not a function-local static: a local static carries a thread-safe // initialisation guard, which is a lock, and this is read from the render tick. Constant @@ -367,7 +443,7 @@ struct SinkSlot { std::atomic owner{0}; AddLightSink sink; draw::Canv inline constinit SinkSlot gSinkSlots[2]{}; inline SinkSlot* sinkSlots() MM_NONBLOCKING { return gSinkSlots; } /// PERMANENTLY EMPTY. A third concurrent runner reads this and finds no sink, so its addLight calls -/// no-op — setAddLightSink deliberately never installs here, because a shared sink would let two +/// no-op: setAddLightSink deliberately never installs here, because a shared sink would let two /// overflow threads write through each other's context. inline const AddLightSink& sinkOverflow() { static const AddLightSink s; return s; } /// The canvas twin of sinkOverflow: data stays null, so a third runner's draw calls no-op. @@ -523,6 +599,37 @@ inline void setPoolSink(particles::Pool* pool, uint32_t scale) MM_NONBLOCKING { if (!pool) detail::releaseIfEmpty(s); } +/// Where trail() asks for its plane, during defineControls only (the pool's own shape). +inline const TrailSizeSink& trailSizeSink() MM_NONBLOCKING { + detail::SinkSlot* s = detail::ownedSlot(false); + static constinit TrailSizeSink none{}; + return s ? s->trailSize : none; +} + +/// Install the trail sizer for one defineControls() run; nullptr to detach. +inline void setTrailSizeSink(TrailSizeFn fn, void* ctx) MM_NONBLOCKING { + detail::SinkSlot* s = detail::ownedSlot(fn != nullptr); + if (!s) return; + s->trailSize = {fn, ctx}; + if (!fn) detail::releaseIfEmpty(s); +} + +/// The trail plane this run may advect and decay, or an empty handle when there is none. +inline FlowSink& flowSink() MM_NONBLOCKING { + detail::SinkSlot* s = detail::ownedSlot(false); + static constinit FlowSink none{}; + return s ? s->flow : none; +} + +/// Hand the flow builtins their planes for one run; a null front detaches. The binding owns the +/// buffers and reads `swapped` afterwards to learn which one now holds the trail. +inline void setFlowSink(const FlowSink& f) MM_NONBLOCKING { + detail::SinkSlot* s = detail::ownedSlot(f.a != nullptr); + if (!s) return; + s->flow = f; + if (!f.a) detail::releaseIfEmpty(s); +} + /// Point addControl at a consumer for the duration of one defineControls() run; nullptr to detach. /// False when the two-slot table is full, which the caller must not treat as an installed sink: /// every addControl would then be a silent no-op and the script would publish no controls at all. @@ -547,7 +654,7 @@ inline void setAddLightSink(AddLightFn fn, void* ctx) { } // Install ONLY into an owned slot. Writing through addLightSink() would install into the shared // overflow sink when both slots are taken, and a second overflow thread would then run through - // the first one's context — the exact aliasing the two-slot table exists to prevent. A third + // the first one's context: the exact aliasing the two-slot table exists to prevent. A third // concurrent runner instead gets no sink at all, so its addLight calls no-op: visibly nothing // placed, rather than lights written through another thread's layout. detail::SinkSlot* s = detail::ownedSlot(true); @@ -626,17 +733,17 @@ inline void setDrawCanvas(const draw::Canvas& cv) MM_NONBLOCKING { if (s) s->canvas = cv; } -/// setPaletteColor(x, y, index, brightness) → one pixel, coloured from the ACTIVE palette. +/// setPaletteColor(x, y, index, brightness) → one pixel, colored from the ACTIVE palette. /// /// One call where a script used to write three: `paletteR/G/B` each returned a single channel, so /// a palette pixel cost three host calls AND three evaluations of whatever expression produced the -/// brightness — the compiler evaluates each argument independently. Measured on an S3, that was +/// brightness: the compiler evaluates each argument independently. Measured on an S3, that was /// 1451 us flat vs 1940 us with a per-pixel falloff; folding it into one call removes two of the /// three calls and two of the three brightness computations. /// /// Takes x/y rather than a flat index so the buffer layout stops leaking into every script: a /// script was writing `mod(bx + dx, width) + mod(by + dy, height) * width` at every call site. -/// Out-of-range coordinates are dropped, not wrapped — a "negative" coordinate arrives as a huge +/// Out-of-range coordinates are dropped, not wrapped: a "negative" coordinate arrives as a huge /// unsigned value, and wrapping it would paint the wrong edge rather than nothing. /// setPalEntry(i, r, g, b) - write one of the sixteen active palette entries. /// @@ -645,7 +752,7 @@ inline void setDrawCanvas(const draw::Canvas& cv) MM_NONBLOCKING { /// a gradient stop list can express. /// /// The index is BOUNDED rather than wrapped: a script computing an index from a control could -/// otherwise write a neighbouring entry and produce a palette nobody wrote, which reads as an engine +/// otherwise write a neighboring entry and produce a palette nobody wrote, which reads as an engine /// fault. Out of range does nothing, which is visible in the picture and blames the script. extern "C" inline uint32_t mm_light_setPalEntry(const uintptr_t* args, uint32_t, const uint8_t*) { const PalSink& s = palSink(); @@ -671,6 +778,21 @@ extern "C" inline uint32_t mm_light_setPalEntryHSV(const uintptr_t* args, uint32 return 0; } +// setPaletteColorZ(x, y, z, index, bri) - one light in a VOLUME, from the active palette. +// +// A separate name rather than an optional argument on setPaletteColor: a builtin's arity is exact +// (the compiler checks `n != fn->argc`), so an optional one would be a change to the call path for +// a case the fixture already tells apart. On a fixture with no depth z is 0 and the two agree. +extern "C" inline uint32_t mm_light_setPaletteColorZ(const uintptr_t* args, uint32_t, const uint8_t*) { + const draw::Canvas& cv = drawCanvas(); + if (!cv.data) return 0; // no canvas installed (a layout, a modifier) + const uint32_t x = uint32_t(args[0]), y = uint32_t(args[1]), z = uint32_t(args[2]); + if (x >= uint32_t(cv.dims.x) || y >= uint32_t(cv.dims.y) || z >= uint32_t(cv.dims.z)) return 0; + draw::pixel(cv, Coord3D{lengthType(x), lengthType(y), lengthType(z)}, + colorFromPalette(*Palettes::active(), byteArg(args[3]), byteArg(args[4]))); + return 0; +} + extern "C" inline uint32_t mm_light_setPaletteColor(const uintptr_t* args, uint32_t, const uint8_t*) { const draw::Canvas& cv = drawCanvas(); if (!cv.data) return 0; // no canvas installed (a layout, a modifier) @@ -691,6 +813,134 @@ extern "C" inline uint32_t mm_light_setPaletteColor(const uintptr_t* args, uint3 /// /// Reaches nothing from a layout or a modifier, where no sink is installed, so the call is a /// no-op there rather than fading a layer the script is not ticking in. +/// trail(1) asks for the trail plane; trail(0) gives it up. Answers whether one is available. +/// +/// The pool's shape, for the pool's reason: the planes are two 16-bit buffers (96 KB on a 20-cube), +/// so they exist only for a script that says it advects, and the ask happens at defineControls +/// where allocation is legal. Called from tick() it REPORTS rather than resizing, so the render +/// path never allocates. +extern "C" inline uint32_t mm_light_trail(const uintptr_t* args, uint32_t, const uint8_t*) { + const TrailSizeSink& s = trailSizeSink(); + if (!s.fn) return flowSink().live() != nullptr ? 1u : 0u; // outside defineControls: report + return s.fn(s.ctx, uint32_t(args[0]) != 0) ? 1u : 0u; +} + +// The flow builtins: a script names a wind and a persistence, and the binding owns the planes. +// +// `flowNoise(scale, strength)` and `flowCurl(scale, strength)` each ADVECT the whole trail plane in +// one call, because the alternative (a script loop calling a per-pixel rule) would cross the script +// boundary once per light, which on a 20-cube is 8000 calls a frame. One call, the loop in C++. + +/// Should this frame do the expensive work? `fieldRate(n)` answers true once every n frames. +/// +/// A script's ONLY practical way to afford a per-pixel loop on a large fixture: crossing the script +/// boundary once per light costs 8000 calls a frame on a cube, and skipping four in five of those is +/// the difference between an effect that runs and one that does not. What a caller puts inside the +/// gate is its own composition; guarding the expensive BIRTH while the flow and the decay run every +/// frame keeps the motion smooth and costs only detail, which is what nebula.mle does. +/// +/// The counter lives with the binding, not the script: a script holding its own frame count would +/// have to reason about what a frame is, which is the system's business. +extern "C" inline uint32_t mm_light_fieldRate(const uintptr_t* args, uint32_t, const uint8_t*) { + FlowSink& f = flowSink(); + if (!f.frame) return 1; // no binding: never skip, so a script still works + const uint32_t n = uint32_t(args[0]); + if (n <= 1) return 1; + return (*f.frame % n) == 0 ? 1u : 0u; +} + +/// Advect the trail along a noise field: two decoupled samples, one per axis. +extern "C" inline uint32_t mm_light_flowNoise(const uintptr_t* args, uint32_t, const uint8_t*) { + FlowSink& f = flowSink(); + if (!f.live() || !f.spare()) return 0; + const uint32_t cells = (uint32_t(args[0]) ? uint32_t(args[0]) : 1u) * 256u; + const int32_t strength = signedArg(args[1]); + const uint32_t t = platform::millis(); + draw::advect16(f.spare(), f.live(), f.w, f.h, f.d, + [&](lengthType x, lengthType y, lengthType z, draw::pos_t& vx, draw::pos_t& vy) { + const uint32_t fx = uint32_t(x) * cells, fy = uint32_t(y) * cells; + const uint32_t fz = uint32_t(z) * cells + t / 4u; + const int32_t nx = int32_t(inoise16(fx, fy, fz)) - 32768; + const int32_t ny = int32_t(inoise16(fx + 0x9E37u, fy + 0x7C15u, fz)) - 32768; + // 64-bit: `strength` is a SCRIPT value and so unbounded, and a 32-bit + // product wraps rather than saturating. curl16 was widened for this; its + // noise sibling needs it more, not less. + vx = draw::pos_t((static_cast(nx) * strength) >> 15); + vy = draw::pos_t((static_cast(ny) * strength) >> 15); + }, draw::Edge::Clamp); + *f.front = !*f.front; // the destination now holds the trail + return 0; +} + +/// Advect the trail along a curl field: the same, but divergence-free, so nothing clumps. +extern "C" inline uint32_t mm_light_flowCurl(const uintptr_t* args, uint32_t, const uint8_t*) { + FlowSink& f = flowSink(); + if (!f.live() || !f.spare()) return 0; + const uint32_t cells = (uint32_t(args[0]) ? uint32_t(args[0]) : 1u) * 256u; + const int32_t strength = signedArg(args[1]); + const uint32_t t = platform::millis(); + draw::advect16(f.spare(), f.live(), f.w, f.h, f.d, + [&](lengthType x, lengthType y, lengthType z, draw::pos_t& vx, draw::pos_t& vy) { + int32_t cx = 0, cy = 0; + curl16(uint32_t(x) * cells, uint32_t(y) * cells, + uint32_t(z) * cells + t / 4u, strength, cx, cy); + vx = draw::pos_t(cx); + vy = draw::pos_t(cy); + }, draw::Edge::Clamp); + *f.front = !*f.front; + return 0; +} + +/// Dim the trail by a half-life in milliseconds: the tail's length, in seconds rather than frames. +/// +/// `trailDecay`, not `decay`: a builtin name is reserved for every script, and `decay` is an +/// ordinary word for a member (`pulse.mle` and `beat-flash.mlp` both declare one). Taking it broke +/// both scripts, which is a real backward-compatibility break for a name this vocabulary does not +/// need. The existing `fade` builtin already pushed authors to `fadeAmt` for the same reason. +extern "C" inline uint32_t mm_light_trailDecay(const uintptr_t* args, uint32_t, const uint8_t*) { + FlowSink& f = flowSink(); + if (!f.live()) return 0; + const size_t n = size_t(f.w) * f.h * f.d * 3; + draw::decay16(f.live(), n, uint32_t(args[0]), f.dtMs); + return 0; +} + +/// Throw light into the trail: a disc at the plane's full width, so the decay has somewhere to go. +/// Takes a palette index rather than a color, as setPaletteColor does. +/// +/// `radius` in whole lights, and it is what decides whether a long tail is visible at all. Advection +/// spreads a head bilinearly, so after N frames one light's worth of brightness covers roughly N +/// pixels: a single-pixel head on a 4-second tail arrives at about 1 part in 255 and the panel reads +/// as a faint smear. A disc injects radius^2 times as much for the same tail, which is the knob that +/// makes persistence usable rather than merely long. +extern "C" inline uint32_t mm_light_emitTrail(const uintptr_t* args, uint32_t, const uint8_t*) { + FlowSink& f = flowSink(); + uint16_t* plane = f.live(); + if (!plane) return 0; + const int32_t cx = signedArg(args[0]), cy = signedArg(args[1]), cz = signedArg(args[2]); + const RGB c = colorFromPalette(*Palettes::active(), byteArg(args[3]), byteArg(args[4])); + const int32_t r = signedArg(args[5]); + const int32_t rad = r < 0 ? 0 : (r > 32 ? 32 : r); // a runaway radius is a full-frame loop + const uint16_t wr = uint16_t((c.r << 8) | c.r); // widened by repeating the byte, so a + const uint16_t wg = uint16_t((c.g << 8) | c.g); // full head is 65535 rather than 65280 + const uint16_t wb = uint16_t((c.b << 8) | c.b); + for (int32_t dy = -rad; dy <= rad; dy++) { + for (int32_t dx = -rad; dx <= rad; dx++) { + if (dx * dx + dy * dy > rad * rad) continue; // a disc, not a square + // Widened for the sum: cx and cy come from the SCRIPT, so either may be near + // INT32_MAX, and adding the radius to that is signed overflow before the bounds test + // ever runs. Only a coordinate that passed the test is narrowed for indexing. + const int64_t x = static_cast(cx) + dx, y = static_cast(cy) + dy; + if (x < 0 || y < 0 || cz < 0 || x >= f.w || y >= f.h || cz >= f.d) continue; + const size_t off = (size_t(cz) * f.h * f.w + size_t(y) * f.w + size_t(x)) * 3; + plane[off + 0] = wr; + plane[off + 1] = wg; + plane[off + 2] = wb; + } + } + return 0; +} + extern "C" inline uint32_t mm_light_fade(const uintptr_t* args, uint32_t, const uint8_t*) { const FadeSink& f = fadeSink(); if (!f.fn) return 0; @@ -1000,7 +1250,7 @@ inline void writeSysVarSlot(uint8_t* arenaSlot, uint32_t value) MM_NONBLOCKING { } /// The system variables a light script can read. Each binding registers the names it actually -/// WRITES, so an unwritten name stays unknown rather than reading a silent 0 — a script that asks +/// WRITES, so an unwritten name stays unknown rather than reading a silent 0: a script that asks /// for something its host never supplies gets a compile error naming it, which is the honest answer. /// /// Registering is also what RESERVES the name: a script cannot declare a control or a loop variable @@ -1068,7 +1318,7 @@ inline SysVarTable effectSysVars() { return lightSysVars(); } inline SysVarTable modifierSysVars() { return lightSysVars(); } // The light-domain built-in table the binding injects into the compiler. setRGB and fill are -// Inline (they lower to stores — the hot-path writers, no per-call cost); random16 is a Call. +// Inline (they lower to stores: the hot-path writers, no per-call cost); random16 is a Call. // The distances are signed and re-centered here. draw::smin already widens its intermediates to 64 // bits, and that is load-bearing: a wrapped smin returns a value larger than BOTH inputs, which // inverts the blend rather than degrading it. @@ -1090,7 +1340,7 @@ inline const BuiltinTable& lightBuiltins() { t.add({"setRGB", 4, /*returns*/ false, BuiltinKind::Inline, nullptr, InlineOp::StoreElem}); // setXYZ(x, y, z) → write one POSITION (bounds-guarded). The same StoreElem as setRGB: // three values at index * stride, and what differs is the destination the binding hands run() - // (a colour buffer for an effect, a coordinate for a modifier), so one op serves both and the + // (a color buffer for an effect, a coordinate for a modifier), so one op serves both and the // engine stays free of any notion of what the three bytes mean. // // A different OP from setRGB, not the same one with an argument hidden: StoreFirst writes @@ -1103,6 +1353,13 @@ inline const BuiltinTable& lightBuiltins() { // fade(amt) → dim every light toward black, FastLED's fadeToBlackBy. The trail // primitive, collected by the layer so N fading effects cost one pass. See mm_light_fade. t.add({"fade", 1, /*returns*/ false, BuiltinKind::Call, &mm_light_fade, {}}); + // The flow family: each advects the whole plane in one call (see the handlers). + t.add({"trail", 1, /*returns*/ true, BuiltinKind::Call, &mm_light_trail, {}}); + t.add({"fieldRate", 1, /*returns*/ true, BuiltinKind::Call, &mm_light_fieldRate, {}}); + t.add({"flowNoise", 2, false, BuiltinKind::Call, &mm_light_flowNoise, {}}); + t.add({"flowCurl", 2, false, BuiltinKind::Call, &mm_light_flowCurl, {}}); + t.add({"trailDecay", 1, false, BuiltinKind::Call, &mm_light_trailDecay, {}}); + t.add({"emitTrail", 6, false, BuiltinKind::Call, &mm_light_emitTrail, {}}); // setPan(index, value) / setTilt(index, value) → aim a moving head. Calls, not Inline stores: // the channel offset comes from the layer's fixture map, which the engine cannot see. // The audio vocabulary. All return 0 without audio, so a script written for a rig with a @@ -1150,12 +1407,23 @@ inline const BuiltinTable& lightBuiltins() { // Mandelbrot with a zero seed, Julia otherwise. The one piece of maths a script cannot // express: it squares SIGNED values and script arithmetic is unsigned. t.add({"escape", 5, /*returns*/ true, BuiltinKind::Call, &mm_light_escape, {}, /*byRef*/ 0, /*byStr*/ 0, /*fixedArgs*/ 0x0f}); - // polarA(dx, dy) / polarR(dx, dy) → polar from a centre. atan16 and dist16 already exist in + // polarA(dx, dy) / polarR(dx, dy) → polar from a center. atan16 and dist16 already exist in // math16.h. NOT named `angle`/`radius`: a script wants those for its own controls // (ring.mll and balls.mle both declare `radius`), and a builtin would shadow them. Exposing // them here is what lets a radial effect drop its precomputed lookup table. t.add({"polarA", 2, /*returns*/ true, BuiltinKind::Call, &mm_light_polarA, {}}); t.add({"polarR", 2, /*returns*/ true, BuiltinKind::Call, &mm_light_polarR, {}}); + // fbm(x, y, octaves) / warp(x, y, strength) → the two compositions over noise() that turn one + // smooth field into cloud and into flow. The compiled effects are written out of these, so a + // script reaches the same vocabulary rather than summing octaves by hand in the grammar. + t.add({"fbm", 3, /*returns*/ true, BuiltinKind::Call, &mm_light_fbm, {}}); + t.add({"warp", 3, /*returns*/ true, BuiltinKind::Call, &mm_light_warp, {}}); + // Their 3D forms, so a script samples through a volume rather than repeating one slice. + t.add({"fbm3", 4, /*returns*/ true, BuiltinKind::Call, &mm_light_fbm3, {}}); + t.add({"warp3", 4, /*returns*/ true, BuiltinKind::Call, &mm_light_warp3, {}}); + // osc(rate, ms, shape) → an LFO, the unit every animated quantity is made of. Stateless, so + // oscillators sharing a rate hold their relationship for as long as the device runs. + t.add({"osc", 3, /*returns*/ true, BuiltinKind::Call, &mm_light_osc, {}}); // addLight(x, y, z) → place a light. A scripted layout's whole vocabulary. t.add({"addLight", 3, /*returns*/ false, BuiltinKind::Call, &mm_light_addLight, {}}); // line(x1, y1, x2, y2, r, g, b) → a segment on the canvas, via the shared draw::line. @@ -1167,9 +1435,12 @@ inline const BuiltinTable& lightBuiltins() { // follows from how the member was declared, so the two can no longer disagree. t.add({"addControl", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_addControl, {}, /*byRef*/ 0x2, /*byStr*/ 0x1}); - // setPaletteColor(x, y, i, bri) → one palette-coloured pixel. The form a script should reach + // setPaletteColor(x, y, i, bri) → one palette-colored pixel. The form a script should reach // for: one call, one brightness evaluation, and no buffer-layout arithmetic at the call site. t.add({"setPaletteColor", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_setPaletteColor, {}}); + // The volumetric write: the same call with the light's depth, so a script paints a cube rather + // than its z = 0 slice. + t.add({"setPaletteColorZ", 5, /*returns*/ false, BuiltinKind::Call, &mm_light_setPaletteColorZ, {}}); // setPalEntry(i,r,g,b) / setPalEntryHSV(i,h,s,v) -> write one of the sixteen ACTIVE palette // entries. A palette script's only output; a no-op in every other role, where no sink is // installed, so an effect calling it changes nothing rather than corrupting the palette. @@ -1202,7 +1473,8 @@ inline const BuiltinTable& lightBuiltins() { /// modifier). Installed and detached in the same bracket as the control sink: sizing a pool and /// declaring a control are the same moment, and sharing the bracket means a script's pool cannot /// be resized from anywhere else. -inline void runDefineControls(MoonLive& engine, PoolSizeFn sizePool = nullptr, void* poolCtx = nullptr) { +inline void runDefineControls(MoonLive& engine, PoolSizeFn sizePool = nullptr, void* poolCtx = nullptr, + TrailSizeFn sizeTrail = nullptr, void* trailCtx = nullptr) { // A script with no defineControls() declares no controls, which is the honest answer for one // that wants no UI: there is nothing to clear and nothing to run. if (!engine.hasEntry(kEntryDefineControls)) return; @@ -1215,12 +1487,14 @@ inline void runDefineControls(MoonLive& engine, PoolSizeFn sizePool = nullptr, v static_cast(ctx)->addDeclaredControl(n, off, lo, hi, type); }, &engine)) return; if (sizePool) setPoolSizeSink(sizePool, poolCtx); + if (sizeTrail) setTrailSizeSink(sizeTrail, trailCtx); engine.clearDeclaredControls(); // re-runnable: rebuild rather than append // A one-light scratch buffer: this entry point writes no pixels, but `run` refuses a null or // undersized one, and honoring that contract costs less than carving out an exception. uint8_t scratch[3] = {}; engine.run(scratch, 1, 3, 0, kEntryDefineControls); if (sizePool) setPoolSizeSink(nullptr, nullptr); + if (sizeTrail) setTrailSizeSink(nullptr, nullptr); setAddControlSink(nullptr, nullptr); } diff --git a/src/light/moonlive/MoonLiveEffect.h b/src/light/moonlive/MoonLiveEffect.h index 4f7c55ed..197bc7dc 100644 --- a/src/light/moonlive/MoonLiveEffect.h +++ b/src/light/moonlive/MoonLiveEffect.h @@ -8,7 +8,7 @@ #include #include -// MoonLiveEffect — a scripted effect rendered by the MoonLive engine (§3.3 of +// MoonLiveEffect: a scripted effect rendered by the MoonLive engine (§3.3 of // livescripts-analysis-top-down.md). The thin binding side of the engine/binding seam: it // IS a first-class EffectBase (role, controls, lifecycle, generic UI), and its tick() // delegates to a compiled MoonLive over this effect's own buffer. @@ -16,7 +16,7 @@ // The effect holds a `source` text control; prepare compiles it through the engine and // tick() runs the emitted native code over the buffer (emit → allocExec → call → write). A // source edit recompiles live; a parse error shows in the module status and the layer goes -// dark — robust, no reboot. +// dark: robust, no reboot. namespace mm { @@ -46,7 +46,7 @@ class MoonLiveEffect : public EffectBase { // script recompiles (the script-editor loop), which re-derives the control set. void defineControls() override { // The script NAME, not the script. The text lives in a file the UI loads, edits and - // saves through /api/file — so a module costs ~32 bytes here instead of a resident + // saves through /api/file: so a module costs ~32 bytes here instead of a resident // kilobyte, and a script is bounded by the filesystem rather than by this array. controls_.addFilePath("script", script_.buffer(), script_.bufferSize(), moonlive::kEffectPick); @@ -64,7 +64,7 @@ class MoonLiveEffect : public EffectBase { } // Compile the source on the cold rebuild path. A failed compile (parse error or no exec - // memory) surfaces in the module status and leaves tick() a no-op — the effect renders + // memory) surfaces in the module status and leaves tick() a no-op: the effect renders // dark, the device keeps running (robustness + no-reboot). A *source* edit re-enters here and // recompiles, so a new script swaps in live; a broken edit just shows its diagnostic. // Compile the script if the file changed, then surface whatever it declares. @@ -73,11 +73,25 @@ class MoonLiveEffect : public EffectBase { // unchanged script costs a read rather than a re-JIT. It reports the status and the dynamic // bytes itself, which is why nothing here repeats that. void prepare() override { + // The next tick is the first: it has no previous frame to measure against. Without this the + // frame after a disable, a re-enable or a resize hands the flow and the decay the whole + // interval the module sat idle, which teleports the trail and fades it away. Trails, Nebula + // and Fluid reset the same flag in prepare() for the same reason; setting it once at + // construction covers only the very first frame of the module's life. + tickStarted_ = false; // The script sizes its own pool from defineControls(), which sync() runs after a compile. script_.setPoolSizer([](void* ctx, uint16_t n) -> uint16_t { return static_cast(ctx)->particles_.resize(n); }, this); + // Whether the script wants a trail plane, asked the same way and for the same reason: two + // 16-bit planes are 96 KB on a 20-cube, so only a script that advects pays for them. + script_.setTrailSizer([](void* ctx, bool want) -> bool { + return static_cast(ctx)->resizeTrail(want); + }, this); script_.sync(moonlive::effectSysVars(), *this); + // The planes follow the FIXTURE, so a resize re-sizes them even though the script did not + // change: sync() only re-runs defineControls when the source did. + if (trailWanted_) resizeTrail(true); // The compile re-derives the declared-control set, so rebuild the control list to surface // it (the same rebuildControls() pattern NetworkModule uses when a state change reshapes // its controls). Each scripted control re-binds to its (stable-address) arena slot. @@ -121,11 +135,44 @@ class MoonLiveEffect : public EffectBase { // computed: framerate independence is the system's property, not the script author's. if (particles_.count() > 0) moonlive::setPoolSink(&particles_.pool(), particles_.advance(elapsed())); + // The trail plane, for a script that advects. Allocated only when one asked for it, since + // it is two 16-bit planes: on a 20-cube that is 96 KB, which a script drawing dots must not + // pay. The binding owns the geometry, the ping-pong and the dt for the same reason it owns + // the particle pool's frame scale: none of it is the script author's to get right. + const uint32_t nowMs = elapsed(); + // A zero delta on the first tick, and on the first after a disable: `nowMs - 0` hands the + // flow and the decay the whole uptime, which teleports the trail and fades it away on the + // frame it starts. Trails, Nebula and Fluid each carry this guard; the binding needs it for + // the same reason. + const uint32_t dt = tickStarted_ ? nowMs - lastTickMs_ : 0u; + lastTickMs_ = nowMs; + tickStarted_ = true; + if (trailA_) { + moonlive::FlowSink f{}; + f.a = trailA_.data(); + f.b = trailB_.data(); + f.front = &trailFront_; + f.frame = &frameCount_; + f.w = width(); f.h = height(); f.d = depth(); + f.dtMs = dt; + moonlive::setFlowSink(f); + } // The frame moment: run `tick` if the script defined one. A script that defines only // `modifyLogical` renders nothing here and folds coordinates instead, which is the author's // choice rather than an error. if (script_.engine().hasEntry(moonlive::kEntryTick)) script_.engine().run(buffer(), nrOfLights(), cpl, elapsed(), moonlive::kEntryTick); + // The trail plane onto the layer, taking each channel's high byte: the one narrowing step, + // and the one the script cannot do for itself. Without it a script advects and decays a + // plane nobody ever reads, which renders black however correct the flow is. + if (trailA_) { + moonlive::setFlowSink({}); + blitTrail(); + } + // Outside the trail branch: the counter is the BINDING's frame count, and `fieldRate(n)` is + // a rate limiter any script may use. Advanced only when a trail existed, a script that + // skips `trail(1)` got a frozen counter and fieldRate answered 1 on every frame forever. + frameCount_++; moonlive::setPoolSink(nullptr, 0); moonlive::setMotionSink(nullptr, nullptr); moonlive::setFadeSink(nullptr, nullptr); @@ -138,10 +185,14 @@ class MoonLiveEffect : public EffectBase { script_.engine().free(); // release the exec block: the destructor role script_.invalidate(); // and forget what was compiled, so re-enabling rebuilds it script_.releaseReporting(*this); + // Forget the trail's shape as well as its memory: EffectBase frees the buffers, but a + // stale trailW_/H_/D_ would make the next prepare() compare the new geometry against a + // shape whose planes no longer exist, and skip the clear it should have done. + releaseTrail(); EffectBase::release(); } - /// Replace the script. The next prepare() compiles it — the same path a UI edit takes, so a + /// Replace the script. The next prepare() compiles it: the same path a UI edit takes, so a /// test and a user exercise identical code. /// Point the module at a script in the shared script directory. The file itself is written by /// the UI (or the File Manager); this only says WHICH one, and the next prepare() compiles it. @@ -160,6 +211,63 @@ class MoonLiveEffect : public EffectBase { // renders nothing until one is named, rather than every new module compiling the same effect. moonlive::MoonLiveScript script_; moonlive::MoonLiveParticles particles_{*this}; + // The trail planes: two, because advection reads one and writes the other (reading and writing + // one buffer would sample pixels the same pass had already moved). `trailFront_` says which + // holds the trail, and the flow builtins flip it through a pointer. + ScratchBuffer trailA_{*this}; + ScratchBuffer trailB_{*this}; + ScratchBuffer trailCarry_{*this}; ///< the dither's per-channel error + bool trailFront_ = true; + uint32_t frameCount_ = 0; ///< the counter fieldRate reads + bool trailWanted_ = false; + uint32_t lastTickMs_ = 0; + bool tickStarted_ = false; ///< has a frame been timed yet + lengthType trailW_ = 0, trailH_ = 0, trailD_ = 0; ///< the shape the planes hold + + /// The trail plane onto the layer. Through `draw::blit16` like the compiled effects: a + /// fourth hand-rolled copy of the narrowing loop is what left this one truncating where the + /// others dithered, which is the divergence blit16 exists to end. + void blitTrail() { + const lengthType w = width(), h = height(), d = depth(); + if (!trailA_ || !trailB_) return; + const uint16_t* live = trailFront_ ? trailA_.data() : trailB_.data(); + draw::blit16(canvas(), live, w, h, d, trailCarry_ ? trailCarry_.data() : nullptr); + } + + /// Size (or free) the trail planes for the current fixture. Returns whether one is available, + /// which is what the script sees: a device too small to hold them reports the truth rather than + /// rendering nothing in silence. + bool resizeTrail(bool want) { + trailWanted_ = want; + // Every exit frees all THREE buffers together: the carry is as much part of the trail as + // the planes, and leaving it behind on a failure path is memory nothing will ever read. + if (!want) { releaseTrail(); return false; } + const lengthType w = width(), h = height(), d = depth(); + const size_t n = static_cast(w) * h * d * 3; + if (n == 0) { releaseTrail(); return false; } + const size_t had = trailA_.count(); + if (!trailA_.resize(n) || !trailB_.resize(n) || !trailCarry_.resize(n)) { + releaseTrail(); // a half-allocated set is worse than none + return false; + } + // Same sample count but a different shape (8x16 to 16x8, or a cube reshaped): resize() kept + // the samples and they are laid out for the old geometry, so they would smear. BOTH planes, + // because the ping-pong swaps the spare one in on the very next frame. The guard Trails, + // Nebula and Fluid all carry; a scripted trail is no different. + if (n == had && (w != trailW_ || h != trailH_ || d != trailD_)) { + std::memset(trailA_.data(), 0, trailA_.bytes()); + std::memset(trailB_.data(), 0, trailB_.bytes()); + std::memset(trailCarry_.data(), 0, trailCarry_.bytes()); // per light, so it reshapes too + } + trailW_ = w; trailH_ = h; trailD_ = d; + return true; + } + + /// Free the trail set. One place, so a new buffer cannot be forgotten on one exit path. + void releaseTrail() { + trailA_.resize(0); trailB_.resize(0); trailCarry_.resize(0); + trailW_ = trailH_ = trailD_ = 0; + } }; } // namespace mm diff --git a/src/light/moonlive/MoonLiveScript.h b/src/light/moonlive/MoonLiveScript.h index ee564629..b354c211 100644 --- a/src/light/moonlive/MoonLiveScript.h +++ b/src/light/moonlive/MoonLiveScript.h @@ -35,6 +35,10 @@ class MoonLiveScript { /// a binding with no particles, which is every binding but the effect today. void setPoolSizer(PoolSizeFn fn, void* ctx) { sizePool_ = fn; poolCtx_ = ctx; } + /// The same for a binding that owns a trail plane: the script asks with `trail(1)`, and only a + /// script that asks pays for the two 16-bit planes. + void setTrailSizer(TrailSizeFn fn, void* ctx) { sizeTrail_ = fn; trailCtx_ = ctx; } + /// Re-read the file and recompile IFF its content hash moved. /// /// Returns true when a NEW program was installed. That return value is load-bearing rather than @@ -86,7 +90,7 @@ class MoonLiveScript { // Declare the controls the script asks for, the way a compiled module does: by RUNNING // defineControls(). Before the binding's rebuildControls(), which turns the declared // list into UI cards. - runDefineControls(engine_, sizePool_, poolCtx_); + runDefineControls(engine_, sizePool_, poolCtx_, sizeTrail_, trailCtx_); // What the script SAYS it is, read once per compile rather than per frame. Both are // cold-path questions the host asks about a program, the same two a compiled module // answers with `Dim dimensions()` and `const char* tags()`. @@ -197,7 +201,7 @@ class MoonLiveScript { const moonlive::DeclaredControl* decls = engine_.declaredControls(n); for (uint8_t i = 0; i < n; i++) { uint8_t* slot = engine_.controlSlot(decls[i].offset); - if (!slot) continue; // engine not compiled yet — controls appear after prepare + if (!slot) continue; // engine not compiled yet: controls appear after prepare // Published as the widget the member's TYPE calls for. Every scalar occupies the same // 4-byte slot, so this is no longer a width dispatch: it is the semantic one, and the // storage underneath is identical in all three cases. @@ -209,13 +213,13 @@ class MoonLiveScript { // byte and bool point at the slot's LOW BYTE, which is only correct because the two // are masked on store: the upper three bytes are always zero, so a 1-byte control // reading and writing that byte sees the member's whole value. On a big-endian target - // the low byte would be at offset+3 — no supported target is one. + // the low byte would be at offset+3: no supported target is one. switch (decls[i].type) { case moonlive::CtrlType::Bool: // NORMALIZED before the byte is ever read as a `bool`. A script's store // truncates rather than normalizing, so a bool member can legally hold 7 // (`flag = 7;` is ordinary arithmetic to the language), and a C++ bool object - // holding anything but 0 or 1 is undefined behaviour the moment it is read. + // holding anything but 0 or 1 is undefined behavior the moment it is read. // One write at publish time settles it; every later write comes through // applyControlValue's parseBool, which yields 0 or 1 by construction. *slot = (*slot != 0) ? 1 : 0; @@ -282,8 +286,10 @@ class MoonLiveScript { char failedScript_[kMaxScriptName + 1] = ""; size_t reportedBytes_ = 0; // what this script last added to the owner's total - PoolSizeFn sizePool_ = nullptr; - void* poolCtx_ = nullptr; + PoolSizeFn sizePool_ = nullptr; + void* poolCtx_ = nullptr; + TrailSizeFn sizeTrail_ = nullptr; + void* trailCtx_ = nullptr; }; } // namespace mm::moonlive diff --git a/src/light/moonlive/MoonLiveScriptFile.h b/src/light/moonlive/MoonLiveScriptFile.h index caab691f..f7c3516c 100644 --- a/src/light/moonlive/MoonLiveScriptFile.h +++ b/src/light/moonlive/MoonLiveScriptFile.h @@ -11,6 +11,11 @@ namespace mm::moonlive { /// Where a scripted module's script file lives. One fixed directory, the way `/.config` holds /// persisted state: a module stores a NAME, not a path, so it cannot reach outside this folder and /// the File Manager has one obvious place to look. +/// The USER's scripts, which the UI writes and the loader prefers. An edit made in the device's +/// own editor lands here, so a factory script of the same name is shadowed from that moment on: +/// pushing a new version of a shipped script to `kFactoryScriptDir` will NOT be seen once a user +/// has saved their own copy. (Two names differing only in case are one file on a case-insensitive +/// desktop and two on the device's LittleFS, which makes that shadowing easy to miss.) inline constexpr const char* kScriptDir = "/moonlive"; /// Where the FACTORY scripts land: the ones the picker offers from the shipped catalog and the UI diff --git a/src/light/moonlive/script_catalog.h b/src/light/moonlive/script_catalog.h index 57a51ab6..b7dda019 100644 --- a/src/light/moonlive/script_catalog.h +++ b/src/light/moonlive/script_catalog.h @@ -15,6 +15,7 @@ namespace mm::moonlive { /// upstream and in the factory script directory on the device. constexpr const char* kEffectCatalog[] = { "aim.mle", + "aurora.mle", "ballpit.mle", "balls.mle", "breathe.mle", @@ -23,11 +24,13 @@ constexpr const char* kEffectCatalog[] = { "crosshair.mle", "dot.mle", "ember.mle", + "fluid.mle", "fountain.mle", "fractal.mle", "gradient.mle", "lines.mle", "metal.mle", + "nebula.mle", "noise.mle", "octopus.mle", "plasma.mle", @@ -38,12 +41,14 @@ constexpr const char* kEffectCatalog[] = { "sparkle.mle", "spectrum.mle", "sweep.mle", + "trails.mle", }; -constexpr size_t kEffectCatalogCount = 24; +constexpr size_t kEffectCatalogCount = 28; /// What each effect above declares about itself, in the same order. /// A dimension of 0 means the script says nothing, so the DEVICE decides the default. constexpr unsigned char kEffectCatalogDim[] = { 1, + 3, 2, 2, 1, @@ -52,11 +57,13 @@ constexpr unsigned char kEffectCatalogDim[] = { 2, 2, 1, + 3, 2, 2, 3, 2, 2, + 3, 2, 2, 2, @@ -67,10 +74,12 @@ constexpr unsigned char kEffectCatalogDim[] = { 3, 2, 1, + 3, }; /// The emoji each declares, "" when it declares none. constexpr const char* kEffectCatalogTags[] = { "💫🎯", + "💫", "💫✨", "💫", "💫", @@ -79,11 +88,13 @@ constexpr const char* kEffectCatalogTags[] = { "💫", "💫", "💫", + "💫🖌️", "💫✨", "💫🖌️", "💫", "💫", "💫🖌️", + "💫🖌️", "💫", "💫🖌️", "💫", @@ -94,6 +105,7 @@ constexpr const char* kEffectCatalogTags[] = { "💫", "💫🎶", "💫🎯", + "💫🖌️", }; constexpr const char* kEffectFolder = "effects"; ///< its directory upstream @@ -207,6 +219,6 @@ constexpr const char* kPaletteCatalogTags[] = { }; constexpr const char* kPaletteFolder = "palettes"; ///< its directory upstream -constexpr size_t kCatalogCount = 42; ///< every factory script, all roles +constexpr size_t kCatalogCount = 46; ///< every factory script, all roles } // namespace mm::moonlive diff --git a/src/light/polar.h b/src/light/polar.h new file mode 100644 index 00000000..ff6e35d3 --- /dev/null +++ b/src/light/polar.h @@ -0,0 +1,332 @@ +#pragma once + +#include "core/Control.h" // ControlList: the controls an effect surfaces for the table +#include "core/ScratchBuffer.h" // ScratchBuffer: self-sizing, owner-tied scratch memory +#include "core/math16.h" // atan16, dist16, angle16 +#include "light/light_types.h" // lengthType +#include "platform/platform.h" // freeHeap, HEAP_RESERVE: the memory gate + +#include + +// PolarLut: the angle and radius of every pixel, computed once and read from a table. +// +// A radial effect addresses the grid by ANGLE and RADIUS rather than x and y, which is what makes +// its motion turn around the center instead of sliding across it. Computed per pixel per frame, +// that address is `atan16` plus `dist16`, and on an ESP32-S3 it measures 1.9 microseconds per +// pixel: 39% of a PolarNoise frame at 64x64, before the field it addresses is sampled at all. The +// address does not change between frames, only the field sampled through it does, so the whole cost +// is recomputation of a constant. +// +// This is that constant, held per layer: two tables the size of the grid, rebuilt when the geometry +// changes and read as two array lookups per pixel afterwards. Effects keep their look and get their +// frame time back, and it compounds, because every radial effect pays the same toll today. +// +// Two widths, because the tradeoff is real. The 8-bit tables cost 2 bytes per pixel and are the +// default: 256 angle steps and 256 radius steps are what an effect sampling a noise field through +// them can actually resolve. The 16-bit tables cost 4 bytes per pixel and are opt-in, for an effect +// whose look breaks up at 8 bits: a slow rotation shows angle steps as visible facets, and a +// palette indexed straight from the radius bands. On a 128x128 wall that is 32 KB against 64 KB. +// +// The tables are a layer's, not an effect's: every effect on that layer shares one address for the +// same grid. An effect declares a PolarLut member, calls prepare() with the grid it is about to +// draw, and reads. prepare() rebuilds only when the geometry or the width actually changed, so +// calling it every frame is free. +// +// On a VOLUMETRIC fixture "angle and radius" has no single meaning, so the projection is a control +// rather than a decision. Which one is right is a property of the fixture, not of the library: +// +// cylindrical - angle and radius on the xy plane, depth carried separately. A tube, a curtain or +// a stack of panels wants this, and it reduces to exactly the 2D behavior when +// depth is 1, so no existing fixture changes. The default for that reason. +// spherical - angle around, angle up, radius from the center. A sphere or a ball of light +// inside a cube wants this; it costs one more table for the second angle. +// radial - distance from the center only, no angle. Shells rather than curtains, and the +// cheapest of the three. +// +// The choice is made once when the table is built, so it costs nothing per sample. +// +// On a device without PSRAM the tables are a real fraction of the heap, so prepare() checks free +// memory against the reserve and declines rather than crowding it: a 32x32 grid asks 2 KB, a 64x64 +// grid 8 KB, and a 128x128 wall 32 KB. The caller falls back to computing the address per pixel and +// the picture is unchanged, which is why the fallback is a supported path rather than an error +// case, and why an effect exposes it as a control. + +namespace mm { + +/// The polar address of every pixel on a grid, as tables. +/// +/// Precision is chosen at prepare() time rather than by type, so an effect can offer it as a +/// control and switch live, which is what the live-reconfiguration rule requires. +class PolarLut { +public: + /// How a volumetric fixture's coordinates become an angle and a radius. Ignored at depth 1, + /// where all three reduce to the same plane. + enum class Mapping : uint8_t { + Cylindrical = 0, ///< angle and radius on xy, depth separate: the 2D behavior, extended + Spherical = 1, ///< angle around and angle up, radius from the center + Radial = 2, ///< distance from the center only + }; + + /// Owner is the module the tables belong to: it accounts their bytes and frees them when the + /// module is disabled, the same contract every ScratchBuffer has. + explicit PolarLut(MoonModule& owner) + : angle8_(owner), radius8_(owner), pitch8_(owner), + angle16_(owner), pitch16_(owner), radius16_(owner) {} + + /// Build the tables for a `w` x `h` grid, if they are not already built for exactly that. + /// `wide` selects the 16-bit tables. The center is the grid's middle in whole pixels, matching + /// what the radial effects compute by hand today. + /// + /// Returns false when the tables are not available, and the caller then computes the address per + /// pixel: an effect must still render on a device too tight for them, just more slowly. Two + /// things make it decline, and the first matters more on a device without PSRAM. It REFUSES the + /// tables when free heap minus the reserve that protects stacks, WiFi and HTTP cannot hold them, + /// rather than taking the last of the heap and starving something else later (the rule + /// MappingLUT established). And if the allocation fails anyway it unwinds to nothing rather than + /// holding a half-built table. Degrade visibly, never crash. + /// The controls an effect surfaces to let a user address a volumetric fixture, and the state + /// behind them. Bound by `addControls` and read by `prepareFor`, so an effect adopting the table + /// writes two calls rather than repeating a member, an option table, a binding and a five-line + /// prepare: three effects had all four copied before this existed. + struct Controls { + bool use = true; ///< read the address from a table rather than computing it + bool wide = false; ///< hold it at full 16-bit precision, at twice the memory + uint8_t mapping = 0; ///< which projection, indexing kMappingOptions + }; + + static constexpr const char* kMappingOptions[] = {"cylindrical", "spherical", "radial"}; + + /// Surface the three controls on `list`. + /// + /// Deliberately asks nothing about the fixture: defineControls() runs before an effect is + /// attached to a layer (and on the throwaway instances the /api/types probe builds), so + /// reaching for width or depth here dereferences a null layer. An earlier version hid the + /// mapping on a flat fixture and segfaulted the framerate sweep for exactly that reason. The + /// mapping is simply always shown; on a fixture with no depth all three agree, so the control + /// is inert rather than wrong. + static void addControls(ControlList& list, Controls& c) { + list.addControl("polarTable", c.use); + list.addControl("polarTable16", c.wide); + list.addSelect("mapping", c.mapping, kMappingOptions, 3); + } + + /// The polar address of one light, computed rather than read: what an effect uses when the table + /// was declined (a device too tight for it) or switched off. + /// + /// Takes the mapping, so the fallback renders the SAME composition the table would have. An + /// earlier version computed cylindrical unconditionally, so a fixture set to spherical or radial + /// silently reverted to cylindrical the moment memory ran short: the effect kept working and + /// quietly showed a different thing, which is worse than not working. + struct Address { + angle16 angle; ///< around the axis + uint32_t radius; ///< in pixels from the center + angle16 pitch; ///< elevation, under Spherical; 0 otherwise + }; + /// The mapping a Controls block selects, clamped to one this class defines. The control is a + /// select index and a saved config can hold a value from a build that offered more of them, so + /// the clamp is what keeps an unknown index rendering as cylindrical rather than reading past + /// the switch. One home: the table builder and every effect's computed fallback ask here. + static Mapping mappingOf(const Controls& c) { + return static_cast(c.mapping > 2 ? 0 : c.mapping); + } + + static Address addressOf(Mapping mapping, int32_t dx, int32_t dy, int32_t dz) { + switch (mapping) { + case Mapping::Radial: + return {0, dist16(dist16(dx, dy), dz), 0}; + case Mapping::Spherical: + return {atan16(dy, dx), dist16(dist16(dx, dy), dz), + atan16(dz, static_cast(dist16(dx, dy)))}; + case Mapping::Cylindrical: + default: + return {atan16(dy, dx), dist16(dx, dy), 0}; + } + } + + /// Build (or release) the table for a fixture of this size, per the controls. The one call an + /// effect makes from prepare(). + bool prepareFor(const Controls& c, lengthType w, lengthType h, lengthType d) { + if (!c.use) { release(); return false; } + return prepare(static_cast(w), static_cast(h), static_cast(d), + c.wide, mappingOf(c)); + } + + /// The 2D form. A separate overload rather than a defaulted depth, because `prepare(w, h, true)` + /// meaning "16-bit" and `prepare(w, h, 1)` meaning "one deep" are one careless argument apart, + /// and a bool that silently becomes a depth is the kind of mistake that compiles and renders + /// wrong. The arity says which one the caller means. + bool prepare(uint16_t w, uint16_t h, bool wide = false) { + return prepare(w, h, 1, wide, Mapping::Cylindrical); + } + + /// The volumetric form: `d` is the fixture's depth and `mapping` how its coordinates become an + /// angle and a radius. At `d` = 1 all three mappings agree and this is the 2D form. + bool prepare(uint16_t w, uint16_t h, uint16_t d, bool wide, + Mapping mapping = Mapping::Cylindrical) { + if (w == w_ && h == h_ && d == d_ && wide == wide_ && mapping == mapping_ && ready_) return true; + w_ = w; h_ = h; d_ = d; wide_ = wide; mapping_ = mapping; ready_ = false; + const std::size_t n = static_cast(w) * h * (d ? d : 1); + if (n == 0) { release(); return false; } // an empty grid holds nothing, not the old table + + // Spherical is the one projection that needs a second angle, so it is the one that costs a + // third table. The others leave it unallocated rather than filled with zeros. + const bool needPitch = mapping == Mapping::Spherical && d > 1; + + // Free the width we are not using, so switching precision live does not hold both, and so + // the budget below is measured against a heap that already has the old tables back. + if (wide) { angle8_.resize(0); radius8_.resize(0); pitch8_.resize(0); } + else { angle16_.resize(0); radius16_.resize(0); pitch16_.resize(0); } + if (!needPitch) { pitch8_.resize(0); pitch16_.resize(0); } + + // The memory gate. freeHeap() is 0 on desktop, which means unlimited, so the gate is an + // ESP32 rule only. The tables are worth having, not worth the last of the heap. + const std::size_t want = n * (wide ? 4u : 2u) + (needPitch ? n * (wide ? 2u : 1u) : 0u); + const std::size_t freeHeap = platform::freeHeap(); + if (freeHeap != 0) { + const std::size_t budget = freeHeap > platform::HEAP_RESERVE ? freeHeap - platform::HEAP_RESERVE : 0; + if (budget < want) { release(); return false; } + } + + if (wide) { + if (!angle16_.resize(n) || !radius16_.resize(n)) { release(); return false; } + if (needPitch && !pitch16_.resize(n)) { release(); return false; } + } else { + if (!angle8_.resize(n) || !radius8_.resize(n)) { release(); return false; } + if (needPitch && !pitch8_.resize(n)) { release(); return false; } + } + + // The longest radius on the fixture: the corner furthest from the center. Radii scale + // against it so the outer edge reaches full scale whatever the aspect ratio, which is what + // makes a radial gradient fill a wide panel instead of banding in a circle inside it. Under + // cylindrical the depth axis is carried separately, so it does not enter the radius and a + // deep fixture measures the same as the panel it is made of. + const int32_t cx = w / 2, cy = h / 2, cz = d / 2; + const int32_t fx = (cx > w - 1 - cx ? cx : w - 1 - cx); + const int32_t fy = (cy > h - 1 - cy ? cy : h - 1 - cy); + const int32_t fz = (cz > d - 1 - cz ? cz : d - 1 - cz); + maxRadius_ = mapping == Mapping::Cylindrical ? dist16(fx, fy) + : dist16(dist16(fx, fy), fz); + if (maxRadius_ == 0) maxRadius_ = 1; // a 1x1 grid: avoid a divide by zero + + for (uint16_t z = 0; z < (d ? d : 1); z++) + for (uint16_t y = 0; y < h; y++) { + for (uint16_t x = 0; x < w; x++) { + const int32_t dx = static_cast(x) - cx; + const int32_t dy = static_cast(y) - cy; + const int32_t dz = static_cast(z) - cz; + + // Through addressOf, the same call the computed fallback makes: the table is a + // CACHE of that function, so a second copy of the projections here is the one way + // the two could disagree, and unit_PolarLut_equivalence pins that they do not. + const Address ad = addressOf(mapping, dx, dy, dz); + angle16 a = ad.angle; + uint32_t r = ad.radius; + angle16 pitch = ad.pitch; + // Scale the radius to full range against the furthest corner, so `radius` is a + // position from center (0) to edge (full scale) rather than a pixel count. Both + // steps ROUND rather than truncate: a caller scaling back to pixels truncates twice + // otherwise and lands a whole pixel short at every radius, which shifts the entire + // field inward by one and is plainly visible as a displaced center. + const uint32_t rs = r >= maxRadius_ ? 65535u : (r * 65535u + maxRadius_ / 2) / maxRadius_; + const std::size_t i = (static_cast(z) * h + y) * w + x; + if (wide) { + angle16_[i] = a; + radius16_[i] = static_cast(rs); + if (needPitch) pitch16_[i] = pitch; + } else { + angle8_[i] = static_cast(a >> 8); + radius8_[i] = static_cast((rs + 128) >> 8 > 255 ? 255 : (rs + 128) >> 8); + if (needPitch) pitch8_[i] = static_cast(pitch >> 8); + } + } + } + ready_ = true; + return true; + } + + /// Free the tables, for a caller that has switched the address back to per-pixel computation. + /// The next prepare() rebuilds them. + void release() { + if (!ready_ && bytes() == 0) return; + angle8_.resize(0); radius8_.resize(0); pitch8_.resize(0); + angle16_.resize(0); radius16_.resize(0); pitch16_.resize(0); + w_ = h_ = d_ = 0; + ready_ = false; + } + + /// True once the tables hold a grid; false if allocation failed and the caller must compute. + bool ready() const { return ready_; } + /// True when the 16-bit tables are the ones built. + bool wide() const { return wide_; } + /// The distance from center to the furthest corner, in pixels: what `radius` is scaled against. + uint32_t maxRadius() const { return maxRadius_; } + /// What the tables currently cost. + std::size_t bytes() const { + return angle8_.bytes() + radius8_.bytes() + pitch8_.bytes() + + angle16_.bytes() + radius16_.bytes() + pitch16_.bytes(); + } + /// Which projection the built tables use. + Mapping mapping() const { return mapping_; } + + /// The elevation of pixel `i` above the xy plane, under the spherical projection. Zero under the + /// other two, which have no second angle: a caller can read it unconditionally. + angle16 pitch(std::size_t i) const { + if (!ready_) return 0; + if (wide_) return pitch16_.count() ? pitch16_.data()[i] : 0; + if (!pitch8_.count()) return 0; + const uint32_t v = pitch8_.data()[i]; + return static_cast((v << 8) | v); + } + + /// The angle of pixel `i` (row-major, y * width + x), as a full angle16 whichever width is + /// built: the 8-bit table widens, so a caller reads one type and the precision is the table's. + /// An angle wraps, so the low byte is filled from the high one and a wrapped value stays + /// continuous across the seam. + angle16 angle(std::size_t i) const { + if (!ready_) return 0; + if (wide_) return angle16_.data()[i]; + const uint32_t v = angle8_.data()[i]; + return static_cast((v << 8) | v); + } + + /// The radius of pixel `i`, 0 at the center to 65535 at the furthest corner. + uint16_t radius(std::size_t i) const { + if (!ready_) return 0; + if (wide_) return radius16_.data()[i]; + // Widen so the top of the 8-bit range reaches the top of the 16-bit one: 255 must read as + // full scale, not as 65280, or the outer edge never arrives. + const uint32_t v = radius8_.data()[i]; + return static_cast((v << 8) | v); + } + + /// The radius of pixel `i` in PIXELS from the center, which is the form a field scaled in grid + /// units wants. Rounded, so it round-trips the stored value rather than landing short. + uint32_t radiusPixels(std::size_t i) const { + if (!ready_) return 0; + return (static_cast(radius(i)) * maxRadius_ + 32768u) >> 16; + } + + /// The angle at (x, y). The index form above is what a pixel loop should use; this is for a + /// caller that has coordinates rather than a running index. + angle16 angleAt(uint16_t x, uint16_t y, uint16_t z = 0) const { return angle(index(x, y, z)); } + /// The radius at (x, y, z). + uint16_t radiusAt(uint16_t x, uint16_t y, uint16_t z = 0) const { return radius(index(x, y, z)); } + /// The elevation at (x, y, z), under the spherical projection. + angle16 pitchAt(uint16_t x, uint16_t y, uint16_t z = 0) const { return pitch(index(x, y, z)); } + /// The running index of (x, y, z), the same order the buffer uses. + std::size_t index(uint16_t x, uint16_t y, uint16_t z = 0) const { + return (static_cast(z) * h_ + y) * w_ + x; + } + +private: + ScratchBuffer angle8_, radius8_, pitch8_; + ScratchBuffer angle16_, pitch16_; + ScratchBuffer radius16_; + uint16_t w_ = 0, h_ = 0, d_ = 0; + Mapping mapping_ = Mapping::Cylindrical; + uint32_t maxRadius_ = 1; + bool wide_ = false; + bool ready_ = false; +}; + +} // namespace mm diff --git a/src/main.cpp b/src/main.cpp index c9c54648..a6c460aa 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,6 +19,8 @@ #include "light/effects/LinesEffect.h" #include "light/effects/RainbowEffect.h" #include "light/effects/WaveEffect.h" +#include "light/effects/FluidEffect.h" +#include "light/effects/NebulaEffect.h" #include "light/effects/NoiseEffect.h" #include "light/effects/MovingHeadEffect.h" #include "light/effects/PacmanEffect.h" @@ -44,8 +46,10 @@ #include "light/effects/SolidEffect.h" #include "light/effects/StarSkyEffect.h" #include "light/effects/SdfShapesEffect.h" +#include "light/effects/AuroraEffect.h" #include "light/effects/PolarNoiseEffect.h" #include "light/effects/WaterRippleEffect.h" +#include "light/effects/TrailsEffect.h" #include "light/effects/TunnelEffect.h" #include "light/effects/EchoEffect.h" #include "light/effects/DissolveEffect.h" @@ -67,7 +71,6 @@ #include "light/effects/RandomEffect.h" #include "light/effects/LissajousEffect.h" #include "light/effects/RubiksCubeEffect.h" -#include "light/effects/Noise2DEffect.h" #include "light/effects/BouncingBallsEffect.h" #include "light/effects/TetrixEffect.h" #include "light/effects/TextEffect.h" @@ -223,7 +226,6 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("MoonLiveEffect", "light/MoonLiveEffect.md"); mm::ModuleFactory::registerType("NetworkReceiveEffect", "light/effects.md#networkreceive"); mm::ModuleFactory::registerType("NoiseEffect", "light/effects.md#noise"); - mm::ModuleFactory::registerType("Noise2DEffect", "light/effects.md#noise2d"); mm::ModuleFactory::registerType("NoiseMeterEffect", "light/effects.md#noisemeter"); mm::ModuleFactory::registerType("PaintBrushEffect", "light/effects.md#paintbrush"); mm::ModuleFactory::registerType("ParticlesEffect", "light/effects.md#particles"); @@ -237,8 +239,12 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("SineEffect", "light/effects.md#sine"); mm::ModuleFactory::registerType("SolidEffect", "light/effects.md#solid"); mm::ModuleFactory::registerType("SdfShapesEffect", "light/effects.md#sdfshapes"); + mm::ModuleFactory::registerType("AuroraEffect", "light/effects.md#aurora"); mm::ModuleFactory::registerType("PolarNoiseEffect", "light/effects.md#polarnoise"); mm::ModuleFactory::registerType("WaterRippleEffect", "light/effects.md#waterripple"); + mm::ModuleFactory::registerType("FluidEffect", "light/effects.md#fluid"); + mm::ModuleFactory::registerType("NebulaEffect", "light/effects.md#nebula"); + mm::ModuleFactory::registerType("TrailsEffect", "light/effects.md#trails"); mm::ModuleFactory::registerType("TunnelEffect", "light/effects.md#tunnel"); mm::ModuleFactory::registerType("EchoEffect", "light/effects.md#echo"); mm::ModuleFactory::registerType("DissolveEffect", "light/effects.md#dissolve"); diff --git a/src/platform/desktop/moonlive_asm_arm64.cpp b/src/platform/desktop/moonlive_asm_arm64.cpp index 946955d2..421ad533 100644 --- a/src/platform/desktop/moonlive_asm_arm64.cpp +++ b/src/platform/desktop/moonlive_asm_arm64.cpp @@ -311,7 +311,18 @@ void HostAssembler::ret() { emit32(0xd65f03c0u); } // calls nest and therefore recurse. // // Pass the host arguments on (the contract is with IrOp::CallScript in core). -void HostAssembler::callLabel(Label l) { +void HostAssembler::callLabel(Label l, Reg d, bool take) { + // The same preservation call() gives a builtin: the whole vreg pool, the host args and x30 to + // the stack, the result parked in x15 (outside the pool) across the restore. Without it a value + // computed before the call and used after it, `a() + b()`, read the second call's result twice. + emit32(0xa9b807e0u); // stp x0, x1, [sp, #-128]! + emit32(0xa9017be2u); // stp x2, x30, [sp, #16] + emit32(0xa90723e3u); // stp x3, x8, [sp, #112] + emit32(0xa90217e4u); // stp x4, x5, [sp, #32] + emit32(0xa9031fe6u); // stp x6, x7, [sp, #48] + emit32(0xa9042be9u); // stp x9, x10, [sp, #64] + emit32(0xa90533ebu); // stp x11,x12, [sp, #80] + emit32(0xa9063bedu); // stp x13,x14, [sp, #96] // Reloading them is a NO-OP on this backend as long as R0..R4 map onto the ABI argument // registers x0..x4 and `bl` leaves them alone, which is why removing these four instructions // does not fail a single test here while the same omission crashes an S3. Emitted anyway, so @@ -319,6 +330,16 @@ void HostAssembler::callLabel(Label l) { for (uint8_t v = 0; v < kHostArgSlots; v++) spillLoad(static_cast(v), hostArgSlot(v)); addFixup(len_, l, FixKind::Call); emit32(0x94000000u); // bl #0: the 26-bit imm is patched below + if (take) emit32(0xaa0003efu); // mov x15, x0 (result → x15, outside the pool) + emit32(0xa94723e3u); // ldp x3, x8, [sp, #112] + emit32(0xa9463bedu); // ldp x13,x14, [sp, #96] + emit32(0xa94533ebu); // ldp x11,x12, [sp, #80] + emit32(0xa9442be9u); // ldp x9, x10, [sp, #64] + emit32(0xa9431fe6u); // ldp x6, x7, [sp, #48] + emit32(0xa94217e4u); // ldp x4, x5, [sp, #32] + emit32(0xa9417be2u); // ldp x2, x30, [sp, #16] + emit32(0xa8c807e0u); // ldp x0, x1, [sp], #128 + if (take) emit32(0xaa0f03e0u | uint32_t(mr(d))); // mov x, x15 } void HostAssembler::patchBranches() { diff --git a/src/platform/desktop/moonlive_asm_host.h b/src/platform/desktop/moonlive_asm_host.h index cb4a2149..51f5a602 100644 --- a/src/platform/desktop/moonlive_asm_host.h +++ b/src/platform/desktop/moonlive_asm_host.h @@ -135,7 +135,9 @@ class HostAssembler { /// Call a function in THIS block, by label: the script-to-script call. The return address is /// linked into x30 (arm64 `bl`) or pushed on the stack (x86-64 `call rel32`); either way the /// callee's prologue preserves it, which is what lets the call nest. - void callLabel(Label l); + /// Every caller vreg is preserved across it exactly as call() does for a builtin, and the + /// return value is delivered into `d` when `take` is set. + void callLabel(Label l, Reg d = R0, bool take = false); void ret(); private: diff --git a/src/platform/desktop/moonlive_asm_x86_64.cpp b/src/platform/desktop/moonlive_asm_x86_64.cpp index 4b3cc181..9d09d185 100644 --- a/src/platform/desktop/moonlive_asm_x86_64.cpp +++ b/src/platform/desktop/moonlive_asm_x86_64.cpp @@ -809,7 +809,11 @@ void HostAssembler::call(Reg d, Reg a, Reg b, Reg c, const void* fn) { // Script-to-script call: bl-equivalent on x64 is `call rel32` (E8 xx xx xx xx). The callee's // prologue saves rbp and nonvolatiles, exactly like our own prologue, so calls nest. -void HostAssembler::callLabel(Label l) { +void HostAssembler::callLabel(Label l, Reg d, bool take) { + // The same preservation call() gives a builtin: push the whole pool, call, write the result over + // the pushed slot of `d` so the pops deliver it. Without it a value computed before the call and + // used after it, `a() + b()`, read the second call's result twice. + for (uint8_t v = 0; v < kRegCount; v++) emitPushReg(this, kX64Reg[v]); // Reload parked host arguments from their spill slots — the contract with IrOp::CallScript // in core, mirroring arm64. On x64 this matters: the arg registers ARE vregs the callee may // have consumed, so their live-across-a-called-function meaning has to be re-established. @@ -819,17 +823,31 @@ void HostAssembler::callLabel(Label l) { // above the 32-byte shadow space. The entry-point function's prologue reads kArg4 from // [rbp+48] and it works because the C++ caller followed Win64 exactly. A script-to-script // call must do the same: without this store, the callee's `mov rdi, [rbp+48]` reads whatever - // happened to be in that stack slot (an old vreg-save byte, a return-address byte from a - // previous call), rdi becomes garbage, and the first host(kArg4) dereferences it and faults. - // [rsp+kShadowSpace] is the outgoing arg-5 slot prologue reserves for exactly this (see the - // frame layout above); call() saves vregs with pushes below rsp and reserves nothing here. + // happened to be in that stack slot, rdi becomes garbage, and the first host(kArg4) + // dereferences it and faults. The pushes above buried the slot prologue reserved, so a fresh + // outgoing area is opened here: shadow + the arg-5 slot, padded to keep rsp 16-aligned (the + // pushes leave it aligned, as call()'s own `sub rsp, kShadowSpace` relies on). // SysV passes arg 5 in r8, which IS R4 in the SysV map; the spillLoad above already put it // in the right register, so no stack store is needed. + emitSubRspImm32(this, int32_t(kShadowSpace) + 16); emitMovMemDispReg(this, x64::RSP, kShadowSpace, xr(R4)); #endif addFixup(len_, l, FixKind::Call); uint8_t b[5] = { 0xE8, 0, 0, 0, 0 }; emitBytes(b, 5); +#if defined(_WIN32) + { + uint8_t add[4] = { rex_(true, false, false, false), 0x83, modrm_(0b11, 0, x64::RSP), + uint8_t(kShadowSpace + 16) }; + emitBytes(add, 4); + } +#endif + if (take) { + uint8_t zx[2] = {0x89, 0xC0}; // mov eax, eax: zero-extend the 32-bit result + emitBytes(zx, 2); + emitMovMemDispReg(this, x64::RSP, pushedSlot(d), x64::RAX); + } + for (uint8_t v = kRegCount; v-- > 0;) emitPopReg(this, kX64Reg[v]); } // Resolve all pending fixups: overwrite the rel32 field in each branch/call with (target - site). diff --git a/src/platform/esp32/moonlive_asm_riscv.cpp b/src/platform/esp32/moonlive_asm_riscv.cpp index fd7a9862..a9db0ceb 100644 --- a/src/platform/esp32/moonlive_asm_riscv.cpp +++ b/src/platform/esp32/moonlive_asm_riscv.cpp @@ -61,11 +61,33 @@ void RiscvAssembler::bind(Label l) { if (l < kMaxLabels) labelPos_[l] = static_c // Pass the host arguments on (the contract is with IrOp::CallScript in core). The RISC-V delta: // there is no window rotation, so the values go straight into the argument registers the callee's // prologue reads. -void RiscvAssembler::callLabel(Label l) { +// The encoders below are defined with the arithmetic ops further down; declared here so callLabel +// can sit with its sibling call-related routines rather than after them. +static uint32_t encAddi(uint8_t rd, uint8_t rs1, int32_t imm); +static uint32_t encSw(uint8_t rs2, uint8_t rs1, int32_t imm); +static uint32_t encLw(uint8_t rd, uint8_t rs1, int32_t imm); + +void RiscvAssembler::callLabel(Label l, Reg d, bool take) { + // The same preservation call() gives a builtin: the whole vreg pool and ra to the stack, the + // callee's a0 stashed in t6 past the restore. Without it a value computed before the call and + // used after it, `a() + b()`, read the second call's result twice. + emit32(encAddi(2, 2, -80)); // addi sp, sp, -80 + emit32(encSw(1, 2, 76)); // sw ra, 76(sp) + static const uint8_t saved[] = {10, 11, 12, 13, 14, 5, 6, 7, 28, 29, 30, 15, 16, 17}; + int off = 0; + for (uint8_t r : saved) { emit32(encSw(r, 2, off)); off += 4; } + // The host arguments are reloaded from the FRAME (s0-relative), so the sp move above does not + // disturb where they come from. for (uint8_t v = 0; v < kHostArgSlots; v++) spillLoad(static_cast(v), hostArgSlot(v)); addFixup(len_, l, FixKind::Jal); // jal x1, 0: opcode 0x6f, rd = 1. The 20-bit immediate is scattered by the patcher. emit32(0x000000efu); + if (take) emit32(encAddi(kScratchFn, 10, 0)); // mv t6, a0 + off = 0; + for (uint8_t r : saved) { emit32(encLw(r, 2, off)); off += 4; } + emit32(encLw(1, 2, 76)); + emit32(encAddi(2, 2, 80)); + if (take) emit32(encAddi(xr(d), kScratchFn, 0)); // mv dst, t6 } // Record a pending branch fixup, guarding the fixed table (overflow_ rather than an OOB write). diff --git a/src/platform/esp32/moonlive_asm_riscv.h b/src/platform/esp32/moonlive_asm_riscv.h index 9babc754..63c5d17e 100644 --- a/src/platform/esp32/moonlive_asm_riscv.h +++ b/src/platform/esp32/moonlive_asm_riscv.h @@ -104,7 +104,9 @@ class RiscvAssembler { void call(Reg d, Reg a, Reg b, Reg c, const void* fn); // standard call to a host built-in /// Call a function in THIS block, by label: the script-to-script call. `jal ra, off` links the /// return address in x1 and jumps; the callee's own prologue saves ra, so recursion works. - void callLabel(Label l); + /// Every caller vreg is preserved across it exactly as call() does for a builtin, and the + /// callee's a0 is delivered into `d` when `take` is set. + void callLabel(Label l, Reg d = R0, bool take = false); void epilogue(); // undo prologue's frame (if any), then ret /// Park `a` where the ABI returns a value, so the host reads it after the call. The move /// happens BEFORE the epilogue's teardown: on a windowed or frame-pointer ABI the diff --git a/src/platform/esp32/moonlive_asm_xtensa.cpp b/src/platform/esp32/moonlive_asm_xtensa.cpp index 9ad95712..11fa0fc2 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.cpp +++ b/src/platform/esp32/moonlive_asm_xtensa.cpp @@ -164,7 +164,14 @@ void XtensaAssembler::addFixup(size_t at, Label label, FixKind kind) { // explicit sp adjust plus an s32i of a0). Reaching a windowed callee with call0 hands it a frame it // never allocated. The callee owes the 32-byte window reserve like any other call8 frame, which its // own per-function prologue provides. -void XtensaAssembler::callLabel(Label l) { +void XtensaAssembler::callLabel(Label l, Reg d, bool take) { + // The same preservation call() gives a builtin. The window protects a2..a7 (R0..R5) by itself; + // a8..a11 (R6..R9) become the callee's a0..a3 and are overwritten, so they go to the frame first, + // in the slots call() owns. Without this a value computed before the call and used after it, + // `a() + b()`, read the second call's result twice. + auto s32i = [&](uint8_t r, uint8_t off4){ const uint8_t enc[3]={uint8_t((r<<4)|2),0x61,off4}; emit(enc,3); }; + auto l32i = [&](uint8_t r, uint8_t off4){ const uint8_t enc[3]={uint8_t((r<<4)|2),0x21,off4}; emit(enc,3); }; + s32i(8, 4); s32i(9, 5); s32i(10, 6); s32i(11, 7); // Pass the host arguments on (the contract is with IrOp::CallScript in core). The Xtensa // delta: call8 ROTATES the window by 8, so the callee's a2..a6 are this routine's a10..a14 and // the arguments are written to the OUTGOING window, not to a2..a6, which stay this frame's own. @@ -180,6 +187,11 @@ void XtensaAssembler::callLabel(Label l) { // xtensa-esp32-elf-as, which encodes `call8 target` at pc 6 with target 0 as a5 ff ff. const uint8_t enc[3] = {0x25, 0x00, 0x00}; emit(enc, 3); + // The result lands in a10, which the restore below overwrites: park it in the frame first, the + // slot call() already uses for exactly this, then deliver it once the pool is back. + if (take) s32i(10, kResultSlot); + l32i(8, 4); l32i(9, 5); l32i(10, 6); l32i(11, 7); + if (take) l32i(ar(d), kResultSlot); } // movi aD, #imm. The narrow byte form carries only 0..255, so a wider constant (a uint16 like diff --git a/src/platform/esp32/moonlive_asm_xtensa.h b/src/platform/esp32/moonlive_asm_xtensa.h index d19cf843..fbbdc68c 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.h +++ b/src/platform/esp32/moonlive_asm_xtensa.h @@ -119,7 +119,12 @@ class XtensaAssembler { /// /// The callee therefore owes the 32-byte window-save reserve like any other call8 frame, which /// per-function prologues already give it. - void callLabel(Label l); + /// + /// Every caller vreg is preserved across the call exactly as call() does for a builtin, and the + /// callee's return value is delivered into `d` when `take` is set. The window rotation is why + /// that value arrives in the CALLER's a10: call8 rotates by 8, so the callee's a2 is this + /// frame's a10. + void callLabel(Label l, Reg d = R0, bool take = false); void epilogue(); // retw.n /// Park `a` where the ABI returns a value, so the host reads it after the call. The move /// happens BEFORE the epilogue's teardown: on a windowed or frame-pointer ABI the diff --git a/src/ui/migrate.js b/src/ui/migrate.js index 255154d7..d0029c75 100644 --- a/src/ui/migrate.js +++ b/src/ui/migrate.js @@ -27,6 +27,11 @@ export const FILE_RENAMES = { // merge made explicit when its value is deterministic; `review` flags what a map cannot decide. export const TYPE_RENAMES = { "Layers": { type: "Effects", date: "2026-08-08" }, + // Noise2D folded into Noise, which is Dim::D3 and renders the same field on a panel. `scale` + // carries; Noise2D's `speed` (a 0..15 divisor) has no equivalent, because Noise takes its rate + // from `bpm` on the shared beat clock rather than a per-effect divisor. + "Noise2DEffect": { type: "NoiseEffect", date: "2026-09-05", + review: "set bpm: Noise2D's speed (0..15) has no equivalent on the beat clock" }, // Infrared was rebuilt around learned-code ROWS, so the single-target controls it used to // carry (`code on/off`, `code brightness up`, ...) have no equivalent: the codes themselves // are gone and the remote has to be re-learned. The module carries over, which is what stops diff --git a/src/ui/preview3d.js b/src/ui/preview3d.js index a2dc8ab5..1ebb5d3e 100644 --- a/src/ui/preview3d.js +++ b/src/ui/preview3d.js @@ -866,7 +866,7 @@ function drawVerts() { // panel (¾ light, ¼ gap) at any size — a big grid is spatially downsampled (the device // sends ~1800 lattice points), so sizing by the full dimension left each dot a fraction of // its cell with big gaps. The sampled points fill the bounding box uniformly, so the pitch - // between neighbours (in grid units) is (boxVolume / count)^(1/activeDims): the square root + // between neighbors (in grid units) is (boxVolume / count)^(1/activeDims): the square root // for a flat grid, the CUBE root for a 3D volume (a cube's points spread over depth, so a // flat √ undercounts the pitch and the dots come out too small — the 3D-gap bug). Convert // that grid pitch to on-screen pixels (canvas px per grid unit) and take 75% of it. The @@ -885,22 +885,45 @@ function drawVerts() { // Fade them by base sprite size — full rings ≥8px, gone ≤4px — so the layout shows on // small/zoomed grids and the lit pattern reads cleanly when dense. Lit dots are never // faded (their alpha ignores uRingFade in the shader). - const ringFade = Math.max(0, Math.min(1, (pointSize - 4) / 4)); + let ringFade = Math.max(0, Math.min(1, (pointSize - 4) / 4)); + // A VOLUME needs the opposite of a flat grid. On a panel the placeholders sit in one plane and + // an opaque one costs nothing; in a cube every dark LED is in front of some other LED, so at a + // large dot size the placeholders stack into a solid grey wall and the lit pattern inside it + // cannot be seen at all. Fade them by the depth they have to be seen through, and further as + // the dots grow: the layout still reads, and the effect shows through it. + if (dims > 2) { + // A volume stacks its placeholders: seen through N slices they compose as 1-(1-a)^N, so + // the same alpha that is a light tint on a panel is a wall in a cube. Solve for the per-LED + // value that holds the TOTAL at a quarter whatever the depth, so the layout stays readable + // without hiding the effect inside it. (The occlusion itself is fixed above, by not writing + // depth; this is what keeps the remaining tint from adding up.) + const kVolumeHaze = 0.25; + const perLed = 1 - Math.pow(1 - kVolumeHaze, 1 / Math.max(1, bZ)); + ringFade = Math.min(ringFade, perLed / 0.22); // 0.22 is the shader's base alpha + } gl.uniform1f(glLocs.uRingFade, ringFade); - // Two passes so lit LEDs always sit ABOVE the grey placeholders (your "lights should layer - // above the circles"). On a flat grid all LEDs share a z-plane, so a single pass let draw - // order + z-fighting clip a lit dot behind a neighbour's placeholder. Pass 1 draws the - // off-LED placeholders and writes depth; pass 2 draws the lit LEDs with depthFunc LEQUAL - // and depth-WRITE off — so a lit dot beats a co-located placeholder (equal depth passes) - // yet lit dots still depth-sort against each other in a true 3D cube under any pan/tilt. - gl.uniform1f(glLocs.uLitPass, 0.0); // placeholders (write depth) - gl.drawArrays(gl.POINTS, 0, lastVertCount); - gl.depthFunc(gl.LEQUAL); + // Two passes so lit LEDs always sit ABOVE the grey placeholders. On a flat grid all LEDs share + // a z-plane, so a single pass let draw order + z-fighting clip a lit dot behind a neighbor's + // placeholder. Pass 1 draws the off-LED placeholders, pass 2 the lit ones with depthFunc LEQUAL + // so they land on top. + // Placeholders do NOT write depth. They are decoration, not geometry: an unlit LED that + // occupies the depth buffer HIDES every lit LED behind it, however transparent it looks, since + // the depth test rejects the later fragment before its alpha is ever considered. That is what + // made a cube a solid wall at large dot sizes: not the grey, the depth. With the write off, a + // dark LED tints what is behind it and nothing more, so a volume is seen through. + // Pass 1, placeholders: depth TEST on (they hide behind lit LEDs in front of them) but depth + // WRITE off, so an unlit LED never occupies the buffer. gl.depthMask(false); - gl.uniform1f(glLocs.uLitPass, 1.0); // lit LEDs, on top + gl.uniform1f(glLocs.uLitPass, 0.0); gl.drawArrays(gl.POINTS, 0, lastVertCount); + // Pass 2, lit LEDs: depth write back ON, so they depth-sort against EACH OTHER in a volume. + // Leaving it off here was the bug's other half: a lit LED at the back of a cube then drew over + // one at the front, because nothing recorded which was nearer. gl.depthMask(true); + gl.depthFunc(gl.LEQUAL); // beats a co-located placeholder + gl.uniform1f(glLocs.uLitPass, 1.0); + gl.drawArrays(gl.POINTS, 0, lastVertCount); gl.depthFunc(gl.LESS); drawBoundingBox(mvp); @@ -930,7 +953,7 @@ function drawVerts() { // A rest beam points along -Z, OUT of the layout toward the viewer. Z is the scene's depth axis // (architecture.md: 2D is the (x,y) face and 3D adds slices across Z), so X and Y are where the // fixtures are ARRANGED and Z is the only axis free to shine along. Aiming down -Y instead would -// send each head along the axis its neighbours occupy, which is what a 1 x N chain of heads made +// send each head along the axis its neighbors occupy, which is what a 1 x N chain of heads made // obvious: every beam ran through the next fixture. // // Pan then sweeps in the (x,z) plane and tilt lifts toward +Y, so a centered head points straight @@ -1137,7 +1160,7 @@ function drawBoundingBox(mvp) { // rendered onto a 2D canvas laid over #preview: project the light's position through the // SAME mvp the GL render uses (so labels track LEDs in 2D AND 3D layouts), to a screen // pixel, and draw its number. Legibility LOD: a number is drawn only if it FITS INSIDE its -// light bulb (the on-screen sprite) — so it never overflows onto neighbours. The font is +// light bulb (the on-screen sprite), so it never overflows onto neighbors. The font is // sized to the sprite, so as you zoom in (sprites grow, depth-corrected) more numbers fit // and appear; zoomed out on a dense grid they don't fit and stay hidden. Behind-camera // points (w ≤ 0) are skipped — essential for 3D. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index ae2269b2..fc626bc5 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable(mm_tests unit/core/unit_platform_audiofft.cpp unit/core/unit_Buffer.cpp unit/core/unit_ScratchBuffer.cpp + unit/core/unit_Oscillators.cpp unit/core/unit_ActiveInstance.cpp unit/core/unit_Color.cpp unit/core/unit_math8.cpp @@ -86,6 +87,7 @@ add_executable(mm_tests unit/light/unit_PanelCardDriver_packet.cpp unit/light/unit_WledAudioSyncPacket.cpp unit/light/unit_BlendMap.cpp + unit/light/unit_fluid.cpp unit/light/unit_draw.cpp unit/light/unit_GameOfLifeEffect.cpp unit/light/unit_DemoReelEffect.cpp @@ -104,6 +106,10 @@ add_executable(mm_tests unit/light/unit_Circle.cpp unit/light/unit_Particles.cpp unit/light/unit_Shader.cpp + unit/light/unit_PolarLut.cpp + unit/light/unit_PolarLut_equivalence.cpp + unit/light/unit_AuroraEffect.cpp + unit/light/unit_NoiseEffect.cpp unit/light/unit_Effects_framerate.cpp unit/light/unit_SineEffect.cpp unit/light/unit_DistortionWavesEffect.cpp @@ -118,6 +124,7 @@ add_executable(mm_tests unit/light/unit_WaveEffect.cpp unit/light/unit_FireEffect.cpp unit/light/unit_GridLayout.cpp + unit/light/unit_Rings241Layout.cpp unit/light/unit_SingleColumnLayout.cpp unit/light/unit_SphereLayout.cpp unit/light/unit_WheelLayout.cpp @@ -146,7 +153,6 @@ add_executable(mm_tests unit/light/unit_RotateModifier.cpp unit/light/unit_RegionModifier.cpp unit/light/unit_PinwheelModifier.cpp - unit/light/unit_NoiseEffect.cpp unit/light/unit_ParticlesEffect.cpp unit/light/unit_PlasmaEffect.cpp unit/light/unit_PreviewDriver.cpp @@ -202,3 +208,8 @@ set_tests_properties(unit_tests PROPERTIES # Scenario runner (replays scenario JSON files in-process) add_executable(mm_scenarios scenario_runner.cpp) target_link_libraries(mm_scenarios PRIVATE mm_core mm_platform) + +# Kernel micro-bench: ns per call for the power-function kernels, on the host. A report, not a +# test (no ctest registration): the numbers go into performance.md and gate a kernel swap. +add_executable(mm_bench bench/bench_kernels.cpp) +target_link_libraries(mm_bench PRIVATE mm_core mm_platform) diff --git a/test/bench/bench_kernels.cpp b/test/bench/bench_kernels.cpp new file mode 100644 index 00000000..5a49affa --- /dev/null +++ b/test/bench/bench_kernels.cpp @@ -0,0 +1,88 @@ +// Kernel micro-bench: nanoseconds per call for the power-function kernels, on the host. +// +// The gate for a kernel swap: the generative-fields plan replaces value noise with gradient noise +// behind the same names and accepts the swap only within 1.3x of the value-noise cost per sample. +// That bound is meaningless without the number it is measured against, so this target records it, +// before the swap, and every later kernel adds a row. Host timings, not ESP32 cycles: the S3 is +// 20-40x slower per core (performance.md, the `collide` measurement) and the ratio between two rows +// is what transfers, not the absolute figure. +// +// Method: sweep a 256x256 grid of 16.0 fixed coordinates at a fixed step (the same shape a noise +// effect samples), accumulate every result into a checksum the compiler cannot elide, repeat, and +// keep the best of several runs so a scheduler hiccup does not become a slower kernel. Output is a +// Markdown table so the rows paste into performance.md unchanged. +// +// Not a doctest: a benchmark that asserts a timing is a flaky test, and one that does not assert is +// a report. This is the report. Run by hand or through moondeck/check/bench_kernels.py. + +#include "core/math16.h" +#include "core/noise.h" + +#include +#include +#include + +namespace { + +constexpr uint32_t kGrid = 256; // samples per axis per pass +constexpr uint32_t kStep = 40; // 16.0 fixed coordinate step: ~6.4 noise cells across +constexpr int kRuns = 5; // best-of + +/// Time one kernel over the grid; returns the best ns per sample across kRuns. +/// +/// The kernel arrives as a TEMPLATE parameter, not a std::function: a type-erased call cannot be +/// inlined, so it adds an indirect call to every sample and lands in the same nanoseconds the +/// measurement is trying to attribute to the kernel. At 5 ns a sample that is a large share of what +/// is being reported. +template +double bench(Fn fn) { + double best = 1e18; + volatile uint32_t sink = 0; // the checksum the optimizer must honor + for (int run = 0; run < kRuns; run++) { + uint32_t acc = 0; + const auto t0 = std::chrono::steady_clock::now(); + for (uint32_t y = 0; y < kGrid; y++) + for (uint32_t x = 0; x < kGrid; x++) + acc += fn(x * kStep, y * kStep, (x + y) * 3); + const auto t1 = std::chrono::steady_clock::now(); + sink = sink + acc; + const double ns = std::chrono::duration(t1 - t0).count() / (kGrid * kGrid); + if (ns < best) best = ns; + } + return best; +} + +/// One row of the table: measure `fn` and print it. +template +void row(const char* name, Fn fn) { + const double ns = bench(fn); + std::printf("| %s | %.1f | %.1f |\n", name, ns, 1000.0 / ns); +} + +} // namespace + +int main() { + using namespace mm; + std::printf("| Kernel | ns/sample | Msamples/s |\n|---|---:|---:|\n"); + row("inoise8 1D", [](uint32_t x, uint32_t, uint32_t) { return uint32_t(inoise8(x)); }); + row("inoise8 2D", [](uint32_t x, uint32_t y, uint32_t) { return uint32_t(inoise8(x, y)); }); + row("inoise8 3D", [](uint32_t x, uint32_t y, uint32_t z) { return uint32_t(inoise8(x, y, z)); }); + row("inoise16 1D", [](uint32_t x, uint32_t, uint32_t) { return uint32_t(inoise16(x)); }); + row("inoise16 2D", [](uint32_t x, uint32_t y, uint32_t) { return uint32_t(inoise16(x, y)); }); + row("inoise16 3D", [](uint32_t x, uint32_t y, uint32_t z) { return uint32_t(inoise16(x, y, z)); }); + row("fbm8 2D, 2 octaves", [](uint32_t x, uint32_t y, uint32_t) { return uint32_t(fbm8(x, y, 2)); }); + row("fbm8 2D, 4 octaves", [](uint32_t x, uint32_t y, uint32_t) { return uint32_t(fbm8(x, y, 4)); }); + row("fbm16 2D, 2 octaves", [](uint32_t x, uint32_t y, uint32_t) { return uint32_t(fbm16(x, y, 2)); }); + row("fbm16 2D, 4 octaves", [](uint32_t x, uint32_t y, uint32_t) { return uint32_t(fbm16(x, y, 4)); }); + row("turbulence8 2D, 2 octaves", [](uint32_t x, uint32_t y, uint32_t) { return uint32_t(turbulence8(x, y, 2)); }); + row("warp8 2D, 1 octave", [](uint32_t x, uint32_t y, uint32_t) { return uint32_t(warp8(x, y, 512, 1)); }); + row("warp8 2D, 2 octaves", [](uint32_t x, uint32_t y, uint32_t) { return uint32_t(warp8(x, y, 512, 2)); }); + row("atan16", [](uint32_t x, uint32_t y, uint32_t) { return uint32_t(atan16(int32_t(y) - 128, int32_t(x) - 128)); }); + row("dist16", [](uint32_t x, uint32_t y, uint32_t) { return dist16(int32_t(x) - 128, int32_t(y) - 128); }); + row("polar address (dist16 + atan16 + kaleido)", [](uint32_t x, uint32_t y, uint32_t) { + const int32_t dx = int32_t(x) - 128, dy = int32_t(y) - 128; + const uint32_t r = dist16(dx, dy); + return uint32_t(kaleido(angle16(atan16(dy, dx) + r * 4), 5)) + r; + }); + return 0; +} diff --git a/test/scenario_runner.cpp b/test/scenario_runner.cpp index 45d5a16f..93b25ef9 100644 --- a/test/scenario_runner.cpp +++ b/test/scenario_runner.cpp @@ -22,6 +22,11 @@ #include "light/moonlive/MoonLiveEffect.h" #include "light/moonlive/MoonLiveModifier.h" #include "light/moonlive/MoonLiveLayout.h" +#include "light/effects/AuroraEffect.h" +#include "light/effects/FluidEffect.h" +#include "light/effects/NebulaEffect.h" +#include "light/effects/TrailsEffect.h" +#include "light/effects/PolarNoiseEffect.h" #include "light/effects/SpiralEffect.h" #include "light/effects/RingsEffect.h" #include "light/effects/RipplesEffect.h" @@ -219,6 +224,11 @@ static void registerScenarioTypes() { mm::ModuleFactory::registerType("MoonLiveEffect"); mm::ModuleFactory::registerType("MoonLiveModifier"); mm::ModuleFactory::registerType("MoonLiveLayout"); + mm::ModuleFactory::registerType("AuroraEffect"); + mm::ModuleFactory::registerType("FluidEffect"); + mm::ModuleFactory::registerType("NebulaEffect"); + mm::ModuleFactory::registerType("TrailsEffect"); + mm::ModuleFactory::registerType("PolarNoiseEffect"); mm::ModuleFactory::registerType("SpiralEffect"); mm::ModuleFactory::registerType("RingsEffect"); mm::ModuleFactory::registerType("RipplesEffect"); diff --git a/test/scenarios/core/scenario_MoonModule_control_change.json b/test/scenarios/core/scenario_MoonModule_control_change.json index f25ee24d..445858c7 100644 --- a/test/scenarios/core/scenario_MoonModule_control_change.json +++ b/test/scenarios/core/scenario_MoonModule_control_change.json @@ -117,14 +117,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 131, + "p50": 134, "p95": 246, - "min": 123, + "min": 117, "max": 248, "n": 32, - "samples": [131, 140, 129, 124, 129, 127, 124, 125, 126, 167, 130, 125, 123, 123, 129, 134, 203, 129, 133, 243, 248, 246, 127, 204, 143, 150, 150, 175, 205, 179, 191, 127] + "samples": [126, 167, 130, 125, 123, 123, 129, 134, 203, 129, 133, 243, 248, 246, 127, 204, 143, 150, 150, 175, 205, 179, 191, 127, 202, 119, 121, 118, 120, 199, 193, 117] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32-eth-wifi": { "tick_us": { @@ -299,14 +299,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 131, + "p50": 132, "p95": 238, - "min": 125, + "min": 117, "max": 247, "n": 32, - "samples": [130, 125, 127, 129, 129, 129, 127, 127, 127, 169, 131, 131, 128, 127, 127, 142, 205, 130, 134, 238, 247, 238, 126, 196, 146, 147, 159, 171, 203, 182, 135, 128] + "samples": [127, 169, 131, 131, 128, 127, 127, 142, 205, 130, 134, 238, 247, 238, 126, 196, 146, 147, 159, 171, 203, 182, 135, 128, 132, 119, 120, 118, 118, 131, 134, 117] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32-eth-wifi": { "tick_us": { @@ -483,12 +483,12 @@ "tick_us": { "p50": 130, "p95": 239, - "min": 126, + "min": 116, "max": 240, "n": 32, - "samples": [128, 129, 128, 128, 129, 129, 128, 128, 128, 168, 132, 132, 127, 127, 129, 135, 204, 130, 133, 240, 239, 238, 126, 186, 147, 148, 158, 173, 206, 180, 126, 128] + "samples": [128, 168, 132, 132, 127, 127, 129, 135, 204, 130, 133, 240, 239, 238, 126, 186, 147, 148, 158, 173, 206, 180, 126, 128, 118, 121, 121, 118, 116, 116, 122, 117] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32-eth-wifi": { "tick_us": { @@ -671,14 +671,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 130, + "p50": 129, "p95": 247, - "min": 125, + "min": 117, "max": 261, "n": 32, - "samples": [125, 129, 127, 128, 129, 128, 131, 128, 128, 168, 130, 131, 128, 127, 127, 139, 205, 129, 131, 261, 247, 241, 127, 186, 148, 148, 157, 174, 192, 182, 127, 128] + "samples": [128, 168, 130, 131, 128, 127, 127, 139, 205, 129, 131, 261, 247, 241, 127, 186, 148, 148, 157, 174, 192, 182, 127, 128, 120, 119, 120, 118, 118, 120, 123, 117] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32-eth-wifi": { "tick_us": { diff --git a/test/scenarios/light/scenario_Audio_mutation.json b/test/scenarios/light/scenario_Audio_mutation.json index 711b5778..5ad49d65 100644 --- a/test/scenarios/light/scenario_Audio_mutation.json +++ b/test/scenarios/light/scenario_Audio_mutation.json @@ -105,14 +105,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 31, - "p95": 122, + "p50": 21, + "p95": 61, "min": 16, - "max": 707, + "max": 77, "n": 32, - "samples": [16, 20, 21, 707, 122, 31, 89, 17, 17, 51, 77, 46, 34, 50, 44, 61, 36, 21, 21, 35, 21, 21, 21, 32, 24, 38, 46, 48, 27, 25, 16, 20] + "samples": [17, 51, 77, 46, 34, 50, 44, 61, 36, 21, 21, 35, 21, 21, 21, 32, 24, 38, 46, 48, 27, 25, 16, 20, 16, 20, 20, 17, 17, 16, 16, 17] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -202,14 +202,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 37, - "p95": 404, + "p50": 33, + "p95": 202, "min": 17, - "max": 882, + "max": 404, "n": 32, - "samples": [21, 20, 21, 882, 305, 79, 184, 18, 23, 37, 202, 120, 48, 106, 40, 404, 41, 17, 25, 35, 34, 70, 30, 33, 26, 53, 66, 59, 35, 38, 18, 22] + "samples": [23, 37, 202, 120, 48, 106, 40, 404, 41, 17, 25, 35, 34, 70, 30, 33, 26, 53, 66, 59, 35, 38, 18, 22, 17, 17, 19, 28, 27, 22, 17, 19] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -316,14 +316,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 44, - "p95": 592, + "p50": 33, + "p95": 213, "min": 17, - "max": 1192, + "max": 413, "n": 32, - "samples": [19, 19, 25, 1192, 592, 50, 152, 19, 24, 33, 413, 58, 53, 66, 47, 213, 86, 17, 18, 32, 40, 66, 28, 33, 28, 57, 60, 63, 45, 44, 37, 20] + "samples": [24, 33, 413, 58, 53, 66, 47, 213, 86, 17, 18, 32, 40, 66, 28, 33, 28, 57, 60, 63, 45, 44, 37, 20, 17, 18, 20, 19, 25, 18, 18, 19] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -413,14 +413,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 45, - "p95": 790, + "p50": 31, + "p95": 125, "min": 19, - "max": 945, + "max": 184, "n": 32, - "samples": [22, 22, 28, 945, 790, 48, 80, 23, 30, 42, 184, 69, 75, 85, 55, 125, 86, 19, 20, 38, 27, 66, 26, 36, 30, 62, 75, 68, 45, 45, 20, 23] + "samples": [30, 42, 184, 69, 75, 85, 55, 125, 86, 19, 20, 38, 27, 66, 26, 36, 30, 62, 75, 68, 45, 45, 20, 23, 20, 21, 20, 21, 31, 20, 20, 21] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -508,14 +508,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 41, - "p95": 307, + "p50": 32, + "p95": 82, "min": 19, - "max": 400, + "max": 110, "n": 32, - "samples": [50, 22, 23, 400, 307, 72, 64, 21, 26, 36, 82, 63, 54, 61, 39, 110, 42, 23, 21, 32, 33, 69, 25, 37, 28, 65, 67, 62, 41, 80, 19, 21] + "samples": [26, 36, 82, 63, 54, 61, 39, 110, 42, 23, 21, 32, 33, 69, 25, 37, 28, 65, 67, 62, 41, 80, 19, 21, 21, 21, 20, 22, 27, 20, 21, 19] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -603,14 +603,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 34, - "p95": 273, - "min": 17, - "max": 484, + "p50": 29, + "p95": 105, + "min": 16, + "max": 142, "n": 32, - "samples": [72, 22, 20, 484, 273, 34, 64, 19, 19, 34, 75, 105, 57, 83, 39, 142, 39, 17, 26, 29, 38, 87, 23, 32, 25, 52, 79, 57, 29, 33, 18, 26] + "samples": [19, 34, 75, 105, 57, 83, 39, 142, 39, 17, 26, 29, 38, 87, 23, 32, 25, 52, 79, 57, 29, 33, 18, 26, 20, 19, 19, 17, 17, 17, 16, 17] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Aurora_fps.json b/test/scenarios/light/scenario_Aurora_fps.json new file mode 100644 index 00000000..bbcd6fca --- /dev/null +++ b/test/scenarios/light/scenario_Aurora_fps.json @@ -0,0 +1,309 @@ +{ + "name": "scenario_Aurora_fps", + "module": "AuroraEffect", + "mode": "mutate", + "also": [ + "OscillatorBank", + "PolarLut", + "GridLayout", + "Layer" + ], + "description": "What a layered shader costs, one control at a time. Aurora samples a warped noise field once per layer per pixel, so its frame time is set by three knobs that multiply: how many layers, how many octaves inside each, and whether the field warps at all. The steps walk them from the cheapest configuration to the most expensive on one grid, so the ratios between the observed numbers are what an author reads to choose settings for a fixture, and a jump in any one of them between commits is a regression with a name. The grid is deliberately 64x64: large enough that per-pixel cost dominates, small enough to run on every target the contracts name.", + "fixture": [ + { + "name": "fix-layouts", + "op": "add_module", + "id": "Layouts", + "type": "Layouts" + }, + { + "name": "fix-layers", + "op": "add_module", + "id": "Effects", + "type": "Effects", + "props": { + "layouts": "Layouts" + } + }, + { + "name": "fix-drivers", + "op": "add_module", + "id": "Drivers", + "type": "Drivers", + "props": { + "effects": "Effects" + } + } + ], + "steps": [ + { + "name": "canvas-clear-layers", + "description": "Self-canvas: clear and rebuild the pipeline this scenario assumes, so it runs from any device state.", + "op": "clear_children", + "id": "Effects" + }, + { + "name": "canvas-clear-layouts", + "op": "clear_children", + "id": "Layouts" + }, + { + "name": "canvas-grid", + "op": "add_module", + "id": "Grid", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 64, + "height": 64 + } + }, + { + "name": "canvas-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "parent_id": "Effects" + }, + { + "name": "aurora-defaults", + "description": "Aurora as it ships: 3 layers, 2 octaves, warp on. This is the number a catalog card promises, and the one every step below is read against.", + "op": "add_module", + "id": "Aurora", + "type": "AuroraEffect", + "parent_id": "Layer", + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 3000, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: Aurora at 64x64, 3 layers, 2 octaves, warp on" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 841, + "p95": 1391, + "min": 474, + "max": 1391, + "n": 15, + "samples": [478, 474, 650, 786, 803, 855, 835, 843, 890, 827, 841, 871, 845, 856, 1391] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "one-layer", + "description": "The cost knob at its cheapest. Layers are the outer multiplier: each one is a full warped field sample per pixel, so this should land near a third of the step above.", + "op": "set_control", + "id": "Aurora", + "key": "layers", + "value": 1, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 1500, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: one layer, roughly a third of the three-layer cost" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 317, + "p95": 355, + "min": 207, + "max": 355, + "n": 15, + "samples": [207, 207, 208, 307, 311, 320, 316, 318, 317, 317, 321, 334, 312, 322, 355] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "one-layer-no-warp", + "description": "Warp off: the field is sampled where it lies rather than where the field displaced it, which is two fewer noise samples per pixel. The cheapest Aurora that still reads as Aurora, and the configuration a large wall runs.", + "op": "set_control", + "id": "Aurora", + "key": "warp", + "value": 0, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 900, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: one layer without domain warping" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 187, + "p95": 254, + "min": 127, + "max": 254, + "n": 15, + "samples": [127, 127, 128, 185, 183, 189, 187, 192, 186, 185, 188, 196, 187, 187, 254] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "four-layers-no-warp", + "description": "Four unwarped layers: the same total field samples as roughly one warped layer, arranged as more structure instead of more folding. Reading this against one-layer-no-warp is how an author trades detail for depth at a fixed budget.", + "op": "set_control", + "id": "Aurora", + "key": "layers", + "value": 4, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 3400, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: four layers, warp off" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 544, + "p95": 581, + "min": 303, + "max": 581, + "n": 15, + "samples": [304, 303, 306, 530, 532, 554, 540, 544, 551, 551, 554, 581, 543, 554, 560] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "four-layers-four-octaves", + "description": "Four unwarped layers at four octaves: the octave knob at its top, with warp still off from the step above. Read against four-layers-no-warp, this is what octaves alone cost.", + "op": "set_control", + "id": "Aurora", + "key": "octaves", + "value": 4, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 7000, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: four layers, four octaves, warp still off" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 937, + "p95": 1236, + "min": 474, + "max": 1236, + "n": 15, + "samples": [474, 474, 480, 921, 919, 974, 933, 936, 938, 945, 964, 997, 937, 956, 1236] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "warp-back-on", + "description": "Warp back on, so the two steps below measure the configuration Aurora actually ships. Setting it explicitly rather than relying on the default is what keeps each number readable on its own.", + "op": "set_control", + "id": "Aurora", + "key": "warp", + "value": 60, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 9000, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: four layers, four octaves, warp on: the true ceiling" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 1455, + "p95": 2116, + "min": 785, + "max": 2116, + "n": 14, + "samples": [937, 785, 1419, 1423, 1465, 1430, 1562, 1455, 1458, 1483, 2116, 1446, 1497, 1493] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "kaleidoscope", + "description": "Fold the composition into six wedges, at the ceiling settings above. The symmetry is applied to the angle before the field is ever sampled, so it costs one modulo per pixel and not a second pass over the field: the small step from warp-back-on is what pins that claim.", + "op": "set_control", + "id": "Aurora", + "key": "segments", + "value": 6, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 9500, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: the fold adds one modulo per pixel, not a pass" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 1498, + "p95": 1920, + "min": 507, + "max": 1920, + "n": 15, + "samples": [507, 814, 821, 1496, 1511, 1506, 1491, 1494, 1498, 1506, 1574, 1920, 1494, 1564, 1543] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "back-to-defaults", + "description": "Octaves back to the shipped 2, with four layers and the fold still on. Returning near the four-layers steps above is what shows the effect carries no state that accumulates across reconfiguration.", + "op": "set_control", + "id": "Aurora", + "key": "octaves", + "value": 2, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 4200, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: four layers, two octaves, folded" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 1103, + "p95": 1211, + "min": 333, + "max": 1211, + "n": 15, + "samples": [333, 643, 658, 1103, 1079, 1108, 1092, 1096, 1104, 1118, 1165, 1211, 1103, 1135, 1128] + }, + "last_updated": "2026-09-05" + } + } + } + ] +} diff --git a/test/scenarios/light/scenario_Driver_mutation.json b/test/scenarios/light/scenario_Driver_mutation.json index 897d765f..013c66c4 100644 --- a/test/scenarios/light/scenario_Driver_mutation.json +++ b/test/scenarios/light/scenario_Driver_mutation.json @@ -76,14 +76,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 43, - "p95": 1198, + "p50": 32, + "p95": 590, "min": 17, "max": 1843, "n": 32, - "samples": [17, 17, 20, 1198, 180, 56, 69, 19, 25, 32, 590, 105, 45, 51, 43, 1843, 56, 18, 99, 32, 36, 147, 25, 37, 25, 51, 73, 88, 61, 43, 17, 21] + "samples": [25, 32, 590, 105, 45, 51, 43, 1843, 56, 18, 99, 32, 36, 147, 25, 37, 25, 51, 73, 88, 61, 43, 17, 21, 17, 20, 20, 17, 19, 20, 19, 18] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -173,14 +173,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 45, - "p95": 665, + "p50": 32, + "p95": 298, "min": 17, - "max": 2596, + "max": 421, "n": 32, - "samples": [21, 17, 21, 2596, 665, 42, 58, 17, 25, 47, 421, 126, 48, 54, 39, 298, 68, 19, 25, 37, 51, 73, 26, 32, 25, 47, 65, 59, 59, 45, 17, 22] + "samples": [25, 47, 421, 126, 48, 54, 39, 298, 68, 19, 25, 37, 51, 73, 26, 32, 25, 47, 65, 59, 59, 45, 17, 22, 17, 20, 20, 17, 27, 20, 20, 20] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -270,14 +270,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 38, + "p50": 32, "p95": 257, "min": 18, "max": 339, "n": 32, - "samples": [38, 21, 21, 177, 160, 49, 61, 20, 29, 32, 257, 339, 46, 53, 38, 222, 67, 25, 24, 36, 37, 61, 25, 32, 25, 48, 64, 63, 52, 46, 21, 18] + "samples": [29, 32, 257, 339, 46, 53, 38, 222, 67, 25, 24, 36, 37, 61, 25, 32, 25, 48, 64, 63, 52, 46, 21, 18, 20, 20, 20, 20, 24, 20, 20, 21] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -365,14 +365,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 49, - "p95": 285, - "min": 20, - "max": 718, + "p50": 33, + "p95": 120, + "min": 17, + "max": 173, "n": 32, - "samples": [21, 22, 28, 718, 285, 71, 68, 20, 33, 50, 106, 114, 52, 64, 50, 120, 83, 20, 25, 33, 25, 49, 31, 38, 27, 55, 173, 76, 54, 47, 20, 24] + "samples": [33, 50, 106, 114, 52, 64, 50, 120, 83, 20, 25, 33, 25, 49, 31, 38, 27, 55, 173, 76, 54, 47, 20, 24, 20, 20, 20, 17, 19, 20, 20, 17] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -460,14 +460,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 47, - "p95": 316, - "min": 19, - "max": 852, + "p50": 29, + "p95": 267, + "min": 17, + "max": 316, "n": 32, - "samples": [31, 19, 25, 852, 244, 94, 87, 19, 27, 68, 135, 87, 47, 74, 136, 267, 50, 20, 20, 32, 29, 62, 28, 35, 26, 59, 316, 66, 36, 52, 21, 21] + "samples": [27, 68, 135, 87, 47, 74, 136, 267, 50, 20, 20, 32, 29, 62, 28, 35, 26, 59, 316, 66, 36, 52, 21, 21, 20, 20, 20, 17, 23, 20, 20, 19] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Effects_composition.json b/test/scenarios/light/scenario_Effects_composition.json index 7b9cdc2a..37548a4f 100644 --- a/test/scenarios/light/scenario_Effects_composition.json +++ b/test/scenarios/light/scenario_Effects_composition.json @@ -106,14 +106,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 497, - "p95": 2259, - "min": 248, - "max": 11415, + "p50": 370, + "p95": 1253, + "min": 142, + "max": 1335, "n": 32, - "samples": [326, 255, 306, 11415, 2259, 507, 742, 252, 253, 499, 1335, 697, 573, 781, 604, 1253, 646, 253, 263, 426, 343, 1044, 366, 485, 370, 731, 711, 768, 496, 497, 248, 248] + "samples": [253, 499, 1335, 697, 573, 781, 604, 1253, 646, 253, 263, 426, 343, 1044, 366, 485, 370, 731, 711, 768, 496, 497, 248, 248, 146, 142, 146, 152, 145, 146, 148, 144] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Fields_polar_lut.json b/test/scenarios/light/scenario_Fields_polar_lut.json new file mode 100644 index 00000000..1ec112a3 --- /dev/null +++ b/test/scenarios/light/scenario_Fields_polar_lut.json @@ -0,0 +1,316 @@ +{ + "name": "scenario_Fields_polar_lut", + "module": "PolarLut", + "mode": "mutate", + "also": [ + "PolarNoiseEffect", + "AuroraEffect", + "GridLayout", + "Layer" + ], + "description": "The polar address table through a running pipeline: it is built for the grid a radial effect draws on, rebuilt when that grid resizes, dropped when the effect switches it off, and doubled when full precision is asked for. What the steps pin is that every one of those transitions happens live, under a rendering pipeline, without a reboot and without a stale table addressing pixels that no longer exist. The frame times are recorded per target and read as a TREND rather than as a pass mark: on a desktop the table and the computed address measure the same, because an out-of-order core with a large cache computes atan16 and dist16 about as fast as it loads two arrays. The 34% the table is worth was measured on an ESP32-S3, and it is an in-order-core property, so the contracts that matter here are the device ones. Aurora is measured last because it samples the field several times per pixel, which is what sets the fps a composition of this shape can promise.", + "fixture": [ + { + "name": "fix-layouts", + "op": "add_module", + "id": "Layouts", + "type": "Layouts" + }, + { + "name": "fix-layers", + "op": "add_module", + "id": "Effects", + "type": "Effects", + "props": { + "layouts": "Layouts" + } + }, + { + "name": "fix-drivers", + "op": "add_module", + "id": "Drivers", + "type": "Drivers", + "props": { + "effects": "Effects" + } + } + ], + "steps": [ + { + "name": "canvas-clear-layers", + "description": "Self-canvas: clear and rebuild the pipeline this scenario assumes, so it runs from any device state.", + "op": "clear_children", + "id": "Effects" + }, + { + "name": "canvas-clear-layouts", + "op": "clear_children", + "id": "Layouts" + }, + { + "name": "canvas-grid", + "description": "A 64x64 panel: large enough that the address table is real memory (8 KB narrow) and small enough to run on every target.", + "op": "add_module", + "id": "Grid", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 64, + "height": 64 + } + }, + { + "name": "canvas-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "parent_id": "Effects" + }, + { + "name": "add-polarnoise", + "description": "A radial effect on the layer. It builds the table in prepare(), so the allocation happens here rather than on the first rendered frame.", + "op": "add_module", + "id": "PolarNoise", + "type": "PolarNoiseEffect", + "parent_id": "Layer", + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 4000, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: PolarNoise at 64x64 through the address table" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 289, + "p95": 387, + "min": 189, + "max": 387, + "n": 16, + "samples": [289, 189, 281, 384, 274, 387, 283, 278, 281, 289, 325, 292, 302, 291, 286, 293] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "table-off", + "description": "Switch the table off under a running effect: the effect computes each pixel's angle and radius instead, which is the path a device short of memory takes. The picture is unchanged, which unit_PolarLut_equivalence pins bit-for-bit at full precision; what this step proves is that the switch takes effect live.", + "op": "set_control", + "id": "PolarNoise", + "key": "polarTable", + "value": false, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 6000, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: the computed-address fallback, slower than the table above" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 281, + "p95": 313, + "min": 183, + "max": 313, + "n": 16, + "samples": [183, 189, 194, 279, 273, 275, 284, 281, 281, 313, 297, 290, 297, 283, 288, 291] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "table-on", + "description": "Back to the table, which prepare() rebuilds. Confirms the switch is live in both directions rather than one-way.", + "op": "set_control", + "id": "PolarNoise", + "key": "polarTable", + "value": true, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 4000, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: back to the table cost" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 282, + "p95": 303, + "min": 168, + "max": 303, + "n": 16, + "samples": [168, 180, 177, 279, 274, 274, 282, 281, 282, 288, 289, 289, 303, 284, 286, 289] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "table-wide", + "description": "Full 16-bit precision: 4 bytes per pixel instead of 2, so the table doubles at this grid. Frame time barely moves on any target, which is what makes precision a memory decision rather than a speed one.", + "op": "set_control", + "id": "PolarNoise", + "key": "polarTable16", + "value": true, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 4200, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: the wide table, near the narrow one in time" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 281, + "p95": 301, + "min": 168, + "max": 301, + "n": 16, + "samples": [168, 175, 179, 278, 274, 273, 284, 277, 282, 285, 286, 286, 301, 281, 287, 290] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "resize-down", + "description": "Shrink the grid under a running effect. The table is rebuilt for the new geometry in prepare(), not carried over: a stale table would address pixels that no longer exist.", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 2400, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: half the pixels, half the table" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 140, + "p95": 147, + "min": 83, + "max": 147, + "n": 16, + "samples": [83, 86, 88, 138, 137, 137, 142, 140, 138, 144, 141, 144, 147, 141, 144, 143] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "resize-up", + "description": "Grow it again, the direction that has to allocate rather than free. On a device without PSRAM this is where prepare() weighs the table against free heap minus the reserve that protects stacks, WiFi and HTTP, and declines it rather than taking the last of the heap; the effect keeps rendering through the computed address.", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 96, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 7000, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: 64x96 with the wide table" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 420, + "p95": 501, + "min": 250, + "max": 501, + "n": 16, + "samples": [250, 257, 264, 422, 413, 410, 501, 413, 417, 422, 421, 428, 444, 420, 451, 442] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "remove-polarnoise", + "description": "Take PolarNoise off the layer before Aurora goes on it. Adding a second effect to a layer does not replace the first: both render, so the step below would have measured the pair and reported it as Aurora's cost.", + "op": "remove_module", + "id": "PolarNoise" + }, + { + "name": "swap-to-aurora", + "description": "Aurora alone on the layer: three layers of warped noise over the same address, each on its own oscillator. Several field samples per pixel, so this is the frame time that decides what the fps contract can promise.", + "op": "add_module", + "id": "Aurora", + "type": "AuroraEffect", + "parent_id": "Layer", + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 40000, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: Aurora at 64x96, 3 layers, 2 octaves" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 1218, + "p95": 1311, + "min": 960, + "max": 1311, + "n": 16, + "samples": [960, 970, 981, 1200, 1187, 1206, 1234, 1239, 1206, 1218, 1228, 1239, 1298, 1269, 1240, 1311] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "aurora-one-layer", + "description": "Drop to a single layer, the cost knob at its cheapest. The ratio against the step above is what an effect author reads to choose a layer count for their fixture.", + "op": "set_control", + "id": "Aurora", + "key": "layers", + "value": 1, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 16000, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "initial contract: roughly a third of the three-layer cost" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 480, + "p95": 604, + "min": 460, + "max": 604, + "n": 16, + "samples": [560, 561, 561, 479, 460, 590, 474, 479, 476, 472, 473, 480, 506, 482, 604, 488] + }, + "last_updated": "2026-09-05" + } + } + } + ] +} diff --git a/test/scenarios/light/scenario_Fluid_solver.json b/test/scenarios/light/scenario_Fluid_solver.json new file mode 100644 index 00000000..75b022f5 --- /dev/null +++ b/test/scenarios/light/scenario_Fluid_solver.json @@ -0,0 +1,376 @@ +{ + "name": "scenario_Fluid_solver", + "module": "FluidEffect", + "mode": "mutate", + "also": [ + "Fluid", + "draw", + "OscillatorBank", + "GridLayout", + "Layer" + ], + "description": "What a stable-fluid solver costs, and that it survives being reshaped underneath itself. Unlike every other flow in the library the velocity here is STATE: six grids the solver owns, reallocated whenever the fixture changes, which is what makes the resize steps a real contract rather than a formality. A stale grid would address cells that no longer exist. The cost knob is `iterations`, the pressure solve that keeps the flow divergence-free: each one is another Gauss-Seidel sweep over the whole grid, so the ladder from 1 to 20 is close to linear and it is what an author reads to pick a value for their fixture. On a cube every slice is its own medium, with nothing carried between slices, so the cost is depth times one panel. The frame times are a TREND per target rather than a pass mark; the solver is several passes over the grid per frame, so this is a desktop and P4 effect and the device numbers are the ones that decide where it can run.", + "fixture": [ + { + "name": "fix-layouts", + "op": "add_module", + "id": "Layouts", + "type": "Layouts" + }, + { + "name": "fix-layers", + "op": "add_module", + "id": "Effects", + "type": "Effects", + "props": { + "layouts": "Layouts" + } + }, + { + "name": "fix-drivers", + "op": "add_module", + "id": "Drivers", + "type": "Drivers", + "props": { + "effects": "Effects" + } + } + ], + "steps": [ + { + "name": "canvas-clear-layers", + "description": "Self-canvas: rebuild the pipeline this scenario assumes, so it runs from any device state.", + "op": "clear_children", + "id": "Effects" + }, + { + "name": "canvas-clear-layouts", + "op": "clear_children", + "id": "Layouts" + }, + { + "name": "canvas-grid", + "description": "A 32x32 panel to start: small enough that the six solver grids are cheap while the shape of the cost is already visible.", + "op": "add_module", + "id": "Grid", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 32, + "height": 32 + } + }, + { + "name": "canvas-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "parent_id": "Effects" + }, + { + "name": "add-fluid", + "description": "The solver on the layer. prepare() allocates six velocity and pressure grids plus two dye planes, so the allocation happens here rather than on the first rendered frame.", + "op": "add_module", + "id": "Fluid", + "type": "FluidEffect", + "parent_id": "Layer", + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 30, + "p95": 73, + "min": 29, + "max": 73, + "n": 10, + "samples": [30, 30, 30, 31, 30, 31, 29, 31, 73, 30] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "iterations-1", + "description": "The pressure solve at its cheapest. At one sweep the flow is not properly divergence-free and reads springy, which is the visible cost of the cheap setting.", + "op": "set_control", + "id": "Fluid", + "key": "iterations", + "value": 1, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 19, + "p95": 37, + "min": 19, + "max": 37, + "n": 10, + "samples": [20, 20, 20, 19, 19, 19, 20, 19, 37, 19] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "iterations-20", + "description": "And at its most expensive. The ratio against the step above is the pressure solve's share of the frame, which is what an author trades against grid size.", + "op": "set_control", + "id": "Fluid", + "key": "iterations", + "value": 20, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 68, + "p95": 101, + "min": 66, + "max": 101, + "n": 10, + "samples": [69, 69, 68, 70, 68, 68, 66, 68, 101, 68] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "iterations-5", + "description": "Back to the default, confirming the knob moves in both directions rather than one way.", + "op": "set_control", + "id": "Fluid", + "key": "iterations", + "value": 5, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 29, + "p95": 37, + "min": 29, + "max": 37, + "n": 10, + "samples": [30, 31, 30, 30, 29, 29, 29, 29, 37, 29] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "grid-64", + "description": "Widen to 64 under a running solver, height still 32: one dimension doubled, so twice the cells, and every one of the six grids is reallocated in prepare(). A solver that carried its old grid over would be addressing cells that no longer exist.", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 65, + "p95": 74, + "min": 63, + "max": 74, + "n": 10, + "samples": [64, 65, 65, 66, 66, 64, 64, 63, 74, 66] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "grid-64-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 133, + "p95": 138, + "min": 130, + "max": 138, + "n": 10, + "samples": [133, 134, 138, 136, 134, 131, 130, 132, 133, 134] + }, + "last_updated": "2026-09-05" + } + }, + "description": "And the height, making it 64x64: four times the cells of the 32x32 the pair started from. Reallocating on each axis separately is the shape a UI resize actually takes." + }, + { + "name": "all-jets", + "description": "Every jet pouring at once. The jets are forcing rather than per-pixel work, so this should barely move the frame time: the solver dominates, which is the point.", + "op": "set_control", + "id": "Fluid", + "key": "jets", + "value": 4, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 133, + "p95": 140, + "min": 131, + "max": 140, + "n": 10, + "samples": [133, 140, 137, 136, 135, 131, 132, 131, 131, 134] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "long-persistence", + "description": "Dye held at its longest half-life, the setting the 16-bit plane exists for: a value multiplied by slightly less than one many times a second has nowhere to go at 8 bits.", + "op": "set_control", + "id": "Fluid", + "key": "persistence", + "value": 255, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 134, + "p95": 147, + "min": 129, + "max": 147, + "n": 10, + "samples": [134, 141, 138, 135, 147, 134, 130, 131, 129, 135] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "cube-20", + "description": "A 20x20x20 cube, the largest volumetric fixture in practice: twenty independent slices, each its own medium, with the jets drifting through them in z. The cost is twenty small solves, close to one 90x90 panel, and it is what decides whether the effect belongs on a cube at all.", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 20, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 36, + "p95": 40, + "min": 35, + "max": 40, + "n": 9, + "samples": [39, 38, 36, 40, 36, 35, 36, 35, 37] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "cube-20-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 20, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 11, + "p95": 12, + "min": 11, + "max": 12, + "n": 9, + "samples": [12, 11, 11, 11, 11, 11, 11, 11, 11] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "cube-20-d", + "op": "set_control", + "id": "Grid", + "key": "depth", + "value": 20, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 222, + "p95": 249, + "min": 219, + "max": 249, + "n": 9, + "samples": [249, 232, 229, 220, 220, 220, 222, 219, 227] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "back-to-panel", + "description": "Depth back to 1: the slices are freed and the panel path is exactly the 2D one again.", + "op": "set_control", + "id": "Grid", + "key": "depth", + "value": 1, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 11, + "p95": 12, + "min": 11, + "max": 12, + "n": 9, + "samples": [12, 11, 11, 11, 11, 11, 11, 11, 11] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "grid-16", + "description": "Shrink it again, the direction that frees rather than allocates, and the one where a solver holding a stale pointer shows up immediately.", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 8, + "p95": 29, + "min": 8, + "max": 29, + "n": 10, + "samples": [29, 9, 9, 9, 8, 8, 8, 8, 8, 9] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "grid-16-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 7, + "p95": 8, + "min": 6, + "max": 8, + "n": 10, + "samples": [7, 7, 7, 7, 6, 7, 6, 7, 8, 8] + }, + "last_updated": "2026-09-05" + } + } + } + ] +} diff --git a/test/scenarios/light/scenario_GridBlacks_blackpixel.json b/test/scenarios/light/scenario_GridBlacks_blackpixel.json index f8841374..4ead1ffd 100644 --- a/test/scenarios/light/scenario_GridBlacks_blackpixel.json +++ b/test/scenarios/light/scenario_GridBlacks_blackpixel.json @@ -92,12 +92,12 @@ "tick_us": { "p50": 5, "p95": 15, - "min": 2, + "min": 1, "max": 27, "n": 32, - "samples": [2, 4, 2, 2, 2, 2, 2, 3, 10, 15, 5, 7, 2, 2, 5, 9, 8, 6, 7, 27, 12, 6, 3, 11, 4, 5, 7, 7, 5, 5, 2, 2] + "samples": [10, 15, 5, 7, 2, 2, 5, 9, 8, 6, 7, 27, 12, 6, 3, 11, 4, 5, 7, 7, 5, 5, 2, 2, 1, 2, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -195,12 +195,12 @@ "tick_us": { "p50": 7, "p95": 25, - "min": 3, + "min": 2, "max": 73, "n": 32, - "samples": [3, 6, 3, 3, 3, 3, 3, 4, 73, 10, 7, 10, 3, 3, 7, 14, 10, 8, 10, 15, 16, 8, 5, 25, 5, 5, 10, 10, 7, 7, 3, 3] + "samples": [73, 10, 7, 10, 3, 3, 7, 14, 10, 8, 10, 15, 16, 8, 5, 25, 5, 5, 10, 10, 7, 7, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_GridLayout_resize.json b/test/scenarios/light/scenario_GridLayout_resize.json index 21d8f58b..f4e9208d 100644 --- a/test/scenarios/light/scenario_GridLayout_resize.json +++ b/test/scenarios/light/scenario_GridLayout_resize.json @@ -117,14 +117,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 131, + "p50": 128, "p95": 311, - "min": 124, + "min": 117, "max": 316, "n": 32, - "samples": [205, 129, 131, 131, 125, 124, 209, 127, 170, 130, 126, 220, 127, 128, 129, 126, 157, 244, 128, 127, 241, 280, 306, 311, 167, 316, 183, 292, 249, 240, 128, 126] + "samples": [170, 130, 126, 220, 127, 128, 129, 126, 157, 244, 128, 127, 241, 280, 306, 311, 167, 316, 183, 292, 249, 240, 128, 126, 120, 118, 120, 128, 117, 119, 122, 117] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32-eth-wifi": { "tick_us": { @@ -299,14 +299,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 70, - "p95": 161, - "min": 63, - "max": 177, + "p50": 68, + "p95": 145, + "min": 59, + "max": 161, "n": 32, - "samples": [103, 70, 70, 66, 69, 67, 177, 68, 141, 68, 68, 108, 68, 68, 69, 69, 74, 135, 66, 63, 132, 145, 118, 161, 84, 144, 90, 126, 125, 118, 68, 68] + "samples": [141, 68, 68, 108, 68, 68, 69, 69, 74, 135, 66, 63, 132, 145, 118, 161, 84, 144, 90, 126, 125, 118, 68, 68, 65, 66, 64, 61, 59, 64, 65, 59] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32-eth-wifi": { "tick_us": { @@ -481,14 +481,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 132, + "p50": 129, "p95": 298, - "min": 125, + "min": 116, "max": 309, "n": 32, - "samples": [204, 128, 130, 135, 128, 129, 127, 128, 164, 132, 129, 204, 127, 127, 129, 128, 149, 309, 129, 125, 247, 283, 243, 298, 166, 249, 182, 209, 247, 240, 126, 127] + "samples": [164, 132, 129, 204, 127, 127, 129, 128, 149, 309, 129, 125, 247, 283, 243, 298, 166, 249, 182, 209, 247, 240, 126, 127, 120, 123, 121, 123, 116, 120, 122, 119] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32-eth-wifi": { "tick_us": { diff --git a/test/scenarios/light/scenario_Layer_base_pipeline.json b/test/scenarios/light/scenario_Layer_base_pipeline.json index 66d562d1..00ba12f9 100644 --- a/test/scenarios/light/scenario_Layer_base_pipeline.json +++ b/test/scenarios/light/scenario_Layer_base_pipeline.json @@ -83,14 +83,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 107, + "p50": 94, "p95": 211, "min": 64, "max": 245, "n": 32, - "samples": [107, 71, 68, 69, 68, 67, 75, 189, 64, 68, 199, 211, 145, 245, 127, 205, 143, 73, 68, 131, 86, 123, 94, 122, 107, 179, 178, 197, 123, 125, 71, 65] + "samples": [64, 68, 199, 211, 145, 245, 127, 205, 143, 73, 68, 131, 86, 123, 94, 122, 107, 179, 178, 197, 123, 125, 71, 65, 72, 70, 69, 66, 64, 66, 66, 64] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Layer_memory_1to1.json b/test/scenarios/light/scenario_Layer_memory_1to1.json index 6b50de37..75957708 100644 --- a/test/scenarios/light/scenario_Layer_memory_1to1.json +++ b/test/scenarios/light/scenario_Layer_memory_1to1.json @@ -85,9 +85,9 @@ "min": 5, "max": 229, "n": 32, - "samples": [5, 5, 9, 5, 9, 5, 5, 5, 6, 229, 32, 21, 6, 11, 19, 12, 18, 11, 11, 10, 40, 14, 7, 10, 7, 9, 41, 27, 10, 11, 5, 5] + "samples": [6, 229, 32, 21, 6, 11, 19, 12, 18, 11, 11, 10, 40, 14, 7, 10, 7, 9, 41, 27, 10, 11, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json index 1f3b77e5..4f34d667 100644 --- a/test/scenarios/light/scenario_Layouts_mutation.json +++ b/test/scenarios/light/scenario_Layouts_mutation.json @@ -78,14 +78,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 33, - "p95": 608, + "p50": 29, + "p95": 79, "min": 16, - "max": 750, + "max": 354, "n": 32, - "samples": [17, 16, 20, 608, 750, 54, 437, 17, 22, 37, 354, 44, 36, 48, 36, 79, 38, 17, 17, 33, 23, 35, 25, 32, 29, 48, 50, 61, 32, 32, 16, 16] + "samples": [22, 37, 354, 44, 36, 48, 36, 79, 38, 17, 17, 33, 23, 35, 25, 32, 29, 48, 50, 61, 32, 32, 16, 16, 17, 17, 16, 17, 17, 16, 16, 17] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -206,14 +206,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 87, - "p95": 1353, - "min": 46, - "max": 1367, + "p50": 75, + "p95": 180, + "min": 43, + "max": 633, "n": 32, - "samples": [47, 46, 52, 1353, 1367, 236, 714, 46, 51, 93, 633, 140, 100, 126, 85, 180, 105, 51, 71, 90, 61, 103, 70, 87, 75, 123, 126, 126, 86, 86, 48, 47] + "samples": [51, 93, 633, 140, 100, 126, 85, 180, 105, 51, 71, 90, 61, 103, 70, 87, 75, 123, 126, 126, 86, 86, 48, 47, 48, 44, 46, 49, 45, 43, 48, 44] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -329,14 +329,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 171, - "p95": 2190, - "min": 89, - "max": 2305, + "p50": 156, + "p95": 381, + "min": 88, + "max": 554, "n": 32, - "samples": [94, 93, 105, 2190, 2305, 1486, 1211, 89, 93, 207, 554, 381, 202, 258, 187, 294, 200, 96, 100, 167, 119, 174, 130, 171, 138, 245, 248, 248, 170, 169, 92, 93] + "samples": [93, 207, 554, 381, 202, 258, 187, 294, 200, 96, 100, 167, 119, 174, 130, 171, 138, 245, 248, 248, 170, 169, 92, 93, 156, 93, 93, 97, 88, 92, 94, 88] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -451,14 +451,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 32, - "p95": 595, + "p50": 27, + "p95": 68, "min": 16, - "max": 719, + "max": 293, "n": 32, - "samples": [20, 20, 20, 595, 719, 126, 409, 20, 21, 61, 64, 293, 42, 55, 32, 68, 38, 21, 16, 31, 27, 36, 24, 32, 25, 47, 47, 48, 33, 31, 20, 20] + "samples": [21, 61, 64, 293, 42, 55, 32, 68, 38, 21, 16, 31, 27, 36, 24, 32, 25, 47, 47, 48, 33, 31, 20, 20, 17, 20, 20, 21, 17, 20, 20, 17] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json index ab9ddbae..ac74bc3d 100644 --- a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json +++ b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json @@ -88,14 +88,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 12, - "p95": 132, + "p50": 9, + "p95": 71, "min": 5, - "max": 1050, + "max": 93, "n": 32, - "samples": [13, 5, 5, 5, 5, 5, 7, 132, 1050, 17, 15, 5, 5, 93, 14, 71, 13, 12, 12, 22, 11, 15, 8, 12, 9, 14, 28, 18, 10, 10, 5, 5] + "samples": [15, 5, 5, 93, 14, 71, 13, 12, 12, 22, 11, 15, 8, 12, 9, 14, 28, 18, 10, 10, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -206,14 +206,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 11, - "p95": 482, + "p50": 9, + "p95": 25, "min": 5, - "max": 1656, + "max": 34, "n": 32, - "samples": [9, 5, 7, 5, 5, 5, 6, 482, 1656, 16, 12, 5, 8, 20, 9, 25, 12, 21, 14, 34, 13, 18, 9, 12, 9, 16, 24, 17, 11, 10, 5, 5] + "samples": [12, 5, 8, 20, 9, 25, 12, 21, 14, 34, 13, 18, 9, 12, 9, 16, 24, 17, 11, 10, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -316,14 +316,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 11, - "p95": 33, + "p50": 9, + "p95": 28, "min": 5, - "max": 184, + "max": 33, "n": 32, - "samples": [10, 5, 5, 5, 5, 5, 6, 22, 184, 20, 33, 5, 6, 18, 11, 15, 13, 12, 19, 18, 15, 19, 9, 10, 9, 17, 28, 17, 11, 10, 5, 5] + "samples": [33, 5, 6, 18, 11, 15, 13, 12, 19, 18, 15, 19, 9, 10, 9, 17, 28, 17, 11, 10, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -426,14 +426,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 14, - "p95": 151, + "p50": 10, + "p95": 29, "min": 5, - "max": 309, + "max": 31, "n": 32, - "samples": [9, 5, 5, 5, 5, 5, 6, 309, 151, 25, 31, 5, 5, 20, 16, 13, 23, 18, 21, 15, 15, 14, 9, 10, 15, 15, 29, 19, 16, 11, 5, 5] + "samples": [31, 5, 5, 20, 16, 13, 23, 18, 21, 15, 15, 14, 9, 10, 15, 15, 29, 19, 16, 11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -529,14 +529,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 12, - "p95": 208, + "p50": 11, + "p95": 41, "min": 5, - "max": 464, + "max": 60, "n": 32, - "samples": [10, 5, 5, 5, 5, 5, 6, 208, 464, 15, 41, 5, 5, 24, 14, 60, 12, 17, 11, 20, 12, 14, 8, 11, 12, 14, 26, 20, 11, 13, 5, 5] + "samples": [41, 5, 5, 24, 14, 60, 12, 17, 11, 20, 12, 14, 8, 11, 12, 14, 26, 20, 11, 13, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -632,14 +632,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 11, - "p95": 187, + "p50": 10, + "p95": 28, "min": 5, - "max": 282, + "max": 29, "n": 32, - "samples": [10, 5, 5, 5, 5, 5, 6, 187, 282, 14, 28, 5, 5, 22, 16, 27, 29, 24, 15, 24, 11, 22, 7, 10, 12, 16, 26, 18, 11, 11, 5, 5] + "samples": [28, 5, 5, 22, 16, 27, 29, 24, 15, 24, 11, 22, 7, 10, 12, 16, 26, 18, 11, 11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -724,6 +724,72 @@ } } }, + { + "name": "transpose-grid-h", + "description": "Make it 16x8, then 8x16 below: the SAME sample count in a different shape. resize() keeps the buffer and its contents, which are laid out for the old geometry, so a scripted trail must clear both of its planes or the previous picture smears across the new one. The compiled effects each carry this guard; the binding does it on the script's behalf.", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 8, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 5, + "p95": 5, + "min": 5, + "max": 5, + "n": 3, + "samples": [5, 5, 5] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "transpose-grid-w", + "description": "And the transpose: 8 wide by 16 high, the same count again. The pair proves the clear fires on a reshape rather than only on a size change, in both directions.", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 8, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 5, + "p95": 5, + "min": 5, + "max": 5, + "n": 3, + "samples": [5, 5, 5] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "transpose-restore", + "description": "Back to the square the rest of the scenario assumes.", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16, + "measure": true, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 5, + "p95": 5, + "min": 5, + "max": 5, + "n": 3, + "samples": [5, 5, 5] + }, + "last_updated": "2026-09-05" + } + } + }, { "name": "remove-moonlive", "description": "Remove the scripted effect. release frees the exec block; the Layer keeps rendering (now empty). Measures add/remove robustness.", @@ -733,14 +799,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 12, - "p95": 81, + "p50": 9, + "p95": 24, "min": 5, - "max": 113, + "max": 64, "n": 32, - "samples": [9, 5, 5, 5, 5, 5, 6, 113, 81, 19, 64, 5, 6, 24, 9, 21, 14, 24, 12, 20, 17, 22, 7, 11, 14, 18, 21, 18, 11, 13, 5, 5] + "samples": [64, 5, 6, 24, 9, 21, 14, 24, 12, 20, 17, 22, 7, 11, 14, 18, 21, 18, 11, 13, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -836,14 +902,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 12, - "p95": 32, + "p50": 8, + "p95": 29, "min": 5, - "max": 169, + "max": 32, "n": 32, - "samples": [12, 5, 5, 5, 5, 5, 6, 169, 9, 17, 27, 6, 5, 17, 8, 12, 15, 29, 12, 32, 13, 24, 8, 10, 15, 15, 29, 18, 10, 13, 5, 5] + "samples": [27, 6, 5, 17, 8, 12, 15, 29, 12, 32, 13, 24, 8, 10, 15, 15, 29, 18, 10, 13, 5, 5, 6, 5, 5, 5, 5, 6, 5, 5, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -939,14 +1005,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 10, - "p95": 37, + "p50": 8, + "p95": 31, "min": 5, - "max": 728, + "max": 37, "n": 32, - "samples": [9, 5, 5, 5, 5, 5, 6, 728, 26, 15, 12, 7, 5, 14, 8, 15, 11, 29, 16, 37, 10, 31, 7, 9, 15, 14, 21, 22, 12, 10, 5, 6] + "samples": [12, 7, 5, 14, 8, 15, 11, 29, 16, 37, 10, 31, 7, 9, 15, 14, 21, 22, 12, 10, 5, 6, 5, 5, 6, 5, 6, 6, 5, 5, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32": { "tick_us": { diff --git a/test/scenarios/light/scenario_MoonLive_pipeline.json b/test/scenarios/light/scenario_MoonLive_pipeline.json index e60f5afd..0be5f09b 100644 --- a/test/scenarios/light/scenario_MoonLive_pipeline.json +++ b/test/scenarios/light/scenario_MoonLive_pipeline.json @@ -379,9 +379,9 @@ "min": 5, "max": 267, "n": 32, - "samples": [5, 5, 10, 5, 5, 5, 5, 5, 6, 267, 185, 26, 70, 5, 5, 46, 54, 25, 10, 25, 19, 20, 13, 30, 7, 12, 47, 17, 14, 11, 5, 5] + "samples": [6, 267, 185, 26, 70, 5, 5, 46, 54, 25, 10, 25, 19, 20, 13, 30, 7, 12, 47, 17, 14, 11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -531,14 +531,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 10, + "p50": 9, "p95": 97, "min": 4, "max": 559, "n": 32, - "samples": [5, 5, 10, 4, 5, 5, 5, 5, 6, 32, 559, 17, 97, 5, 4, 25, 18, 19, 11, 19, 20, 15, 12, 32, 7, 9, 14, 21, 10, 9, 5, 5] + "samples": [6, 32, 559, 17, 97, 5, 4, 25, 18, 19, 11, 19, 20, 15, 12, 32, 7, 9, 14, 21, 10, 9, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -687,9 +687,9 @@ "min": 5, "max": 520, "n": 32, - "samples": [5, 5, 11, 5, 5, 5, 5, 5, 6, 520, 159, 26, 10, 6, 5, 36, 13, 11, 12, 12, 21, 19, 20, 17, 8, 8, 17, 18, 10, 10, 5, 6] + "samples": [6, 520, 159, 26, 10, 6, 5, 36, 13, 11, 12, 12, 21, 19, 20, 17, 8, 8, 17, 18, 10, 10, 5, 6, 5, 6, 5, 5, 6, 5, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -989,9 +989,9 @@ "min": 5, "max": 117, "n": 32, - "samples": [5, 5, 10, 5, 5, 5, 5, 5, 6, 117, 34, 27, 32, 6, 6, 13, 8, 20, 13, 18, 20, 21, 24, 25, 8, 10, 10, 18, 10, 10, 5, 5] + "samples": [6, 117, 34, 27, 32, 6, 6, 13, 8, 20, 13, 18, 20, 21, 24, 25, 8, 10, 10, 18, 10, 10, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -1131,9 +1131,9 @@ "min": 5, "max": 397, "n": 32, - "samples": [5, 5, 11, 5, 5, 5, 5, 5, 6, 96, 35, 26, 19, 7, 5, 10, 22, 397, 12, 17, 18, 22, 24, 11, 8, 9, 13, 24, 10, 11, 5, 5] + "samples": [6, 96, 35, 26, 19, 7, 5, 10, 22, 397, 12, 17, 18, 22, 24, 11, 8, 9, 13, 24, 10, 11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json index a3bf5366..f777e88b 100644 --- a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json +++ b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json @@ -89,14 +89,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 6, + "p50": 5, "p95": 165, - "min": 3, + "min": 2, "max": 241, "n": 32, - "samples": [3, 3, 7, 3, 6, 3, 3, 3, 3, 165, 15, 12, 3, 3, 9, 132, 241, 7, 8, 18, 17, 13, 4, 19, 4, 5, 21, 14, 6, 6, 3, 3] + "samples": [3, 165, 15, 12, 3, 3, 9, 132, 241, 7, 8, 18, 17, 13, 4, 19, 4, 5, 21, 14, 6, 6, 3, 3, 3, 2, 3, 3, 3, 2, 2, 3] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json index 182f3eae..57880f0b 100644 --- a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json +++ b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json @@ -89,14 +89,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 129, + "p50": 126, "p95": 283, - "min": 124, + "min": 117, "max": 302, "n": 32, - "samples": [189, 145, 130, 127, 124, 133, 125, 133, 126, 125, 126, 124, 235, 127, 129, 124, 126, 125, 150, 127, 128, 238, 283, 277, 174, 302, 182, 214, 235, 240, 127, 126] + "samples": [126, 125, 126, 124, 235, 127, 129, 124, 126, 125, 150, 127, 128, 238, 283, 277, 174, 302, 182, 214, 235, 240, 127, 126, 118, 119, 117, 121, 117, 119, 119, 118] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Trails_ladder.json b/test/scenarios/light/scenario_Trails_ladder.json new file mode 100644 index 00000000..8988d142 --- /dev/null +++ b/test/scenarios/light/scenario_Trails_ladder.json @@ -0,0 +1,323 @@ +{ + "name": "scenario_Trails_ladder", + "module": "TrailsEffect", + "mode": "mutate", + "also": [ + "draw", + "OscillatorBank", + "GridLayout", + "Layer" + ], + "description": "What a flow costs as a fixture grows, and what it costs to become a volume. Trails runs three passes over its own plane every frame (advect it along the flow, decay it by a half-life, write the emitters), so its cost is per light and the ladder is what proves that: 16x16, 32x32, 64x64 and then a 20x20x20 cube, which has roughly twice the lights of the 64x64 panel and must cost roughly twice as much rather than something worse. The last two steps hold the geometry still and move the controls instead, because a control that changes the cost would be the thing an author has to know about: persistence must not (the decay is a multiply per sample whatever the half-life) and dots must barely (an emitter is one write). The plane is 16-bit and there are two of them, so the memory each step reports is 6 bytes per light, which is the honest price of a tail that fades smoothly.", + "fixture": [ + { + "name": "fix-layouts", + "op": "add_module", + "id": "Layouts", + "type": "Layouts" + }, + { + "name": "fix-layers", + "op": "add_module", + "id": "Effects", + "type": "Effects", + "props": { + "layouts": "Layouts" + } + }, + { + "name": "fix-drivers", + "op": "add_module", + "id": "Drivers", + "type": "Drivers", + "props": { + "effects": "Effects" + } + } + ], + "steps": [ + { + "name": "canvas-clear-layers", + "description": "Self-canvas: clear and rebuild the pipeline this scenario assumes, so it runs from any device state.", + "op": "clear_children", + "id": "Effects" + }, + { + "name": "canvas-clear-layouts", + "op": "clear_children", + "id": "Layouts" + }, + { + "name": "canvas-grid", + "description": "A 16x16 panel to start the ladder: the smallest size a trail reads on, and the one every larger step is compared against per light.", + "op": "add_module", + "id": "Grid", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 16, + "height": 16, + "depth": 1 + } + }, + { + "name": "canvas-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "parent_id": "Effects" + }, + { + "name": "trails-16", + "description": "Trails as it ships, on 16x16. The tick is one advect plus one decay over the whole plane, plus a noise sample per light for the flow, so the per-light cost here is what every step below multiplies.", + "op": "add_module", + "id": "Trails", + "type": "TrailsEffect", + "parent_id": "Layer", + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 40, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "measured 2026-09-04, budget ~3x p50: Trails at 16x16, 3 dots, curl off" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 11, + "p95": 13, + "min": 10, + "max": 13, + "n": 10, + "samples": [11, 10, 10, 10, 11, 11, 11, 11, 11, 13] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "grid-32", + "description": "Four times the lights. Transport and decay are both per light, so this step and the next are what show the cost is linear rather than hiding a per-frame constant.", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "grid-32-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 180, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "measured 2026-09-04, budget ~3x p50: 32x32, four times 16x16" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 45, + "p95": 57, + "min": 40, + "max": 57, + "n": 10, + "samples": [57, 40, 42, 41, 45, 45, 45, 45, 45, 47] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "grid-64", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "grid-64-h", + "description": "64x64: the size a panel user actually runs, and the one the catalog card's fps claim is read from.", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 650, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "measured 2026-09-04, budget ~3x p50: 64x64, the panel size the card quotes" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 180, + "p95": 208, + "min": 160, + "max": 208, + "n": 10, + "samples": [208, 160, 166, 165, 182, 178, 180, 184, 180, 183] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "cube-20", + "description": "The volumetric case, and the bench fixture: a 20x20x20 cube is 8000 lights against the panel's 4096, and every slice is carried by the same flow. What this pins is that 3D is the same cost per light, not a different algorithm.", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 20 + }, + { + "name": "cube-20-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 20 + }, + { + "name": "cube-20-d", + "op": "set_control", + "id": "Grid", + "key": "depth", + "value": 20, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 1000, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "measured 2026-09-04, budget ~3x p50: the 20-cube, 8000 lights" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 348, + "p95": 358, + "min": 313, + "max": 358, + "n": 10, + "samples": [323, 313, 331, 324, 358, 348, 353, 357, 352, 357] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "long-tail", + "description": "Persistence at its top. The decay is a multiply per sample whatever the half-life is, so a long tail must cost the same as a short one: a step that moved here would mean the decay had become conditional on its own result.", + "op": "set_control", + "id": "Trails", + "key": "persistence", + "value": 255, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 1000, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "measured 2026-09-04, budget ~3x p50: a long tail costs what a short one costs" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 351, + "p95": 608, + "min": 312, + "max": 608, + "n": 10, + "samples": [321, 312, 334, 332, 442, 353, 358, 354, 351, 608] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "more-dots", + "description": "Eight emitters rather than three. A dot is one write, so this is the cheapest control on the effect and the step proves it: the emitters are not what a large fixture pays for.", + "op": "set_control", + "id": "Trails", + "key": "dots", + "value": 8, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 1000, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "measured 2026-09-04, budget ~3x p50: emitters are nearly free next to the per-light passes" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 351, + "p95": 370, + "min": 312, + "max": 370, + "n": 10, + "samples": [319, 312, 336, 350, 359, 351, 354, 354, 365, 370] + }, + "last_updated": "2026-09-05" + } + } + }, + { + "name": "back-to-panel", + "description": "Back to a flat 64x64, which frees the cube's planes. Returning near the earlier 64x64 number is what shows the effect carries no state that accumulates across a resize: the planes are rebuilt for the new geometry rather than reused.", + "op": "set_control", + "id": "Grid", + "key": "depth", + "value": 1 + }, + { + "name": "back-to-panel-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "back-to-panel-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "contract": { + "desktop-macos": { + "tick_us": 520, + "free_heap": 0, + "set_by": "2026-09-04", + "reason": "measured 2026-09-04, budget ~3x p50: back to the 64x64 number above" + } + }, + "observed": { + "desktop-macos": { + "tick_us": { + "p50": 178, + "p95": 189, + "min": 164, + "max": 189, + "n": 10, + "samples": [164, 169, 169, 166, 183, 178, 180, 182, 189, 186] + }, + "last_updated": "2026-09-05" + } + } + } + ] +} diff --git a/test/scenarios/light/scenario_modifier_chain.json b/test/scenarios/light/scenario_modifier_chain.json index 987074ba..18f0c87e 100644 --- a/test/scenarios/light/scenario_modifier_chain.json +++ b/test/scenarios/light/scenario_modifier_chain.json @@ -101,14 +101,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 17, - "p95": 205, + "p50": 16, + "p95": 86, "min": 8, - "max": 270, + "max": 163, "n": 32, - "samples": [9, 8, 10, 270, 205, 35, 32, 8, 163, 17, 36, 86, 20, 23, 20, 28, 31, 8, 9, 16, 14, 17, 12, 18, 15, 24, 32, 43, 17, 16, 8, 8] + "samples": [163, 17, 36, 86, 20, 23, 20, 28, 31, 8, 9, 16, 14, 17, 12, 18, 15, 24, 32, 43, 17, 16, 8, 8, 8, 8, 8, 10, 9, 8, 9, 8] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -161,14 +161,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 15, - "p95": 86, - "min": 7, - "max": 457, + "p50": 14, + "p95": 49, + "min": 6, + "max": 86, "n": 32, - "samples": [7, 7, 9, 457, 81, 17, 24, 7, 26, 14, 86, 49, 16, 23, 18, 36, 36, 7, 7, 14, 10, 21, 11, 15, 13, 22, 23, 26, 14, 14, 7, 7] + "samples": [26, 14, 86, 49, 16, 23, 18, 36, 36, 7, 7, 14, 10, 21, 11, 15, 13, 22, 23, 26, 14, 14, 7, 7, 6, 7, 7, 9, 7, 6, 9, 7] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -219,14 +219,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 48, - "p95": 325, - "min": 22, - "max": 466, + "p50": 41, + "p95": 94, + "min": 21, + "max": 143, "n": 32, - "samples": [26, 24, 27, 466, 325, 55, 72, 24, 29, 46, 94, 143, 50, 59, 59, 83, 74, 27, 23, 48, 30, 65, 33, 48, 41, 63, 72, 69, 43, 46, 26, 22] + "samples": [29, 46, 94, 143, 50, 59, 59, 83, 74, 27, 23, 48, 30, 65, 33, 48, 41, 63, 72, 69, 43, 46, 26, 22, 24, 22, 22, 28, 21, 21, 24, 21] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -252,14 +252,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 78, - "p95": 473, - "min": 45, - "max": 2547, + "p50": 72, + "p95": 138, + "min": 37, + "max": 473, "n": 32, - "samples": [46, 46, 46, 2547, 173, 108, 125, 45, 62, 78, 116, 473, 98, 116, 90, 127, 123, 47, 47, 72, 52, 88, 55, 77, 73, 112, 110, 138, 77, 83, 47, 47] + "samples": [62, 78, 116, 473, 98, 116, 90, 127, 123, 47, 47, 72, 52, 88, 55, 77, 73, 112, 110, 138, 77, 83, 47, 47, 43, 43, 44, 41, 42, 40, 44, 37] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json index e1d215e4..39fac74d 100644 --- a/test/scenarios/light/scenario_modifier_swap.json +++ b/test/scenarios/light/scenario_modifier_swap.json @@ -151,14 +151,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 22, - "p95": 359, + "p50": 16, + "p95": 87, "min": 8, - "max": 1133, + "max": 246, "n": 32, - "samples": [8, 8, 10, 359, 1133, 28, 148, 8, 8, 22, 37, 87, 20, 22, 44, 57, 26, 9, 8, 17, 11, 33, 12, 17, 49, 25, 246, 71, 16, 35, 8, 8] + "samples": [8, 22, 37, 87, 20, 22, 44, 57, 26, 9, 8, 17, 11, 33, 12, 17, 49, 25, 246, 71, 16, 35, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32-eth": { "tick_us": { @@ -295,14 +295,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 46, - "p95": 456, - "min": 22, - "max": 1237, + "p50": 42, + "p95": 160, + "min": 20, + "max": 321, "n": 32, - "samples": [23, 23, 27, 456, 1237, 73, 206, 22, 23, 43, 80, 321, 63, 75, 135, 85, 64, 24, 22, 42, 31, 46, 33, 43, 61, 65, 160, 87, 45, 74, 25, 25] + "samples": [23, 43, 80, 321, 63, 75, 135, 85, 64, 24, 22, 42, 31, 46, 33, 43, 61, 65, 160, 87, 45, 74, 25, 25, 21, 21, 20, 21, 21, 20, 22, 21] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32-eth": { "tick_us": { @@ -439,14 +439,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 24, - "p95": 421, - "min": 9, + "p50": 16, + "p95": 76, + "min": 8, "max": 513, "n": 32, - "samples": [10, 13, 10, 421, 79, 37, 62, 9, 10, 16, 24, 76, 43, 513, 63, 38, 30, 11, 10, 16, 11, 36, 12, 16, 29, 25, 47, 49, 18, 39, 10, 10] + "samples": [10, 16, 24, 76, 43, 513, 63, 38, 30, 11, 10, 16, 11, 36, 12, 16, 29, 25, 47, 49, 18, 39, 10, 10, 10, 9, 10, 8, 9, 11, 10, 8] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32-eth": { "tick_us": { diff --git a/test/scenarios/light/scenario_perf_full.json b/test/scenarios/light/scenario_perf_full.json index 51285b84..4923bd34 100644 --- a/test/scenarios/light/scenario_perf_full.json +++ b/test/scenarios/light/scenario_perf_full.json @@ -85,14 +85,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 5, - "p95": 15, - "min": 2, + "p50": 4, + "p95": 11, + "min": 1, "max": 22, "n": 32, - "samples": [2, 2, 3, 15, 7, 5, 7, 2, 2, 7, 9, 9, 22, 8, 8, 11, 7, 2, 2, 4, 3, 5, 3, 5, 4, 8, 9, 9, 5, 5, 2, 2] + "samples": [2, 7, 9, 9, 22, 8, 8, 11, 7, 2, 2, 4, 3, 5, 3, 5, 4, 8, 9, 9, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -205,14 +205,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 5, - "p95": 48, - "min": 2, + "p50": 4, + "p95": 15, + "min": 1, "max": 51, "n": 32, - "samples": [2, 2, 3, 48, 8, 5, 7, 2, 2, 7, 7, 7, 51, 15, 8, 12, 7, 2, 2, 4, 4, 5, 4, 5, 5, 7, 7, 14, 5, 5, 2, 2] + "samples": [2, 7, 7, 7, 51, 15, 8, 12, 7, 2, 2, 4, 4, 5, 4, 5, 5, 7, 7, 14, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -325,14 +325,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 5, - "p95": 24, - "min": 2, + "p50": 4, + "p95": 12, + "min": 1, "max": 28, "n": 32, - "samples": [2, 2, 3, 24, 7, 5, 7, 2, 2, 5, 10, 7, 28, 10, 6, 9, 7, 2, 2, 4, 3, 5, 3, 5, 4, 7, 8, 12, 5, 5, 2, 2] + "samples": [2, 5, 10, 7, 28, 10, 6, 9, 7, 2, 2, 4, 3, 5, 3, 5, 4, 7, 8, 12, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -568,14 +568,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 5, - "p95": 12, - "min": 2, + "p50": 4, + "p95": 9, + "min": 1, "max": 31, "n": 32, - "samples": [2, 2, 3, 12, 9, 6, 6, 2, 2, 7, 7, 8, 9, 6, 6, 7, 7, 2, 2, 4, 3, 7, 3, 5, 4, 7, 8, 31, 5, 5, 2, 2] + "samples": [2, 7, 7, 8, 9, 6, 6, 7, 7, 2, 2, 4, 3, 7, 3, 5, 4, 7, 8, 31, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -686,14 +686,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 5, + "p50": 4, "p95": 26, - "min": 2, + "min": 1, "max": 70, "n": 32, - "samples": [2, 2, 3, 8, 7, 7, 8, 2, 2, 5, 7, 10, 70, 8, 6, 16, 7, 2, 2, 4, 3, 6, 3, 5, 4, 7, 26, 9, 5, 5, 2, 2] + "samples": [2, 5, 7, 10, 70, 8, 6, 16, 7, 2, 2, 4, 3, 6, 3, 5, 4, 7, 26, 9, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -815,14 +815,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 5, + "p50": 4, "p95": 13, - "min": 2, + "min": 1, "max": 13, "n": 32, - "samples": [2, 2, 3, 9, 7, 5, 7, 2, 2, 5, 7, 7, 11, 9, 6, 13, 7, 2, 2, 4, 3, 13, 3, 5, 4, 7, 7, 8, 5, 5, 2, 2] + "samples": [2, 5, 7, 7, 11, 9, 6, 13, 7, 2, 2, 4, 3, 13, 3, 5, 4, 7, 7, 8, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -948,14 +948,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 5, - "p95": 19, - "min": 2, - "max": 27, + "p50": 4, + "p95": 9, + "min": 1, + "max": 11, "n": 32, - "samples": [2, 2, 3, 19, 27, 5, 9, 2, 2, 5, 7, 7, 9, 7, 7, 8, 7, 2, 2, 4, 3, 7, 3, 5, 4, 7, 11, 8, 5, 5, 2, 2] + "samples": [2, 5, 7, 7, 9, 7, 7, 8, 7, 2, 2, 4, 3, 7, 3, 5, 4, 7, 11, 8, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -1055,14 +1055,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 5, - "p95": 21, - "min": 2, - "max": 23, + "p50": 4, + "p95": 13, + "min": 1, + "max": 21, "n": 32, - "samples": [2, 2, 3, 23, 20, 5, 7, 2, 2, 5, 7, 8, 8, 7, 6, 13, 7, 2, 2, 4, 3, 6, 3, 5, 4, 7, 12, 21, 5, 6, 2, 2] + "samples": [2, 5, 7, 8, 8, 7, 6, 13, 7, 2, 2, 4, 3, 6, 3, 5, 4, 7, 12, 21, 5, 6, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32p4rev1-eth": { "tick_us": { @@ -1168,14 +1168,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 5, - "p95": 12, - "min": 2, + "p50": 4, + "p95": 11, + "min": 1, "max": 26, "n": 32, - "samples": [2, 2, 3, 12, 7, 5, 7, 2, 2, 5, 7, 10, 7, 26, 6, 6, 7, 2, 2, 4, 3, 5, 3, 5, 4, 7, 9, 11, 9, 5, 2, 2] + "samples": [2, 5, 7, 10, 7, 26, 6, 6, 7, 2, 2, 4, 3, 5, 3, 5, 4, 7, 9, 11, 9, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -1292,14 +1292,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 19, + "p50": 16, "p95": 136, - "min": 9, + "min": 4, "max": 167, "n": 32, - "samples": [9, 9, 12, 80, 72, 21, 27, 10, 9, 19, 34, 37, 136, 167, 23, 36, 27, 10, 9, 16, 13, 20, 15, 19, 19, 29, 27, 38, 35, 19, 9, 9] + "samples": [9, 19, 34, 37, 136, 167, 23, 36, 27, 10, 9, 16, 13, 20, 15, 19, 19, 29, 27, 38, 35, 19, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -1416,14 +1416,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 87, - "p95": 270, - "min": 40, + "p50": 73, + "p95": 255, + "min": 17, "max": 346, "n": 32, - "samples": [41, 41, 50, 234, 270, 99, 141, 43, 41, 87, 138, 153, 207, 346, 100, 169, 102, 41, 41, 73, 58, 137, 62, 82, 74, 123, 255, 174, 113, 82, 41, 40] + "samples": [41, 87, 138, 153, 207, 346, 100, 169, 102, 41, 41, 73, 58, 137, 62, 82, 74, 123, 255, 174, 113, 82, 41, 40, 17, 17, 17, 18, 18, 17, 18, 18] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -1540,14 +1540,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 368, + "p50": 303, "p95": 1023, - "min": 173, + "min": 70, "max": 2016, "n": 32, - "samples": [177, 177, 213, 948, 764, 440, 562, 186, 178, 349, 567, 679, 561, 781, 468, 747, 408, 178, 175, 303, 237, 529, 276, 354, 308, 542, 2016, 1023, 414, 368, 174, 173] + "samples": [178, 349, 567, 679, 561, 781, 468, 747, 408, 178, 175, 303, 237, 529, 276, 354, 308, 542, 2016, 1023, 414, 368, 174, 173, 71, 70, 70, 74, 74, 71, 70, 73] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -1672,14 +1672,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 28, + "p50": 7, + "p95": 18, "min": 4, - "max": 50, + "max": 28, "n": 32, - "samples": [4, 4, 5, 50, 19, 9, 14, 4, 4, 9, 14, 18, 13, 28, 10, 14, 16, 4, 4, 7, 6, 15, 7, 9, 8, 14, 13, 14, 9, 9, 4, 4] + "samples": [4, 9, 14, 18, 13, 28, 10, 14, 16, 4, 4, 7, 6, 15, 7, 9, 8, 14, 13, 14, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -1796,14 +1796,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 35, - "p95": 98, - "min": 17, + "p50": 30, + "p95": 87, + "min": 15, "max": 108, "n": 32, - "samples": [17, 17, 21, 98, 64, 42, 55, 18, 18, 35, 86, 60, 58, 87, 47, 108, 44, 18, 17, 30, 23, 39, 27, 35, 30, 59, 72, 57, 35, 37, 17, 17] + "samples": [18, 35, 86, 60, 58, 87, 47, 108, 44, 18, 17, 30, 23, 39, 27, 35, 30, 59, 72, 57, 35, 37, 17, 17, 15, 15, 15, 16, 16, 16, 16, 16] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -1920,14 +1920,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 144, + "p50": 120, "p95": 398, - "min": 69, + "min": 61, "max": 662, "n": 32, - "samples": [71, 70, 87, 363, 308, 218, 235, 73, 74, 141, 357, 261, 226, 662, 172, 398, 203, 71, 71, 122, 92, 191, 107, 140, 120, 298, 360, 297, 144, 165, 69, 69] + "samples": [74, 141, 357, 261, 226, 662, 172, 398, 203, 71, 71, 122, 92, 191, 107, 140, 120, 298, 360, 297, 144, 165, 69, 69, 62, 62, 61, 66, 65, 64, 63, 64] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -2044,14 +2044,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 589, - "p95": 2027, - "min": 278, + "p50": 489, + "p95": 2011, + "min": 247, "max": 12932, "n": 32, - "samples": [282, 281, 748, 1898, 2027, 1059, 930, 293, 289, 560, 1418, 895, 1286, 2011, 693, 989, 717, 285, 279, 489, 371, 879, 429, 558, 499, 1114, 12932, 1097, 562, 589, 278, 279] + "samples": [289, 560, 1418, 895, 1286, 2011, 693, 989, 717, 285, 279, 489, 371, 879, 429, 558, 499, 1114, 12932, 1097, 562, 589, 278, 279, 252, 249, 247, 256, 265, 251, 255, 259] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -2204,13 +2204,13 @@ "desktop-macos": { "tick_us": { "p50": 2, - "p95": 9, + "p95": 8, "min": 1, "max": 13, "n": 32, - "samples": [1, 1, 1, 9, 3, 3, 3, 1, 1, 2, 3, 4, 3, 3, 3, 3, 3, 1, 1, 2, 1, 8, 2, 2, 2, 5, 13, 4, 2, 2, 1, 1] + "samples": [1, 2, 3, 4, 3, 3, 3, 3, 3, 1, 1, 2, 1, 8, 2, 2, 2, 5, 13, 4, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32": { "tick_us": { @@ -2327,14 +2327,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 23, + "p50": 7, + "p95": 20, "min": 4, - "max": 25, + "max": 23, "n": 32, - "samples": [4, 4, 5, 25, 13, 10, 13, 4, 4, 9, 23, 14, 13, 13, 18, 13, 11, 5, 4, 8, 6, 20, 7, 9, 7, 19, 18, 13, 9, 9, 4, 4] + "samples": [4, 9, 23, 14, 13, 13, 18, 13, 11, 5, 4, 8, 6, 20, 7, 9, 7, 19, 18, 13, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32": { "tick_us": { @@ -2451,14 +2451,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 35, - "p95": 113, - "min": 17, - "max": 166, + "p50": 30, + "p95": 74, + "min": 15, + "max": 113, "n": 32, - "samples": [17, 17, 22, 166, 64, 35, 65, 18, 17, 39, 62, 54, 51, 68, 49, 56, 41, 19, 18, 31, 24, 65, 27, 35, 30, 74, 113, 52, 35, 35, 18, 17] + "samples": [17, 39, 62, 54, 51, 68, 49, 56, 41, 19, 18, 31, 24, 65, 27, 35, 30, 74, 113, 52, 35, 35, 18, 17, 16, 15, 15, 16, 16, 15, 16, 16] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32": { "tick_us": { @@ -2575,14 +2575,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 151, - "p95": 574, - "min": 70, - "max": 731, + "p50": 120, + "p95": 570, + "min": 62, + "max": 574, "n": 32, - "samples": [75, 73, 87, 731, 461, 157, 249, 72, 71, 145, 570, 221, 206, 245, 168, 235, 164, 74, 75, 178, 92, 245, 109, 139, 120, 370, 574, 268, 151, 141, 70, 70] + "samples": [71, 145, 570, 221, 206, 245, 168, 235, 164, 74, 75, 178, 92, 245, 109, 139, 120, 370, 574, 268, 151, 141, 70, 70, 63, 63, 62, 66, 66, 63, 64, 65] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32": { "tick_us": { diff --git a/test/scenarios/light/scenario_perf_light.json b/test/scenarios/light/scenario_perf_light.json index 62836950..4629cfd6 100644 --- a/test/scenarios/light/scenario_perf_light.json +++ b/test/scenarios/light/scenario_perf_light.json @@ -101,14 +101,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 5, - "p95": 12, - "min": 2, - "max": 23, + "p50": 4, + "p95": 10, + "min": 1, + "max": 10, "n": 32, - "samples": [2, 2, 3, 23, 12, 5, 7, 2, 2, 5, 7, 8, 7, 8, 5, 10, 5, 2, 2, 4, 3, 9, 3, 5, 4, 7, 7, 10, 5, 5, 2, 2] + "samples": [2, 5, 7, 8, 7, 8, 5, 10, 5, 2, 2, 4, 3, 9, 3, 5, 4, 7, 7, 10, 5, 5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -336,9 +336,9 @@ "min": 1, "max": 3, "n": 32, - "samples": [1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 3, 2, 2, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 2, 1, 1, 1, 2, 2, 2, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 2, 1, 1, 3, 2, 2, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 2, 1, 1, 1, 2, 2, 2, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -452,11 +452,11 @@ "p50": 2, "p95": 4, "min": 1, - "max": 20, + "max": 4, "n": 32, - "samples": [1, 1, 1, 20, 3, 3, 3, 1, 1, 2, 3, 3, 3, 4, 3, 3, 3, 1, 1, 2, 1, 3, 2, 2, 2, 4, 4, 4, 2, 2, 1, 1] + "samples": [1, 2, 3, 3, 3, 4, 3, 3, 3, 1, 1, 2, 1, 3, 2, 2, 2, 4, 4, 4, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -573,14 +573,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 24, + "p50": 7, + "p95": 23, "min": 4, "max": 49, "n": 32, - "samples": [4, 4, 5, 17, 12, 13, 24, 4, 4, 9, 23, 16, 11, 13, 11, 19, 10, 4, 4, 7, 6, 10, 7, 9, 7, 14, 49, 14, 9, 10, 4, 4] + "samples": [4, 9, 23, 16, 11, 13, 11, 19, 10, 4, 4, 7, 6, 10, 7, 9, 7, 14, 49, 14, 9, 10, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { @@ -697,14 +697,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 35, - "p95": 134, - "min": 17, - "max": 255, + "p50": 30, + "p95": 76, + "min": 15, + "max": 78, "n": 32, - "samples": [18, 18, 21, 134, 255, 47, 60, 18, 18, 35, 51, 76, 42, 64, 42, 58, 41, 18, 17, 30, 23, 61, 27, 35, 30, 52, 78, 54, 36, 35, 17, 17] + "samples": [18, 35, 51, 76, 42, 64, 42, 58, 41, 18, 17, 30, 23, 61, 27, 35, 30, 52, 78, 54, 36, 35, 17, 17, 15, 15, 15, 16, 16, 15, 15, 17] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_peripheral_grid_sweep.json b/test/scenarios/light/scenario_peripheral_grid_sweep.json index 1f2dc873..4d358efd 100644 --- a/test/scenarios/light/scenario_peripheral_grid_sweep.json +++ b/test/scenarios/light/scenario_peripheral_grid_sweep.json @@ -173,14 +173,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 33, + "p50": 7, + "p95": 14, "min": 4, - "max": 35, + "max": 20, "n": 32, - "samples": [4, 4, 5, 35, 20, 9, 33, 4, 4, 9, 13, 12, 13, 20, 10, 13, 10, 4, 4, 7, 6, 11, 7, 9, 8, 13, 14, 14, 9, 9, 4, 4] + "samples": [4, 9, 13, 12, 13, 20, 10, 13, 10, 4, 4, 7, 6, 11, 7, 9, 8, 13, 14, 14, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -300,14 +300,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 36, - "p95": 160, - "min": 17, - "max": 169, + "p50": 31, + "p95": 74, + "min": 15, + "max": 88, "n": 32, - "samples": [18, 18, 22, 169, 74, 48, 160, 18, 18, 36, 64, 74, 48, 48, 41, 52, 41, 18, 18, 31, 23, 59, 27, 36, 31, 56, 88, 55, 36, 35, 17, 17] + "samples": [18, 36, 64, 74, 48, 48, 41, 52, 41, 18, 18, 31, 23, 59, 27, 36, 31, 56, 88, 55, 36, 35, 17, 17, 15, 15, 15, 18, 17, 16, 16, 16] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -427,14 +427,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 147, - "p95": 309, - "min": 69, - "max": 761, + "p50": 123, + "p95": 241, + "min": 62, + "max": 298, "n": 32, - "samples": [72, 72, 86, 761, 309, 172, 287, 74, 71, 169, 241, 298, 210, 217, 164, 223, 165, 71, 71, 122, 92, 230, 108, 141, 123, 211, 241, 219, 147, 144, 69, 69] + "samples": [71, 169, 241, 298, 210, 217, 164, 223, 165, 71, 71, 122, 92, 230, 108, 141, 123, 211, 241, 219, 147, 144, 69, 69, 63, 63, 62, 151, 65, 63, 63, 66] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -554,14 +554,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 581, - "p95": 1947, - "min": 277, - "max": 2629, + "p50": 485, + "p95": 1394, + "min": 247, + "max": 1401, "n": 32, - "samples": [287, 282, 331, 1466, 1947, 595, 2629, 299, 285, 586, 1394, 1401, 690, 1207, 666, 947, 667, 283, 287, 482, 369, 938, 460, 580, 485, 881, 908, 1089, 581, 575, 277, 278] + "samples": [285, 586, 1394, 1401, 690, 1207, 666, 947, 667, 283, 287, 482, 369, 938, 460, 580, 485, 881, 908, 1089, 581, 575, 277, 278, 251, 247, 250, 590, 264, 252, 255, 260] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -702,14 +702,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 10, + "p50": 8, "p95": 35, "min": 4, "max": 36, "n": 32, - "samples": [4, 5, 5, 26, 28, 9, 14, 4, 4, 36, 17, 35, 10, 16, 10, 19, 12, 4, 4, 8, 6, 10, 7, 9, 11, 24, 14, 14, 11, 9, 4, 4] + "samples": [4, 36, 17, 35, 10, 16, 10, 19, 12, 4, 4, 8, 6, 10, 7, 9, 11, 24, 14, 14, 11, 9, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -829,14 +829,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 36, - "p95": 121, - "min": 17, - "max": 137, + "p50": 30, + "p95": 98, + "min": 15, + "max": 121, "n": 32, - "samples": [18, 17, 21, 101, 103, 35, 137, 18, 17, 51, 72, 65, 41, 98, 42, 70, 48, 18, 18, 30, 23, 47, 27, 36, 33, 121, 55, 56, 36, 35, 17, 17] + "samples": [17, 51, 72, 65, 41, 98, 42, 70, 48, 18, 18, 30, 23, 47, 27, 36, 33, 121, 55, 56, 36, 35, 17, 17, 15, 16, 16, 17, 16, 15, 15, 16] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -956,14 +956,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 142, - "p95": 424, - "min": 69, - "max": 534, + "p50": 121, + "p95": 315, + "min": 61, + "max": 331, "n": 32, - "samples": [70, 72, 83, 534, 424, 139, 272, 74, 69, 152, 241, 331, 163, 290, 166, 207, 186, 71, 73, 121, 92, 173, 111, 185, 127, 315, 228, 206, 142, 142, 70, 69] + "samples": [69, 152, 241, 331, 163, 290, 166, 207, 186, 71, 73, 121, 92, 173, 111, 185, 127, 315, 228, 206, 142, 142, 70, 69, 63, 61, 65, 68, 68, 62, 64, 64] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -1083,14 +1083,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 571, - "p95": 1666, - "min": 279, - "max": 2008, + "p50": 481, + "p95": 1281, + "min": 249, + "max": 1396, "n": 32, - "samples": [282, 378, 331, 1666, 2008, 592, 1042, 305, 280, 571, 944, 1281, 626, 1125, 661, 1396, 739, 286, 285, 481, 369, 640, 430, 749, 509, 863, 881, 895, 560, 564, 279, 279] + "samples": [280, 571, 944, 1281, 626, 1125, 661, 1396, 739, 286, 285, 481, 369, 640, 430, 749, 509, 863, 881, 895, 560, 564, 279, 279, 253, 249, 310, 316, 263, 254, 254, 259] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -1231,14 +1231,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 53, + "p50": 8, + "p95": 18, "min": 4, - "max": 98, + "max": 53, "n": 32, - "samples": [4, 4, 5, 98, 24, 9, 17, 4, 4, 9, 15, 13, 9, 18, 10, 53, 10, 4, 4, 8, 6, 9, 7, 9, 8, 13, 15, 14, 9, 9, 4, 4] + "samples": [4, 9, 15, 13, 9, 18, 10, 53, 10, 4, 4, 8, 6, 9, 7, 9, 8, 13, 15, 14, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -1358,14 +1358,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 35, - "p95": 138, - "min": 17, - "max": 257, + "p50": 30, + "p95": 74, + "min": 15, + "max": 138, "n": 32, - "samples": [17, 19, 20, 257, 81, 35, 51, 19, 17, 35, 51, 59, 35, 74, 41, 138, 41, 18, 18, 30, 23, 35, 27, 35, 31, 53, 53, 55, 36, 35, 17, 17] + "samples": [17, 35, 51, 59, 35, 74, 41, 138, 41, 18, 18, 30, 23, 35, 27, 35, 31, 53, 53, 55, 36, 35, 17, 17, 16, 15, 15, 16, 16, 16, 15, 16] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -1485,14 +1485,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 141, - "p95": 431, - "min": 69, - "max": 1064, + "p50": 120, + "p95": 275, + "min": 62, + "max": 294, "n": 32, - "samples": [71, 74, 83, 1064, 431, 152, 225, 96, 70, 141, 225, 217, 148, 275, 165, 265, 294, 72, 71, 120, 92, 152, 107, 141, 123, 209, 232, 222, 140, 140, 69, 70] + "samples": [70, 141, 225, 217, 148, 275, 165, 265, 294, 72, 71, 120, 92, 152, 107, 141, 123, 209, 232, 222, 140, 140, 69, 70, 62, 62, 62, 67, 66, 64, 63, 64] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -1612,14 +1612,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 564, - "p95": 1708, - "min": 274, - "max": 2746, + "p50": 436, + "p95": 995, + "min": 247, + "max": 1188, "n": 32, - "samples": [283, 283, 332, 2746, 1708, 569, 1378, 305, 278, 634, 1188, 914, 558, 883, 660, 995, 918, 289, 286, 436, 368, 781, 428, 570, 439, 852, 834, 938, 564, 561, 278, 274] + "samples": [278, 634, 1188, 914, 558, 883, 660, 995, 918, 289, 286, 436, 368, 781, 428, 570, 439, 852, 834, 938, 564, 561, 278, 274, 252, 247, 251, 277, 262, 251, 254, 252] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -1760,14 +1760,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 32, + "p50": 7, + "p95": 14, "min": 4, - "max": 55, + "max": 20, "n": 32, - "samples": [4, 4, 5, 18, 55, 11, 32, 4, 4, 9, 14, 14, 11, 20, 10, 14, 13, 4, 4, 7, 6, 11, 7, 9, 7, 13, 13, 14, 9, 10, 4, 4] + "samples": [4, 9, 14, 14, 11, 20, 10, 14, 13, 4, 4, 7, 6, 11, 7, 9, 7, 13, 13, 14, 9, 10, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -1887,14 +1887,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 36, - "p95": 158, - "min": 17, - "max": 187, + "p50": 27, + "p95": 64, + "min": 15, + "max": 144, "n": 32, - "samples": [17, 18, 21, 158, 187, 37, 73, 19, 18, 37, 144, 55, 41, 50, 42, 64, 52, 18, 18, 27, 23, 47, 27, 35, 28, 52, 51, 52, 35, 36, 18, 17] + "samples": [18, 37, 144, 55, 41, 50, 42, 64, 52, 18, 18, 27, 23, 47, 27, 35, 28, 52, 51, 52, 35, 36, 18, 17, 16, 15, 15, 17, 16, 16, 15, 16] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -2014,14 +2014,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 144, - "p95": 634, - "min": 68, - "max": 1010, + "p50": 109, + "p95": 321, + "min": 61, + "max": 447, "n": 32, - "samples": [71, 69, 80, 1010, 438, 177, 634, 74, 72, 148, 447, 211, 153, 321, 165, 228, 207, 76, 71, 110, 92, 243, 107, 140, 109, 206, 203, 208, 141, 144, 69, 68] + "samples": [72, 148, 447, 211, 153, 321, 165, 228, 207, 76, 71, 110, 92, 243, 107, 140, 109, 206, 203, 208, 141, 144, 69, 68, 62, 62, 61, 64, 65, 63, 64, 63] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { @@ -2141,14 +2141,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 586, - "p95": 2098, - "min": 279, - "max": 3242, + "p50": 437, + "p95": 1612, + "min": 246, + "max": 1685, "n": 32, - "samples": [283, 283, 317, 1791, 3242, 1519, 2098, 305, 279, 586, 1685, 886, 594, 1612, 850, 899, 784, 287, 285, 484, 368, 874, 429, 568, 437, 919, 811, 847, 581, 606, 279, 281] + "samples": [279, 586, 1685, 886, 594, 1612, 850, 899, 784, 287, 285, 484, 368, 874, 429, 568, 437, 919, 811, 847, 581, 606, 279, 281, 251, 248, 246, 258, 271, 249, 255, 253] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_peripheral_switch.json b/test/scenarios/light/scenario_peripheral_switch.json index 332de59c..12568a3b 100644 --- a/test/scenarios/light/scenario_peripheral_switch.json +++ b/test/scenarios/light/scenario_peripheral_switch.json @@ -172,14 +172,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 38, + "p50": 8, + "p95": 16, "min": 4, - "max": 45, + "max": 17, "n": 32, - "samples": [4, 4, 5, 45, 19, 38, 21, 5, 4, 9, 13, 14, 11, 14, 10, 14, 16, 4, 4, 8, 6, 13, 9, 9, 7, 14, 13, 17, 9, 9, 4, 4] + "samples": [4, 9, 13, 14, 11, 14, 10, 14, 16, 4, 4, 8, 6, 13, 9, 9, 7, 14, 13, 17, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32p4rev1-eth": { "tick_us": { @@ -293,14 +293,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 40, + "p50": 7, + "p95": 16, "min": 4, - "max": 58, + "max": 29, "n": 32, - "samples": [4, 5, 5, 29, 40, 39, 58, 5, 4, 9, 29, 16, 10, 14, 15, 14, 14, 4, 4, 8, 6, 11, 7, 10, 7, 13, 13, 16, 9, 9, 4, 4] + "samples": [4, 9, 29, 16, 10, 14, 15, 14, 14, 4, 4, 8, 6, 11, 7, 10, 7, 13, 13, 16, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32p4rev1-eth": { "tick_us": { @@ -414,14 +414,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 69, + "p50": 7, + "p95": 17, "min": 4, - "max": 160, + "max": 22, "n": 32, - "samples": [4, 4, 5, 26, 15, 69, 160, 5, 4, 9, 22, 14, 10, 13, 14, 17, 15, 4, 4, 8, 6, 11, 7, 9, 7, 14, 13, 14, 9, 9, 4, 4] + "samples": [4, 9, 22, 14, 10, 13, 14, 17, 15, 4, 4, 8, 6, 11, 7, 9, 7, 14, 13, 14, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32p4rev1-eth": { "tick_us": { @@ -534,14 +534,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 72, + "p50": 8, + "p95": 17, "min": 4, - "max": 358, + "max": 17, "n": 32, - "samples": [4, 4, 5, 24, 72, 20, 358, 5, 4, 9, 13, 14, 10, 14, 17, 16, 17, 4, 4, 8, 6, 9, 9, 9, 7, 14, 13, 14, 9, 9, 4, 4] + "samples": [4, 9, 13, 14, 10, 14, 17, 16, 17, 4, 4, 8, 6, 9, 9, 9, 7, 14, 13, 14, 9, 9, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32p4rev1-eth": { "tick_us": { @@ -655,14 +655,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 42, + "p50": 8, + "p95": 17, "min": 4, "max": 65, "n": 32, - "samples": [4, 4, 5, 42, 24, 16, 26, 6, 4, 9, 65, 15, 10, 17, 13, 13, 17, 4, 4, 8, 6, 10, 10, 9, 7, 13, 13, 15, 9, 9, 4, 4] + "samples": [4, 9, 65, 15, 10, 17, 13, 13, 17, 4, 4, 8, 6, 10, 10, 9, 7, 13, 13, 15, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32p4rev1-eth": { "tick_us": { @@ -792,14 +792,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 9, - "p95": 29, + "p50": 8, + "p95": 18, "min": 4, - "max": 66, + "max": 24, "n": 32, - "samples": [4, 4, 5, 66, 14, 29, 12, 6, 4, 9, 24, 13, 10, 13, 13, 14, 16, 4, 4, 8, 6, 11, 9, 9, 7, 13, 14, 18, 9, 9, 4, 4] + "samples": [4, 9, 24, 13, 10, 13, 13, 14, 16, 4, 4, 8, 6, 11, 9, 9, 7, 13, 14, 18, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-03" + "last_updated": "2026-09-05" }, "esp32p4rev1-eth": { "tick_us": { diff --git a/test/unit/core/moonlive_device_codegen.inc b/test/unit/core/moonlive_device_codegen.inc index 283508da..61699047 100644 --- a/test/unit/core/moonlive_device_codegen.inc +++ b/test/unit/core/moonlive_device_codegen.inc @@ -253,6 +253,29 @@ TEST_CASE("every function in a class starts where a call can reach it, on " MM_I // // The predicate matches the DESTINATION only. Which register holds the value is the allocator's // choice and may change; that it is moved into the ABI's return register is the contract. +// The CALLER side of the same contract, and the half nothing else reaches. Taking a value means +// stashing the callee's result before the vreg pool is restored over it: the restore reloads the +// very register the value arrived in. Host backends prove the SEMANTICS (unit_moonlive_spill and +// unit_moonlive_compiler run the code), but these two ISAs are never executed here, so a wrong +// stash is silent, and a value-returning call is exactly where an encoding slip reads as a +// plausible number rather than a crash. +TEST_CASE("taking a script function's value stashes it past the pool restore on " MM_ISA_NAME) { + bool ok = false; + // The value must be USED after a second call, so the pool restore genuinely runs over it. + const auto code = emitBytes("class T {\n" + " int a() { return 40; }\n" + " int b() { return 5; }\n" + " void tick() { setRGB(0, a() + b(), 0, 0); }\n" + "}\n", + mm::moonlive::effectSysVars(), ok); + REQUIRE(ok); + REQUIRE(code.size() > 4); + bool found = false; + for (size_t i = 0; i + 4 <= code.size(); i += MM_ISA_RET_STRIDE) + if (MM_ISA_STASHES_RESULT(&code[i])) { found = true; break; } + CHECK(found); +} + TEST_CASE("a returned value reaches the return register on " MM_ISA_NAME) { bool ok = false; // A function that returns a COMPUTED value: a literal could be materialized straight into the diff --git a/test/unit/core/unit_Oscillators.cpp b/test/unit/core/unit_Oscillators.cpp new file mode 100644 index 00000000..6703b4fc --- /dev/null +++ b/test/unit/core/unit_Oscillators.cpp @@ -0,0 +1,174 @@ +// @module oscillators +// @also math16 + +// The oscillator bank: the animated quantities of a generative field, advanced once per frame and +// read per pixel. These pin what an effect author relies on: that a value stays inside the range +// they asked for, that two oscillators sharing a rate hold their relationship for as long as the +// device runs, that changing a rate mid-run does not jump the picture, and that the bank costs one +// pass per frame rather than one per read. + +#include "doctest.h" +#include "core/oscillators.h" + +using namespace mm; + +namespace { + +/// Run a bank forward to `untilMs` on the device clock, in `dtMs` steps, the way a render loop +/// does. `from` is where the clock already stands, so successive calls continue rather than +/// restarting: the first advance of a fresh bank only establishes the time base (BeatPhase's +/// first-tick guard), and calling this again from 0 would establish it a second time. +template +uint32_t run(OscillatorBank& bank, uint32_t untilMs, uint32_t dtMs, uint32_t from = 0) { + if (from == 0) bank.advance(0); + uint32_t t = from; + while (t + dtMs <= untilMs) { t += dtMs; bank.advance(t); } + return t; +} + +} // namespace + +TEST_CASE("an oscillator stays inside the range the effect asked for") { + OscillatorBank<1> bank; + bank.set(0, {.rate = 120, .low = 10, .high = 200, .phaseOffset = 0, .wave = Wave::Sine}); + int32_t lo = 1 << 30, hi = -(1 << 30); + uint32_t t = 0; + for (uint32_t f = 0; f < 2000; f++) { + bank.advance(t); + t += 7; // an irregular frame time, as a real loop has + lo = bank.value(0) < lo ? bank.value(0) : lo; + hi = bank.value(0) > hi ? bank.value(0) : hi; + } + CHECK(lo >= 10); + CHECK(hi <= 200); + CHECK(lo < 20); // and it actually reaches the ends + CHECK(hi > 190); +} + +TEST_CASE("a range given backwards runs the shape backwards") { + OscillatorBank<2> bank; + bank.set(0, {.rate = 60, .low = 0, .high = 100, .phaseOffset = 0, .wave = Wave::Saw}); + bank.set(1, {.rate = 60, .low = 100, .high = 0, .phaseOffset = 0, .wave = Wave::Saw}); + run(bank, 200, 5); + // Same phase, mirrored ranges: the two are reflections, so they sum to the span. + CHECK(bank.value(0) + bank.value(1) == 100); +} + +TEST_CASE("two oscillators at the same rate hold their phase relationship indefinitely") { + // The property a composition depends on: layers set a quarter-cycle apart must not drift, or a + // deliberate arrangement decays into noise over an evening. + OscillatorBank<2> bank; + bank.set(0, {.rate = 45, .low = 0, .high = 65535, .phaseOffset = 0, .wave = Wave::Sine}); + bank.set(1, {.rate = 45, .low = 0, .high = 65535, .phaseOffset = 16384, .wave = Wave::Sine}); + + run(bank, 1600, 16); + const int32_t earlyGap = static_cast(bank.phase(1)) - static_cast(bank.phase(0)); + uint32_t t = 1600; + for (uint32_t f = 0; f < 200000; f++) { t += 16; bank.advance(t); } // ~an hour of frames + const int32_t lateGap = static_cast(bank.phase(1)) - static_cast(bank.phase(0)); + CHECK(earlyGap == lateGap); +} + +TEST_CASE("a rate of zero holds the picture still") { + OscillatorBank<1> bank; + bank.set(0, {.rate = 0, .low = 0, .high = 65535, .phaseOffset = 0, .wave = Wave::Sine}); + run(bank, 8000, 16); + CHECK(bank.phase(0) == 0); + CHECK(bank.value(0) == 32768); // a sine at phase zero sits at its midpoint +} + +TEST_CASE("changing the rate continues from where the phase stands, without jumping") { + // Live reconfiguration: turning a speed control must not make the picture leap. + OscillatorBank<1> bank; + bank.set(0, {.rate = 30, .low = 0, .high = 65535, .phaseOffset = 0, .wave = Wave::Saw}); + uint32_t t = 0; + for (uint32_t f = 0; f < 50; f++) { bank.advance(t); t += 16; } + const uint16_t before = bank.unitValue(0); + CHECK(before > 0); // it is genuinely mid-cycle, not at the start + + Oscillator faster = bank.get(0); + faster.rate = 240; + bank.set(0, faster); + bank.advance(t); // the very next frame, one 16 ms step + const uint16_t after = bank.unitValue(0); + + // One frame at the new rate moves the phase by a frame's worth, not to a new place entirely. + const int32_t step = static_cast(after) - static_cast(before); + CHECK(step >= 0); + CHECK(step < 6000); // 16 ms at 240 BPM is ~4.3% of a cycle +} + +TEST_CASE("the four waveforms have the shapes their names promise") { + OscillatorBank<4> bank; + for (uint8_t i = 0; i < 4; i++) + bank.set(i, {.rate = 60, .low = 0, .high = 65535, .phaseOffset = 0, .wave = static_cast(i)}); + + // A quarter of a cycle in (60 BPM is one cycle a second, so 250 ms): the sine is at its peak, + // the triangle halfway up its rise, the saw a quarter of the way along, the square still low. + uint32_t t = run(bank, 250, 10); + CHECK(bank.unitValue(0) > 64000); // Sine + CHECK(bank.unitValue(1) == doctest::Approx(32768).epsilon(0.05)); // Triangle + CHECK(bank.unitValue(2) == doctest::Approx(16384).epsilon(0.05)); // Saw + CHECK(bank.unitValue(3) == 0); // Square + + // Half a cycle: the sine is back through its midpoint on the way down, the triangle at ITS peak + // (it turns at the half, where the sine turned at the quarter), the square now high. + t = run(bank, 500, 10, t); + CHECK(bank.unitValue(0) == doctest::Approx(32768).epsilon(0.02)); + CHECK(bank.unitValue(1) > 64000); + CHECK(bank.unitValue(3) == 65535); + + // And a full cycle brings every shape back to where it started. + run(bank, 1000, 10, t); + CHECK(bank.unitValue(1) == 0); + CHECK(bank.unitValue(2) == 0); +} + +TEST_CASE("a square wave is only ever fully on or fully off") { + OscillatorBank<1> bank; + bank.set(0, {.rate = 90, .low = 0, .high = 255, .phaseOffset = 0, .wave = Wave::Square}); + uint32_t t = 0; + bool sawLow = false, sawHigh = false; + for (uint32_t f = 0; f < 300; f++) { + bank.advance(t); + t += 11; // 3.3 s, about five cycles at 90 BPM + const int32_t v = bank.value(0); + CHECK((v == 0 || v == 255)); // never anything in between + sawLow = sawLow || v == 0; + sawHigh = sawHigh || v == 255; + } + CHECK(sawLow); + CHECK(sawHigh); // and it does switch +} + +TEST_CASE("an out-of-range oscillator index reads as zero rather than crashing the device") { + // An effect driving the bank from a user control must not be able to fault it. + OscillatorBank<2> bank; + bank.set(9, {.rate = 60, .low = 0, .high = 100, .phaseOffset = 0, .wave = Wave::Sine}); + run(bank, 160, 16); + CHECK(bank.value(9) == 0); + CHECK(bank.phase(9) == 0); + CHECK(bank.unitValue(200) == 0); +} + +TEST_CASE("the first frame establishes the time base instead of jumping the phase") { + // An effect enabled after the device has been up for an hour starts at zero, not an hour in. + OscillatorBank<1> bank; + bank.set(0, {.rate = 60, .low = 0, .high = 65535, .phaseOffset = 0, .wave = Wave::Saw}); + bank.advance(3600000u); // first call, an hour on the clock + CHECK(bank.phase(0) == 0); + bank.advance(3600016u); + // One 16 ms frame in, not an hour: at 60 BPM that is 16/1000 of a turn, about 1048. + CHECK(bank.phase(0) > 1000); + CHECK(bank.phase(0) < 1100); +} + +TEST_CASE("reset restarts the motion without losing the configuration") { + OscillatorBank<1> bank; + bank.set(0, {.rate = 120, .low = 5, .high = 9, .phaseOffset = 0, .wave = Wave::Triangle}); + run(bank, 1600, 16); + bank.reset(); + CHECK(bank.phase(0) == 0); + CHECK(bank.get(0).rate == 120); + CHECK(bank.get(0).high == 9); +} diff --git a/test/unit/core/unit_fields.cpp b/test/unit/core/unit_fields.cpp index 07a53d33..84cb2c35 100644 --- a/test/unit/core/unit_fields.cpp +++ b/test/unit/core/unit_fields.cpp @@ -70,7 +70,7 @@ TEST_CASE("fbm with no octaves is a flat field") { CHECK(fbm8(999, 111, 0) == 128); } -// A field must be a FIELD: neighbouring points are similar, distant points are not. This is what +// A field must be a FIELD: neighboring points are similar, distant points are not. This is what // separates noise from a raw hash, and it must survive the octave sum. TEST_CASE("fbm is smooth: neighbours resemble each other more than distant points") { const int here = fbm8(5000, 5000, 3); diff --git a/test/unit/core/unit_math16.cpp b/test/unit/core/unit_math16.cpp index e0f9a08d..3c1f40da 100644 --- a/test/unit/core/unit_math16.cpp +++ b/test/unit/core/unit_math16.cpp @@ -12,6 +12,7 @@ #include #include +#include #include using namespace mm; @@ -298,7 +299,7 @@ TEST_CASE("hashInt is a pure function of its inputs") { CHECK(hashInt(5, 9, 0, 42) == hashInt(5, 9, 0, 42)); } -TEST_CASE("hashInt gives neighbouring pixels unrelated values") { +TEST_CASE("hashInt gives neighboring pixels unrelated values") { // Adjacent inputs must not produce adjacent outputs, or a dissolve would appear in stripes. int differing = 0; for (uint32_t x = 0; x < 64; x++) @@ -449,3 +450,48 @@ TEST_CASE("a beat completes one full cycle per beat, at any tempo") { // A zero tempo is a still frame rather than a divide-by-zero. CHECK(mm::beat16(0, 1234) == 0); } + +// halfLifeKeep: the decay a caller states as "half of it is gone after N ms". The properties below +// are what make it framerate-independent, which is the whole reason it exists. + +TEST_CASE("a half-life decay loses exactly half its value over one half-life") { + CHECK(mm::halfLifeKeep(100, 100) == 32768); // one half-life: half survives + CHECK(mm::halfLifeKeep(200, 100) == 16384); // two: a quarter + CHECK(mm::halfLifeKeep(400, 100) == 4096); // four: a sixteenth + // Nothing elapsed, or no half-life asked for, leaves the value alone rather than erasing it. + CHECK(mm::halfLifeKeep(0, 100) == 65536); + CHECK(mm::halfLifeKeep(50, 0) == 65536); + // A long stall decays to nothing instead of wrapping around to bright. + CHECK(mm::halfLifeKeep(100000, 100) == 0); +} + +TEST_CASE("two decay steps reach the same place as one step of twice the time") { + // The framerate-independence property, and the reason the half-life form replaces a per-frame + // fade: a device rendering at 30 fps and one at 60 must dim a trail at the same rate in + // SECONDS. Written as decay(2dt) == decay(dt)^2, which is what that means arithmetically. + // + // The tolerance is one count at the BYTE width every caller narrows to (a channel is 8 bits), + // not at the 16-bit width of the weight itself: 256 of 65536. Measured worst case is 11. + for (uint32_t halfLife : {10u, 100u, 1000u, 5000u}) { + for (uint32_t dt = 1; dt < halfLife * 2; dt += 7) { + const uint64_t once = mm::halfLifeKeep(dt, halfLife); + const uint64_t twice = mm::halfLifeKeep(2 * dt, halfLife); + const uint64_t squared = (once * once) >> 16; + const int64_t diff = static_cast(twice) - static_cast(squared); + CHECK(std::llabs(diff) <= 256); + } + } +} + +TEST_CASE("halving twice as often dims at the same rate, so framerate cannot change a trail") { + // The same property stated the way a user meets it: run 20 frames of 10 ms and 10 frames of + // 20 ms over the same 200 ms, and a value must land in the same place either way. + const uint32_t halfLife = 250; + uint64_t fast = 65535, slow = 65535; + for (int i = 0; i < 20; i++) fast = (fast * mm::halfLifeKeep(10, halfLife)) >> 16; + for (int i = 0; i < 10; i++) slow = (slow * mm::halfLifeKeep(20, halfLife)) >> 16; + // Both cover 200 ms of a 250 ms half-life, so both should sit near 65535 * 2^-0.8. + CHECK(std::llabs(static_cast(fast) - static_cast(slow)) <= 256); + CHECK(fast > 36000); // 2^-0.8 is 0.574, so ~37600 + CHECK(fast < 39000); +} diff --git a/test/unit/core/unit_moonlive_codegen_riscv.cpp b/test/unit/core/unit_moonlive_codegen_riscv.cpp index c4604c70..84434eca 100644 --- a/test/unit/core/unit_moonlive_codegen_riscv.cpp +++ b/test/unit/core/unit_moonlive_codegen_riscv.cpp @@ -53,13 +53,22 @@ namespace mm { using namespace ::mm; using namespace ::mm::moonlive; #define MM_GOLD_FX_LEN 164u // +4: one branch #define MM_GOLD_FILLLOOP_LEN 360u // +24: fits on every backend since the host args moved to the frame #define MM_GOLD_FXLOOP_LEN 252u // +16: four branches -#define MM_GOLD_FXLOOP_HASH 1379319229u -#define MM_GOLD_FX_HASH 4146299475u +// The two hashes moved 2026-09-04 when kMaxLocals went 16 -> 32 (the first volumetric script needed +// 19 slots). A wider frame changes the prologue's reserve and every slot offset, so identical source +// emits different bytes: a frame-offset change is exactly what this hash exists to surface, and it +// did. The LENGTHS are unchanged, which is the evidence it is offsets rather than different code. +#define MM_GOLD_FXLOOP_HASH 3140471189u +#define MM_GOLD_FX_HASH 2676401519u // `mv a0, xN` is `addi a0, xN, 0`: opcode 0x13, funct3 0, rd = x10 (a0), imm 0. rs1 is the // allocator's choice, so it is masked out; rd and the immediate are the contract. #define MM_ISA_RET_WRITES_RETREG(p) \ (((uint32_t((p)[0]) | (uint32_t((p)[1]) << 8) | (uint32_t((p)[2]) << 16) | \ (uint32_t((p)[3]) << 24)) & 0xfff07fffu) == 0x00000513u) +// The caller-side stash: `mv t6, a0`, i.e. `addi t6, a0, 0`. rd = x31 (t6), rs1 = x10 (a0), +// imm 0. The callee's value is parked past the pool restore, which would otherwise overwrite a0. +#define MM_ISA_STASHES_RESULT(p) \ + ((uint32_t((p)[0]) | (uint32_t((p)[1]) << 8) | (uint32_t((p)[2]) << 16) | \ + (uint32_t((p)[3]) << 24)) == 0x00050f93u) #define MM_ISA_RET_STRIDE 4 #define MM_ISA_LOWER mm_riscv_backend::mm::moonlive::lowerToBytes // The assembler type itself, so the stack-budget check can measure the object the compile path diff --git a/test/unit/core/unit_moonlive_codegen_x86_64.cpp b/test/unit/core/unit_moonlive_codegen_x86_64.cpp index 113359d2..dd393cd7 100644 --- a/test/unit/core/unit_moonlive_codegen_x86_64.cpp +++ b/test/unit/core/unit_moonlive_codegen_x86_64.cpp @@ -535,12 +535,18 @@ TEST_CASE("x86_64: callLabel(L) patches to rel32 target - (site + 5)") { if (A.bytes()[i] == 0xE8) { callAt = i; break; } } REQUIRE(callAt != size_t(-1)); - // Now bind the label just after the call and finalize; rel = 0. + // Bind the label after the call and finalize. rel is measured from the END of the E8 + // instruction to the label, so it counts whatever callLabel emits AFTER the call: the pool + // restore that delivers a script function's return value. Asserting rel == 0 pinned the old + // shape, where the call was the last thing emitted. + const size_t afterCall = callAt + 5; A.bind(l); A.finalize(); + const int32_t expected = static_cast(A.size() - afterCall); int32_t rel; std::memcpy(&rel, A.bytes() + callAt + 1, 4); - CHECK(rel == 0); + CHECK(rel == expected); + CHECK(rel > 0); // the restore is emitted between the call and the label } // ================================================================================================= diff --git a/test/unit/core/unit_moonlive_codegen_xtensa.cpp b/test/unit/core/unit_moonlive_codegen_xtensa.cpp index ec4d7cfc..d3f5dfdb 100644 --- a/test/unit/core/unit_moonlive_codegen_xtensa.cpp +++ b/test/unit/core/unit_moonlive_codegen_xtensa.cpp @@ -56,12 +56,20 @@ namespace mm { using namespace ::mm; using namespace ::mm::moonlive; // narrow form's 4-bit offset field could not reach and // silently wrapped to offset 0. One byte per sys-var read. #define MM_GOLD_FXLOOP_LEN 190u -#define MM_GOLD_FXLOOP_HASH 307181036u -#define MM_GOLD_FX_HASH 2796457628u +// The two hashes moved 2026-09-04 when kMaxLocals went 16 -> 32 (the first volumetric script needed +// 19 slots). A wider frame changes the prologue's reserve and every slot offset, so identical source +// emits different bytes: a frame-offset change is exactly what this hash exists to surface, and it +// did. The LENGTHS are unchanged, which is the evidence it is offsets rather than different code. +#define MM_GOLD_FXLOOP_HASH 1859543084u +#define MM_GOLD_FX_HASH 4201448700u // `mov.n a2, aN`: two bytes, {(dst << 4) | 0xd, src}. a2 is the windowed ABI's return register and // where R0 lives, so the first byte is 0x2d whatever the source. This backend emits BYTES in memory // order (not 24-bit words), so the pair is read as it sits. #define MM_ISA_RET_WRITES_RETREG(p) ((p)[0] == 0x2du) +// The caller-side stash: `s32i a10, a1, kResultSlot`. The callee returns in the CALLER's a10 +// (call8 rotates the window by 8), and the pool restore below is about to overwrite it, so it is +// parked in the frame first. Bytes: (10<<4)|2 = 0xa2, then 0x61, then the word offset 8. +#define MM_ISA_STASHES_RESULT(p) ((p)[0] == 0xa2u && (p)[1] == 0x61u && (p)[2] == 0x08u) #define MM_ISA_RET_STRIDE 1 #define MM_ISA_LOWER mm_xtensa_backend::mm::moonlive::lowerToBytes // The assembler type itself, so the stack-budget check can measure the object the compile path diff --git a/test/unit/core/unit_moonlive_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp index d98e49f3..10eb3e60 100644 --- a/test/unit/core/unit_moonlive_compiler.cpp +++ b/test/unit/core/unit_moonlive_compiler.cpp @@ -1534,7 +1534,7 @@ TEST_CASE("a member declaration and a typed function are told apart") { #if MM_MOONLIVE_HAS_HOST_JIT TEST_CASE("an array is indexed correctly at both element widths, by a computed index") { // The index scaling, from the outside. `idx * width` is emitted as a SHIFT (width 4) or skipped - // entirely (width 1), so a wrong shift or a wrongly skipped one reads the neighbouring element + // entirely (width 1), so a wrong shift or a wrongly skipped one reads the neighboring element // rather than failing loudly. A CONSTANT index would not catch it: the interesting path is an // index the engine computes at run time, which is what a loop counter is. // @@ -1800,3 +1800,124 @@ TEST_CASE("a fixed local reports its scaling where it is read") { kTable, kSys, out, sizeof(out)); CHECK_FALSE(r.ok); } + +// A script's own function can hand a value back, so a helper computes rather than only acts. The +// return machinery already existed at the HOST boundary (a function declares int or void, and the +// exit parks the value in the ABI register); what was missing was the call site taking it. +// +// What makes it harder than moving a register: the result has to survive the calls that FOLLOW it +// in the same expression. So a script call now preserves the whole vreg pool exactly as a builtin +// call does, and the callee's value is delivered into the destination vreg once that pool is back. +// Parking it in the frame's argument region instead was tried and rotated the enclosing call's +// arguments, since that region is still being filled as the expression parses. +TEST_CASE("every shipped script still compiles after a builtin is added") { + // A builtin name is reserved for every script, so ADDING one can stop an existing script + // compiling: its member of that name becomes a call. Measured, not hypothetical: a `decay` + // builtin broke pulse.mle and beat-flash.mlp, which both declare `byte decay`, and the + // diagnostic pointed at the member rather than at the name that took it. It is `trailDecay`. + // + // The names below are the ones a member ACTUALLY uses in the shipped scripts today. A new + // builtin wanting one of them takes a compound name instead, as `fade` pushed authors to + // `fadeAmt`. (`scale` is a long-standing core builtin and no script declares it, so the rule + // is about what scripts use, not about every ordinary word.) + static constexpr const char* kNamesScriptsDeclare[] = { + "decay", "speed", "fade", "bri", "hue", "zoom", "twist", "contrast", "sparkle", + }; + const moonlive::BuiltinTable& t = moonlive::lightBuiltins(); + for (const char* word : kNamesScriptsDeclare) { + // `fade` is the exception that proves the rule: it was taken first, and every script since + // has written `fadeAmt`. Nothing else here may become a builtin. + if (std::strcmp(word, "fade") == 0) continue; + INFO("a builtin took a name the shipped scripts declare as a member: ", std::string(word)); + CHECK(t.find(word, std::strlen(word)) == nullptr); + } +} + +TEST_CASE("the builtin table has room for every name the light domain registers") { + // The table fails SILENTLY when full: add() returns false, nothing checks each call, and the + // script reports "unknown function" for a builtin that plainly exists in the source. The + // registration-time guard only prints, which no CI run reads, so this is the check that fails. + // It also keeps real headroom visible: the table hit 61 of 64 when the flow builtins landed. + const moonlive::BuiltinTable& t = moonlive::lightBuiltins(); + CHECK_FALSE(t.full()); // nothing was dropped + CHECK(t.registered() < moonlive::BuiltinTable::kMax); + // A domain within a couple of names of the cap is one commit from the silent failure. + CHECK(moonlive::BuiltinTable::kMax - t.registered() >= 4); +} + +TEST_CASE("a script function's return value can be used in an expression") { + moonlive::MoonLive eng; + REQUIRE(eng.compile( + "class T {\n" + " int answer() { return 21; }\n" + " void tick() { setRGB(0, answer(), answer() + 1, 0); }\n" + "}\n", kTable, kSys)); + std::vector buf(4 * 3, 0); + eng.run(buf.data(), 4, 3, 0, "tick"); + CHECK(buf[0] == 21); // what the helper returned + CHECK(buf[1] == 22); // and it is usable in arithmetic + eng.free(); +} + +TEST_CASE("a returned value survives the calls that follow it in the same expression") { + // The case that exposed both wrong designs: with the result in a register `a() + b()` read 6 + // (b's value twice), and with it in the argument region the arguments came out rotated. + moonlive::MoonLive eng; + REQUIRE(eng.compile( + "class T {\n" + " int a() { return 10; }\n" + " int b() { return 3; }\n" + " void tick() { setRGB(0, a() + b(), a(), b()); }\n" + "}\n", kTable, kSys)); + std::vector buf(4 * 3, 0); + eng.run(buf.data(), 4, 3, 0, "tick"); + CHECK(buf[0] == 13); + CHECK(buf[1] == 10); + CHECK(buf[2] == 3); + eng.free(); +} + +TEST_CASE("a helper's value can drive a loop and a member") { + // What the feature is for: a helper that computes, called where a number is needed. + moonlive::MoonLive eng; + REQUIRE(eng.compile( + "class T {\n" + " byte level = 4;\n" + " int scaled() { return level * 10; }\n" + " void tick() {\n" + " for (int i = 0; i < 2; i = i + 1) { setRGB(i, scaled(), scaled() + i, 0); }\n" + " }\n" + "}\n", kTable, kSys)); + std::vector buf(4 * 3, 0); + eng.run(buf.data(), 4, 3, 0, "tick"); + CHECK(buf[0] == 40); + CHECK(buf[1] == 40); + CHECK(buf[3] == 40); + CHECK(buf[4] == 41); + eng.free(); +} + +TEST_CASE("a void function cannot be used as a value") { + // Reading the return register of a function that never wrote it would hand the script whatever + // the last call left there: a plausible number, silently wrong. + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile( + "class T {\n" + " void act() { }\n" + " void tick() { setRGB(0, act(), 0, 0); }\n" + "}\n", kTable, kSys)); + eng.free(); +} + +TEST_CASE("a returning function still works as a statement, with its value dropped") { + moonlive::MoonLive eng; + REQUIRE(eng.compile( + "class T {\n" + " int side() { return 7; }\n" + " void tick() { side(); setRGB(0, 1, 0, 0); }\n" + "}\n", kTable, kSys)); + std::vector buf(4 * 3, 0); + eng.run(buf.data(), 4, 3, 0, "tick"); + CHECK(buf[0] == 1); + eng.free(); +} diff --git a/test/unit/core/unit_moonlive_spill.cpp b/test/unit/core/unit_moonlive_spill.cpp index b60e719e..b0283ad1 100644 --- a/test/unit/core/unit_moonlive_spill.cpp +++ b/test/unit/core/unit_moonlive_spill.cpp @@ -1,6 +1,8 @@ // @module MoonLive #include "doctest.h" + +#include #include "moonlive_script_wrap.h" #include "core/moonlive/MoonLiveCompiler.h" #include "core/moonlive/MoonLiveSpill.h" @@ -86,6 +88,62 @@ TEST_CASE("a script renders identical pixels at a squeezed register budget as at CHECK(litCount(full) == 6); // and it actually did something } +// A returned value is a DEFINITION, and the allocator has to see it as one. +// +// The rewriter remaps every operand of every op it renumbers, but only remaps a `dst` for an op +// that declares it writes one. A value-returning call did not, so after a compaction the call still +// wrote its pre-compaction register while the consumer read the new one: `a() + b()` came out as +// whatever that register happened to hold. Invisible at the host's own budget, which takes the +// already-fits path and renumbers nothing, so this drives the SQUEEZED budget through the script's +// `tick` entry rather than the block start a plain renderAt would call. +TEST_CASE("a value returned by a script function survives a squeezed register budget") { + const char* src = + "class T {\n" + " int a() { return 40; }\n" + " int b() { return 5; }\n" + " void tick() {\n" + " int p = a() + b();\n" + " int q = a() + a();\n" + " int r = b() + b();\n" + " int s = p + q;\n" + " for (int i = 0; i < 3; i = i + 1) { setRGB(i, p, q, r + s - 90); }\n" + " }\n" + "}\n"; + + auto renderTick = [&](const moonlive::RegBudget* budget) { + uint8_t code[moonlive::kCodeCap]; + auto r = moonlive::compileSource(src, kT, kSys, code, sizeof(code), budget); + REQUIRE(r.ok); + // Enter at `tick`, not at the block start: a multi-function script begins with its helpers, + // and calling the block start would run `a()` as the program. + uint16_t tickOffset = 0xFFFF; + for (uint8_t i = 0; i < r.entryCount; i++) + if (std::strncmp(r.entries[i].name, "tick", r.entries[i].nameLen) == 0) tickOffset = r.entries[i].offset; + REQUIRE(tickOffset != 0xFFFF); + + std::vector buf(4 * 3, 0); + void* blk = platform::allocExec(r.len); + REQUIRE(blk != nullptr); + platform::writeExec(blk, code, r.len); + uint8_t arena[moonlive::kArenaBytes] = {}; + for (uint8_t i = 0; i < r.memberCount; i++) arena[r.members[i].offset] = r.members[i].def; + reinterpret_cast(static_cast(blk) + tickOffset)(buf.data(), 4, 3, 0, arena); + platform::freeExec(blk, r.len); + return buf; + }; + + const auto tightBudget = squeezed(11, 1); + const auto full = renderTick(nullptr); + const auto tight = renderTick(&tightBudget); + + CHECK(full == tight); + for (int i = 0; i < 3; i++) { + CHECK(tight[i * 3 + 0] == 45); // a() + b() + CHECK(tight[i * 3 + 1] == 80); // a() + a() + CHECK(tight[i * 3 + 2] == 45); // (b()+b()) + (p+q) - 90 + } +} + // The back-edge case. Naive first-def-to-last-use intervals look dead early in a loop body, so the // allocator would hand a counter's register away and the next iteration would read someone else's // value — placing lights twice, or not at all. Nested, so the extension has to apply innermost-first. diff --git a/test/unit/core/unit_noise.cpp b/test/unit/core/unit_noise.cpp index 59fc2cc4..62a9d127 100644 --- a/test/unit/core/unit_noise.cpp +++ b/test/unit/core/unit_noise.cpp @@ -3,24 +3,25 @@ #include "doctest.h" #include "core/noise.h" +#include // std::abs on doubles: GCC does not get it transitively + #include using namespace mm; // Determinism: the same coordinate always gives the same value (a pure function of position), -// so a field is reproducible frame to frame and across the 1D/2D/3D entry points at z/y = 0. +// so a field is reproducible frame to frame and across the 2D/3D entry points at z = 0. TEST_CASE("noise: inoise8 is deterministic and the lower-D calls agree at zero on the extra axes") { CHECK(inoise8(1234u) == inoise8(1234u)); CHECK(inoise8(50u, 80u) == inoise8(50u, 80u)); CHECK(inoise8(7u, 9u, 11u) == inoise8(7u, 9u, 11u)); - // 2D at y=0 equals 1D at the same x (the hash uses 0 for the absent axes in both). - CHECK(inoise8(300u, 0u) == inoise8(300u)); + // 1D is NOT 2D at y=0: it draws ±1 gradients of its own (core/noise.h says why). // 3D at z=0 equals 2D at the same (x,y). CHECK(inoise8(640u, 128u, 0u) == inoise8(640u, 128u)); } -// Smoothness: neighbouring positions WITHIN a cell (sub-256 steps) differ only a little — that's -// what makes it value noise rather than a raw hash (which would jump randomly every step). +// Smoothness: neighboring positions WITHIN a cell (sub-256 steps) differ only a little: that's +// what makes it noise rather than a raw hash (which would jump randomly every step). TEST_CASE("noise: inoise8 varies smoothly inside a cell") { // Walk across one cell (x from 0x100 to 0x1FF — cell index 1) in small steps; consecutive // samples must not jump wildly. (Across a cell BOUNDARY it can change more — that's expected.) @@ -89,9 +90,109 @@ TEST_CASE("16-bit interpolation stays exact across the full range") { } TEST_CASE("16-bit noise is smooth where the 8-bit form would step") { - // The whole point of the tier: sampling finer than the 8-bit LUT resolves must produce - // intermediate values rather than a staircase. + // The whole point of the tier: sampling finer than an 8-bit fraction resolves must produce + // intermediate values rather than a staircase. Four cells along x at a fixed y, sixteen samples + // per 8-bit step: a field stepping at 8 bits could show at most 4 * 256 distinct values. std::set values; - for (uint32_t f = 0; f < 256; f++) values.insert(inoise16(0x10000u + f * 16u)); - CHECK(values.size() > 150); // a stepped field repeats a handful; rounding costs a few + for (uint32_t f = 0; f < 4u * 4096u; f++) values.insert(inoise16(0x10000u + f * 16u, 0x18000u)); + CHECK(values.size() > 2000); +} + +TEST_CASE("fbm keeps its full range however many octaves are summed") { + // Octaves are near-independent, so their spread grows like the root of the sum of squares while + // the normalizer divides by the sum of amplitudes. Left uncorrected the field narrows with every + // octave added: 4 octaves measured 54..199 of 0..255, so an effect stretching the top of the + // field could never reach full brightness and every fbm read flatter than the noise under it. + for (uint8_t octaves = 1; octaves <= 4; octaves++) { + uint8_t lo = 255, hi = 0; + for (uint32_t y = 0; y < 1200; y += 7) + for (uint32_t x = 0; x < 1200; x += 7) { + const uint8_t v = fbm8(x * 40, y * 40, octaves); + lo = v < lo ? v : lo; + hi = v > hi ? v : hi; + } + CHECK(lo < 32); // reaches the dark end + CHECK(hi > 224); // and the bright end, at every octave count + } +} + +TEST_CASE("16-bit fbm keeps its range too") { + for (uint8_t octaves = 1; octaves <= 4; octaves++) { + uint16_t lo = 65535, hi = 0; + for (uint32_t y = 0; y < 900; y += 7) + for (uint32_t x = 0; x < 900; x += 7) { + const uint16_t v = fbm16(x * 2600, y * 2600, octaves); + lo = v < lo ? v : lo; + hi = v > hi ? v : hi; + } + CHECK(lo < 8192); + CHECK(hi > 57343); + } +} + +// The contract that makes the field library dimension-generic: a 2D call is the 3D call with the +// missing axis at zero. Without it a volumetric fixture and a panel would sample different fields +// for the same coordinates, and an effect could not simply pass z through. +TEST_CASE("every field kernel's 2D form is its 3D form with z at zero") { + for (uint32_t y = 0; y < 4000; y += 231) { + for (uint32_t x = 0; x < 4000; x += 197) { + CHECK(fbm8(x, y, 2) == fbm8(x, y, 0u, 2)); + CHECK(fbm8(x, y, 4) == fbm8(x, y, 0u, 4)); + CHECK(fbm16(x, y, 2) == fbm16(x, y, 0u, 2)); + CHECK(fbm16(x, y, 3) == fbm16(x, y, 0u, 3)); + CHECK(turbulence8(x, y, 2) == turbulence8(x, y, 0u, 2)); + CHECK(warp8(x, y, 240, 1) == warp8(x, y, 0u, 240, 1)); + CHECK(warp8(x, y, 512, 2) == warp8(x, y, 0u, 512, 2)); + } + } +} + +TEST_CASE("the z axis actually changes the field, rather than being carried and ignored") { + // The other half of the contract: passing z must do something, or "3D support" is a signature + // change. A volumetric fixture's slices have to differ from each other. + int differing = 0, total = 0; + for (uint32_t y = 0; y < 3000; y += 311) { + for (uint32_t x = 0; x < 3000; x += 271) { + total++; + if (fbm8(x, y, 0u, 2) != fbm8(x, y, 3000u, 2)) differing++; + } + } + REQUIRE(total > 50); + CHECK(differing * 4 > total * 3); // three quarters of samples move with z +} + +TEST_CASE("a curl field has no sources or sinks, so what it carries cannot pile up") { + // The reason curl exists rather than sampling noise straight into a velocity. A field with + // divergence has places where flow converges (anything carried there collects into a clump) and + // places where it diverges (the medium thins to nothing). Curl is the perpendicular gradient of + // a potential, so its divergence is zero by construction, and what it carries keeps its shape. + // + // Measured here against the naive alternative over the same points: two noise samples used + // directly as vx and vy. + double curlDiv = 0, naiveDiv = 0; + int n = 0; + constexpr uint32_t kCell = 1u << 16, kEps = 4096; + for (uint32_t y = kCell * 2; y < kCell * 10; y += kCell / 2) { + for (uint32_t x = kCell * 2; x < kCell * 10; x += kCell / 2) { + int32_t ax, ay, bx, by, cx2, cy2, dx2, dy2; + mm::curl16(x + kEps, y, 1000, ax, ay); + mm::curl16(x - kEps, y, 1000, bx, by); + mm::curl16(x, y + kEps, 1000, cx2, cy2); + mm::curl16(x, y - kEps, 1000, dx2, dy2); + curlDiv += std::abs(static_cast(ax - bx) + static_cast(cy2 - dy2)); + + const auto naive = [](uint32_t a, uint32_t b, bool second) { + const int32_t v = second ? static_cast(mm::inoise16(a + 0x9E37u, b + 0x7C15u)) + : static_cast(mm::inoise16(a, b)); + return static_cast(v - 32768) * 1000.0 / 32768.0; + }; + naiveDiv += std::abs((naive(x + kEps, y, false) - naive(x - kEps, y, false)) + + (naive(x, y + kEps, true) - naive(x, y - kEps, true))); + n++; + } + } + REQUIRE(n > 0); + // Two orders of magnitude apart, measured: curl ~0.3, noise-as-velocity ~90. + CHECK(curlDiv / n < 5.0); + CHECK(naiveDiv / n > 20.0); } diff --git a/test/unit/light/unit_AuroraEffect.cpp b/test/unit/light/unit_AuroraEffect.cpp new file mode 100644 index 00000000..89cb38b2 --- /dev/null +++ b/test/unit/light/unit_AuroraEffect.cpp @@ -0,0 +1,182 @@ +// @module AuroraEffect +// @also polar, oscillators, noise + +// Aurora is a composition rather than a picture of anything, so what is pinned here is that the +// composition behaves: that curtains appear and are distinct rather than an even haze, that the +// contrast control decides how much of the field lights, that the layers move independently, and +// that raising the cost knob costs something. The golden pins the plumbing; these pin the look. + +#include "doctest.h" +#include "light/effects/AuroraEffect.h" +#include "light/layers/Layer.h" +#include "light/layouts/GridLayout.h" +#include "light/layouts/Layouts.h" +#include "platform/platform.h" + +#include +#include +#include + +using namespace mm; + +namespace { + +template +std::vector render3(lengthType w, lengthType h, lengthType d, F configure, + uint16_t frames = 60, uint32_t startMs = 1000); + +/// Render Aurora with `configure` applied, and return the final frame. +template +std::vector render(lengthType w, lengthType h, F configure, uint16_t frames = 60, uint32_t startMs = 1000) { + return render3(w, h, 1, configure, frames, startMs); +} + +/// The volumetric form: `d` lights deep. +template +std::vector render3(lengthType w, lengthType h, lengthType d, F configure, + uint16_t frames, uint32_t startMs) { + platform::setTestNowMs(startMs); + Layouts layouts; + GridLayout grid; + Layer layer; + AuroraEffect effect; + configure(effect); + grid.width = w; grid.height = h; grid.depth = d; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&effect); + layer.applyState(); + for (uint16_t f = 0; f < frames; f++) { + platform::setTestNowMs(startMs + static_cast(f) * 20); + layer.tick(); + } + auto& buf = layer.buffer(); + return std::vector(buf.data(), buf.data() + buf.bytes()); +} + +/// The share of channels that are lit at all. +double litShare(const std::vector& f) { + std::size_t lit = 0; + for (uint8_t v : f) lit += v > 8 ? 1 : 0; + return double(lit) / f.size(); +} + +/// How many channels differ between two frames. +std::size_t differing(const std::vector& a, const std::vector& b) { + std::size_t n = 0; + for (std::size_t i = 0; i < a.size() && i < b.size(); i++) n += a[i] != b[i] ? 1 : 0; + return n; +} + +} // namespace + +TEST_CASE("the contrast control decides how much of the field lights up") { + // This is what makes Aurora curtains rather than cloud: a high window leaves only the peaks of + // the field visible, a low one lets most of it through. + const auto sharp = render(32, 32, [](AuroraEffect& e) { e.contrast = 200; }); + const auto soft = render(32, 32, [](AuroraEffect& e) { e.contrast = 40; }); + CHECK(litShare(sharp) < litShare(soft)); + CHECK(litShare(sharp) < 0.5); // a minority of the panel is curtain + CHECK(litShare(soft) > 0.5); // and the field itself covers it +} + +TEST_CASE("curtains appear rather than an even wash of light") { + // A field that lit every pixel equally would be a blur. The frame must have real dark and real + // bright in it at the default contrast. + const auto frame = render(32, 32, [](AuroraEffect&) {}); + uint8_t lo = 255, hi = 0; + for (uint8_t v : frame) { lo = v < lo ? v : lo; hi = v > hi ? v : hi; } + CHECK(lo < 16); // somewhere is genuinely dark + CHECK(hi > 200); // somewhere is genuinely bright +} + +TEST_CASE("the composition keeps moving, and no two moments look alike") { + const auto early = render(32, 32, [](AuroraEffect&) {}, 30); + const auto late = render(32, 32, [](AuroraEffect&) {}, 240); + CHECK(differing(early, late) > early.size() / 8); +} + +TEST_CASE("a still speed holds the picture, so a fixture can be frozen") { + const auto a = render(32, 32, [](AuroraEffect& e) { e.speed = 0; }, 30); + const auto b = render(32, 32, [](AuroraEffect& e) { e.speed = 0; }, 240); + CHECK(differing(a, b) == 0); +} + +TEST_CASE("each layer adds structure, so the cost knob buys something") { + // One layer is a single field; three layers interfere. If more layers changed nothing, the + // effect's main control would be paying for nothing. + const auto one = render(32, 32, [](AuroraEffect& e) { e.layers = 1; }); + const auto three = render(32, 32, [](AuroraEffect& e) { e.layers = 3; }); + CHECK(differing(one, three) > one.size() / 4); +} + +TEST_CASE("the kaleidoscope fold makes the composition symmetric") { + // Folding the angle into wedges must actually repeat the field around the center. + const auto folded = render(33, 33, [](AuroraEffect& e) { e.segments = 4; e.twist = 0; }); + // Sample a ring of pixels and check the fold repeats: opposite wedges carry the same field. + const std::size_t w = 33; + std::size_t same = 0, total = 0; + for (std::size_t k = 4; k < 16; k++) { + const std::size_t left = (16 * w + (16 - k)) * 3; + const std::size_t right = (16 * w + (16 + k)) * 3; + const int d = folded[left] > folded[right] ? folded[left] - folded[right] + : folded[right] - folded[left]; + same += d < 32 ? 1 : 0; + total++; + } + CHECK(same * 2 >= total); // the mirrored halves largely agree +} + +TEST_CASE("Aurora renders on a grid too small to have a center") { + // Robustness: any size, any order. A 1x1 and a 2x1 grid must not fault or divide by zero. + const auto tiny = render(1, 1, [](AuroraEffect&) {}, 5); + CHECK(tiny.size() == 3); + const auto strip = render(2, 1, [](AuroraEffect&) {}, 5); + CHECK(strip.size() == 6); +} + +TEST_CASE("Aurora renders the same picture whether or not the polar table is available") { + // The table is an optimization, not part of the look: a device that cannot spare the memory + // gets the same composition. + const auto tabled = render(32, 32, [](AuroraEffect& e) { e.polar.wide = true; }); + const auto exact = render(32, 32, [](AuroraEffect& e) { e.polar.use = false; }); + CHECK(differing(tabled, exact) == 0); +} + +TEST_CASE("on a volumetric fixture the curtains have depth instead of one repeated slice") { + // What a cube buys over a panel: the field is sampled through the volume, so a light at the + // front and one at the back of the same column are in different parts of the composition. + const auto f = render3(8, 8, 6, [](AuroraEffect& e) { e.contrast = 40; }, 40); + const std::size_t slice = 8 * 8 * 3; + REQUIRE(f.size() >= slice * 6); + std::size_t diff = 0; + for (std::size_t i = 0; i < slice; i++) diff += f[i] != f[slice * 5 + i] ? 1 : 0; + CHECK(diff > slice / 4); +} + +TEST_CASE("each projection composes the volume differently") { + // The control earns its place only if the three look different on the same fixture. + const auto cyl = render3(8, 8, 6, [](AuroraEffect& e) { e.polar.mapping = 0; e.contrast = 40; }, 40); + const auto sph = render3(8, 8, 6, [](AuroraEffect& e) { e.polar.mapping = 1; e.contrast = 40; }, 40); + const auto rad = render3(8, 8, 6, [](AuroraEffect& e) { e.polar.mapping = 2; e.contrast = 40; }, 40); + std::size_t a = 0, b = 0; + for (std::size_t i = 0; i < cyl.size(); i++) { + a += cyl[i] != sph[i] ? 1 : 0; + b += cyl[i] != rad[i] ? 1 : 0; + } + CHECK(a > cyl.size() / 8); + CHECK(b > cyl.size() / 8); +} + +TEST_CASE("Aurora renders on a strip, a panel and a cube alike") { + // Any effect on any dimension: a strip is one line through a field designed around two axes, so + // it is not what Aurora is for, but it must still light rather than fail or go dark. + for (auto dims : {std::array{64, 1, 1}, {16, 16, 1}, {8, 8, 8}}) { + const auto f = render3(dims[0], dims[1], dims[2], [](AuroraEffect& e) { e.contrast = 30; }, 30); + REQUIRE(f.size() == static_cast(dims[0]) * dims[1] * dims[2] * 3); + std::size_t lit = 0; + for (uint8_t v : f) lit += v > 0 ? 1 : 0; + CHECK(lit > 0); + } +} diff --git a/test/unit/light/unit_Canvas.cpp b/test/unit/light/unit_Canvas.cpp index d8015787..ec667d37 100644 --- a/test/unit/light/unit_Canvas.cpp +++ b/test/unit/light/unit_Canvas.cpp @@ -66,7 +66,7 @@ TEST_CASE("Canvas pixel writes land where get reads them, and clip outside") { CHECK(c.b == 30); // A write outside the grid is silently dropped, and reading there is black — never a crash and - // never a stray byte in a neighbouring light (the robustness rule). + // never a stray byte in a neighboring light (the robustness rule). draw::pixel(s.cv, {99, 99, 0}, RGB{255, 255, 255}); const RGB out = draw::get(s.cv, {99, 99, 0}); CHECK(out.r == 0); diff --git a/test/unit/light/unit_Circle.cpp b/test/unit/light/unit_Circle.cpp index ca19a525..8cb269e4 100644 --- a/test/unit/light/unit_Circle.cpp +++ b/test/unit/light/unit_Circle.cpp @@ -117,7 +117,7 @@ TEST_CASE("a negative radius draws nothing") { // The reason lineAA exists: a diagonal that is not at 45 degrees lands between cells, and Wu // splits it rather than snapping. A perfectly diagonal line has nothing to split. -TEST_CASE("an anti-aliased line spreads a shallow diagonal over neighbouring cells") { +TEST_CASE("an anti-aliased line spreads a shallow diagonal over neighboring cells") { Surface s(8, 8); draw::lineAA(s.cv, {0, 0, 0}, {7, 3, 0}, RGB{255, 0, 0}); // Somewhere along the run, two vertically-adjacent cells are both partly lit — the AA signature. diff --git a/test/unit/light/unit_Drivers_container.cpp b/test/unit/light/unit_Drivers_container.cpp index a8e7ab01..dec87e59 100644 --- a/test/unit/light/unit_Drivers_container.cpp +++ b/test/unit/light/unit_Drivers_container.cpp @@ -4,6 +4,7 @@ #include "light/drivers/Drivers.h" #include "light/drivers/LightPresetsModule.h" // the non-deletable boot-wired preset library #include "../core/conditional_controls.h" // mm::test::setControlValue +#include "platform/platform.h" // gpioRead: the desktop reads back what gpioWrite put there #include @@ -207,3 +208,64 @@ TEST_CASE("a probe Drivers (controls read, never prepared) leaves the scripted-p CHECK(std::strcmp(mm::LivePalettes::nameAt(0), "running.mlp") == 0); mm::LivePalettes::clear(); } + +// The power relay is the physical expression of "the lights are off", and brightness 0 is off as +// much as `on` = false is: a WLED-style client says off by sending bri 0 without touching `on`, and +// a strip at zero still draws its idle current through a closed relay. So the relay opens at +// brightness 0 and closes again the moment brightness returns, with `on` unchanged either way. +TEST_CASE("the relay opens at brightness 0 and closes again when brightness returns") { + mm::platform::clearTestGpioLevel(); + mm::Drivers drivers; + std::strcpy(drivers.relayPins, "12"); + drivers.on = true; + drivers.brightness = 100; + drivers.onControlChanged("relayPins"); // entering the pin closes the relay at once + CHECK(mm::platform::gpioRead(12)); + + drivers.brightness = 0; + drivers.onControlChanged("brightness"); + CHECK_FALSE(mm::platform::gpioRead(12)); // off by brightness, `on` still true + + drivers.brightness = 1; + drivers.onControlChanged("brightness"); + CHECK(mm::platform::gpioRead(12)); // the smallest non-zero brightness is on + + drivers.on = false; + drivers.onControlChanged("on"); + CHECK_FALSE(mm::platform::gpioRead(12)); // and `on` still opens it whatever brightness says + mm::platform::clearTestGpioLevel(); +} + +// A typo in the relay list must not leave the previous relays closed. Reporting the parse error and +// returning looked right, but the pins from the last VALID list stayed asserted on GPIOs no control +// named any more: the strip kept its power through a brightness of zero, and nothing in the UI said +// why. An unparseable list means no relays, which is the same state as an empty one. +TEST_CASE("a typo in the relay list releases the relays it used to hold") { + mm::platform::clearTestGpioLevel(); + mm::Drivers drivers; + std::strcpy(drivers.relayPins, "12,13"); + drivers.on = true; + drivers.brightness = 100; + drivers.onControlChanged("relayPins"); + CHECK(mm::platform::gpioRead(12)); + CHECK(mm::platform::gpioRead(13)); + + // Mid-edit the list is briefly nonsense, which is the normal way a user types one. + std::strcpy(drivers.relayPins, "12,,x"); + drivers.onControlChanged("relayPins"); + CHECK_FALSE(mm::platform::gpioRead(12)); + CHECK_FALSE(mm::platform::gpioRead(13)); + + // And the driver has forgotten them, so a later brightness change does not resurrect either pin. + drivers.brightness = 200; + drivers.onControlChanged("brightness"); + CHECK_FALSE(mm::platform::gpioRead(12)); + CHECK_FALSE(mm::platform::gpioRead(13)); + + // A corrected list takes effect normally. + std::strcpy(drivers.relayPins, "13"); + drivers.onControlChanged("relayPins"); + CHECK(mm::platform::gpioRead(13)); + CHECK_FALSE(mm::platform::gpioRead(12)); + mm::platform::clearTestGpioLevel(); +} diff --git a/test/unit/light/unit_Effects_golden.cpp b/test/unit/light/unit_Effects_golden.cpp index 1b42cbb2..6c1e3d56 100644 --- a/test/unit/light/unit_Effects_golden.cpp +++ b/test/unit/light/unit_Effects_golden.cpp @@ -8,7 +8,13 @@ // AudioSpectrum) are deliberately ABSENT: their output depends on whatever the audio service holds, // so a hash over their frames would pin the test rig's audio state rather than the effect. Their // migrations rely on their behaviour tests plus the Canvas equivalence test in unit_Canvas. -// @also SineEffect, PlasmaEffect, NoiseEffect, DistortionWavesEffect, LavaLampEffect, MetaballsEffect, SpiralEffect, RingsEffect, WaveEffect, RainbowEffect +// @also AuroraEffect, BallpitEffect, BouncingBallsEffect, DissolveEffect, DistortionWavesEffect, EchoEffect, +// @also FireEffect, FireworksEffect, FishTankEffect, FixedRectangleEffect, FluidEffect, +// @also FlyingToastersEffect, GameOfLifeEffect, LavaLampEffect, LissajousEffect, MetaballsEffect, +// @also NebulaEffect, NoiseEffect, PacmanEffect, PlasmaEffect, PolarNoiseEffect, PraxisEffect, +// @also RainbowEffect, RingsEffect, RubiksCubeEffect, SdfShapesEffect, SineEffect, SolidEffect, +// @also SphereMoveEffect, SpiralEffect, StarFieldEffect, StarSkyEffect, TetrixEffect, TextEffect, +// @also TrailsEffect, TruchetEffect, TunnelEffect, WaterRippleEffect, WaveEffect // Pins the EXACT rendered output of the time-driven effects, so the power-function migration's // "renders exactly the same" claim is proved rather than asserted. @@ -25,9 +31,10 @@ // uses the first call as the time base only. Verified as the SOLE cause by reproducing the old // first-tick behaviour on top of BeatPhase and watching the original hash return. // -// The control cases prove it: WaveEffect and NoiseEffect ALREADY carried that guard, and their -// hashes did NOT move across the same migration. So a moved hash here means "this effect gained the -// guard", not "the migration drifted". +// The control case proves it: WaveEffect ALREADY carried that guard, and its hash did NOT move +// across the same migration. So a moved hash here means "this effect gained the guard", not "the +// migration drifted". (NoiseEffect was a second control case until the gradient-noise swap and the +// two-noise merge moved its hash for reasons of their own, recorded below.) // // Every other hash below was captured from the code BEFORE the migration and must not move. If one does, // either the migration changed the arithmetic (a bug — the accumulators are meant to be identical) @@ -45,7 +52,6 @@ #include "light/effects/BouncingBallsEffect.h" #include "light/effects/FixedRectangleEffect.h" #include "light/effects/LissajousEffect.h" -#include "light/effects/Noise2DEffect.h" #include "light/effects/PraxisEffect.h" #include "light/effects/SolidEffect.h" #include "light/effects/SphereMoveEffect.h" @@ -59,11 +65,16 @@ #include "light/effects/MetaballsEffect.h" #include "light/effects/NoiseEffect.h" #include "light/effects/PlasmaEffect.h" +#include "light/effects/FireEffect.h" #include "light/effects/RainbowEffect.h" +#include "light/effects/AuroraEffect.h" #include "light/effects/RingsEffect.h" #include "light/effects/SdfShapesEffect.h" #include "light/effects/PolarNoiseEffect.h" #include "light/effects/WaterRippleEffect.h" +#include "light/effects/FluidEffect.h" +#include "light/effects/NebulaEffect.h" +#include "light/effects/TrailsEffect.h" #include "light/effects/TunnelEffect.h" #include "light/effects/EchoEffect.h" #include "light/effects/DissolveEffect.h" @@ -89,11 +100,40 @@ using namespace mm; // throttling it twice. BouncingBalls moved because the Layer now resets its fade clock in // prepare(), which discards the idle gap before the first frame: the guarantee LissajousEffect used // to give for its own trail, given once for every effect. Reviewed and re-blessed together. +// Four goldens moved on 2026-09-03 when the noise core became Perlin improved gradient noise +// (PolarNoise, Tunnel, Noise, Noise2D). The names, coordinates and output ranges are unchanged; the +// field itself is a different, smoother one, which is the point of the swap. No other golden moved, +// which is the evidence that the change is confined to the noise callers. +// The three fbm effects (PolarNoise, Tunnel, Aurora) moved again on 2026-09-04 when fbm stopped +// narrowing its own range: octaves are near-independent, so their spread grows like the root of the +// sum of squares while the normalizer divides by the sum of amplitudes, and 4 octaves had shrunk to +// 54..199 of 0..255. Every fbm field is now higher contrast, which is a visible improvement rather +// than a neutral change; unit_noise pins the range at every octave count. +// Trails moved on 2026-09-04, for two deliberate fixes. Its first tick now measures a ZERO delta +// rather than the whole uptime, which had teleported the flow and decayed the trail away on the +// frame it started. And its emitters are paced by TIME rather than firing every frame: writing a +// head per frame injects light at the framerate (measured 1.37 at 1200 fps against 60, which the +// framerate audit caught). Scaling the head's brightness by dt was tried first and is wrong here, +// because writeWide SETS the pixel rather than accumulating, so twenty dim writes do not add up to +// one bright one and the fast device came out twice as dark instead. +// Tunnel moved on 2026-09-04 for the same reason as PolarNoise below, and Spiral moved with them +// (it has no golden). All three are pinned instead by unit_PolarLut_equivalence, which renders each +// through the table and through the computed address and requires the 16-bit table to be identical. +// PolarNoise moved again on 2026-09-04: it reads its per-pixel angle and radius from PolarLut +// instead of calling atan16 and dist16 every frame, and takes its drift from an oscillator. The +// address is the same one, and unit_PolarLut_equivalence pins that the 16-bit table renders +// BIT-IDENTICALLY to the computed path; the default 8-bit table quantizes the angle to 256 steps, +// which is what moved the hash. TEST_CASE("time-driven effects render byte-identical frames (migration guard)") { SUBCASE("two SDF shapes orbit and melt together, with a soft edge") { SdfShapesEffect e; golden::checkGolden("SdfShapesEffect", golden::renderHash(e, 16, 16, 1), 0xbcfb74b4836606a3ull); } - SUBCASE("a warped noise field folded into a kaleidoscope") { PolarNoiseEffect e; golden::checkGolden("PolarNoiseEffect", golden::renderHash(e, 16, 16, 1), 0x5e888644938f8851ull); } + SUBCASE("a warped noise field folded into a kaleidoscope") { PolarNoiseEffect e; golden::checkGolden("PolarNoiseEffect", golden::renderHash(e, 16, 16, 1), 0x8d48e0d1e0180610ull); } + SUBCASE("heat rises, cools and colors through the palette") { FireEffect e; golden::checkGolden("FireEffect", golden::renderHash(e, 16, 16, 1), 0x1cadbabb59bc489bull); } + SUBCASE("dye poured into a simulated medium, carried by the flow it works out") { FluidEffect e; golden::checkGolden("FluidEffect", golden::renderHash(e, 16, 16, 1), 0xdef67ab1f131e137ull); } + SUBCASE("a field births light and a curl flow carries it into a cloud") { NebulaEffect e; golden::checkGolden("NebulaEffect", golden::renderHash(e, 16, 16, 1), 0xc42116cc9f9cc33full); } + SUBCASE("dots thrown into a flow, leaving tails it carries and bends") { TrailsEffect e; golden::checkGolden("TrailsEffect", golden::renderHash(e, 16, 16, 1), 0x77993423ebe2f58cull); } + SUBCASE("layered noise curtains, each drifting on its own clock") { AuroraEffect e; golden::checkGolden("AuroraEffect", golden::renderHash(e, 16, 16, 1), 0xfb4329a20959443dull); } SUBCASE("drops ripple, reflect off the edges and interfere") { WaterRippleEffect e; golden::checkGolden("WaterRippleEffect", golden::renderHash(e, 16, 16, 1), 0xa11f9c4f27cba8d5ull); } - SUBCASE("a texture-mapped tunnel flying toward a vanishing point") { TunnelEffect e; golden::checkGolden("TunnelEffect", golden::renderHash(e, 16, 16, 1), 0xa2f6752d82436fc1ull); } + SUBCASE("a texture-mapped tunnel flying toward a vanishing point") { TunnelEffect e; golden::checkGolden("TunnelEffect", golden::renderHash(e, 16, 16, 1), 0x7b4d4451a3de3887ull); } SUBCASE("the previous frame fed back zoomed and rotated, leaving trails") { EchoEffect e; golden::checkGolden("EchoEffect", golden::renderHash(e, 16, 16, 1), 0x53d2ba4d4fdf9499ull); } SUBCASE("two colour fields trade places pixel by pixel") { DissolveEffect e; golden::checkGolden("DissolveEffect", golden::renderHash(e, 16, 16, 1), 0xeb7810ca874152bcull); } SUBCASE("shells rise, stall at their apex and burst into falling sparks") { FireworksEffect e; golden::checkGolden("FireworksEffect", golden::renderHash(e, 16, 16, 1), 0x5ffbfcab94c90a94ull); } @@ -107,7 +147,7 @@ TEST_CASE("time-driven effects render byte-identical frames (migration guard)") SUBCASE("arc tiles join into endless winding paths") { TruchetEffect e; golden::checkGolden("TruchetEffect", golden::renderHash(e, 16, 16, 1), 0xdcb9b41536eff043ull); } SUBCASE("SineEffect") { SineEffect e; golden::checkGolden("SineEffect", golden::renderHash(e, 16, 16, 1), 0xe96c6fd2da1b264bull); } SUBCASE("PlasmaEffect") { PlasmaEffect e; golden::checkGolden("PlasmaEffect", golden::renderHash(e, 16, 16, 1), 0xfe821e9102099b93ull); } - SUBCASE("NoiseEffect") { NoiseEffect e; golden::checkGolden("NoiseEffect", golden::renderHash(e, 16, 16, 1), 0xdeb42f569f324cebull); } + SUBCASE("NoiseEffect") { NoiseEffect e; golden::checkGolden("NoiseEffect", golden::renderHash(e, 16, 16, 1), 0xf3c36c9cccfdd04full); } SUBCASE("DistortionWavesEffect") { DistortionWavesEffect e; golden::checkGolden("DistortionWavesEffect", golden::renderHash(e, 16, 16, 1), 0xe4cd8111e8159133ull); } SUBCASE("LavaLampEffect") { LavaLampEffect e; golden::checkGolden("LavaLampEffect", golden::renderHash(e, 16, 16, 1), 0x3c312e8a75b9ac83ull); } SUBCASE("MetaballsEffect") { MetaballsEffect e; golden::checkGolden("MetaballsEffect", golden::renderHash(e, 16, 16, 1), 0x96a26bf931ad8341ull); } @@ -119,7 +159,6 @@ TEST_CASE("time-driven effects render byte-identical frames (migration guard)") SUBCASE("BouncingBallsEffect") { BouncingBallsEffect e; golden::checkGolden("BouncingBallsEffect", golden::renderHash(e, 16, 16, 1), 0x8b89c982e566f5b4ull); } SUBCASE("FixedRectangleEffect") { FixedRectangleEffect e; golden::checkGolden("FixedRectangleEffect", golden::renderHash(e, 16, 16, 1), 0x22b828f908e9ce1cull); } SUBCASE("LissajousEffect") { LissajousEffect e; golden::checkGolden("LissajousEffect", golden::renderHash(e, 16, 16, 1), 0x7a5f13102f039d12ull); } - SUBCASE("Noise2DEffect") { Noise2DEffect e; golden::checkGolden("Noise2DEffect", golden::renderHash(e, 16, 16, 1), 0xefbc5485de148631ull); } SUBCASE("PraxisEffect") { PraxisEffect e; golden::checkGolden("PraxisEffect", golden::renderHash(e, 16, 16, 1), 0x0420f0404b3f12c5ull); } SUBCASE("SolidEffect") { SolidEffect e; golden::checkGolden("SolidEffect", golden::renderHash(e, 16, 16, 1), 0x56711c1cf0c8ae83ull); } SUBCASE("SphereMoveEffect") { SphereMoveEffect e; golden::checkGolden("SphereMoveEffect", golden::renderHash(e, 16, 16, 1), 0xb3f3d7c75fe49fdbull); } diff --git a/test/unit/light/unit_MoonLiveScripts.cpp b/test/unit/light/unit_MoonLiveScripts.cpp index 5ce711af..867f36ee 100644 --- a/test/unit/light/unit_MoonLiveScripts.cpp +++ b/test/unit/light/unit_MoonLiveScripts.cpp @@ -311,7 +311,7 @@ TEST_CASE("a script reads elapsed time, so it can animate") { // (adjacent points inside one cell differ a little, not wildly) and it actually VARIES across the // field (a constant would be smooth too, and useless). #if MM_MOONLIVE_HAS_HOST_JIT -TEST_CASE("noise is smooth across neighbouring points, and varies across the field") { +TEST_CASE("noise is smooth across neighboring points, and varies across the field") { uint8_t code[4096]; // One light per sample: light i gets the noise at x = i * 64, so the 32 lights walk 8 whole // cells (256 units each) and the buffer IS a real slice of the field, not a corner of one cell. diff --git a/test/unit/light/unit_Noise2DEffect.cpp b/test/unit/light/unit_Noise2DEffect.cpp deleted file mode 100644 index 6fda905c..00000000 --- a/test/unit/light/unit_Noise2DEffect.cpp +++ /dev/null @@ -1,91 +0,0 @@ -// @module Noise2DEffect - -#include "doctest.h" -#include "light/layouts/Layouts.h" -#include "light/effects/Noise2DEffect.h" -#include "light/layouts/GridLayout.h" - -// A single frame on an 8×8 grid fills the buffer with a palette-mapped noise field (non-zero). -TEST_CASE("Noise2DEffect writes a non-zero palette-mapped noise field") { - mm::Layouts layouts; - mm::GridLayout grid; - grid.width = 8; - grid.height = 8; - grid.depth = 1; - layouts.addChild(&grid); - - mm::Layer layer; - layer.setLayouts(&layouts); - layer.setChannelsPerLight(3); - - mm::Noise2DEffect noise; - layer.addChild(&noise); - - layer.applyState(); - // Palettes::active() is a process-wide static any prior test can mutate; pin a colorful palette - // (Rainbow=0) so the non-black assertion is order-independent. - mm::Palettes::setActive(0); - layer.tick(); - - auto& buf = layer.buffer(); - REQUIRE(buf.data() != nullptr); - REQUIRE(buf.count() == 64); - - bool hasNonZero = false; - for (size_t i = 0; i < buf.bytes(); i++) { - if (buf.data()[i] != 0) { hasNonZero = true; break; } - } - CHECK(hasNonZero); -} - -// The field is spatial: distant pixels read different noise samples, so their colors differ. -TEST_CASE("Noise2DEffect distant pixels carry different colors") { - mm::Layouts layouts; - mm::GridLayout grid; - grid.width = 16; - grid.height = 16; - grid.depth = 1; - layouts.addChild(&grid); - - mm::Layer layer; - layer.setLayouts(&layouts); - layer.setChannelsPerLight(3); - - mm::Noise2DEffect noise; - noise.scale = 64; // default zoom: at (0,0) vs (8,8) the noise coords are (0,0) vs (512,512) - layer.addChild(&noise); - - layer.applyState(); - mm::Palettes::setActive(0); - layer.tick(); - - auto* data = layer.buffer().data(); - uint8_t r0 = data[0], g0 = data[1], b0 = data[2]; - size_t idx88 = (8 * 16 + 8) * 3; - uint8_t r1 = data[idx88], g1 = data[idx88 + 1], b1 = data[idx88 + 2]; - // Value noise is smooth but not constant; widely separated coords sample different field values, - // which index different palette entries. - CHECK((r0 != r1 || g0 != g1 || b0 != b1)); -} - -// Effects must run at every grid size: a 0×0×0 layer renders without crashing (the cols/rows guard). -TEST_CASE("Noise2DEffect survives a degenerate 0x0 grid") { - mm::Layouts layouts; - mm::GridLayout grid; - grid.width = 0; - grid.height = 0; - grid.depth = 0; - layouts.addChild(&grid); - - mm::Layer layer; - layer.setLayouts(&layouts); - layer.setChannelsPerLight(3); - - mm::Noise2DEffect noise; - layer.addChild(&noise); - - layer.applyState(); - layer.tick(); // must not crash on an empty grid - - CHECK(layer.buffer().count() == 0); -} diff --git a/test/unit/light/unit_NoiseEffect.cpp b/test/unit/light/unit_NoiseEffect.cpp index 0ec7e987..8acea7f0 100644 --- a/test/unit/light/unit_NoiseEffect.cpp +++ b/test/unit/light/unit_NoiseEffect.cpp @@ -1,164 +1,127 @@ // @module NoiseEffect -// @also PlasmaEffect, RainbowEffect +// @also noise, Palette + +// Noise is the plainest field effect: a gradient-noise sample straight into the palette. Its one +// character control decides what moves, which is what used to be two separate effects (the second +// was Noise2D, whose morph behavior is the `morph` option here). These pin that both options render, +// that they differ, and that each moves the way its name says. #include "doctest.h" -#include "light/layouts/Layouts.h" + +#include "golden_frame.h" // ScopedTestClock: restores the real clock on scope exit #include "light/effects/NoiseEffect.h" -#include "light/effects/PlasmaEffect.h" -#include "light/effects/RainbowEffect.h" +#include "light/layers/Layer.h" #include "light/layouts/GridLayout.h" - -// Hash one z-slice of the layer buffer (used by 3D-depth tests below). -static uint32_t hashSlice(const uint8_t* data, size_t sliceBytes) { - uint32_t h = 2166136261u; - for (size_t i = 0; i < sliceBytes; i++) { h ^= data[i]; h *= 16777619u; } - return h; -} - -// One tick on an 8×8 grid leaves at least one non-zero byte (noise paints somewhere). -TEST_CASE("NoiseEffect writes non-zero RGB data to buffer") { - mm::Layouts layouts; - mm::GridLayout grid; - grid.width = 8; - grid.height = 8; - grid.depth = 1; +#include "light/layouts/Layouts.h" +#include "platform/platform.h" + +#include +#include +#include +#include + +using namespace mm; + +namespace { + +/// Render Noise for `frames` on a w x h x d fixture and return the final buffer. +std::vector render(lengthType w, lengthType h, lengthType d, uint8_t motion, + uint16_t frames = 40) { + // RAII, so the override is cleared even if a REQUIRE below exits early: a leaked test clock + // freezes time for every test that runs after this one in the same binary. + const mm::golden::ScopedTestClock clock(1000); + Layouts layouts; + GridLayout grid; + Layer layer; + NoiseEffect effect; + effect.motion = motion; + grid.width = w; grid.height = h; grid.depth = d; layouts.addChild(&grid); - - mm::Layer layer; layer.setLayouts(&layouts); layer.setChannelsPerLight(3); - - mm::NoiseEffect noise; - layer.addChild(&noise); - + layer.addChild(&effect); layer.applyState(); - layer.tick(); - - auto& buf = layer.buffer(); - REQUIRE(buf.data() != nullptr); - - bool hasNonZero = false; - for (size_t i = 0; i < buf.bytes(); i++) { - if (buf.data()[i] != 0) { hasNonZero = true; break; } + for (uint16_t f = 0; f < frames; f++) { + platform::setTestNowMs(1000 + static_cast(f) * 20); + layer.tick(); } - CHECK(hasNonZero); + auto& buf = layer.buffer(); + return std::vector(buf.data(), buf.data() + buf.bytes()); } -// Opposite corners of a 16×16 grid carry different colors (noise is not flat). -TEST_CASE("NoiseEffect produces spatial variation") { - mm::Layouts layouts; - mm::GridLayout grid; - grid.width = 16; - grid.height = 16; - grid.depth = 1; - layouts.addChild(&grid); - - mm::Layer layer; - layer.setLayouts(&layouts); - layer.setChannelsPerLight(3); - - mm::NoiseEffect noise; - layer.addChild(&noise); - - layer.applyState(); - layer.tick(); - - auto* data = layer.buffer().data(); - // Compare corners — noise should produce different values - uint8_t r0 = data[0], g0 = data[1], b0 = data[2]; - size_t lastIdx = (16 * 16 - 1) * 3; - uint8_t r1 = data[lastIdx], g1 = data[lastIdx + 1], b1 = data[lastIdx + 2]; - CHECK((r0 != r1 || g0 != g1 || b0 != b1)); +std::size_t differing(const std::vector& a, const std::vector& b) { + std::size_t n = 0; + for (std::size_t i = 0; i < a.size() && i < b.size(); i++) n += a[i] != b[i] ? 1 : 0; + return n; } -// Noise and Rainbow produce visibly different frames on the same grid (sanity check that they're distinct algorithms). -TEST_CASE("NoiseEffect produces different output than RainbowEffect") { - mm::Layouts layouts; - mm::GridLayout grid; - grid.width = 8; - grid.height = 8; - grid.depth = 1; - layouts.addChild(&grid); +} // namespace - // Render rainbow - mm::Layer layer1; - layer1.setLayouts(&layouts); - layer1.setChannelsPerLight(3); - mm::RainbowEffect rainbow; - layer1.addChild(&rainbow); - layer1.applyState(); - layer1.tick(); - - // Render noise - mm::Layer layer2; - layer2.setLayouts(&layouts); - layer2.setChannelsPerLight(3); - mm::NoiseEffect noise; - layer2.addChild(&noise); - layer2.applyState(); - layer2.tick(); - - // Compare buffers — should differ - bool differs = false; - for (size_t i = 0; i < layer1.buffer().bytes(); i++) { - if (layer1.buffer().data()[i] != layer2.buffer().data()[i]) { - differs = true; - break; - } +TEST_CASE("both motions paint a field rather than a flat wash") { + for (uint8_t motion = 0; motion < 2; motion++) { + const auto f = render(16, 16, 1, motion); + std::set values(f.begin(), f.end()); + CHECK(values.size() > 8); // a real field, not one repeated color } - CHECK(differs); } -// Z-axis variation tests: with depth > 1 each z-slice must differ from the -// next. A 2D-only effect (the previous behaviour) produced identical slices — -// these tests pin the bug fixed. - -// With depth > 1, adjacent and distant z-slices each render differently (3D noise, not a stack of identical 2D slices). -TEST_CASE("NoiseEffect produces different output per z-slice with depth > 1") { - mm::Layouts layouts; - mm::GridLayout grid; - grid.width = 8; grid.height = 8; grid.depth = 8; - layouts.addChild(&grid); - - mm::Layer layer; - layer.setLayouts(&layouts); - layer.setChannelsPerLight(3); - mm::NoiseEffect noise; - layer.addChild(&noise); - layer.applyState(); - layer.tick(); - - const size_t sliceBytes = static_cast(grid.width) * grid.height * 3; - REQUIRE(layer.buffer().bytes() == sliceBytes * grid.depth); +TEST_CASE("drift moves the field across the fixture; morph changes it in place") { + // The distinction the control exists for, and the reason the two used to be separate effects. + // Drift scrolls the sample coordinates, so a later frame is the same field shifted. Morph holds + // the coordinates and advances time, so the field changes without going anywhere. Either way + // the picture must move, which is what this checks: a still frame would mean the motion control + // does nothing at all. + for (uint8_t motion = 0; motion < 2; motion++) { + const auto early = render(24, 24, 1, motion, 10); + const auto late = render(24, 24, 1, motion, 120); + CHECK(differing(early, late) > early.size() / 4); + } +} - uint32_t h0 = hashSlice(layer.buffer().data() + 0 * sliceBytes, sliceBytes); - uint32_t h1 = hashSlice(layer.buffer().data() + 1 * sliceBytes, sliceBytes); - uint32_t h4 = hashSlice(layer.buffer().data() + 4 * sliceBytes, sliceBytes); - CHECK(h0 != h1); // adjacent slices differ - CHECK(h0 != h4); // distant slices differ +TEST_CASE("the two motions are genuinely different fields") { + const auto drift = render(24, 24, 1, 0, 60); + const auto morph = render(24, 24, 1, 1, 60); + CHECK(differing(drift, morph) > drift.size() / 2); } -// Same z-slice variation requirement holds for Plasma — each depth plane renders differently. -TEST_CASE("PlasmaEffect produces different output per z-slice with depth > 1") { - mm::Layouts layouts; - mm::GridLayout grid; - grid.width = 8; grid.height = 8; grid.depth = 8; - layouts.addChild(&grid); +TEST_CASE("on a volumetric fixture drifting slices differ from each other") { + // What a 3D fixture buys: the light's own depth is the third noise axis, so the field has real + // depth rather than one slice repeated. Morph spends that axis on time instead, so its slices + // are identical by design and only drift is checked here. + const auto f = render(8, 8, 4, 0, 40); + const std::size_t slice = 8 * 8 * 3; + REQUIRE(f.size() >= slice * 4); + std::size_t diff = 0; + for (std::size_t i = 0; i < slice; i++) diff += f[i] != f[slice * 3 + i] ? 1 : 0; + CHECK(diff > slice / 4); +} - mm::Layer layer; - layer.setLayouts(&layouts); - layer.setChannelsPerLight(3); - mm::PlasmaEffect plasma; - layer.addChild(&plasma); - layer.applyState(); - layer.tick(); +TEST_CASE("Noise renders on a strip, a panel and a cube alike") { + // Any effect on any fixture: a 1D strip is not what a field effect is designed around, but it + // must still paint something rather than failing or going dark. + for (auto dims : {std::array{64, 1, 1}, {16, 16, 1}, {8, 8, 8}}) { + const auto f = render(dims[0], dims[1], dims[2], 0); + REQUIRE(f.size() == static_cast(dims[0]) * dims[1] * dims[2] * 3); + std::size_t lit = 0; + for (uint8_t v : f) lit += v > 0 ? 1 : 0; + CHECK(lit > f.size() / 8); + } +} - const size_t sliceBytes = static_cast(grid.width) * grid.height * 3; - REQUIRE(layer.buffer().bytes() == sliceBytes * grid.depth); +TEST_CASE("Noise survives a degenerate grid rather than faulting") { + const auto f = render(0, 0, 1, 0, 3); + CHECK(f.empty()); +} - uint32_t h0 = hashSlice(layer.buffer().data() + 0 * sliceBytes, sliceBytes); - uint32_t h2 = hashSlice(layer.buffer().data() + 2 * sliceBytes, sliceBytes); - uint32_t h7 = hashSlice(layer.buffer().data() + 7 * sliceBytes, sliceBytes); - CHECK(h0 != h2); - CHECK(h0 != h7); +TEST_CASE("morph shows the same field in every slice, because time is its third axis") { + // The two motions divide the one spare axis between them: drift spends it on depth, morph on + // time. So a volumetric fixture under morph is the same field repeated, which is what the + // catalog card promises. The code briefly added a depth term here as well, which made morph a + // second drift and left the documentation wrong rather than the behavior. + const auto f = render(8, 8, 4, 1, 40); + const std::size_t slice = 8 * 8 * 3; + REQUIRE(f.size() >= slice * 4); + for (std::size_t s = 1; s < 4; s++) + for (std::size_t i = 0; i < slice; i++) + REQUIRE(f[i] == f[slice * s + i]); } diff --git a/test/unit/light/unit_Particles.cpp b/test/unit/light/unit_Particles.cpp index f4493f8f..c1beeff2 100644 --- a/test/unit/light/unit_Particles.cpp +++ b/test/unit/light/unit_Particles.cpp @@ -703,7 +703,7 @@ TEST_CASE("spreadLane gives every slot its own lane, for any count") { TEST_CASE("spreadLane interleaves rather than striding in order") { // EVERY consecutive pair must be non-adjacent, not merely one of them. The weaker form of // this test passed against a stride of `slots - 1`, which is coprime but congruent to -1, so - // consecutive slots walked DOWN neighbouring lanes and nothing was interleaved at all. + // consecutive slots walked DOWN neighboring lanes and nothing was interleaved at all. constexpr mm::lengthType kExtent = 27720; // lcm(1..12): every count below divides it for (uint16_t slots = 5; slots <= 12; slots++) { if (slots == 6) continue; // 6 has no coprime near 3 (2 and 3 both share a factor) diff --git a/test/unit/light/unit_PolarLut.cpp b/test/unit/light/unit_PolarLut.cpp new file mode 100644 index 00000000..55c3385b --- /dev/null +++ b/test/unit/light/unit_PolarLut.cpp @@ -0,0 +1,336 @@ +// @module polar +// @also math16, ScratchBuffer + +// The polar address table: the angle and radius of every pixel, computed once instead of per frame. +// These pin what a radial effect depends on when it reads the table instead of calling atan16 and +// dist16: that the address is the same one it would have computed, that the center and the edges +// land where the geometry says, that resizing the grid rebuilds it, and that a device too tight for +// the tables still renders rather than crashing. + +#include "doctest.h" +#include "core/MoonModule.h" +#include "light/effects/AuroraEffect.h" +#include "light/effects/PolarNoiseEffect.h" +#include "light/effects/SpiralEffect.h" +#include "light/effects/TunnelEffect.h" +#include "light/polar.h" +#include "platform/platform.h" + +#include + +using namespace mm; + +namespace { + +/// A bare module to own the tables, the fixture the ScratchBuffer tests use. +struct Owner : MoonModule { + PolarLut lut{*this}; +}; + +/// The signed difference between two angles, the short way around the circle. +int32_t angleDelta(angle16 a, angle16 b) { + int32_t d = static_cast(a) - static_cast(b); + if (d > 32768) d -= 65536; + if (d < -32768) d += 65536; + return d < 0 ? -d : d; +} + +} // namespace + +TEST_CASE("the table gives the same angle a radial effect would compute per pixel") { + Owner o; + REQUIRE(o.lut.prepare(32, 32, /*wide=*/true)); + const int32_t cx = 16, cy = 16; + for (uint16_t y = 0; y < 32; y++) { + for (uint16_t x = 0; x < 32; x++) { + const angle16 expected = atan16(static_cast(y) - cy, static_cast(x) - cx); + CHECK(o.lut.angleAt(x, y) == expected); + } + } +} + +TEST_CASE("the 8-bit table is the same address at 256 steps, which is what it costs half the memory for") { + Owner o; + REQUIRE(o.lut.prepare(24, 24, /*wide=*/false)); + const int32_t cx = 12, cy = 12; + int32_t worst = 0; + for (uint16_t y = 0; y < 24; y++) + for (uint16_t x = 0; x < 24; x++) { + const angle16 exact = atan16(static_cast(y) - cy, static_cast(x) - cx); + const int32_t d = angleDelta(o.lut.angleAt(x, y), exact); + worst = d > worst ? d : worst; + } + CHECK(worst < 256); // within one 8-bit step of the exact angle, never further +} + +TEST_CASE("the radius runs from nothing at the center to full scale at the furthest corner") { + Owner o; + REQUIRE(o.lut.prepare(33, 33, true)); + CHECK(o.lut.radiusAt(16, 16) == 0); // the center pixel + CHECK(o.lut.radiusAt(0, 0) == 65535); // a corner, the furthest point + CHECK(o.lut.radiusAt(32, 32) > 60000); + // Halfway out along an axis is about half the scale of the diagonal corner. + CHECK(o.lut.radiusAt(32, 16) > 40000); + CHECK(o.lut.radiusAt(32, 16) < 55000); +} + +TEST_CASE("a wide panel fills to its edges instead of banding in a circle inside it") { + // The aspect-ratio property: radii scale against the furthest corner, so a 64x16 strip reaches + // full scale at its ends rather than saturating everywhere past the short axis. + Owner o; + REQUIRE(o.lut.prepare(64, 16, true)); + CHECK(o.lut.radiusAt(0, 8) > 55000); // the far end of the long axis + CHECK(o.lut.radiusAt(32, 0) < 40000); // the edge of the short axis is nearer + CHECK(o.lut.radiusAt(32, 8) == 0); +} + +TEST_CASE("angles point the way the geometry says") { + Owner o; + REQUIRE(o.lut.prepare(65, 65, true)); + // atan16(dy, dx): zero along +x, a quarter turn per axis, going the way atan2 goes. + CHECK(angleDelta(o.lut.angleAt(64, 32), 0) < 512); + CHECK(angleDelta(o.lut.angleAt(32, 64), 16384) < 512); + CHECK(angleDelta(o.lut.angleAt(0, 32), 32768) < 512); + CHECK(angleDelta(o.lut.angleAt(32, 0), 49152) < 512); +} + +TEST_CASE("opposite sides of the center face opposite ways") { + Owner o; + REQUIRE(o.lut.prepare(41, 41, true)); + for (uint16_t k = 1; k < 20; k++) { + const angle16 a = o.lut.angleAt(static_cast(20 + k), 20); + const angle16 b = o.lut.angleAt(static_cast(20 - k), 20); + CHECK(angleDelta(a, static_cast(b + 32768)) < 512); + CHECK(o.lut.radiusAt(static_cast(20 + k), 20) == o.lut.radiusAt(static_cast(20 - k), 20)); + } +} + +TEST_CASE("resizing the grid rebuilds the table for the new geometry") { + Owner o; + REQUIRE(o.lut.prepare(16, 16, true)); + const uint16_t small = o.lut.radiusAt(0, 0); + const std::size_t smallBytes = o.lut.bytes(); + + REQUIRE(o.lut.prepare(64, 64, true)); + CHECK(o.lut.bytes() == smallBytes * 16); // 16x the pixels + CHECK(o.lut.radiusAt(0, 0) == small); // a corner is still a corner + CHECK(o.lut.radiusAt(32, 32) == 0); // but the center moved with the grid +} + +TEST_CASE("preparing the same geometry again costs nothing") { + Owner o; + REQUIRE(o.lut.prepare(32, 32, false)); + const angle16 before = o.lut.angleAt(7, 9); + const std::size_t bytes = o.lut.bytes(); + for (int i = 0; i < 100; i++) CHECK(o.lut.prepare(32, 32, false)); // as a tick() would + CHECK(o.lut.angleAt(7, 9) == before); + CHECK(o.lut.bytes() == bytes); +} + +TEST_CASE("switching precision live does not hold both tables") { + // An effect offering precision as a control switches while running; the memory must follow. + Owner o; + REQUIRE(o.lut.prepare(32, 32, false)); + const std::size_t narrow = o.lut.bytes(); + CHECK(narrow == 32 * 32 * 2); // one byte of angle and one of radius + + REQUIRE(o.lut.prepare(32, 32, true)); + CHECK(o.lut.bytes() == 32 * 32 * 4); // two bytes each, and NOT six + CHECK(o.lut.wide()); + + REQUIRE(o.lut.prepare(32, 32, false)); + CHECK(o.lut.bytes() == narrow); + CHECK_FALSE(o.lut.wide()); +} + +TEST_CASE("the tables are the module's memory, freed with it") { + Owner o; + CHECK(o.dynamicBytes() == 0); + REQUIRE(o.lut.prepare(48, 48, true)); + CHECK(o.dynamicBytes() == 48 * 48 * 4); + REQUIRE(o.lut.prepare(48, 48, false)); + CHECK(o.dynamicBytes() == 48 * 48 * 2); +} + +TEST_CASE("an empty grid reports not ready instead of building a table of nothing") { + Owner o; + CHECK_FALSE(o.lut.prepare(0, 0, false)); + CHECK_FALSE(o.lut.ready()); + CHECK(o.lut.angle(0) == 0); // and reads are safe, not a fault + CHECK(o.lut.radius(0) == 0); +} + +TEST_CASE("a single-pixel grid is its own center") { + Owner o; + REQUIRE(o.lut.prepare(1, 1, true)); + CHECK(o.lut.radiusAt(0, 0) == 0); + CHECK(o.lut.maxRadius() >= 1); // never zero: nothing divides by it +} + +TEST_CASE("the index form and the coordinate form address the same pixel") { + // A pixel loop reads by running index; both must agree or a loop silently shears the field. + Owner o; + REQUIRE(o.lut.prepare(20, 12, true)); + for (uint16_t y = 0; y < 12; y++) + for (uint16_t x = 0; x < 20; x++) { + const std::size_t i = static_cast(y) * 20 + x; + CHECK(o.lut.angle(i) == o.lut.angleAt(x, y)); + CHECK(o.lut.radius(i) == o.lut.radiusAt(x, y)); + } +} + +TEST_CASE("the table is refused rather than taking the last of a small heap") { + // On a device without PSRAM the tables are a real fraction of the heap. Asking for a wall-sized + // grid must leave the reserve that protects stacks, WiFi and HTTP intact, and the caller then + // computes the address per pixel instead. The desktop reports unlimited heap, so this pins the + // arithmetic of the gate rather than the allocation. + Owner o; + const std::size_t reserve = platform::HEAP_RESERVE; + const std::size_t free = platform::freeHeap(); + if (free == 0) { + // Desktop: unlimited, so even a wall-sized table is granted and the fallback is unused. + CHECK(o.lut.prepare(128, 128, true)); + CHECK(o.lut.bytes() == 128 * 128 * 4); + } else { + const std::size_t budget = free > reserve ? free - reserve : 0; + const std::size_t want = 128u * 128u * 4u; + CHECK(o.lut.prepare(128, 128, true) == (budget >= want)); + } +} + +TEST_CASE("a refused table leaves nothing allocated behind") { + Owner o; + REQUIRE(o.lut.prepare(32, 32, false)); + CHECK(o.dynamicBytes() > 0); + CHECK_FALSE(o.lut.prepare(0, 0, false)); // an impossible grid + CHECK(o.lut.bytes() == 0); + CHECK(o.dynamicBytes() == 0); + CHECK_FALSE(o.lut.ready()); +} + +TEST_CASE("releasing the table gives the memory back and the next prepare rebuilds it") { + Owner o; + REQUIRE(o.lut.prepare(48, 48, false)); + const std::size_t held = o.dynamicBytes(); + CHECK(held == 48 * 48 * 2); + o.lut.release(); + CHECK(o.dynamicBytes() == 0); + CHECK_FALSE(o.lut.ready()); + REQUIRE(o.lut.prepare(48, 48, false)); + CHECK(o.dynamicBytes() == held); + CHECK(o.lut.radiusAt(24, 24) == 0); // and it is the same table as before +} + +// --- volumetric fixtures ---------------------------------------------------- +// +// "Angle and radius" has no single meaning in three dimensions, so the projection is a control. +// These pin what each one promises, and that the default costs an existing 2D fixture nothing. + +TEST_CASE("at one light deep every projection is the flat address, so no panel changes") { + // The property that makes cylindrical a safe default: a panel is a depth-1 volume, and all + // three mappings have to agree there or switching one would alter a fixture that has no depth. + Owner flat, cyl, sph, rad; + REQUIRE(flat.lut.prepare(24, 24, true)); + REQUIRE(cyl.lut.prepare(24, 24, 1, true, PolarLut::Mapping::Cylindrical)); + REQUIRE(sph.lut.prepare(24, 24, 1, true, PolarLut::Mapping::Spherical)); + REQUIRE(rad.lut.prepare(24, 24, 1, true, PolarLut::Mapping::Radial)); + for (uint16_t y = 0; y < 24; y++) + for (uint16_t x = 0; x < 24; x++) { + CHECK(cyl.lut.angleAt(x, y) == flat.lut.angleAt(x, y)); + CHECK(cyl.lut.radiusAt(x, y) == flat.lut.radiusAt(x, y)); + CHECK(rad.lut.radiusAt(x, y) == flat.lut.radiusAt(x, y)); + CHECK(sph.lut.angleAt(x, y) == flat.lut.angleAt(x, y)); + } +} + +TEST_CASE("cylindrical carries depth separately, so every slice reads the same address") { + // A tube or a stack of panels: the pattern is the same at every height, which is what lets an + // effect use depth for something else entirely. + Owner o; + REQUIRE(o.lut.prepare(16, 16, 8, true, PolarLut::Mapping::Cylindrical)); + for (uint16_t z = 1; z < 8; z++) + for (uint16_t y = 0; y < 16; y += 3) + for (uint16_t x = 0; x < 16; x += 3) { + CHECK(o.lut.angleAt(x, y, z) == o.lut.angleAt(x, y, 0)); + CHECK(o.lut.radiusAt(x, y, z) == o.lut.radiusAt(x, y, 0)); + } +} + +TEST_CASE("radial measures distance from the center of the volume, so the field reads as shells") { + Owner o; + REQUIRE(o.lut.prepare(9, 9, 9, true, PolarLut::Mapping::Radial)); + CHECK(o.lut.radiusAt(4, 4, 4) == 0); // the center light + CHECK(o.lut.radiusAt(0, 0, 0) == 65535); // a corner, the furthest point + CHECK(o.lut.radiusAt(8, 8, 8) > 60000); + // Equidistant lights share a radius whichever axis they lie on: that is what makes it a shell. + CHECK(o.lut.radiusAt(0, 4, 4) == o.lut.radiusAt(4, 0, 4)); + CHECK(o.lut.radiusAt(4, 4, 0) == o.lut.radiusAt(4, 0, 4)); +} + +TEST_CASE("spherical adds an elevation, so a sphere maps evenly instead of pinching") { + Owner o; + REQUIRE(o.lut.prepare(17, 17, 17, true, PolarLut::Mapping::Spherical)); + // On the equator the elevation is zero; above and below the center it points up and down. + CHECK(o.lut.pitchAt(16, 8, 8) == 0); + const angle16 up = o.lut.pitchAt(16, 8, 16); + const angle16 down = o.lut.pitchAt(16, 8, 0); + CHECK(up > 0); + CHECK(up < 32768); // above the plane, less than a quarter turn + CHECK(down > 32768); // below it, wrapped the other way + // And the radius is the distance through the volume, not across a slice. + CHECK(o.lut.radiusAt(8, 8, 8) == 0); + CHECK(o.lut.radiusAt(8, 8, 16) > 30000); +} + +TEST_CASE("only spherical pays for the third table") { + // The second angle is what spherical needs and the others do not, so it is what spherical alone + // allocates: a fixture on cylindrical must not carry memory for a value it never reads. + Owner cyl, sph; + REQUIRE(cyl.lut.prepare(16, 16, 4, false, PolarLut::Mapping::Cylindrical)); + REQUIRE(sph.lut.prepare(16, 16, 4, false, PolarLut::Mapping::Spherical)); + CHECK(cyl.lut.bytes() == 16 * 16 * 4 * 2); // angle + radius + CHECK(sph.lut.bytes() == 16 * 16 * 4 * 3); // + elevation + CHECK(cyl.lut.pitchAt(3, 3, 2) == 0); // and reading it is safe, not a fault +} + +TEST_CASE("switching projection live rebuilds the table and frees what the new one does not need") { + Owner o; + REQUIRE(o.lut.prepare(16, 16, 4, false, PolarLut::Mapping::Spherical)); + const std::size_t withPitch = o.lut.bytes(); + REQUIRE(o.lut.prepare(16, 16, 4, false, PolarLut::Mapping::Cylindrical)); + CHECK(o.lut.bytes() < withPitch); + CHECK(o.lut.mapping() == PolarLut::Mapping::Cylindrical); +} + +TEST_CASE("the volumetric index matches the buffer's own ordering") { + // A pixel loop reads by running index; if the table ordered its lights differently the field + // would be sheared through the volume rather than merely wrong at one light. + Owner o; + REQUIRE(o.lut.prepare(5, 4, 3, true, PolarLut::Mapping::Spherical)); + std::size_t i = 0; + for (uint16_t z = 0; z < 3; z++) + for (uint16_t y = 0; y < 4; y++) + for (uint16_t x = 0; x < 5; x++, i++) { + CHECK(o.lut.index(x, y, z) == i); + CHECK(o.lut.angle(i) == o.lut.angleAt(x, y, z)); + CHECK(o.lut.radius(i) == o.lut.radiusAt(x, y, z)); + } +} + +TEST_CASE("an effect can bind the polar controls before it has a fixture") { + // defineControls() runs before an effect is attached to a layer, and again on the throwaway + // instances the /api/types probe builds, so anything it asks about the fixture dereferences a + // null layer. An earlier addControls() hid the mapping on a flat fixture and segfaulted the + // framerate sweep for exactly that reason. + AuroraEffect a; + PolarNoiseEffect p; + TunnelEffect t; + SpiralEffect s; + a.defineControls(); + p.defineControls(); + t.defineControls(); + s.defineControls(); + // And the controls are all there, on an effect that has never seen a layer. + CHECK(a.controls().count() > 3); + CHECK(s.controls().count() > 3); +} diff --git a/test/unit/light/unit_PolarLut_equivalence.cpp b/test/unit/light/unit_PolarLut_equivalence.cpp new file mode 100644 index 00000000..7a4b431f --- /dev/null +++ b/test/unit/light/unit_PolarLut_equivalence.cpp @@ -0,0 +1,121 @@ +// @module polar +// @also PolarNoiseEffect + +// The migration check for the polar table: an effect reading the table must look like the same +// effect, not merely a plausible one. PolarNoise is rendered twice on the same grid at the same +// frames, once through the table and once computing atan16/dist16 per pixel, and the frames are +// compared pixel by pixel. This is the evidence behind the re-baselined golden. + +#include "doctest.h" + +#include "golden_frame.h" // ScopedTestClock: restores the real clock on scope exit +#include "light/effects/AuroraEffect.h" +#include "light/effects/PolarNoiseEffect.h" +#include "light/effects/SpiralEffect.h" +#include "light/effects/TunnelEffect.h" +#include "light/layers/Layer.h" +#include "light/layouts/GridLayout.h" +#include "light/layouts/Layouts.h" +#include "platform/platform.h" + +#include +#include + +using namespace mm; + +namespace { + +/// Render one effect for `frames` and return the final buffer. `EffectT` is any effect carrying the +/// polar-table controls, so the same comparison covers every effect that reads the address. +template +std::vector render(lengthType w, lengthType h, bool useTable, bool wide = false, + uint16_t frames = 60, lengthType d = 1, uint8_t mapping = 0) { + // RAII, so the override is cleared even if a REQUIRE below exits early: a leaked test clock + // freezes time for every test that runs after this one in the same binary. + const mm::golden::ScopedTestClock clock(1000); + Layouts layouts; + GridLayout grid; + Layer layer; + EffectT effect; + effect.polar.use = useTable; + effect.polar.wide = wide; + effect.polar.mapping = mapping; + grid.width = w; grid.height = h; grid.depth = d; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&effect); + layer.applyState(); + for (uint16_t f = 0; f < frames; f++) { + platform::setTestNowMs(1000 + static_cast(f) * 20); + layer.tick(); + } + auto& buf = layer.buffer(); + return std::vector(buf.data(), buf.data() + buf.bytes()); +} + +/// The share of bytes that differ by more than `tol`, and the worst difference seen. +struct Diff { double share; int worst; }; +Diff compare(const std::vector& a, const std::vector& b, int tol) { + REQUIRE(a.size() == b.size()); + REQUIRE(!a.empty()); + std::size_t over = 0; + int worst = 0; + for (std::size_t i = 0; i < a.size(); i++) { + const int d = a[i] > b[i] ? a[i] - b[i] : b[i] - a[i]; + if (d > worst) worst = d; + if (d > tol) over++; + } + return {double(over) / a.size(), worst}; +} + +} // namespace + +TEST_CASE("the 16-bit table renders exactly what computing the address per pixel renders") { + // At full precision the table IS the computation, cached: same angle, same radius, same field. + // Anything less would mean the table had introduced an error of its own. Every effect that + // reads the address is checked, because each scales and truncates it differently. + SUBCASE("PolarNoise") { + const Diff d = compare(render(64, 64, true, true), render(64, 64, false), 0); + CHECK(d.worst == 0); + } + SUBCASE("Tunnel") { + const Diff d = compare(render(64, 64, true, true), render(64, 64, false), 0); + CHECK(d.worst == 0); + } + SUBCASE("Spiral") { + const Diff d = compare(render(64, 64, true, true), render(64, 64, false), 0); + CHECK(d.worst == 0); + } +} + +TEST_CASE("the 8-bit table costs a quantized angle, and nothing else") { + // The default trades 2 bytes per pixel for 256 angle steps. That shows up where the field is + // steepest and nowhere else, so most of the picture is untouched and no pixel is wildly wrong. + const auto tabled = render(64, 64, true); + const auto exact = render(64, 64, false); + const Diff d = compare(tabled, exact, 8); + CHECK(d.share < 0.15); // a minority of channels differ at all + const Diff gross = compare(tabled, exact, 64); + CHECK(gross.share < 0.02); // and almost none differ grossly +} + +TEST_CASE("an effect still renders when the polar table cannot be built") { + // Degrade visibly, never crash: with the table off the effect computes the address per pixel. + const auto exact = render(32, 32, false); + std::size_t lit = 0; + for (uint8_t v : exact) lit += v > 0 ? 1 : 0; + CHECK(lit > exact.size() / 4); +} + +TEST_CASE("the computed fallback keeps the mapping the table would have used") { + // A device too tight for the table must show the SAME composition, not a different one. The + // fallback computed cylindrical unconditionally at first, so a fixture set to spherical or + // radial silently reverted the moment memory ran short: still rendering, quietly wrong. + for (uint8_t mapping = 0; mapping < 3; mapping++) { + const auto tabled = render(8, 8, true, /*wide=*/true, 40, 5, mapping); + const auto exact = render(8, 8, false, /*wide=*/false, 40, 5, mapping); + const Diff d = compare(tabled, exact, 0); + CHECK(d.worst == 0); + } +} diff --git a/test/unit/light/unit_Rings241Layout.cpp b/test/unit/light/unit_Rings241Layout.cpp new file mode 100644 index 00000000..09d40c91 --- /dev/null +++ b/test/unit/light/unit_Rings241Layout.cpp @@ -0,0 +1,99 @@ +// @module Rings241Layout +// @also SingleColumnLayout + +// Pins the 241-LED disc's wiring order. The disc is soldered either from the center LED outward +// (MoonLight's order, the default) or from the outer 60-LED ring inward, and a layout that only +// knew one would light the wrong ring for the other. The coordinates are the same set either way; +// what `outside in` changes is which index lands on which ring. + +#include "doctest.h" +#include "light/layouts/Rings241Layout.h" + +#include +#include + +namespace { + +struct CoordEntry { + mm::nrOfLightsType idx; + mm::lengthType x, y, z; + bool operator==(const CoordEntry& o) const { return x == o.x && y == o.y && z == o.z; } +}; + +void collectCoord(void* ctx, mm::nrOfLightsType idx, mm::lengthType x, mm::lengthType y, mm::lengthType z) { + static_cast*>(ctx)->push_back({idx, x, y, z}); +} + +std::vector coordsOf(const mm::LayoutBase& layout) { + std::vector out; + layout.placeLights(mm::CoordSink{collectCoord, nullptr, &out}); + return out; +} + +constexpr mm::nrOfLightsType kTotal = 241; +constexpr mm::nrOfLightsType kOuter = 60; // the largest ring, wired first when outside in + +} // namespace + +// Indices are contiguous 0..240 whatever the wiring order: a gap or a repeat is a light the driver +// never writes, so the disc would carry a permanently dark pixel. +TEST_CASE("Rings241 emits 241 consecutively indexed lights in either wiring order") { + for (bool outsideIn : {false, true}) { + mm::Rings241Layout disc; + disc.outside_in = outsideIn; + CHECK(disc.lightCount() == kTotal); + const auto coords = coordsOf(disc); + REQUIRE(coords.size() == kTotal); + for (mm::nrOfLightsType i = 0; i < kTotal; i++) CHECK(coords[i].idx == i); + } +} + +// The default wires from the center out: light 0 IS the center LED (the 1-LED ring), and the last +// sixty lights are the outer ring. This is MoonLight's order, so an existing disc keeps its picture. +TEST_CASE("Rings241 wires from the center outward by default") { + mm::Rings241Layout disc; + const auto coords = coordsOf(disc); + REQUIRE(coords.size() == kTotal); + // The center is the shared origin every ring is drawn around; the 1-LED ring sits exactly on it. + const auto& center = coords[0]; + // Every outer-ring light is farther from the center than every inner light: the outer sixty + // must be the last sixty, or the wiring order is not center-out. + auto dist2 = [&](const CoordEntry& c) { + const int dx = c.x - center.x, dy = c.y - center.y; + return dx * dx + dy * dy; + }; + int innerMax = 0; + for (mm::nrOfLightsType i = 1; i < kTotal - kOuter; i++) innerMax = std::max(innerMax, dist2(coords[i])); + for (mm::nrOfLightsType i = kTotal - kOuter; i < kTotal; i++) CHECK(dist2(coords[i]) > innerMax); +} + +// Outside in flips the ring SEQUENCE and nothing else: the first sixty lights are the outer ring in +// the same direction they were wired center-out, and the center LED is last. Asserted against the +// default's output ring by ring, so a reversal that also flipped the direction around a ring, or +// that moved a coordinate, is caught rather than passing on a symmetric disc. +TEST_CASE("outside in puts light 0 on the outer ring and the center LED last, rings unchanged inside") { + mm::Rings241Layout in, out; + out.outside_in = true; + const auto a = coordsOf(in); + const auto b = coordsOf(out); + REQUIRE(a.size() == kTotal); + REQUIRE(b.size() == kTotal); + + // Walk the default's rings from the last (outer) to the first (center) and expect them to appear + // in `out` in that order, each ring's own LED order intact. + constexpr mm::nrOfLightsType sizes[9] = {1, 8, 12, 16, 24, 32, 40, 48, 60}; + mm::nrOfLightsType starts[9]; + mm::nrOfLightsType acc = 0; + for (int r = 0; r < 9; r++) { starts[r] = acc; acc += sizes[r]; } + + mm::nrOfLightsType o = 0; + for (int r = 8; r >= 0; r--) { + for (mm::nrOfLightsType i = 0; i < sizes[r]; i++) { + CHECK(b[o] == a[starts[r] + i]); // same coordinate, same position within the ring + CHECK(b[o].idx == o); + o++; + } + } + CHECK(o == kTotal); + CHECK(b[kTotal - 1] == a[0]); // the center LED is the last light when wired outside in +} diff --git a/test/unit/light/unit_RmtLedDriver_pins.cpp b/test/unit/light/unit_RmtLedDriver_pins.cpp index 4a4ef14e..1b9dab49 100644 --- a/test/unit/light/unit_RmtLedDriver_pins.cpp +++ b/test/unit/light/unit_RmtLedDriver_pins.cpp @@ -479,3 +479,90 @@ TEST_CASE("RmtLedDriver tick is crash-safe for every pin configuration") { } CHECK(true); // reached here ⇒ no crash in any subcase } + +// --- wire timing ------------------------------------------------------------ +// +// A strip's bit rate is not universal: the shipped default satisfies WS2812, WS2812B and SK6812 +// together, but a 12V WS2811 in its low-speed mode wants twice the bit cell and reads the default +// as noise past the first few lights (issue #94). The `timing` control is what lets a user say so. + +TEST_CASE("the default timing is the one that drives WS2812B and SK6812 alike") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + wire(d, src, corr, 8); + const auto& c = d.wireTimingForTest(); + CHECK(c.t0h_ns == 350); + CHECK(c.t1h_ns == 700); + CHECK(c.period_ns == 1250); +} + +TEST_CASE("selecting 400 kHz doubles the bit cell, which is what a 12V WS2811 strip decodes") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + d.timing = 1; + wire(d, src, corr, 8); + const auto& c = d.wireTimingForTest(); + CHECK(c.t0h_ns == 500); + CHECK(c.t1h_ns == 1200); + CHECK(c.period_ns == 2500); +} + +TEST_CASE("the WS2811 fast mode keeps the 1.25 us cell with narrower pulses") { + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + d.timing = 2; + wire(d, src, corr, 8); + const auto& c = d.wireTimingForTest(); + CHECK(c.t0h_ns == 250); + CHECK(c.t1h_ns == 600); + CHECK(c.period_ns == 1250); +} + +TEST_CASE("custom timing is taken as the user typed it") { + // The escape hatch: a strip whose datasheet matches no preset is a control change rather than a + // firmware release, and the numbers a user finds by trying are ones they can report back. + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + d.timing = 3; + d.t0hNs = 400; d.t1hNs = 850; d.periodNs = 1400; + wire(d, src, corr, 8); + const auto& c = d.wireTimingForTest(); + CHECK(c.t0h_ns == 400); + CHECK(c.t1h_ns == 850); + CHECK(c.period_ns == 1400); +} + +TEST_CASE("custom timing that no chip could decode is ordered rather than emitted") { + // A "1" pulse shorter than a "0" pulse, or a cell too short to contain the pulse, is not a + // slow strip: it is a typo. Emitting it would drive the line with something undecodable, so the + // values are ordered into a shape a receiver can at least read. + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + d.timing = 3; + d.t0hNs = 900; d.t1hNs = 300; d.periodNs = 400; + wire(d, src, corr, 8); + const auto& c = d.wireTimingForTest(); + CHECK(c.t1h_ns > c.t0h_ns); + CHECK(c.period_ns > c.t1h_ns); +} + +TEST_CASE("switching timing takes effect without reconfiguring the pins") { + // Live reconfiguration: a user trying presets against a strip must not have to re-enter their + // wiring between attempts, and the frame after the change carries the new timing. + mm::RmtLedDriver d; + mm::Buffer src; + mm::Correction corr; + std::strncpy(d.pins, "2,4", sizeof(d.pins)); + wire(d, src, corr, 18); + CHECK(d.wireTimingForTest().period_ns == 1250); + + d.timing = 1; + d.applyState(); // what a control change triggers + CHECK(d.wireTimingForTest().period_ns == 2500); + CHECK(std::strcmp(d.pins, "2,4") == 0); // and the wiring is untouched +} diff --git a/test/unit/light/unit_Splat.cpp b/test/unit/light/unit_Splat.cpp index 190674a8..eecba50e 100644 --- a/test/unit/light/unit_Splat.cpp +++ b/test/unit/light/unit_Splat.cpp @@ -1,7 +1,7 @@ // @module draw // @also Canvas -// draw::splat draws a point at a fractional position by spreading its light over the neighbouring +// draw::splat draws a point at a fractional position by spreading its light over the neighboring // pixels. What makes it correct rather than merely soft is conservation: a point contributes exactly // its own brightness, wherever it lands. If the weights summed to more than one pixel's worth, a // moving point would pulse brighter as it crossed cell boundaries; if less, it would dim. diff --git a/test/unit/light/unit_draw.cpp b/test/unit/light/unit_draw.cpp index bbe0c85d..f107c273 100644 --- a/test/unit/light/unit_draw.cpp +++ b/test/unit/light/unit_draw.cpp @@ -3,6 +3,9 @@ #include "doctest.h" #include "light/draw.h" +#include +#include // the upscale tests' planes: GCC needs it named + using namespace mm; namespace { @@ -108,7 +111,7 @@ TEST_CASE("draw: line shorten pulls the far endpoint back toward the start") { namespace { // Reference blur (the FastLED blur1d carryover-seep, written the slow-but-obvious way) along x for -// one row, used to pin draw::blur's fast byte-level pass to the canonical behaviour. Mirrors +// one row, used to pin draw::blur's fast byte-level pass to the canonical behavior. Mirrors // MoonLight's blurRows for a single row. void refBlurRowX(Buffer& b, Coord3D dims, lengthType y, lengthType z, uint8_t amt) { const uint8_t keep = static_cast(255 - amt), seep = static_cast(amt >> 1); @@ -127,13 +130,13 @@ void refBlurRowX(Buffer& b, Coord3D dims, lengthType y, lengthType z, uint8_t am } // namespace // draw::blur on a 1D row matches the canonical carryover-seep reference byte-for-byte (same -// behaviour as FastLED blur1d / MoonLight blurRows), and is symmetric around a centred bright pixel. +// behavior as FastLED blur1d / MoonLight blurRows), and is symmetric around a centerd bright pixel. TEST_CASE("draw: blur matches the reference carryover-seep on a 1D row") { Buffer got, ref; Coord3D dims{5, 1, 1}; REQUIRE(got.allocate(5, 3)); REQUIRE(ref.allocate(5, 3)); - // A single white pixel in the centre of both buffers. + // A single white pixel in the center of both buffers. draw::pixel(got, dims, {2, 0, 0}, {255, 255, 255}); draw::pixel(ref, dims, {2, 0, 0}, {255, 255, 255}); @@ -144,18 +147,18 @@ TEST_CASE("draw: blur matches the reference carryover-seep on a 1D row") { const RGB g = at(got, dims, x, 0, 0), r = at(ref, dims, x, 0, 0); CHECK(g.r == r.r); CHECK(g.g == r.g); CHECK(g.b == r.b); } - // Centre stays brightest, the two immediate neighbours are equally lit (symmetry), the centre - // still has the most energy, and it spread outward (neighbours non-black). + // Center stays brightest, the two immediate neighbors are equally lit (symmetry), the center + // still has the most energy, and it spread outward (neighbors non-black). CHECK(at(got, dims, 1, 0, 0).r == at(got, dims, 3, 0, 0).r); CHECK(at(got, dims, 2, 0, 0).r > at(got, dims, 1, 0, 0).r); CHECK_FALSE(isBlack(at(got, dims, 1, 0, 0))); CHECK_FALSE(isBlack(at(got, dims, 3, 0, 0))); } -// blur runs separably on every axis with extent>1: a 2D blur spreads a centre pixel to all four -// orthogonal neighbours; a 3D blur reaches the z neighbours too. And it never writes out of bounds. +// blur runs separably on every axis with extent>1: a 2D blur spreads a center pixel to all four +// orthogonal neighbors; a 3D blur reaches the z neighbors too. And it never writes out of bounds. TEST_CASE("draw: blur spreads in 2D and 3D and is safe at degenerate sizes") { - { // 2D: centre pixel of a 5×5 reaches its 4 orthogonal neighbours. + { // 2D: center pixel of a 5×5 reaches its 4 orthogonal neighbors. Buffer buf; Coord3D dims{5, 5, 1}; REQUIRE(buf.allocate(25, 3)); draw::pixel(buf, dims, {2, 2, 0}, {255, 255, 255}); @@ -164,10 +167,10 @@ TEST_CASE("draw: blur spreads in 2D and 3D and is safe at degenerate sizes") { CHECK_FALSE(isBlack(at(buf, dims, 3, 2, 0))); // +x CHECK_FALSE(isBlack(at(buf, dims, 2, 1, 0))); // -y CHECK_FALSE(isBlack(at(buf, dims, 2, 3, 0))); // +y - // x/y symmetry: the four orthogonal neighbours carry equal energy. + // x/y symmetry: the four orthogonal neighbors carry equal energy. CHECK(at(buf, dims, 1, 2, 0).r == at(buf, dims, 2, 1, 0).r); } - { // 3D: the z neighbours light up too. + { // 3D: the z neighbors light up too. Buffer buf; Coord3D dims{3, 3, 3}; REQUIRE(buf.allocate(27, 3)); draw::pixel(buf, dims, {1, 1, 1}, {255, 255, 255}); @@ -179,14 +182,14 @@ TEST_CASE("draw: blur spreads in 2D and 3D and is safe at degenerate sizes") { Buffer buf; Coord3D dims{1, 1, 1}; REQUIRE(buf.allocate(1, 3)); draw::pixel(buf, dims, {0, 0, 0}, {200, 100, 50}); - draw::blur(buf, dims, 255); // nothing to seep — must be a safe no-op + draw::blur(buf, dims, 255); // nothing to seep: must be a safe no-op CHECK(at(buf, dims, 0, 0, 0).r == 200); draw::blur(buf, dims, 0); // amt 0 returns immediately CHECK(at(buf, dims, 0, 0, 0).r == 200); } } -// A glyph blits in the correct orientation — neither X-mirrored (a 'b' as a 'd') nor Y-flipped. +// A glyph blits in the correct orientation: neither X-mirrored (a 'b' as a 'd') nor Y-flipped. // 'L' is the ideal probe: its vertical bar must be on the LEFT and its foot on the BOTTOM row. This // guards the column-bit and row-direction reads, so the DemoReel name overlay renders each letter // upright and un-mirrored. @@ -211,7 +214,7 @@ TEST_CASE("draw: glyph renders upright and un-mirrored (the 'L' probe)") { for (lengthType x = 0; x < f.width; x++) if (!isBlack(at(buf, dims, x, static_cast(f.height - 2), 0))) bottomLit++; // row 6 (row 7 is blank) CHECK(bottomLit >= 4); // the foot spans several columns near the bottom - // The top row has only the single bar pixel, not the foot — so top != bottom (Y not flipped). + // The top row has only the single bar pixel, not the foot: so top != bottom (Y not flipped). int topLit = 0; for (lengthType x = 0; x < f.width; x++) if (!isBlack(at(buf, dims, x, 0, 0))) topLit++; @@ -292,3 +295,469 @@ TEST_CASE("draw::sprite mirrors horizontally without moving the sprite") { CHECK(isBlack(at(buf, dims, 0, 0, 0))); // flipped: the same footprint, mirrored CHECK(at(buf, dims, 3, 0, 0).r == 10); } + +// draw::decay is the framerate-independent trail fade: a duration, not a per-frame amount. These +// pin the property a user actually sees, which is that the same effect looks the same on a slow +// device and a fast one. + +TEST_CASE("decay dims a plane by half over one half-life at a realistic frame time") { + Buffer buf; + Coord3D dims{4, 4, 1}; + REQUIRE(buf.allocate(16, 3)); + const draw::Canvas cv = draw::Canvas::of(buf, 4, 4, 1); + draw::fill(cv, RGB{200, 200, 200}); + for (int i = 0; i < 10; i++) draw::decay(cv, 500, 50); // 500 ms at a 20 fps cadence + const RGB c = at(buf, dims, 1, 1, 0); + CHECK(c.r > 90); // half of 200, allowing for integer rounding + CHECK(c.r < 105); +} + +TEST_CASE("a 16-bit trail plane decays at the same rate whatever the framerate") { + // The property a byte plane CANNOT hold: re-rounding a byte hundreds of times a second either + // erases the trail (truncating) or freezes it solid (rounding). Measured, decaying 200 over a + // 500 ms half-life in 500 ms of frames, where the exact answer is 100: a byte plane gives 96 at + // 50 ms frames, 73 at 5 ms and 0 at 1 ms. The wide plane below holds 100/101/102. + auto runWide = [](int frames, uint32_t dt) { + uint16_t plane[16]; + for (uint16_t& v : plane) v = 200 * 257; // 200 widened to 16 bits + for (int i = 0; i < frames; i++) draw::decay16(plane, 16, 500, dt); + return static_cast(plane[5] / 257); // narrowed back to a byte + }; + const int slow = runWide(10, 50); // 20 fps + const int mid = runWide(100, 5); // 200 fps + const int fast = runWide(500, 1); // 1000 fps + CHECK(slow > 95); + CHECK(slow < 105); + CHECK(std::abs(slow - mid) <= 3); + CHECK(std::abs(slow - fast) <= 3); // the framerate independence the half-life form is for +} + +TEST_CASE("decay leaves a plane alone when no time has passed, and clears it after a long stall") { + Buffer buf; + Coord3D dims{4, 4, 1}; + REQUIRE(buf.allocate(16, 3)); + const draw::Canvas cv = draw::Canvas::of(buf, 4, 4, 1); + draw::fill(cv, RGB{123, 45, 67}); + + draw::decay(cv, 500, 0); // a frame that took no time changes nothing + CHECK(at(buf, dims, 0, 0, 0).r == 123); + draw::decay(cv, 0, 100); // no half-life asked for: also nothing + CHECK(at(buf, dims, 0, 0, 0).g == 45); + + draw::decay(cv, 10, 100000); // a long stall goes black rather than wrapping bright + CHECK(isBlack(at(buf, dims, 0, 0, 0))); +} + +// draw::advect moves a plane along a velocity field: the transport half of a flow, and what a +// trail is made of. It samples BACKWARD, so every destination pixel is written exactly once. + +TEST_CASE("advect carries the picture along the flow, one whole pixel at a time") { + Buffer src, dst; + Coord3D dims{8, 8, 1}; + REQUIRE(src.allocate(64, 3)); + REQUIRE(dst.allocate(64, 3)); + const draw::Canvas s = draw::Canvas::of(src, 8, 8, 1); + const draw::Canvas d = draw::Canvas::of(dst, 8, 8, 1); + draw::pixel(s, {2, 3, 0}, RGB{200, 100, 50}); + + // One pixel to the right per frame, so what was at x=2 must be found at x=3. + draw::advect(d, s, [](lengthType, lengthType, lengthType, draw::pos_t& vx, draw::pos_t& vy) { + vx = draw::kSubOne; vy = 0; + }); + CHECK(at(dst, dims, 3, 3, 0).r == 200); + CHECK(isBlack(at(dst, dims, 2, 3, 0))); +} + +TEST_CASE("a uniform field survives being advected, so a flow does not dim what it carries") { + // The property that separates transport from blur: moving a region of equal values must not + // change them, whatever the sub-pixel offset. A half-pixel step is the worst case, since it + // blends two neighbors at full weight. + Buffer src, dst; + Coord3D dims{8, 8, 1}; + REQUIRE(src.allocate(64, 3)); + REQUIRE(dst.allocate(64, 3)); + const draw::Canvas s = draw::Canvas::of(src, 8, 8, 1); + const draw::Canvas d = draw::Canvas::of(dst, 8, 8, 1); + draw::fill(s, RGB{180, 180, 180}); + draw::advect(d, s, [](lengthType, lengthType, lengthType, draw::pos_t& vx, draw::pos_t& vy) { + vx = draw::kSubOne / 2; vy = draw::kSubOne / 2; + }); + CHECK(at(dst, dims, 4, 4, 0).r == 180); +} + +TEST_CASE("the edge rule decides whether a flow loops the grid or leaves it") { + Buffer src, dst; + Coord3D dims{4, 4, 1}; + REQUIRE(src.allocate(16, 3)); + REQUIRE(dst.allocate(16, 3)); + const draw::Canvas s = draw::Canvas::of(src, 4, 4, 1); + const draw::Canvas d = draw::Canvas::of(dst, 4, 4, 1); + draw::pixel(s, {3, 1, 0}, RGB{255, 0, 0}); // lit at the right edge + + auto right = [](lengthType, lengthType, lengthType, draw::pos_t& vx, draw::pos_t& vy) { + vx = draw::kSubOne; vy = 0; + }; + // Wrapping: what leaves the right edge arrives at the left. + draw::advect(d, s, right, draw::Edge::Wrap); + CHECK(at(dst, dims, 0, 1, 0).r == 255); + // Clamping: it does not come back, and the edge column holds its own value instead. + draw::fill(d, RGB{0, 0, 0}); + draw::advect(d, s, right, draw::Edge::Clamp); + CHECK(isBlack(at(dst, dims, 0, 1, 0))); +} + +TEST_CASE("advect moves every slice of a volume, so a cube flows like a panel") { + // 3D is the default shape for this phase: the bench fixture is a 20-cube. A D2 rule leaves z + // alone, and each slice must still be carried. + Buffer src, dst; + Coord3D dims{4, 4, 3}; + REQUIRE(src.allocate(48, 3)); + REQUIRE(dst.allocate(48, 3)); + const draw::Canvas s = draw::Canvas::of(src, 4, 4, 3); + const draw::Canvas d = draw::Canvas::of(dst, 4, 4, 3); + draw::pixel(s, {1, 1, 0}, RGB{90, 0, 0}); + draw::pixel(s, {1, 1, 2}, RGB{200, 0, 0}); // a different value in the far slice + + draw::advect(d, s, [](lengthType, lengthType, lengthType, draw::pos_t& vx, draw::pos_t& vy) { + vx = draw::kSubOne; vy = 0; + }); + CHECK(at(dst, dims, 2, 1, 0).r == 90); // each slice carried its own content + CHECK(at(dst, dims, 2, 1, 2).r == 200); +} + +// disc and sphere: the SDF-shaded fills, where a sub-pixel center means a small shape can move +// between cells instead of jumping one at a time. + +TEST_CASE("a disc lights its interior and softens its edge") { + Buffer buf; + Coord3D dims{9, 9, 1}; + REQUIRE(buf.allocate(81, 3)); + const draw::Canvas cv = draw::Canvas::of(buf, 9, 9, 1); + draw::disc(cv, draw::toSub(4), draw::toSub(4), draw::toSub(3), RGB{255, 255, 255}); + + CHECK(at(buf, dims, 4, 4, 0).r == 255); // the middle is solid + CHECK(isBlack(at(buf, dims, 0, 0, 0))); // a far corner is untouched + // The rim is partial: neither full nor black, which is the anti-aliasing. + const uint8_t rim = at(buf, dims, 4, 1, 0).r; + CHECK(rim > 0); + CHECK(rim < 255); +} + +TEST_CASE("two overlapping discs brighten where they meet, because light adds") { + Buffer buf; + Coord3D dims{8, 8, 1}; + REQUIRE(buf.allocate(64, 3)); + const draw::Canvas cv = draw::Canvas::of(buf, 8, 8, 1); + draw::disc(cv, draw::toSub(3), draw::toSub(4), draw::toSub(2), RGB{100, 0, 0}); + const uint8_t single = at(buf, dims, 4, 4, 0).r; + draw::disc(cv, draw::toSub(5), draw::toSub(4), draw::toSub(2), RGB{100, 0, 0}); + CHECK(at(buf, dims, 4, 4, 0).r > single); // the overlap is brighter than one alone +} + +TEST_CASE("a sphere fills a volume, so a cube gets a ball rather than a stack of discs") { + Buffer buf; + Coord3D dims{7, 7, 7}; + REQUIRE(buf.allocate(343, 3)); + const draw::Canvas cv = draw::Canvas::of(buf, 7, 7, 7); + draw::sphere(cv, draw::toSub(3), draw::toSub(3), draw::toSub(3), draw::toSub(2), + RGB{255, 255, 255}); + CHECK(at(buf, dims, 3, 3, 3).r == 255); // the center of the volume + CHECK(at(buf, dims, 3, 3, 1).r > 0); // and it reaches along z + CHECK(isBlack(at(buf, dims, 0, 0, 0))); // but not into the corners +} + +// The velocity rules: plain functions, so an effect can drive them from anything. + +TEST_CASE("the wind blows every point the same way") { + draw::pos_t vx = 0, vy = 0; + draw::flowWind(0, 256, vx, vy); // angle 0 is +x + CHECK(vx > 200); + CHECK(vy > -20); + CHECK(vy < 20); + draw::flowWind(16384, 256, vx, vy); // a quarter turn is +y + CHECK(vy > 200); +} + +TEST_CASE("a radial flow points away from its center, and inward when reversed") { + draw::pos_t vx = 0, vy = 0; + draw::flowRadial(6, 3, 3, 3, 256, vx, vy); // to the right of center: pushed further right + CHECK(vx > 100); + draw::flowRadial(6, 3, 3, 3, -256, vx, vy); // negative speed draws it back in + CHECK(vx < -100); + // The center itself has no direction to move, and must not divide by zero reaching for one. + draw::flowRadial(3, 3, 3, 3, 256, vx, vy); + CHECK(vx == 0); + CHECK(vy == 0); +} + +TEST_CASE("a spiral is a radial flow with a turn added, so it both circles and escapes") { + draw::pos_t rx = 0, ry = 0, sx = 0, sy = 0; + draw::flowRadial(6, 3, 3, 3, 256, rx, ry); + draw::flowSpiral(6, 3, 3, 3, 256, 256, sx, sy); + CHECK(sx == rx); // the outward part is the same + CHECK(sy != ry); // and the angular part is what it adds +} + +// draw::quantize is the 16-to-8 boundary every wide pipeline ends at. What the modes buy is not +// more levels, it is where the discarded half of the value goes. + +TEST_CASE("truncating a slow gradient bands it, and dithering restores its true mean") { + // A dark, slow ramp is the worst case: 64 distinct 16-bit values that truncation flattens to a + // handful of steps. Neither mode can add levels to a single frame; what ordered dithering fixes + // is the MEAN, so the band sits where the real value is rather than below it. + double trueSum = 0, truncSum = 0, ditherSum = 0; + uint8_t carry = 0; + for (int i = 0; i < 64; i++) { + const uint16_t v = static_cast(65535 * 0.10 + (65535 * 0.02) * i / 64); + trueSum += v / 257.0; + truncSum += draw::quantize(v, draw::Dither::None, carry); + ditherSum += draw::quantize(v, draw::Dither::Ordered, carry, i % 8, i / 8); + } + const double trueMean = trueSum / 64, truncMean = truncSum / 64, ditherMean = ditherSum / 64; + CHECK(std::abs(ditherMean - trueMean) < std::abs(truncMean - trueMean)); // closer to the truth + CHECK(std::abs(ditherMean - trueMean) < 0.5); +} + +TEST_CASE("temporal dithering resolves neighbors that a single frame cannot") { + // The property that makes a 16-bit pipeline worth having on 8-bit LEDs: eight values a fraction + // of a byte apart all truncate to the same one or two levels, but averaged over frames they + // separate, and the eye does that averaging. This is what turns a stepped fade into a smooth + // one. + constexpr int kLights = 8, kFrames = 32; + uint16_t v[kLights]; + uint8_t carry[kLights] = {}; + long sum[kLights] = {}; + for (int i = 0; i < kLights; i++) v[i] = static_cast(65535 * 0.10 + 40 * i); + + for (int f = 0; f < kFrames; f++) + for (int i = 0; i < kLights; i++) sum[i] += draw::quantize(v[i], draw::Dither::Temporal, carry[i]); + + // Every neighbor's average is distinct and ordered, which truncation cannot manage. + for (int i = 1; i < kLights; i++) { + const double lo = double(sum[i - 1]) / kFrames, hi = double(sum[i]) / kFrames; + CHECK(hi > lo); + } + // And each tracks its own true value, not merely its neighbor's order. + for (int i = 0; i < kLights; i++) + CHECK(std::abs(double(sum[i]) / kFrames - v[i] / 257.0) < 0.6); +} + +TEST_CASE("a dithered value never wraps past full, so a bright light cannot flash black") { + uint8_t carry = 0; + for (int f = 0; f < 100; f++) { + CHECK(draw::quantize(65535, draw::Dither::Temporal, carry) == 255); + CHECK(draw::quantize(65535, draw::Dither::Ordered, carry, f % 4, f % 4) == 255); + } + // Black stays black under every mode: a carry must not light an unlit pixel. + uint8_t c2 = 0; + for (int f = 0; f < 100; f++) CHECK(draw::quantize(0, draw::Dither::Temporal, c2) == 0); +} + +TEST_CASE("the ordered pattern differs per z, so a volume does not repeat one texture") { + // On a cube every slice would otherwise share a threshold, and the dither would read as a + // pattern stamped through the volume rather than as noise. + uint8_t carry = 0; + const uint16_t v = 0x2080; // a value squarely between two byte levels + bool differs = false; + for (lengthType z = 1; z < 4; z++) + for (lengthType y = 0; y < 4 && !differs; y++) + for (lengthType x = 0; x < 4 && !differs; x++) + if (draw::quantize(v, draw::Dither::Ordered, carry, x, y, z) + != draw::quantize(v, draw::Dither::Ordered, carry, x, y, 0)) differs = true; + CHECK(differs); +} + +// draw::upscale16 is the fieldScale lever: compute a smooth field at a fraction of the fixture's +// resolution and interpolate the rest, which is nearly free visually because a field is smooth. + +TEST_CASE("an upscaled plane keeps a uniform value, so a flat field does not gain texture") { + std::vector src(4 * 4 * 3, 30000), dst(16 * 16 * 3, 0); + std::vector taps(16); + draw::upscale16(dst.data(), 16, 16, 1, src.data(), 4, 4, 1, taps.data(), taps.size()); + for (uint16_t v : dst) CHECK(v == 30000); +} + +TEST_CASE("an upscaled ramp stays monotonic, so a gradient does not gain steps or reversals") { + // 8 wide, ramping left to right; stretched to 32. Every step must be non-decreasing, which is + // what a bilinear stretch guarantees and a nearest-neighbor one would not. + std::vector src(8 * 3), dst(32 * 3, 0); + for (int x = 0; x < 8; x++) + for (int c = 0; c < 3; c++) src[x * 3 + c] = static_cast(x * 8000); + std::vector taps(32); + draw::upscale16(dst.data(), 32, 1, 1, src.data(), 8, 1, 1, taps.data(), taps.size()); + for (int x = 1; x < 32; x++) CHECK(dst[x * 3] >= dst[(x - 1) * 3]); + CHECK(dst[0] == 0); // the ends reach the source's ends + CHECK(dst[31 * 3] == 7 * 8000); +} + +TEST_CASE("a flat field stretches across a volume's depth without z work") { + // A 2D field on a 3D fixture: every slice gets the same picture, which is what a depth-1 source + // means, and it costs no interpolation along z. + std::vector src(4 * 4 * 3), dst(8 * 8 * 4 * 3, 0); + for (size_t i = 0; i < src.size(); i++) src[i] = static_cast(i * 700); + std::vector taps(8); + draw::upscale16(dst.data(), 8, 8, 4, src.data(), 4, 4, 1, taps.data(), taps.size()); + const size_t slice = 8 * 8 * 3; + for (size_t z = 1; z < 4; z++) + for (size_t i = 0; i < slice; i++) REQUIRE(dst[i] == dst[z * slice + i]); +} + +TEST_CASE("a volumetric field interpolates along z as well, so a coarse cube fills a fine one") { + // Two source slices, black and white. The destination's middle slices must land between them + // rather than snapping to one. + std::vector src(2 * 2 * 2 * 3), dst(4 * 4 * 8 * 3, 0); + for (int z = 0; z < 2; z++) + for (int i = 0; i < 2 * 2 * 3; i++) src[z * 12 + i] = z ? 60000 : 0; + std::vector taps(4); + draw::upscale16(dst.data(), 4, 4, 8, src.data(), 2, 2, 2, taps.data(), taps.size()); + const size_t slice = 4 * 4 * 3; + CHECK(dst[0] == 0); // the near face is still black + CHECK(dst[7 * slice] == 60000); // the far face still white + CHECK(dst[3 * slice] > 5000); // and the middle is genuinely between + CHECK(dst[3 * slice] < 55000); +} + +TEST_CASE("an upscaled saddle stays inside the values it was given, so a field that dips does not light up") { + // The fixtures above are uniform or monotone, so every interpolation ascends. A noise field is a + // saddle almost everywhere: along one diagonal it rises, along the other it falls. A descending + // pair is where an unsigned difference wraps, and blending a wrapped row against an unwrapped one + // lands far outside the input range (this fixture produced 98354 for inputs of 0 and 100). + std::vector src(2 * 2 * 3, 0), dst(8 * 8 * 3, 0); + for (int c = 0; c < 3; c++) { + src[(0 * 2 + 0) * 3 + c] = 100; // high, low + src[(1 * 2 + 1) * 3 + c] = 100; // low, high + } + std::vector taps(8); + draw::upscale16(dst.data(), 8, 8, 1, src.data(), 2, 2, 1, taps.data(), taps.size()); + for (uint16_t v : dst) CHECK(v <= 100); +} + +TEST_CASE("upscale16 declines rather than writing when the caller's tap table is too small") { + // The table is the caller's because tick() can neither allocate nor spare a large frame. A short + // one is a programming error, and the safe answer is to do nothing rather than run off its end. + std::vector src(4 * 4 * 3, 30000), dst(16 * 16 * 3, 7); + std::vector taps(4); // 16 columns need 16 + draw::upscale16(dst.data(), 16, 16, 1, src.data(), 4, 4, 1, taps.data(), taps.size()); + for (uint16_t v : dst) CHECK(v == 7); // untouched +} + +// advect16 and blit16 are what every wide-plane effect (Trails, Nebula, Fluid, and a scripted +// trail) actually renders through, and neither had a test: the 8-bit advect was pinned and its +// 16-bit sibling was not, which is how a plane-wide primitive ships unverified. + +TEST_CASE("a 16-bit plane carried by a whole-cell flow arrives intact, so light is transported rather than smeared away") { + // One lit cell, pushed one whole cell to the right each frame. A whole-cell step has no + // bilinear fraction, so the value must survive exactly: any loss here is the transport itself + // leaking, which over a hundred frames of a trail is the difference between a tail and a haze. + const lengthType W = 8, H = 1; + std::vector a(size_t(W) * H * 3, 0), b(a.size(), 0); + a[(0 * 3) + 0] = 60000; a[(0 * 3) + 1] = 40000; a[(0 * 3) + 2] = 20000; + uint16_t* src = a.data(); uint16_t* dst = b.data(); + for (int step = 1; step <= 3; step++) { + draw::advect16(dst, src, W, H, 1, + [](lengthType, lengthType, lengthType, draw::pos_t& vx, draw::pos_t& vy) { + vx = draw::pos_t(draw::kSubOne); // exactly one cell + vy = 0; + }, draw::Edge::Clamp); + std::swap(src, dst); + CHECK(src[(size_t(step) * 3) + 0] == 60000); + CHECK(src[(size_t(step) * 3) + 1] == 40000); + CHECK(src[(size_t(step) * 3) + 2] == 20000); + } +} + +TEST_CASE("a half-cell flow splits a 16-bit sample between the two cells it straddles, and loses none of it") { + // The bilinear case: half a cell of motion puts half the light in each neighbor. What matters + // is that the TOTAL is conserved, which is what keeps a long trail from fading on its own. + const lengthType W = 8; + std::vector a(size_t(W) * 3, 0), b(a.size(), 0); + a[(4 * 3) + 0] = 40000; + draw::advect16(b.data(), a.data(), W, 1, 1, + [](lengthType, lengthType, lengthType, draw::pos_t& vx, draw::pos_t& vy) { + vx = draw::pos_t(draw::kSubOne / 2); vy = 0; + }, draw::Edge::Clamp); + uint32_t total = 0; + for (lengthType x = 0; x < W; x++) total += b[(size_t(x) * 3)]; + CHECK(total >= 39000); // conserved, bar the fixed-point rounding + CHECK(total <= 40000); + CHECK(b[(4 * 3)] > 0); // and it straddles the two cells + CHECK(b[(5 * 3)] > 0); +} + +TEST_CASE("a flow off the edge circulates under Wrap and carries the light out of the grid under Clamp") { + // The advection samples BACKWARD (every destination asks where its contents came from), so a + // leftward flow means cell x reads cell x+1. Under Wrap the left column reads the right one and + // the light comes round; under Clamp nothing reads past the wall, so light that flows off the + // edge is GONE rather than piling up against it. Both are correct and an effect picks one: a + // trail that should circulate wants Wrap, a fluid in a box wants Clamp. + const lengthType W = 4; + std::vector a(size_t(W) * 3, 0), b(a.size(), 0); + a[(0 * 3)] = 50000; // at the left wall, pushed further left + auto push = [](lengthType, lengthType, lengthType, draw::pos_t& vx, draw::pos_t& vy) { + vx = draw::pos_t(-draw::kSubOne); vy = 0; + }; + draw::advect16(b.data(), a.data(), W, 1, 1, push, draw::Edge::Wrap); + CHECK(b[(size_t(W - 1) * 3)] == 50000); // came round the far side, intact + std::fill(b.begin(), b.end(), uint16_t(0)); + draw::advect16(b.data(), a.data(), W, 1, 1, push, draw::Edge::Clamp); + uint32_t total = 0; + for (lengthType x = 0; x < W; x++) total += b[(size_t(x) * 3)]; + CHECK(total == 0); // left the grid rather than banking against the wall +} + +TEST_CASE("blit16 narrows a wide plane to the canvas, and dithering carries the error a truncation would drop") { + // A value just under the halfway point of an 8-bit step: truncation reports the lower step on + // every frame forever, while the carry accumulates and reaches the higher one part of the time. + // That difference IS the smooth fade the 16-bit planes exist for. + const lengthType W = 4, H = 4; + Buffer buf; + REQUIRE(buf.allocate(static_cast(W) * H, 3)); + buf.clear(); + const draw::Canvas cv = draw::Canvas::of(buf, W, H, 1); + std::vector plane(size_t(W) * H * 3, uint16_t(0x01C0)); // 1.75 of an 8-bit step + std::vector carry(plane.size(), 0); + + draw::blit16(cv, plane.data(), W, H, 1, nullptr); + for (size_t i = 0; i < size_t(W) * H * 3; i++) REQUIRE(buf.data()[i] == 1); // truncated, always + + int higher = 0; + for (int frame = 0; frame < 8; frame++) { + draw::blit16(cv, plane.data(), W, H, 1, carry.data()); + if (buf.data()[0] == 2) higher++; + } + CHECK(higher > 0); // the carry reaches the step above + CHECK(higher < 8); // but not on every frame: it is a ratio, not a bias +} + +TEST_CASE("scrolling along y moves every column, and along z every cell of the volume") { + // The lines of a y-scroll are the (z, x) COLUMNS, and those are not evenly spaced by one + // stride: x steps by a light within a slice, z steps by a whole slice. Treating them as one + // evenly-spaced sequence scrolled the first column of each slice and left the rest standing, + // which reads as a partial scroll nobody would call a scroll. + Buffer buf; REQUIRE(buf.allocate(9, 3)); buf.clear(); + const draw::Canvas cv = draw::Canvas::of(buf, 3, 3, 1); + for (lengthType y = 0; y < 3; y++) + for (lengthType x = 0; x < 3; x++) + draw::pixel(cv, {x, y, 0}, RGB{static_cast(10 * (y + 1)), static_cast(x + 1), 0}); + draw::scroll(cv, 1, 1, true); // rotate down by one row + for (lengthType x = 0; x < 3; x++) { // EVERY column rotated, and kept its own tag + CHECK(buf.data()[(size_t(0) * 3 + x) * 3] == 30); + CHECK(buf.data()[(size_t(1) * 3 + x) * 3] == 10); + CHECK(buf.data()[(size_t(2) * 3 + x) * 3] == 20); + CHECK(buf.data()[(size_t(0) * 3 + x) * 3 + 1] == x + 1); + } + + // The z axis has the same shape: its lines are the (y, x) cells, one per column of the volume. + Buffer vol; REQUIRE(vol.allocate(8, 3)); vol.clear(); + const draw::Canvas cube = draw::Canvas::of(vol, 2, 2, 2); + for (lengthType z = 0; z < 2; z++) + for (lengthType y = 0; y < 2; y++) + for (lengthType x = 0; x < 2; x++) + draw::pixel(cube, {x, y, z}, RGB{static_cast(z + 1), 0, 0}); + draw::scroll(cube, 2, 1, true); // swap the two slices + for (lengthType y = 0; y < 2; y++) + for (lengthType x = 0; x < 2; x++) { + CHECK(vol.data()[((size_t(0) * 2 + y) * 2 + x) * 3] == 2); // the far slice came near + CHECK(vol.data()[((size_t(1) * 2 + y) * 2 + x) * 3] == 1); + } +} diff --git a/test/unit/light/unit_fluid.cpp b/test/unit/light/unit_fluid.cpp new file mode 100644 index 00000000..fe934f2f --- /dev/null +++ b/test/unit/light/unit_fluid.cpp @@ -0,0 +1,224 @@ +// @module Fluid +// @also draw, FluidEffect + +// The stable-fluid solver. What matters is not that it computes something, but the three +// properties that separate a fluid from a field of arrows: it stays divergence-free (so what it +// carries neither piles up nor drains away), a push actually moves the medium downstream, and a +// field at rest STAYS at rest rather than drifting on its own rounding. + +#include "doctest.h" +#include "light/fluid.h" +#include "core/MoonModule.h" +#include "golden_frame.h" // the effect harness: Layouts, Grid, Layer +#include "light/effects/FluidEffect.h" + +#include +#include + +using namespace mm; + +namespace { +/// A bare owner: ScratchBuffer needs a module to register with, and the solver needs nothing else. +struct Owner : MoonModule { + const char* name() const { return "fluidTest"; } +}; + +/// The worst absolute divergence over the interior. Zero is a perfect flow; the solver relaxes +/// toward it rather than reaching it exactly, which is what the tolerance below is about. +int64_t worstDivergence(const Fluid& f) { + const int32_t* vx = f.velocityX(); + const int32_t* vy = f.velocityY(); + const lengthType w = f.width(), h = f.height(); + int64_t worst = 0; + for (lengthType y = 1; y < h - 1; y++) + for (lengthType x = 1; x < w - 1; x++) { + const size_t i = static_cast(y) * w + x; + const int64_t d = static_cast(vx[i + 1]) - vx[i - 1] + + vy[i + w] - vy[i - w]; + const int64_t a = d < 0 ? -d : d; + if (a > worst) worst = a; + } + return worst; +} +} // namespace + +TEST_CASE("each step drives the flow further toward divergence-free") { + // The property `project` exists for, stated as the method actually promises it. A single pass + // cannot cancel a point impulse: the discrete pressure gradient is spread over neighboring + // cells while the push is concentrated in one, so a float reference with the same scaling also + // leaves about 60% of it after one pass. What must hold is that the solver CONVERGES, and that + // is what this pins: the divergence falls with every step and ends far below where it began. + Owner owner; + Fluid fluid(owner); + REQUIRE(fluid.resize(24, 24)); + + for (int i = 0; i < 8; i++) fluid.addVelocity(12, 12, 3 * Fluid::kOne, Fluid::kOne); + const int64_t pushed = worstDivergence(fluid); + REQUIRE(pushed > 0); + + int64_t previous = pushed; + for (int f = 0; f < 8; f++) { + fluid.step(0, Fluid::kOne / 60, 5); + const int64_t now = worstDivergence(fluid); + // Never materially worse. Once it has settled the last bits wobble by a thousand or so, + // which is integer rounding in the relaxation rather than divergence, so the bound is + // generous in absolute terms and still catches a solver that is actually growing: a + // diverging one doubles rather than drifting. + CHECK(now <= previous + previous / 4 + 2000); + previous = now; + } + // And it settles to where only the PRESSURE SOLVE can take it. This bound is what makes the + // test about `project` rather than about advection: measured over these 8 steps, the worst + // divergence falls from 1572864 to about 13000 with the solve and stalls near 253000 without + // it, so a bound of a hundredth passes only when the projection is actually running. + CHECK(previous < pushed / 100); +} + +TEST_CASE("more solver iterations leave less divergence, so the cost knob buys correctness") { + // `iterations` is the honest cost control: it is a relaxation, so more passes converge further. + // If this did not hold, the knob would be paying for nothing. + Owner owner; + Fluid coarse(owner), fine(owner); + REQUIRE(coarse.resize(24, 24)); + REQUIRE(fine.resize(24, 24)); + for (int i = 0; i < 8; i++) { + coarse.addVelocity(12, 12, 3 * Fluid::kOne, Fluid::kOne); + fine.addVelocity(12, 12, 3 * Fluid::kOne, Fluid::kOne); + } + coarse.step(0, Fluid::kOne / 60, 1); + fine.step(0, Fluid::kOne / 60, 20); + CHECK(worstDivergence(fine) <= worstDivergence(coarse)); +} + +TEST_CASE("a jet carries the medium downstream, so a push is felt where it points") { + // A fluid must TRANSPORT. Push right at one place and the velocity a few cells to the right + // must pick it up: without advection the push would stay where it was made. + Owner owner; + Fluid fluid(owner); + REQUIRE(fluid.resize(32, 16)); + const lengthType jetX = 6, jetY = 8; + for (int f = 0; f < 30; f++) { + for (int i = 0; i < 4; i++) fluid.addVelocity(jetX, jetY, 4 * Fluid::kOne, 0); + fluid.step(0, Fluid::kOne / 60, 5); + } + const int32_t* vx = fluid.velocityX(); + const size_t downstream = static_cast(jetY) * 32 + (jetX + 5); + CHECK(vx[downstream] > 0); // the medium is moving right, well past the jet itself +} + +TEST_CASE("a fluid at rest stays at rest, so an idle fixture does not drift") { + // Nothing pushed, so nothing may move. A solver that leaks energy from its own rounding would + // show a fixture creeping while the user is doing nothing at all. + Owner owner; + Fluid fluid(owner); + REQUIRE(fluid.resize(16, 16)); + for (int f = 0; f < 60; f++) fluid.step(Fluid::kOne / 100, Fluid::kOne / 60, 5); + const int32_t* vx = fluid.velocityX(); + const int32_t* vy = fluid.velocityY(); + for (size_t i = 0; i < 16 * 16; i++) { + CHECK(vx[i] == 0); + CHECK(vy[i] == 0); + } +} + +TEST_CASE("a long stall leaves a plausible field rather than infinities") { + // The reason this solver and not an explicit one: it is unconditionally stable, so a frame that + // took a whole second resumes with a field a viewer would accept instead of a broken fixture. + Owner owner; + Fluid fluid(owner); + REQUIRE(fluid.resize(16, 16)); + for (int i = 0; i < 16; i++) fluid.addVelocity(8, 8, 6 * Fluid::kOne, 6 * Fluid::kOne); + fluid.step(0, Fluid::kOne, 5); // dt of a FULL SECOND + + const int32_t* vx = fluid.velocityX(); + for (size_t i = 0; i < 16 * 16; i++) { + CHECK(std::abs(vx[i]) < 100 * Fluid::kOne); // bounded, not exploded + } +} + +TEST_CASE("a grid too small to have an interior is refused rather than half-built") { + Owner owner; + Fluid fluid(owner); + CHECK_FALSE(fluid.resize(2, 2)); // no interior cell at all + CHECK_FALSE(fluid.valid()); + CHECK(fluid.resize(8, 8)); + CHECK(fluid.valid()); + fluid.release(); + CHECK_FALSE(fluid.valid()); +} + +// The release path. MoonModule::release() frees every ScratchBuffer a module registered, and it runs +// whenever the module or an ancestor is disabled. The re-enable only REQUESTS a prepare for the next +// scheduler loop, and Layer::tick runs every child whose enabled flag is set, so one frame can tick +// between the release and the prepare that rebuilds the grids. That frame crashed the desktop with a +// null dye plane (2026-09-05). The solver and the effect both read readiness from their buffers now. + +TEST_CASE("a fluid whose grids were released reports itself not ready, and resizing to the same shape rebuilds them") { + Owner o; Fluid f(o); + REQUIRE(f.resize(16, 16)); + REQUIRE(f.valid()); + o.release(); // what a disabled ancestor does to every buffer + CHECK_FALSE(f.valid()); + CHECK(f.velocityX() == nullptr); + f.step(0, Fluid::kOne / 50, 5); // must be a no-op rather than a write through null + f.addVelocity(8, 8, Fluid::kOne, 0); + // prepare() runs again with the SAME grid: the shape alone must not pass as "already built". + REQUIRE(f.resize(16, 16)); + CHECK(f.valid()); + CHECK(f.velocityX() != nullptr); +} + +TEST_CASE("Fluid ticks dark rather than crashing on the frame between a release and its prepare, then renders again") { + golden::ScopedTestClock clock(1000); + Layouts layouts; GridLayout grid; Layer layer; FluidEffect effect; + grid.width = 32; grid.height = 32; grid.depth = 1; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&effect); + layer.applyState(); + for (uint16_t i = 0; i < 20; i++) { platform::setTestNowMs(1000 + i * 20u); layer.tick(); } + + effect.release(); // the disable, with the enabled flag still set + platform::setTestNowMs(1500); + layer.tick(); // the frame before the requested prepare: no crash + + layer.applyState(); // the prepare the scheduler services next loop + bool lit = false; + for (uint16_t i = 0; i < 40 && !lit; i++) { + platform::setTestNowMs(1600 + i * 20u); + layer.tick(); + const auto& buf = layer.buffer(); + for (size_t b = 0; b < buf.bytes(); b++) if (buf.data()[b]) { lit = true; break; } + } + CHECK(lit); +} + + +TEST_CASE("Fluid reshaped to the same light count starts from black rather than the old layout's dye") { + // 8x16 to 16x8: the sample count is identical, so resize() keeps the buffer and its contents, + // which are laid out for the OLD geometry. Both planes must be cleared, because the ping-pong + // swaps the spare one in on the very next frame. Trails and Nebula carry the same guard. + // + // The check reads the planes through prepare() alone, with no tick in between: a rendered frame + // pours fresh dye on top and would hide a stale plane behind it. + golden::ScopedTestClock clock(1000); + Layouts layouts; GridLayout grid; Layer layer; FluidEffect effect; + grid.width = 8; grid.height = 16; grid.depth = 1; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&effect); + layer.applyState(); + for (uint16_t i = 0; i < 40; i++) { platform::setTestNowMs(1000 + i * 20u); layer.tick(); } + + uint64_t before = 0; + for (size_t k = 0; k < effect.dyeSamples(); k++) before += effect.dyeAt(k); + REQUIRE(before > 0); // there is dye that could carry over + + grid.width = 16; grid.height = 8; // the same count, transposed + layer.applyState(); + uint64_t after = 0; + for (size_t k = 0; k < effect.dyeSamples(); k++) after += effect.dyeAt(k); + CHECK(after == 0); // both planes cleared for the new geometry +}