From 43bba8c7fc703faf6c86ec95be9657bbf8c50ca6 Mon Sep 17 00:00:00 2001 From: ewowi Date: Fri, 22 May 2026 17:15:50 +0200 Subject: [PATCH] Add 10 stateless and stateful effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten new effects, registered with ModuleFactory and available in the pipeline: - Stateless (zero-heap): Plasma, PlasmaPalette, Metaballs, Checkerboard, Spiral, Ripples, GlowParticles, LavaLamp - Stateful (own dynamic buffer, freed when disabled): Fire (heat grid), Particles (trail buffer) Each effect ships a module spec in docs/moonmodules/light/effects/ and a doctest case (test_fire, test_metaballs, test_particles, test_plasma, test_stateless_effects — 23 cases). color.h gains palette helpers shared by the palette-driven effects. main.cpp registers all ten; the startup pipeline keeps the single default Noise effect. This is the effects-only half of the more-effects work. Dynamic effect management (the /api/types + /api/modules/move endpoints and the add-effect picker UI) is intentionally left out — it lands separately. Effects use the existing MoonModule `enabled()` system control (Fire/Particles free their buffer when disabled); no parallel `enabled` field is introduced. Reduced pre-commit (effects + docs, no render-loop or platform change): desktop build clean, 104 unit-test cases pass (23 new), 8 scenarios pass, platform boundary + spec check (20/20) PASS. ESP32 build/flash skipped — no ESP32-specific change. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/history/assessment-2026-05.md | 160 ++++++++++++++++++ .../light/effects/CheckerboardEffect.md | 19 +++ docs/moonmodules/light/effects/FireEffect.md | 40 +++++ .../light/effects/GlowParticlesEffect.md | 21 +++ .../light/effects/LavaLampEffect.md | 18 ++ .../light/effects/MetaballsEffect.md | 30 ++++ .../light/effects/ParticlesEffect.md | 40 +++++ .../moonmodules/light/effects/PlasmaEffect.md | 40 +++++ .../light/effects/PlasmaPaletteEffect.md | 18 ++ .../light/effects/RipplesEffect.md | 21 +++ .../moonmodules/light/effects/SpiralEffect.md | 18 ++ docs/testing.md | 146 +++++----------- src/core/color.h | 46 +++++ src/light/CheckerboardEffect.h | 58 +++++++ src/light/FireEffect.h | 147 ++++++++++++++++ src/light/GlowParticlesEffect.h | 121 +++++++++++++ src/light/LavaLampEffect.h | 113 +++++++++++++ src/light/MetaballsEffect.h | 75 ++++++++ src/light/ParticlesEffect.h | 148 ++++++++++++++++ src/light/PlasmaEffect.h | 67 ++++++++ src/light/PlasmaPaletteEffect.h | 101 +++++++++++ src/light/RipplesEffect.h | 114 +++++++++++++ src/light/SpiralEffect.h | 59 +++++++ src/main.cpp | 20 +++ test/CMakeLists.txt | 5 + test/test_fire.cpp | 75 ++++++++ test/test_metaballs.cpp | 56 ++++++ test/test_particles.cpp | 71 ++++++++ test/test_plasma.cpp | 91 ++++++++++ test/test_stateless_effects.cpp | 118 +++++++++++++ 30 files changed, 1950 insertions(+), 106 deletions(-) create mode 100644 docs/history/assessment-2026-05.md create mode 100644 docs/moonmodules/light/effects/CheckerboardEffect.md create mode 100644 docs/moonmodules/light/effects/FireEffect.md create mode 100644 docs/moonmodules/light/effects/GlowParticlesEffect.md create mode 100644 docs/moonmodules/light/effects/LavaLampEffect.md create mode 100644 docs/moonmodules/light/effects/MetaballsEffect.md create mode 100644 docs/moonmodules/light/effects/ParticlesEffect.md create mode 100644 docs/moonmodules/light/effects/PlasmaEffect.md create mode 100644 docs/moonmodules/light/effects/PlasmaPaletteEffect.md create mode 100644 docs/moonmodules/light/effects/RipplesEffect.md create mode 100644 docs/moonmodules/light/effects/SpiralEffect.md create mode 100644 src/light/CheckerboardEffect.h create mode 100644 src/light/FireEffect.h create mode 100644 src/light/GlowParticlesEffect.h create mode 100644 src/light/LavaLampEffect.h create mode 100644 src/light/MetaballsEffect.h create mode 100644 src/light/ParticlesEffect.h create mode 100644 src/light/PlasmaEffect.h create mode 100644 src/light/PlasmaPaletteEffect.h create mode 100644 src/light/RipplesEffect.h create mode 100644 src/light/SpiralEffect.h create mode 100644 test/test_fire.cpp create mode 100644 test/test_metaballs.cpp create mode 100644 test/test_particles.cpp create mode 100644 test/test_plasma.cpp create mode 100644 test/test_stateless_effects.cpp diff --git a/docs/history/assessment-2026-05.md b/docs/history/assessment-2026-05.md new file mode 100644 index 00000000..3c2fa79c --- /dev/null +++ b/docs/history/assessment-2026-05.md @@ -0,0 +1,160 @@ +# Objective Assessment of the Project + +Based on the code, specs (`docs/moonmodules/`), architecture docs, and `docs/plan.md`. + +--- + +## Short Conclusion + +This is **no longer a "specs-only" project**. There is a **real end-to-end pipeline** running (layout → layer → effects/modifier → blend/LUT → drivers → HTTP/WebSocket UI), with **serious testing** and **working ESP32 hardware**. The structure aligns well with the v3 philosophy (simple, data-driven, minimal magic). + +At the same time, it is still **far from the complete vision** described in the README/architecture (many effects, LED drivers, WiFi, persistence, multi-layer support, parallelism). The README status ("architecture phase, implementation starting") is **outdated**. + +**Overall:** strong **technical foundation + process** (~7/10), but the end-user product is still **about halfway there** (~4–5/10 compared to Release 1.0 in `plan.md`). + +--- + +# How Far Along Is It? + +## What *is* already done (core system) + +| Area | Status | +|------|--------| +| **MoonModule + controls + scheduler** | Complete, consistently used | +| **Pipeline** | LayoutGroup → GridLayout → Layer → effects → modifier → DriverGroup → ArtNet + Preview | +| **Mapping** | MappingLUT, MirrorModifier, BlendMap, 1:1 optimization without extra buffer | +| **Effects** | Rainbow, Noise | +| **Drivers** | ArtNet send, Preview (WebSocket binary frames) | +| **HTTP API** | `/api/state`, `/api/control`, `/api/system`, module CRUD (`POST/DELETE /api/modules`) | +| **Web UI** | Vanilla JS, WebSocket, sliders/toggles/text, **WebGL 3D preview** | +| **Desktop** | Builds and runs (`mmv3`, port 8080) | +| **ESP32** | Ethernet (Olimex Gateway), same pipeline, live scenarios on hardware | +| **Tooling** | MoonDeck, platform-boundary check, scenario runners (in-process + live) | +| **Tests** | 10 unit test files + 5 JSON scenarios + live scenarios (desktop + ESP32) | + +The promoted specs in `docs/moonmodules/` (18 total) mostly match the implementation in `src/`, which is unusually good for an agent-driven project. + +--- + +## What is still missing (or intentionally postponed) + +From `docs/plan.md` (items 9–13) and draft specs: + +- **System MoonModule** as an actual module (diagnostics partially exist in `/api/system`, but not as a MoonModule in the tree) +- **WiFi**, **config persistence**, **UI type picker** + dynamic effect switching from the browser +- **README quick-start** (you already got it working; the repo docs have not caught up) +- **WS2812 / APA102 / direct DMX output** — currently only ArtNet network output +- **Many effects/layouts** from `moonmodules_draft/` (Wheel, GameOfLife, Ripples, Rotate, ...) +- **Multi-layer support** (architecture docs describe Layer A/B/C; code currently uses a single `Layer` in `main.cpp`, and `DriverGroup` comments reference "multi-layer later") +- **Parallelism / core affinity** — documented, not implemented in the scheduler +- **Teensy / Raspberry Pi support** — only mentioned in docs, no `src/platform/teensy` etc. + +### Rough completion estimates + +- **Vertical slice (single installation, ArtNet + browser):** ~**65–70%** +- **Full architectural vision** (all platforms, all protocols, rich effects library): ~**25–30%** +- **Release 1.0** ("flash, WiFi, browser, settings persistence"): item 8 seems complete; 9–13 still open → ~**50%** toward that milestone + +--- + +# How Well Is It Built? + +## Strong Points + +### 1. Architecture and discipline + +`CLAUDE.md`, `architecture.md`, promoted specs, and `check_platform_boundary.py` create a **real engineering framework**. Platform-specific code is isolated in `src/platform/`; the rest compiles everywhere. That prevents the typical drift seen in v1/v2. + +### 2. The MoonModule pattern actually works + +One lifecycle (`setup` → `onBuildControls` → `onAllocateMemory` → `loop` / `loop20ms`), generic children, `ModuleRole`, and a runtime CRUD factory. Learnable and extensible without requiring UI rewrites for every effect. + +### 3. Memory-conscious design + +The `memory-1to1` and `memory-lut` scenarios prove intentional memory decisions (no LUT buffer for 1:1 mappings, but one for mirror mappings). `performance.md` measures both desktop and ESP32 performance — unusually useful this early. + +### 4. Test strategy + +Unit → scenario → live HTTP tests is **mature** for embedded/C++. Hardware verification on 128×128 without PSRAM is explicitly documented in `testing.md`. + +### 5. UI philosophy + +No npm/runtime dependency chain in the UI; controls are rendered from module state. WebSocket + debounce + in-place updates follow the original UI spec from v1 — much of it is already implemented in `app.js`. + +### 6. Code style + +Header-only modules, `constexpr`, `std::span`, `-Werror`, and no TODOs in `src/`. Fits the "understandable in 30 seconds" philosophy per file (except perhaps `HttpServerModule.h`). + +--- + +## Weak Points / Technical Debt + +### 1. `HttpServerModule.h` is a monolith + +HTTP, WebSocket, JSON parsing, static files, CRUD — everything lives in one large header. Functional, but the opposite of "single-file simplicity" from a maintenance perspective. + +### 2. Rebuild propagation is coarse + +Every control change calls `scheduler_->rebuild()` → triggering `onAllocateMemory()` on **all** top-level modules. The architecture docs describe fine-grained propagation; this is not there yet. + +### 3. The layer pipeline is intentionally limited + +One modifier during LUT build; multiple sequential effects are supported, but the default stack in `main.cpp` is fixed. Runtime CRUD exists via the API, but the **UI** lacks a type picker (`/api/types` does not exist yet). + +### 4. ESP32 performance + +According to `performance.md`, at 128×128, roughly **51% of the tick time is ArtNet UDP** — no pacing/limiting yet as recommended in `decisions.md`. Functionally fine (~17 FPS), but not scalable to "10,000+ LEDs" on this chip without optimization. + +### 5. Documentation vs reality + +The README still says "implementation starting"; `plan.md` describes live scenarios as "next steps" while `testing.md` states they already pass on hardware. This is confusing for new developers. + +### 6. Platform breadth + +Vision = ESP32 + desktop + Teensy + Raspberry Pi. +Implementation = desktop + ESP32 (Ethernet). The rest remains roadmap material. + +--- + +# Evaluation by Dimension + +| Dimension | Score | Notes | +|-----------|------|------| +| **Architecture (design)** | 8/10 | Clear core/domain separation, MoonModule everywhere, strong docs | +| **Core implementation** | 7/10 | Pipeline works, tested, hardware-proven | +| **Extensibility** | 7/10 | Factory + specs; HttpServer/UI not yet fully generic for "everything from browser" | +| **Testing & quality** | 8/10 | Strong for project size; includes live + memory scenarios | +| **Product/UX (end-user)** | 5/10 | Works locally; no WiFi flash flow, persistence, or type picker | +| **Performance (ESP32)** | 6/10 | 128×128 works; ArtNet dominates; no LED DMA path | +| **Documentation** | 7/10 | Excellent internally; README/plan are out of sync | + +--- + +# Comparison to Earlier Iterations (`docs/history/`) + +This feels like a **deliberate response to v1/v2 bloat**: + +- Less "framework for the framework" +- Specs before code +- Strict platform boundaries and checklists +- One working pipeline instead of 60 effects without tests + +The project is beyond the "prototype" stage, but not yet at MoonLight/WLED-level breadth in terms of effects and drivers. + +--- + +# What I Would Prioritize (for reference only) + +As a product owner, aligned with `plan.md`: + +1. Update README + quick-start +2. System MoonModule + WiFi (for Release 1.0) +3. Config persistence +4. UI type picker + module switching (API is partially there already) +5. ArtNet pacing/batching on ESP32 + +--- + +# One-Sentence Summary + +The project is **technically solid and well-controlled** for an early v3 cycle, with a **working LED pipeline and serious testing**; however, it is **not yet a complete end-user product** where "a customer flashes an ESP32 and starts playing with effects," and the **README significantly understates** how far the implementation already is. diff --git a/docs/moonmodules/light/effects/CheckerboardEffect.md b/docs/moonmodules/light/effects/CheckerboardEffect.md new file mode 100644 index 00000000..c250aea9 --- /dev/null +++ b/docs/moonmodules/light/effects/CheckerboardEffect.md @@ -0,0 +1,19 @@ +# Checkerboard 2D Effect + +Animated checker pattern with two configurable hues. + +## Controls + +- `enabled` (bool) — from `EffectBase` +- `cell_size` (uint8_t, default 4, range 1-32) — cell width/height in grid units +- `bpm` (uint8_t, default 60, range 1-255) — phase shift speed (cells appear to move) +- `hue_a` (uint8_t, default 0) — colour of even cells +- `hue_b` (uint8_t, default 128) — colour of odd cells + +## Rendering + +Checker bit from `(x/cell + y/cell + phase) & 1`. `dynamicBytes()` = 0. + +## Tests + +[Module test](../../../testing.md#stateless-effects) — non-zero output, spatial variation. diff --git a/docs/moonmodules/light/effects/FireEffect.md b/docs/moonmodules/light/effects/FireEffect.md new file mode 100644 index 00000000..8b35c033 --- /dev/null +++ b/docs/moonmodules/light/effects/FireEffect.md @@ -0,0 +1,40 @@ +# Fire 2D Effect + +Classic demoscene fire simulation on the XY plane. Maintains a `width x height` heat field; sparks spawn at the bottom row, drift upward with cooling, and are mapped to a black-red-yellow-white palette. + +## Controls + +- `enabled` (bool, default true) — inherited from `EffectBase` +- `cooling` (uint8_t, default 55, range 10-200) — heat loss per frame (higher = shorter flames) +- `sparking` (uint8_t, default 120, range 50-255) — probability of new sparks at the bottom row +- `hue_shift` (uint8_t, default 0, range 0-255) — rotate the fire palette around the colour wheel (0 = classic fire, 96 = green ghost-fire, 160 = blue plasma-fire) + +## Rendering + +Per frame (in this order): + +1. **Cool** every cell by a small random amount. +2. **Rise** — each row averages from the row below (4-tap neighbourhood), heat propagates from `y = h-1` up toward `y = 0`. +3. **Sparks** — up to 4 random sparks at the bottom row each frame, gated by `sparking`. +4. **Render** the heat field to RGB via the fire palette (or hue-rotated HSV when `hue_shift != 0`). + +Integer-only, no floats. Internal PRNG (LCG) avoids `rand()`. + +## Memory + +Allocates `width * height` bytes for the heat buffer in `onAllocateMemory()` when `enabled` is true. Freed in `teardown()` and when disabled. Toggling `enabled` triggers a scheduler rebuild that (re)allocates. + +| Logical size | Heat buffer | +|--------------|-------------| +| 64x64 | 4 KB | +| 128x128 | 16 KB | + +`dynamicBytes()` reports the live size. + +## Tests + +[Module test: FireEffect](../../../testing.md#fire) — buffer becomes non-zero after several frames of sparking. + +## Prior art + +Standard demoscene fire (Lode's tutorials, FastLED's `Fire2012`). Adapted to the integer-only, no-Arduino style of this codebase. diff --git a/docs/moonmodules/light/effects/GlowParticlesEffect.md b/docs/moonmodules/light/effects/GlowParticlesEffect.md new file mode 100644 index 00000000..39f5d61f --- /dev/null +++ b/docs/moonmodules/light/effects/GlowParticlesEffect.md @@ -0,0 +1,21 @@ +# Glow Particles 2D Effect + +Soft-glowing particles rendered as a metaball field. Particles move with independent velocities and bounce off the edges; the per-pixel field summation produces chaotic organic blobs — like `MetaballsEffect` with more freedom of movement. + +## Controls + +- `enabled` (bool) — from `EffectBase` +- `count` (uint8_t, default 5, range 1-8) — number of glow sources +- `speed` (uint8_t, default 60, range 1-255) — movement speed +- `radius` (uint8_t, default 24, range 4-64) — influence radius (larger = more merging) +- `hue_shift` (uint8_t, default 0, range 0-255) — global hue rotation + +## Rendering + +Position update uses 12.4 fixed-point arithmetic. Per pixel: `field += (radius² × 64) / (dx² + dy² + 1)` across all active particles. Brightness clamps to 255; hue derives from the field magnitude. + +No heap allocations. Per-particle state: 8 bytes × 8 = 64 bytes. + +## Tests + +[Module test](../../../testing.md#stateless-effects) — non-zero output, spatial variation. diff --git a/docs/moonmodules/light/effects/LavaLampEffect.md b/docs/moonmodules/light/effects/LavaLampEffect.md new file mode 100644 index 00000000..24feed95 --- /dev/null +++ b/docs/moonmodules/light/effects/LavaLampEffect.md @@ -0,0 +1,18 @@ +# Lava Lamp 2D Effect + +Three slow blobs whose summed field is mapped through a black → red → orange → yellow → white palette. Atmospheric, fluid look — like a real lava lamp rather than the bright HSV of `MetaballsEffect`. + +## Controls + +- `enabled` (bool) — from `EffectBase` +- `bpm` (uint8_t, default 8, range 1-64) — orbit speed in beats per minute +- `radius` (uint8_t, default 36, range 8-80) — blob influence radius +- `intensity` (uint8_t, default 200, range 64-255) — how strongly the field maps into the palette + +## Rendering + +Three integer-orbited blobs (`sin8` for x/y at independent phases and speeds). Per pixel: `field += (radius² × 64) / (dx² + dy² + 1)` summed over the blobs, then `palette_[(field × intensity) >> 8]`. Palette is a 256-entry constexpr table in flash (768 bytes). No heap allocations. + +## Tests + +[Module test](../../../testing.md#stateless-effects) — non-zero output, spatial variation. diff --git a/docs/moonmodules/light/effects/MetaballsEffect.md b/docs/moonmodules/light/effects/MetaballsEffect.md new file mode 100644 index 00000000..f36fc03d --- /dev/null +++ b/docs/moonmodules/light/effects/MetaballsEffect.md @@ -0,0 +1,30 @@ +# Metaballs 2D Effect + +Four "blobs" moving on the XY plane via integer sin/cos, with a metaball field summation per pixel. Visually similar to a lava lamp — blobs fluidly merge and separate. + +## Controls + +- `enabled` (bool, default true) — inherited from `EffectBase` +- `bpm` (uint8_t, default 30, range 1-255) — orbit speed in beats per minute +- `radius` (uint8_t, default 28, range 4-64) — ball influence radius (larger = more merging) +- `hue_shift` (uint8_t, default 0, range 0-255) — rotate the resulting hue + +## Rendering + +Per frame: +- Compute 4 ball positions `(bx, by)` using `sin8()` at different phases and orbit speeds. Stateless apart from a phase accumulator (BPM-stable). + +Per pixel: +- For each ball: `field += (radius^2 * 64) / (dx^2 + dy^2 + 1)` +- Brightness = clamped `field`; hue = `(field >> 1) + hue_shift` +- Output via `hsvToRgb(hue, 240, brightness)` + +Four divisions per pixel — acceptable for desktop and ESP32 LX7. No floats, no heap (`dynamicBytes()` = 0). + +## Tests + +[Module test: MetaballsEffect](../../../testing.md#metaballs) — non-zero output, spatial variation. + +## Prior art + +Classic demoscene effect (1980s). Same integer field-summation technique as countless WLED ports. diff --git a/docs/moonmodules/light/effects/ParticlesEffect.md b/docs/moonmodules/light/effects/ParticlesEffect.md new file mode 100644 index 00000000..6643c814 --- /dev/null +++ b/docs/moonmodules/light/effects/ParticlesEffect.md @@ -0,0 +1,40 @@ +# Particles 2D Effect + +A swarm of particles drifting on the XY plane with persistent trails. Each particle has fixed-point position, velocity, and hue. A private RGB trail buffer is faded each frame, particles are drawn on top, and the trail buffer is copied to the layer buffer (which the Layer clears every frame). + +## Controls + +- `enabled` (bool, default true) — inherited from `EffectBase` +- `count` (uint8_t, default 32, range 1-64) — number of active particles +- `speed` (uint8_t, default 80, range 1-255) — velocity multiplier +- `fade` (uint8_t, default 240, range 200-255) — trail persistence (255 = no fade, 200 = quick fade) +- `hue_shift` (uint8_t, default 0, range 0-255) — rotate all particle hues + +## Rendering + +Per frame: + +1. **Fade** every byte in the trail buffer via `scale8(byte, fade)` (integer multiply, no float). +2. **Update + draw** — for each active particle: advance position using 12.4 fixed-point math, bounce off edges, draw RGB into the trail buffer. +3. **Copy** the trail buffer into the layer buffer. + +Maximum particles is `MAX_PARTICLES = 64` (a compile-time constant). Particle state is a fixed array — no heap allocation for the particle list itself. + +## Memory + +Allocates `width * height * channelsPerLight` bytes for the persistent trail buffer in `onAllocateMemory()` when `enabled` is true. Freed in `teardown()` and when disabled. + +| Logical size | Trail buffer (RGB) | +|--------------|--------------------| +| 64x64 | 12 KB | +| 128x128 | 48 KB | + +Particle list (`64 * 8` bytes) is part of `sizeof(ParticlesEffect)`, not `dynamicBytes()`. `dynamicBytes()` reports only the trail buffer. + +## Tests + +[Module test: ParticlesEffect](../../../testing.md#particles) — buffer becomes non-zero after one frame (particles draw immediately). + +## Prior art + +Classic "snow"/"stars"/"fireflies" effect from many LED firmwares. The fade-and-draw approach (a.k.a. *afterimage*) matches FastLED's `fadeToBlackBy` idiom. diff --git a/docs/moonmodules/light/effects/PlasmaEffect.md b/docs/moonmodules/light/effects/PlasmaEffect.md new file mode 100644 index 00000000..27b84cce --- /dev/null +++ b/docs/moonmodules/light/effects/PlasmaEffect.md @@ -0,0 +1,40 @@ +# Plasma 2D Effect + +Animated plasma pattern from four summed sine waves on orthogonal and diagonal axes. Default effect in the desktop and ESP32 pipeline. + +## Controls + +- `bpm` (uint8_t, default 30, range 1-255) — animation speed in beats per minute +- `scale_x` (uint8_t, default 16, range 1-64) — horizontal wave length in grid cells (`step = 256 / scale_x`) +- `scale_y` (uint8_t, default 16, range 1-64) — vertical wave length in grid cells +- `hue_shift` (uint8_t, default 0, range 0-255) — rotates the entire color wheel + +## Rendering + +Four `sin8` lookups per pixel (256-byte constexpr LUT in flash, no float, no heap). Waves: + +- Horizontal: `sin8(x * step_x + t1)` +- Vertical: `sin8(y * step_y + t2)` — hoisted per row +- Diagonal: `sin8(x * step_x + y * step_x - t3)` +- Anti-diagonal: `sin8(x * step_y + 128 - y * step_y + t1)` + +Sum averaged (`>> 2`), add `hue_shift`, map via `hsvToRgb(hue, 255, 255)`. + +### Performance + +- No division or modulo in the inner loop — `step_x`/`step_y` computed once per frame +- Nested `for (y) for (x)` with row pointer — no `i % w` per pixel +- Y-dependent terms hoisted outside the x-loop +- `dynamicBytes()` = 0 — no `onAllocateMemory` override + +Phase accumulator matches NoiseEffect pattern — BPM changes do not jump the animation. + +## Tests + +[Module test: PlasmaEffect](../../../testing.md#plasma) — non-zero output, spatial variation, differs from NoiseEffect. + +Default pipeline uses Plasma + MirrorModifier (see `src/main.cpp`). + +## Prior art + +Classic demoscene plasma effect (sum of sines). Integer sin8 LUT approach matches FastLED-style tables. No direct v1/v2 module port — simpler than NoiseEffect (no hash/bilinear). diff --git a/docs/moonmodules/light/effects/PlasmaPaletteEffect.md b/docs/moonmodules/light/effects/PlasmaPaletteEffect.md new file mode 100644 index 00000000..5f197f26 --- /dev/null +++ b/docs/moonmodules/light/effects/PlasmaPaletteEffect.md @@ -0,0 +1,18 @@ +# Plasma Palette 2D Effect + +Same four-sine plasma field as `PlasmaEffect`, but colours come from a 256-entry fire-ocean RGB palette in flash instead of `hsvToRgb`. + +## Controls + +- `enabled` (bool) — from `EffectBase` +- `bpm` (uint8_t, default 30, range 1-255) +- `scale_x` (uint8_t, default 16, range 1-64) +- `scale_y` (uint8_t, default 16, range 1-64) + +## Rendering + +Plasma index `((s1+s2+s3+s4)>>2)` maps through `palette_[256]` (768 bytes flash). `dynamicBytes()` = 0. + +## Tests + +[Module test](../../../testing.md#stateless-effects) — non-zero output, spatial variation. diff --git a/docs/moonmodules/light/effects/RipplesEffect.md b/docs/moonmodules/light/effects/RipplesEffect.md new file mode 100644 index 00000000..7ce85d2f --- /dev/null +++ b/docs/moonmodules/light/effects/RipplesEffect.md @@ -0,0 +1,21 @@ +# Ripples 2D Effect + +Expanding concentric rings from random centre points. Each ripple grows outward and respawns once it has expanded past the visible area. Multiple ripples overlap with additive blending. + +## Controls + +- `enabled` (bool) — from `EffectBase` +- `count` (uint8_t, default 4, range 1-8) — number of simultaneously active ripples +- `speed` (uint8_t, default 60, range 1-255) — expansion rate +- `thickness` (uint8_t, default 3, range 1-16) — ring thickness in pixels +- `hue_shift` (uint8_t, default 0, range 0-255) — global hue rotation + +## Rendering + +Per pixel, for each ripple: octagonal distance `dist8(dx, dy)` is compared to the ripple's current radius. Pixels within `thickness` of the ring get an intensity falloff plus an age-based fade so old, wide ripples disappear softly. + +No heap allocations. Per-ripple state: position + radius + hue (~40 bytes total for 8 ripples). + +## Tests + +[Module test](../../../testing.md#stateless-effects) — non-zero output, spatial variation. diff --git a/docs/moonmodules/light/effects/SpiralEffect.md b/docs/moonmodules/light/effects/SpiralEffect.md new file mode 100644 index 00000000..3e14912c --- /dev/null +++ b/docs/moonmodules/light/effects/SpiralEffect.md @@ -0,0 +1,18 @@ +# Spiral 2D Effect + +Rotating spiral from angle + distance. Uses shared `atan2_8` and `dist8` in `core/color.h`. + +## Controls + +- `enabled` (bool) — from `EffectBase` +- `bpm` (uint8_t, default 40, range 1-255) — rotation speed +- `twist` (uint8_t, default 4, range 1-16) — tightness of spiral arms +- `hue_shift` (uint8_t, default 0, range 0-255) — colour offset + +## Rendering + +Centre at `(w/2, h/2)`. `hue = atan2_8(dy,dx) + dist*twist - t + hue_shift`. `dynamicBytes()` = 0. + +## Tests + +[Module test](../../../testing.md#stateless-effects) — non-zero output, spatial variation. diff --git a/docs/testing.md b/docs/testing.md index bfe8c4fd..f5e86781 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -26,22 +26,10 @@ Tests `MoonModule` lifecycle and `Control` binding in `src/core/MoonModule.h` an - Lifecycle methods called (setup, teardown) - Name get/set -- `typeName` independent of `name` — factory sets typeName once, name can be overridden - Parent get/set -- Dirty flag set/clear (future persistence consumer) - Control binding via ControlList (uint8_t, bool) - Pointer binding: changing variable updates control, changing via pointer updates variable - ControlList clear and rebuild -- ReadOnly control: binds to char buffer, reflects updates -- Select control: binds to uint8_t index, stores options pointer in aux, option count in max -- Progress control: binds to uint32_t value, total stored in aux - -### SystemModule (`test/test_system_module.cpp`) {#systemmodule} - -Tests `SystemModule` in `src/core/SystemModule.h`. - -- MAC-to-deviceName: desktop MAC DE:AD:BE:EF:CA:FE produces "MM-CAFE" -- Controls include deviceName as Text, uptime/fps/chip as ReadOnly ### Buffer (`test/test_buffer.cpp`) {#buffer} @@ -96,6 +84,43 @@ Tests `NoiseEffect` in `src/light/NoiseEffect.h`. - Different positions produce different colors (spatial variation) - Produces different output than RainbowEffect +### Plasma Effect (`test/test_plasma.cpp`) {#plasma} + +Tests `PlasmaEffect` in `src/light/PlasmaEffect.h`. + +- Buffer contains non-zero RGB data after render +- Different positions produce different colors (spatial variation) +- Produces different output than NoiseEffect + +### Metaballs Effect (`test/test_metaballs.cpp`) {#metaballs} + +Tests `MetaballsEffect` in `src/light/MetaballsEffect.h`. + +- Buffer contains non-zero RGB data after render +- Different positions produce different colors (spatial variation) + +### Fire Effect (`test/test_fire.cpp`) {#fire} + +Tests `FireEffect` in `src/light/FireEffect.h`. + +- Heat buffer allocated to `width * height` bytes when enabled +- Buffer non-zero after ~50 frames of high sparking +- Heat buffer freed when disabled via `setEnabled(false)` + +### Particles Effect (`test/test_particles.cpp`) {#particles} + +Tests `ParticlesEffect` in `src/light/ParticlesEffect.h`. + +- Trail buffer allocated to `width * height * cpl` bytes when enabled +- Buffer non-zero after a single render (particles draw immediately) +- Trail buffer freed when disabled via `setEnabled(false)` + +### Stateless effects (`test/test_stateless_effects.cpp`) {#stateless-effects} + +Tests zero-heap effects: `CheckerboardEffect`, `SpiralEffect`, `PlasmaPaletteEffect`, `RipplesEffect`, `GlowParticlesEffect`, `LavaLampEffect`. + +Each effect: buffer contains non-zero RGB after render; corners produce different colours (spatial variation). + ### MappingLUT (`test/test_mapping_lut.cpp`) {#mappinglut} Tests `MappingLUT` in `src/light/MappingLUT.h`. @@ -124,50 +149,6 @@ Tests `blendMap()` in `src/light/BlendMap.h`. - 1:N mapping: logical pixel appears at multiple physical positions - Additive blend with clamping -### MoonModule::moveChildTo (`test/test_movechild.cpp`) {#movechild} - -Tests `MoonModule::moveChildTo(child, newIndex)` in `src/core/MoonModule.h`. - -- No-op when already at target index returns false -- Forward move (toward end) shifts intervening siblings left -- Backward move (toward start) shifts intervening siblings right -- One-position swap (matches up/down buttons in the UI) -- Out-of-range newIndex rejected without modifying the tree -- Non-child argument rejected without modifying the tree -- Middle-to-middle move - -### ModuleFactory (`test/test_module_factory.cpp`) {#module-factory} - -Tests `ModuleFactory` in `src/core/ModuleFactory.h`. - -- `registerType()` captures `T::role()` via a stack-allocated probe instance — no per-type boilerplate -- `create()` returns a module of the registered type with `typeName()` set -- `create()` returns nullptr for unknown or null type names -- `typeName()` and `typeRole()` are bounds-safe (out-of-range returns nullptr / Generic) -- Dynamic capacity grows past the initial 4 — registering 10 throwaway types all succeed and remain discoverable - -### Preview Driver (`test/test_preview_driver.cpp`) {#preview-driver} - -Tests `PreviewDriver` downsampling in `src/light/PreviewDriver.h`. Each case builds a GridLayout → LayoutGroup → Layer pipeline, wires the driver, and inspects the resulting [PreviewFrame](moonmodules/light/drivers/PreviewDriver.md). - -- `detail` 1/2/3 on a 128×128 grid select distinct strides (8/4/3 → 16/32/43 voxels per axis) — the three settings are visibly different -- The frame always carries the original grid dimensions (`origWidth/Height/Depth`) so the `decompress` UI hint can block-replicate back to the real resolution -- `detail = 3` (the largest payload) stays within lwIP's TCP send-buffer budget: ≤1849 voxels, payload + 13-byte header < 5760 B — so the non-blocking `writeChunks` completes whole -- A grid under the voxel budget is copied 1:1 (stride 1, no downsample) -- The strided copy is channel-agnostic: an RGBW (4-channel) source still produces a 3 B/voxel RGB frame -- Default controls: `fps = 12`, `detail = 2`, `decompress = false` - -### Filesystem persistence (`test/test_filesystem_persistence.cpp`) {#filesystem-persistence} - -Four test cases for [FilesystemModule](moonmodules/core/FilesystemModule.md): - -- **Value round-trip**: set `deviceName` on a running Scheduler+SystemModule, wait for the 2s debounce flush, recreate a fresh Scheduler+modules, and assert the deviceName is loaded from disk. -- **Structural reconciliation**: hand-write a `Layer.json` describing a one-child tree (`RainbowEffect`). Build a live two-child tree (`NoiseEffect` + `MirrorModifier`) and run `scheduler.setup()`. Assert the live tree was reconciled — NoiseEffect swapped to RainbowEffect, Mirror trimmed. -- **Valid JSON with children**: flush a Layer subtree containing two children (each with controls) and assert the raw file contains `"MirrorModifier",` / `"NoiseEffect",` — guards against the missing-comma bug between a child's `"N.type"` field and its first control. -- **Singleton survives probe lifecycle**: construct the real FS instance + register via `setScheduler` (which now binds the singleton), factory-construct+destruct a probe instance (mimicking what `/api/types` does to capture defaults), and assert that `flushPending()` still saves a marked-dirty subtree after the probe is gone — guards against the bug where the probe's destructor cleared the static singleton, silently breaking persistence for the rest of the device's life. - -All four use `platform::fsSetRoot()` to redirect the config root into `/tmp/mm_*_test_/` for isolation. Combined wall time ~2.3s (the debounce window in the first test dominates). - ## Scenario Tests Scenario tests verify the integrated pipeline. Defined as JSON in `test/scenarios/`. Run with `./build/test/mm_scenarios` or via MoonDeck (PC → 01 - Pipeline). @@ -179,8 +160,7 @@ Sets up the core pipeline: LayoutGroup → GridLayout → Layer → RainbowEffec - Buffer allocated after setup - Buffer size matches layout light count - Buffer contains non-zero data after rendering 200 frames -- `fps.min_pct: 80` — render FPS within 20% of the saved baseline (regression guard) -- `fps.min_fps_led_product: 294912` — an absolute throughput floor (see below) +- FPS >= 30 (performance bound) ### mirror (`test/scenarios/mirror.json`) {#scenario-mirror} @@ -219,39 +199,6 @@ Tests performance impact of control changes on a running system. - Checks FPS bounds - Supports baseline comparison for regression detection -### mdns-toggle (`test/scenarios/mdns-toggle.json`) {#scenario-mdns-toggle} - -Measures FPS impact of enabling/disabling mDNS on a running device. - -- Toggles mDNS on → off → on, measures FPS at each step -- Final step asserts `fps.min_pct: 80` — re-enabling mDNS keeps FPS within 20% of baseline - -### grid-resize (`test/scenarios/grid-resize.json`) {#scenario-grid-resize} - -Exercises the light-pipeline adaptive allocation path. Useful for catching regressions in the Layer/DriverGroup allocate path under memory pressure (see plan 11.5 in `docs/plan.md`). - -- Mirror state reset to known values at start (mirrorX=true, mirrorY=true) so subsequent runs don't contaminate each other -- Sizes the grid to 128×128, then shrinks to 128×64, then grows back to 128×128 -- Only the shrink step asserts a bound (`fps.min_pct: 80`) because shrinks always release memory and should match or beat baseline. Grow steps deliberately have no bound — they're inherently slower at a larger size. - -### preview-detail (`test/scenarios/preview-detail.json`) {#scenario-preview-detail} - -Toggles the [PreviewDriver](moonmodules/light/drivers/PreviewDriver.md)'s `detail` (1/2/3) and `decompress` controls on a running device and measures the render-FPS impact. - -- `detail-1`, `detail-2` and both `decompress` steps assert `fps.min_pct: 80`; `detail-3` asserts `fps.min_pct: 70`. The downsample strided copy runs on the render task, so higher detail has a real, **known and accepted** cost (see [performance.md](performance.md) — "Preview `detail` cost on the render tick"; detail 3 measures ~79% of a settled baseline). `decompress` is purely client-side (the browser block-replicates) and cannot change the render tick at all. -- All steps assert a **relative** bound only. A single ESP32 scenario step swings too much (see "Run-to-run tick variance" in performance.md) for an absolute FPS floor to be meaningful here — the absolute `min_fps_led_product` floor is enforced in `collect_kpi.py --commit`, which uses a settled reading. -- Live-only (only `set_control` steps, no `add_module`) — the in-process runner skips it. - -## The FPS×lights throughput floor - -`fps.min_fps_led_product` is an absolute performance floor that scales to any grid. The value is an **FPS × light-count product**; the per-grid floor is `product / lightCount`. Anchored at the 128×128 reference (18 FPS × 16384 lights = 294912), so a smaller grid must run proportionally faster (64×64 = 4096 lights → 72 FPS floor). - -It is compared against the measured **tick time** (the device's native unit — FPS is only ever derived by lossy integer division), as `tick_us ≤ lightCount × 1e6 / product`. Enforced in three places: - -- `test/scenario_runner.cpp` — in-process; the desktop runs far above any floor, so this passes trivially and just exercises the bound logic. -- `scripts/scenario/run_live_scenario.py` — live; derives the light count from the layout modules' `width/height/depth` in `/api/state` and enforces the floor for real on ESP32. -- `scripts/check/collect_kpi.py` — pre-commit (`--commit` mode); fails the commit if a captured ESP32 tick exceeds the budget. - ### Running live scenarios ```bash @@ -263,17 +210,15 @@ uv run scripts/scenario/run_live_scenario.py --compare-baseline # check ### Live scenario behavior -Most scenarios use relative FPS bounds (`min_pct`) so they pass on any device — desktop at 10K FPS or ESP32 at 17 FPS. `base-pipeline` additionally carries the absolute `min_fps_led_product` throughput floor (see above); `preview-detail` deliberately does not — a single preview-toggle step swings too much for an absolute floor (the floor is enforced instead by `collect_kpi.py --commit` on a settled reading). Settle time is 3 seconds to let the pipeline stabilize after rebuilds. +All scenarios use relative FPS bounds (`min_pct`) so they pass on any device — desktop at 10K FPS or ESP32 at 17 FPS. Settle time is 3 seconds to let the pipeline stabilize after rebuilds. Scenarios that add modules (`base-pipeline`, `memory-1to1`) create temporary modules on the running device. These are cleaned up after each scenario (`- Rainbow (cleanup)`). Modules that already exist show `=` instead of `+`. Memory tracking works on ESP32: `freeHeap` and `freeInternalHeap` report real values. Desktop returns 0 (unlimited). The control-change scenario verifies no memory leaks by checking heap returns to baseline after mirror toggle. -`control-change` and `grid-resize` do `set_control` on modules named `Mirror` / `Noise`. They pass only against a device tree that uses those exact names — a tree with `MirrorModifier` / `RainbowEffect` instead reports "module not found" for those steps. This is a scenario/device-tree naming assumption, not a firmware regression; align the scenario JSON with the device's actual module names (or vice versa) before relying on those two scenarios as a gate. - ## Hardware Verification -The live scenarios pass on both desktop and ESP32. Per-module timing, memory allocation, and sizeof measurements for each platform are in [performance.md](performance.md). +All 5 live scenarios pass on both desktop and ESP32 with `min_pct: 80` relative bounds. Per-module timing, memory allocation, and sizeof measurements for each platform are in [performance.md](performance.md). ### ESP32 — Olimex ESP32-Gateway Rev G (no PSRAM) @@ -282,17 +227,6 @@ The live scenarios pass on both desktop and ESP32. Per-module timing, memory all - Ethernet (LAN8720 RMII) connects via DHCP - Device discovery from MoonDeck finds ESP32 on port 80 -### ESP32 build profiles - -The pre-commit ESP32 build must build **both** profiles warning-free under `-Werror`: - -- `build_esp32.py --profile default` — exercises the `hasWiFi == true` code path. -- `build_esp32.py --profile eth-only` — exercises the `hasWiFi == false` path (WiFi compiled out; NetworkModule's `if constexpr` WiFi branches dropped). - -A clean `-Werror` build of each profile *is* the test — `hasWiFi` gating is compile-time, and NetworkModule has no desktop unit tests (OS networking). Desktop builds cover `hasWiFi == true` independently. - -"Warning-free" here means **compiler** warnings on project code. The ESP-IDF *configure* step emits one harmless CMake warning — `gdbinit.cmake: Error while generating esp_rom gdbinit` because `ESP_ROM_ELF_DIR` is unset (it is normally exported by `$IDF_PATH/export.sh`). It affects only `idf.py gdb`, not the firmware, and is expected when building via `build_esp32.py` rather than a full IDF export shell. - ## Adding Tests **Module test:** add a `TEST_CASE` to the appropriate `test/test_*.cpp` file. If the module doesn't have a test file yet, create one and add it to `test/CMakeLists.txt`. Update the matching section in this file. diff --git a/src/core/color.h b/src/core/color.h index dfb5605d..4eb095e2 100644 --- a/src/core/color.h +++ b/src/core/color.h @@ -38,4 +38,50 @@ constexpr uint8_t scale8(uint8_t val, uint8_t scale) { return static_cast(((static_cast(val) * static_cast(scale)) + 1 + ((static_cast(val) * static_cast(scale)) >> 8)) >> 8); } +// 256-entry sine LUT: sin(2*pi*i/256)*127+128, stored in flash. +// Integer-only sin/cos for effects. Use sin8 for sine, cos8 = sin8(i + 64). +inline constexpr uint8_t sin8_lut[256] = { + 128,131,134,137,140,144,147,150,153,156,159,162,165,168,171,174, + 177,179,182,185,188,191,193,196,199,201,204,206,209,211,213,216, + 218,220,222,224,226,228,230,232,234,235,237,239,240,241,243,244, + 245,246,248,249,250,250,251,252,253,253,254,254,254,255,255,255, + 255,255,255,255,254,254,254,253,253,252,251,250,250,249,248,246, + 245,244,243,241,240,239,237,235,234,232,230,228,226,224,222,220, + 218,216,213,211,209,206,204,201,199,196,193,191,188,185,182,179, + 177,174,171,168,165,162,159,156,153,150,147,144,140,137,134,131, + 128,125,122,119,116,112,109,106,103,100,97,94,91,88,85,82, + 79,77,74,71,68,65,63,60,57,55,52,50,47,45,43,40, + 38,36,34,32,30,28,26,24,22,21,19,17,16,15,13,12, + 11,10,8,7,6,6,5,4,3,3,2,2,2,1,1,1, + 1,1,1,1,2,2,2,3,3,4,5,6,6,7,8,10, + 11,12,13,15,16,17,19,21,22,24,26,28,30,32,34,36, + 38,40,43,45,47,50,52,55,57,60,63,65,68,71,74,77, + 79,82,85,88,91,94,97,100,103,106,109,112,116,119,122,125 +}; + +constexpr uint8_t sin8(uint8_t i) { return sin8_lut[i]; } +constexpr uint8_t cos8(uint8_t i) { return sin8_lut[static_cast(i + 64)]; } + +// Fast octant atan2: returns 0-255 for full circle (y, x) in -32768..32767 +constexpr uint8_t atan2_8(int16_t y, int16_t x) { + uint8_t r = 0; + if (y < 0) { y = static_cast(-y); r = 0x80; } + if (x < 0) { x = static_cast(-x); r = static_cast(r | 0x40); } + uint8_t offset = (x > y) ? 0 : 32; + if (x < y) { + int16_t t = y; + y = x; + x = t; + } + uint8_t b = (x == 0) ? 0 : static_cast((static_cast(y) * 64) / static_cast(x)); + return static_cast(r + offset + b); +} + +// Octagonal distance approximation (no sqrt) +constexpr uint8_t dist8(int16_t dx, int16_t dy) { + int16_t ax = dx < 0 ? static_cast(-dx) : dx; + int16_t ay = dy < 0 ? static_cast(-dy) : dy; + return static_cast(ax > ay ? ax + (ay >> 1) : ay + (ax >> 1)); +} + } // namespace mm diff --git a/src/light/CheckerboardEffect.h b/src/light/CheckerboardEffect.h new file mode 100644 index 00000000..ae80307e --- /dev/null +++ b/src/light/CheckerboardEffect.h @@ -0,0 +1,58 @@ +#pragma once + +#include "light/Layer.h" +#include "core/color.h" + +namespace mm { + +class CheckerboardEffect : public EffectBase { +public: + uint8_t cell_size = 4; + uint8_t bpm = 60; + uint8_t hue_a = 0; + uint8_t hue_b = 128; + + void onBuildControls() override { + controls_.addUint8("cell_size", cell_size, 1, 255); + controls_.addUint8("bpm", bpm, 1, 255); + controls_.addUint8("hue_a", hue_a, 0, 255); + controls_.addUint8("hue_b", hue_b, 0, 255); + } + + void loop() override { + uint8_t* buf = buffer(); + lengthType w = width(); + lengthType h = height(); + uint8_t cpl = channelsPerLight(); + + uint32_t now = elapsed(); + uint32_t dt = now - lastElapsed_; + lastElapsed_ = now; + phase_ += static_cast(dt) * bpm * 256 / 60000; + uint8_t phaseCell = static_cast(phase_ >> 4); + + RGB ca = hsvToRgb(hue_a, 255, 255); + RGB cb = hsvToRgb(hue_b, 255, 255); + + for (lengthType y = 0; y < h; y++) { + uint8_t cy = static_cast(static_cast(y) / cell_size); + uint8_t* row = buf + static_cast(y) * static_cast(w) * cpl; + for (lengthType x = 0; x < w; x++) { + uint8_t cx = static_cast(static_cast(x) / cell_size); + bool on = ((cx + cy + phaseCell) & 1) != 0; + RGB c = on ? cb : ca; + + if (cpl >= 1) row[0] = c.r; + if (cpl >= 2) row[1] = c.g; + if (cpl >= 3) row[2] = c.b; + row += cpl; + } + } + } + +private: + uint64_t phase_ = 0; + uint32_t lastElapsed_ = 0; +}; + +} // namespace mm diff --git a/src/light/FireEffect.h b/src/light/FireEffect.h new file mode 100644 index 00000000..6583b218 --- /dev/null +++ b/src/light/FireEffect.h @@ -0,0 +1,147 @@ +#pragma once + +#include "light/Layer.h" +#include "core/color.h" +#include "platform/platform.h" + +#include + +namespace mm { + +class FireEffect : public EffectBase { +public: + uint8_t cooling = 55; + uint8_t sparking = 120; + uint8_t hue_shift = 0; + + void onBuildControls() override { + controls_.addUint8("cooling", cooling, 1, 255); + controls_.addUint8("sparking", sparking, 1, 255); + controls_.addUint8("hue_shift", hue_shift, 0, 255); + } + + void onAllocateMemory() override { + nrOfLightsType count = nrOfLights(); + if (enabled() && count > 0) { + if (count != heatCount_) { + releaseHeat(); + heat_ = static_cast(platform::alloc(count)); + if (heat_) { + std::memset(heat_, 0, count); + heatCount_ = count; + } + } + } else { + releaseHeat(); + } + setDynamicBytes(heatCount_); + } + + void teardown() override { + releaseHeat(); + setDynamicBytes(0); + } + + ~FireEffect() override { + releaseHeat(); + } + + void loop() override { + if (!heat_) return; + + uint8_t* buf = buffer(); + lengthType w = width(); + lengthType h = height(); + uint8_t cpl = channelsPerLight(); + + // 1. Cool every cell by a small random amount + uint8_t coolMax = static_cast((static_cast(cooling) * 10) / (h > 0 ? h : 1) + 2); + for (nrOfLightsType i = 0; i < heatCount_; i++) { + uint8_t c = rand8() % coolMax; + heat_[i] = (heat_[i] > c) ? static_cast(heat_[i] - c) : 0; + } + + // 2. Heat rises: each row averages from the row below (y = h-1 is "bottom", rises to y=0) + for (lengthType y = 0; y + 1 < h; y++) { + for (lengthType x = 0; x < w; x++) { + lengthType yb = y + 1; + lengthType xl = x > 0 ? x - 1 : 0; + lengthType xr = (x + 1 < w) ? x + 1 : x; + uint16_t sum = heat_[yb * w + xl]; + sum += heat_[yb * w + x]; + sum += heat_[yb * w + xr]; + sum += heat_[yb * w + x]; + heat_[y * w + x] = static_cast(sum >> 2); + } + } + + // 3. Random sparks at the bottom row + if (h > 0) { + lengthType bottomRow = static_cast(h - 1); + for (uint8_t i = 0; i < 4; i++) { + if (rand8() < sparking) { + uint8_t sx = static_cast((static_cast(rand8()) * w) >> 8); + uint8_t add = static_cast(160 + (rand8() & 0x5F)); + uint16_t newHeat = static_cast(heat_[bottomRow * w + sx]) + add; + heat_[bottomRow * w + sx] = newHeat > 255 ? 255 : static_cast(newHeat); + } + } + } + + // 4. Render heat to RGB via fire palette (with optional hue rotation) + for (nrOfLightsType i = 0; i < heatCount_; i++) { + RGB c = heatToRgb(heat_[i], hue_shift); + size_t off = static_cast(i) * cpl; + if (cpl >= 1) buf[off + 0] = c.r; + if (cpl >= 2) buf[off + 1] = c.g; + if (cpl >= 3) buf[off + 2] = c.b; + } + } + +private: + uint8_t* heat_ = nullptr; + nrOfLightsType heatCount_ = 0; + uint32_t rngState_ = 0xC0FFEEu; + + uint8_t rand8() { + rngState_ = rngState_ * 1103515245u + 12345u; + return static_cast((rngState_ >> 16) & 0xFF); + } + + void releaseHeat() { + if (heat_) { + platform::free(heat_); + heat_ = nullptr; + } + heatCount_ = 0; + } + + // Heat 0-255 mapped to black -> red -> yellow -> white. + // hue_shift rotates the resulting RGB around the colour wheel (via HSV). + static RGB heatToRgb(uint8_t heat, uint8_t hue_shift) { + uint8_t r, g, b; + if (heat < 85) { + r = static_cast(heat * 3); + g = 0; + b = 0; + } else if (heat < 170) { + r = 255; + g = static_cast((heat - 85) * 3); + b = 0; + } else { + r = 255; + g = 255; + b = static_cast((heat - 170) * 3); + } + if (hue_shift == 0) return {r, g, b}; + // Cheap hue rotation: re-encode via HSV using max-channel as value + uint8_t maxc = r > g ? (r > b ? r : b) : (g > b ? g : b); + if (maxc == 0) return {0, 0, 0}; + // Approximate hue from RGB by walking the existing fire ramp range + // and rotating it by hue_shift. Simpler: just rotate via hsvToRgb with + // heat as the hue input. + return hsvToRgb(static_cast(heat + hue_shift), 255, maxc); + } +}; + +} // namespace mm diff --git a/src/light/GlowParticlesEffect.h b/src/light/GlowParticlesEffect.h new file mode 100644 index 00000000..db4c499e --- /dev/null +++ b/src/light/GlowParticlesEffect.h @@ -0,0 +1,121 @@ +#pragma once + +#include "light/Layer.h" +#include "core/color.h" + +namespace mm { + +// Soft-glowing particles rendered as a metaball field. Particles move with +// independent velocities and bounce off the edges. Field summation gives a +// chaotic, organic blob look — like metaballs with more freedom. +class GlowParticlesEffect : public EffectBase { +public: + static constexpr uint8_t MAX_PARTICLES = 8; + + uint8_t count = 5; + uint8_t speed = 60; + uint8_t radius = 24; + uint8_t hue_shift = 0; + + void onBuildControls() override { + controls_.addUint8("count", count, 1, 255); + controls_.addUint8("speed", speed, 1, 255); + controls_.addUint8("radius", radius, 4, 255); + controls_.addUint8("hue_shift", hue_shift, 0, 255); + } + + void loop() override { + uint8_t* buf = buffer(); + lengthType w = width(); + lengthType h = height(); + uint8_t cpl = channelsPerLight(); + if (w <= 0 || h <= 0) return; + + if (!initialized_) initParticles(w, h); + + uint32_t now = elapsed(); + uint32_t dt = now - lastElapsed_; + lastElapsed_ = now; + + int16_t maxXfp = static_cast((w - 1) << 4); + int16_t maxYfp = static_cast((h - 1) << 4); + + for (uint8_t i = 0; i < count && i < MAX_PARTICLES; i++) { + auto& p = particles_[i]; + int16_t sx = static_cast((static_cast(p.vx) * speed * static_cast(dt)) >> 12); + int16_t sy = static_cast((static_cast(p.vy) * speed * static_cast(dt)) >> 12); + p.x = static_cast(p.x + sx); + p.y = static_cast(p.y + sy); + if (p.x < 0) { p.x = 0; p.vx = static_cast(-p.vx); } + if (p.x > maxXfp) { p.x = maxXfp; p.vx = static_cast(-p.vx); } + if (p.y < 0) { p.y = 0; p.vy = static_cast(-p.vy); } + if (p.y > maxYfp) { p.y = maxYfp; p.vy = static_cast(-p.vy); } + } + + int16_t bx[MAX_PARTICLES] = {}; + int16_t by[MAX_PARTICLES] = {}; + for (uint8_t i = 0; i < count && i < MAX_PARTICLES; i++) { + bx[i] = static_cast(particles_[i].x >> 4); + by[i] = static_cast(particles_[i].y >> 4); + } + int32_t r2 = static_cast(radius) * radius; + + for (lengthType y = 0; y < h; y++) { + uint8_t* row = buf + static_cast(y) * static_cast(w) * cpl; + for (lengthType x = 0; x < w; x++) { + uint32_t field = 0; + for (uint8_t i = 0; i < count && i < MAX_PARTICLES; i++) { + int32_t dx = static_cast(x) - bx[i]; + int32_t dy = static_cast(y) - by[i]; + int32_t d2 = dx * dx + dy * dy + 1; + field += static_cast((r2 * 64) / d2); + } + uint8_t bright = field > 255 ? 255 : static_cast(field); + uint8_t hue = static_cast((field >> 1) + hue_shift); + RGB c = hsvToRgb(hue, 240, bright); + if (cpl >= 1) row[0] = c.r; + if (cpl >= 2) row[1] = c.g; + if (cpl >= 3) row[2] = c.b; + row += cpl; + } + } + } + +private: + struct Particle { + int16_t x; // 12.4 fixed-point pixel position + int16_t y; + int8_t vx; + int8_t vy; + uint8_t hue; + uint8_t pad; + }; + + Particle particles_[MAX_PARTICLES] = {}; + bool initialized_ = false; + uint32_t lastElapsed_ = 0; + uint32_t rngState_ = 0xACE1BEEFu; + + uint8_t rand8() { + rngState_ = rngState_ * 1103515245u + 12345u; + return static_cast((rngState_ >> 16) & 0xFF); + } + + void initParticles(lengthType w, lengthType h) { + for (uint8_t i = 0; i < MAX_PARTICLES; i++) { + particles_[i].x = static_cast((static_cast(rand8()) * w) >> 4); + particles_[i].y = static_cast((static_cast(rand8()) * h) >> 4); + int8_t vx = static_cast((rand8() >> 1) - 32); + int8_t vy = static_cast((rand8() >> 1) - 32); + if (vx == 0) vx = 1; + if (vy == 0) vy = 1; + particles_[i].vx = vx; + particles_[i].vy = vy; + particles_[i].hue = rand8(); + particles_[i].pad = 0; + } + initialized_ = true; + } +}; + +} // namespace mm diff --git a/src/light/LavaLampEffect.h b/src/light/LavaLampEffect.h new file mode 100644 index 00000000..db655f18 --- /dev/null +++ b/src/light/LavaLampEffect.h @@ -0,0 +1,113 @@ +#pragma once + +#include "light/Layer.h" +#include "core/color.h" + +namespace mm { + +// Atmospheric lava-lamp: three slow blobs whose summed field is mapped +// through a black → red → orange → yellow → white palette. +// Distinct from MetaballsEffect (which is fast, HSV-coloured). +class LavaLampEffect : public EffectBase { +public: + static constexpr uint8_t NUM_BLOBS = 3; + + uint8_t bpm = 8; + uint8_t radius = 36; + uint8_t intensity = 200; + + void onBuildControls() override { + controls_.addUint8("bpm", bpm, 1, 255); + controls_.addUint8("radius", radius, 8, 255); + controls_.addUint8("intensity", intensity, 1, 255); + } + + void loop() override { + uint8_t* buf = buffer(); + lengthType w = width(); + lengthType h = height(); + uint8_t cpl = channelsPerLight(); + if (w <= 0 || h <= 0) return; + + uint32_t now = elapsed(); + uint32_t dt = now - lastElapsed_; + lastElapsed_ = now; + phase_ += static_cast(dt) * bpm * 256 / 60000; + uint8_t t = static_cast(phase_); + + int16_t bx[NUM_BLOBS] = {}; + int16_t by[NUM_BLOBS] = {}; + static constexpr uint8_t SPEED_MUL[NUM_BLOBS] = { 1, 2, 1 }; + static constexpr uint8_t PHASE_X[NUM_BLOBS] = { 0, 80, 160 }; + static constexpr uint8_t PHASE_Y[NUM_BLOBS] = { 64, 200, 100 }; + for (uint8_t b = 0; b < NUM_BLOBS; b++) { + uint8_t tb = static_cast(t * SPEED_MUL[b]); + bx[b] = static_cast((sin8(static_cast(tb + PHASE_X[b])) * w) >> 8); + by[b] = static_cast((sin8(static_cast(tb + PHASE_Y[b])) * h) >> 8); + } + int32_t r2 = static_cast(radius) * radius; + + for (lengthType y = 0; y < h; y++) { + uint8_t* row = buf + static_cast(y) * static_cast(w) * cpl; + for (lengthType x = 0; x < w; x++) { + uint32_t field = 0; + for (uint8_t b = 0; b < NUM_BLOBS; b++) { + int32_t dx = static_cast(x) - bx[b]; + int32_t dy = static_cast(y) - by[b]; + int32_t d2 = dx * dx + dy * dy + 1; + field += static_cast((r2 * 64) / d2); + } + uint32_t scaled = (field * intensity) >> 8; + uint8_t idx = scaled > 255 ? 255 : static_cast(scaled); + const RGB& c = palette_[idx]; + if (cpl >= 1) row[0] = c.r; + if (cpl >= 2) row[1] = c.g; + if (cpl >= 3) row[2] = c.b; + row += cpl; + } + } + } + +private: + uint64_t phase_ = 0; + uint32_t lastElapsed_ = 0; + + // Lava palette: black → deep red → red → orange → yellow → near-white. + // 256 RGB entries (768 bytes) — stored in flash. + static constexpr RGB palette_[256] = { + {0,0,0}, {4,0,0}, {8,0,0}, {12,0,0}, {16,0,0}, {20,0,0}, {24,0,0}, {28,0,0}, + {32,0,0}, {36,0,0}, {40,0,0}, {44,0,0}, {48,0,0}, {52,0,0}, {56,0,0}, {60,0,0}, + {64,0,0}, {68,0,0}, {72,0,0}, {76,0,0}, {80,0,0}, {84,0,0}, {88,0,0}, {92,0,0}, + {96,0,0}, {100,0,0}, {104,0,0}, {108,0,0}, {112,0,0}, {116,0,0}, {120,0,0}, {124,0,0}, + {128,0,0}, {132,0,0}, {136,0,0}, {140,0,0}, {144,0,0}, {148,0,0}, {152,0,0}, {156,0,0}, + {160,0,0}, {164,0,0}, {168,0,0}, {172,0,0}, {176,0,0}, {180,0,0}, {184,0,0}, {188,0,0}, + {192,0,0}, {196,0,0}, {200,0,0}, {204,0,0}, {208,0,0}, {212,0,0}, {216,0,0}, {220,0,0}, + {224,0,0}, {228,0,0}, {232,0,0}, {236,0,0}, {240,0,0}, {244,0,0}, {248,0,0}, {252,0,0}, + {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, + {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, {255,0,0}, + {255,0,0}, {255,2,0}, {255,4,0}, {255,6,0}, {255,8,0}, {255,10,0}, {255,12,0}, {255,14,0}, + {255,16,0}, {255,18,0}, {255,20,0}, {255,22,0}, {255,24,0}, {255,26,0}, {255,28,0}, {255,30,0}, + {255,32,0}, {255,34,0}, {255,36,0}, {255,38,0}, {255,40,0}, {255,42,0}, {255,44,0}, {255,46,0}, + {255,48,0}, {255,50,0}, {255,52,0}, {255,54,0}, {255,56,0}, {255,58,0}, {255,60,0}, {255,62,0}, + {255,64,0}, {255,66,0}, {255,68,0}, {255,70,0}, {255,72,0}, {255,74,0}, {255,76,0}, {255,78,0}, + {255,80,0}, {255,82,0}, {255,84,0}, {255,86,0}, {255,88,0}, {255,90,0}, {255,92,0}, {255,94,0}, + {255,96,0}, {255,98,0}, {255,100,0}, {255,102,0}, {255,104,0}, {255,106,0}, {255,108,0}, {255,110,0}, + {255,112,0}, {255,114,0}, {255,116,0}, {255,118,0}, {255,120,0}, {255,122,0}, {255,124,0}, {255,126,0}, + {255,128,0}, {255,130,0}, {255,132,0}, {255,134,0}, {255,136,0}, {255,138,0}, {255,140,0}, {255,142,0}, + {255,144,0}, {255,146,0}, {255,148,0}, {255,150,0}, {255,152,0}, {255,154,0}, {255,156,0}, {255,158,0}, + {255,160,0}, {255,162,0}, {255,164,0}, {255,166,0}, {255,168,0}, {255,170,0}, {255,172,0}, {255,174,0}, + {255,176,0}, {255,178,0}, {255,180,0}, {255,182,0}, {255,184,0}, {255,186,0}, {255,188,0}, {255,190,0}, + {255,192,0}, {255,194,0}, {255,196,0}, {255,198,0}, {255,200,0}, {255,202,0}, {255,204,0}, {255,206,0}, + {255,208,0}, {255,210,0}, {255,212,0}, {255,214,0}, {255,216,0}, {255,218,0}, {255,220,0}, {255,222,0}, + {255,224,0}, {255,226,0}, {255,228,0}, {255,230,0}, {255,232,0}, {255,234,0}, {255,236,0}, {255,238,0}, + {255,240,0}, {255,242,4}, {255,244,8}, {255,246,12}, {255,248,16}, {255,250,20}, {255,252,24}, {255,254,28}, + {255,255,32}, {255,255,36}, {255,255,40}, {255,255,44}, {255,255,48}, {255,255,52}, {255,255,56}, {255,255,60}, + {255,255,64}, {255,255,68}, {255,255,72}, {255,255,76}, {255,255,80}, {255,255,84}, {255,255,88}, {255,255,92}, + {255,255,96}, {255,255,100}, {255,255,104}, {255,255,108}, {255,255,112}, {255,255,116}, {255,255,120}, {255,255,124}, + {255,255,128}, {255,255,132}, {255,255,136}, {255,255,140}, {255,255,144}, {255,255,148}, {255,255,152}, {255,255,156}, + {255,255,160}, {255,255,164}, {255,255,168}, {255,255,172}, {255,255,176}, {255,255,180}, {255,255,184}, {255,255,188}, + {255,255,192}, {255,255,196}, {255,255,200}, {255,255,204}, {255,255,208}, {255,255,212}, {255,255,216}, {255,255,220} + }; +}; + +} // namespace mm diff --git a/src/light/MetaballsEffect.h b/src/light/MetaballsEffect.h new file mode 100644 index 00000000..809dfb18 --- /dev/null +++ b/src/light/MetaballsEffect.h @@ -0,0 +1,75 @@ +#pragma once + +#include "light/Layer.h" +#include "core/color.h" + +namespace mm { + +class MetaballsEffect : public EffectBase { +public: + uint8_t bpm = 30; + uint8_t radius = 28; + uint8_t hue_shift = 0; + + static constexpr uint8_t NUM_BALLS = 4; + + void onBuildControls() override { + controls_.addUint8("bpm", bpm, 1, 255); + controls_.addUint8("radius", radius, 4, 255); + controls_.addUint8("hue_shift", hue_shift, 0, 255); + } + + void loop() override { + uint8_t* buf = buffer(); + lengthType w = width(); + lengthType h = height(); + uint8_t cpl = channelsPerLight(); + + uint32_t now = elapsed(); + uint32_t dt = now - lastElapsed_; + lastElapsed_ = now; + phase_ += static_cast(dt) * bpm * 256 / 60000; + uint8_t t = static_cast(phase_); + + int16_t bx[NUM_BALLS]; + int16_t by[NUM_BALLS]; + static constexpr uint8_t SPEED_MUL[NUM_BALLS] = { 1, 2, 3, 1 }; + static constexpr uint8_t PHASE_X[NUM_BALLS] = { 0, 30, 60, 120 }; + static constexpr uint8_t PHASE_Y[NUM_BALLS] = { 64, 94, 124, 184 }; + for (uint8_t b = 0; b < NUM_BALLS; b++) { + uint8_t tb = static_cast(t * SPEED_MUL[b]); + bx[b] = static_cast((sin8(static_cast(tb + PHASE_X[b])) * w) >> 8); + by[b] = static_cast((sin8(static_cast(tb + PHASE_Y[b])) * h) >> 8); + } + + // Field strength: sum of r^2 / (d^2 + 1) + int32_t r2 = static_cast(radius) * radius; + + for (lengthType y = 0; y < h; y++) { + uint8_t* row = buf + static_cast(y) * w * cpl; + for (lengthType x = 0; x < w; x++) { + uint32_t field = 0; + for (uint8_t b = 0; b < NUM_BALLS; b++) { + int32_t dx = static_cast(x) - bx[b]; + int32_t dy = static_cast(y) - by[b]; + int32_t d2 = dx * dx + dy * dy + 1; + field += static_cast((r2 * 64) / d2); + } + uint8_t bright = field > 255 ? 255 : static_cast(field); + uint8_t hue = static_cast((field >> 1) + hue_shift); + RGB c = hsvToRgb(hue, 240, bright); + + if (cpl >= 1) row[0] = c.r; + if (cpl >= 2) row[1] = c.g; + if (cpl >= 3) row[2] = c.b; + row += cpl; + } + } + } + +private: + uint64_t phase_ = 0; + uint32_t lastElapsed_ = 0; +}; + +} // namespace mm diff --git a/src/light/ParticlesEffect.h b/src/light/ParticlesEffect.h new file mode 100644 index 00000000..41bd29b2 --- /dev/null +++ b/src/light/ParticlesEffect.h @@ -0,0 +1,148 @@ +#pragma once + +#include "light/Layer.h" +#include "core/color.h" +#include "platform/platform.h" + +#include + +namespace mm { + +class ParticlesEffect : public EffectBase { +public: + static constexpr uint8_t MAX_PARTICLES = 64; + + uint8_t count = 32; + uint8_t speed = 80; + uint8_t fade = 240; + uint8_t hue_shift = 0; + + void onBuildControls() override { + controls_.addUint8("count", count, 1, 255); + controls_.addUint8("speed", speed, 1, 255); + controls_.addUint8("fade", fade, 1, 255); + controls_.addUint8("hue_shift", hue_shift, 0, 255); + } + + void onAllocateMemory() override { + nrOfLightsType lights = nrOfLights(); + uint8_t cpl = channelsPerLight(); + size_t needed = static_cast(lights) * cpl; + if (enabled() && needed > 0) { + if (needed != trailBytes_) { + releaseTrail(); + trail_ = static_cast(platform::alloc(needed)); + if (trail_) { + std::memset(trail_, 0, needed); + trailBytes_ = needed; + } + } + initParticles(); + } else { + releaseTrail(); + } + setDynamicBytes(trailBytes_); + } + + void teardown() override { + releaseTrail(); + setDynamicBytes(0); + } + + ~ParticlesEffect() override { + releaseTrail(); + } + + void loop() override { + if (!trail_) return; + + lengthType w = width(); + lengthType h = height(); + uint8_t cpl = channelsPerLight(); + uint8_t* buf = buffer(); + + // 1. Fade the persistent trail buffer + for (size_t i = 0; i < trailBytes_; i++) { + trail_[i] = scale8(trail_[i], fade); + } + + // 2. Update and draw particles + int16_t maxXfp = static_cast((w - 1) << 4); + int16_t maxYfp = static_cast((h - 1) << 4); + for (uint8_t i = 0; i < count && i < MAX_PARTICLES; i++) { + auto& p = particles_[i]; + + int16_t sx = static_cast((static_cast(p.vx) * speed) >> 6); + int16_t sy = static_cast((static_cast(p.vy) * speed) >> 6); + p.x = static_cast(p.x + sx); + p.y = static_cast(p.y + sy); + + if (p.x < 0) { p.x = 0; p.vx = static_cast(-p.vx); } + if (p.x > maxXfp) { p.x = maxXfp; p.vx = static_cast(-p.vx); } + if (p.y < 0) { p.y = 0; p.vy = static_cast(-p.vy); } + if (p.y > maxYfp) { p.y = maxYfp; p.vy = static_cast(-p.vy); } + + lengthType px = static_cast(p.x >> 4); + lengthType py = static_cast(p.y >> 4); + if (px >= 0 && px < w && py >= 0 && py < h) { + RGB c = hsvToRgb(static_cast(p.hue + hue_shift), 255, 255); + size_t off = (static_cast(py) * w + px) * cpl; + if (cpl >= 1) trail_[off + 0] = c.r; + if (cpl >= 2) trail_[off + 1] = c.g; + if (cpl >= 3) trail_[off + 2] = c.b; + } + } + + // 3. Copy persistent trail buffer to layer buffer (layer cleared it) + std::memcpy(buf, trail_, trailBytes_); + } + +private: + struct Particle { + int16_t x; // 12.4 fixed-point pixel position + int16_t y; + int8_t vx; + int8_t vy; + uint8_t hue; + uint8_t pad; + }; + + Particle particles_[MAX_PARTICLES] = {}; + bool initialized_ = false; + uint8_t* trail_ = nullptr; + size_t trailBytes_ = 0; + uint32_t rngState_ = 0xBADF00Du; + + uint8_t rand8() { + rngState_ = rngState_ * 1103515245u + 12345u; + return static_cast((rngState_ >> 16) & 0xFF); + } + + void initParticles() { + lengthType w = width(); + lengthType h = height(); + if (w <= 0 || h <= 0) return; + if (initialized_) return; + for (uint8_t i = 0; i < MAX_PARTICLES; i++) { + particles_[i].x = static_cast((static_cast(rand8()) * w) >> 4); + particles_[i].y = static_cast((static_cast(rand8()) * h) >> 4); + particles_[i].vx = static_cast(rand8()) >> 1; + particles_[i].vy = static_cast(rand8()) >> 1; + if (particles_[i].vx == 0) particles_[i].vx = 1; + if (particles_[i].vy == 0) particles_[i].vy = 1; + particles_[i].hue = rand8(); + particles_[i].pad = 0; + } + initialized_ = true; + } + + void releaseTrail() { + if (trail_) { + platform::free(trail_); + trail_ = nullptr; + } + trailBytes_ = 0; + } +}; + +} // namespace mm diff --git a/src/light/PlasmaEffect.h b/src/light/PlasmaEffect.h new file mode 100644 index 00000000..3a060ff2 --- /dev/null +++ b/src/light/PlasmaEffect.h @@ -0,0 +1,67 @@ +#pragma once + +#include "light/Layer.h" +#include "core/color.h" + +namespace mm { + +class PlasmaEffect : public EffectBase { +public: + uint8_t bpm = 30; + uint8_t scale_x = 16; + uint8_t scale_y = 16; + uint8_t hue_shift = 0; + + void onBuildControls() override { + controls_.addUint8("bpm", bpm, 1, 255); + controls_.addUint8("scale_x", scale_x, 1, 255); + controls_.addUint8("scale_y", scale_y, 1, 255); + controls_.addUint8("hue_shift", hue_shift, 0, 255); + } + + void loop() override { + uint8_t* buf = buffer(); + lengthType w = width(); + lengthType h = height(); + uint8_t cpl = channelsPerLight(); + + uint32_t now = elapsed(); + uint32_t dt = now - lastElapsed_; + lastElapsed_ = now; + phase_ += static_cast(dt) * bpm * static_cast(w) * 64 / 60000; + + uint8_t step_x = static_cast(256 / scale_x); + uint8_t step_y = static_cast(256 / scale_y); + uint8_t t1 = static_cast(phase_); + uint8_t t2 = static_cast(phase_ * 2); + uint8_t t3 = static_cast(phase_ * 3); + + for (lengthType y = 0; y < h; y++) { + uint8_t s2_y = sin8(static_cast(static_cast(y) * step_y + t2)); + uint8_t yx_off = static_cast(static_cast(y) * step_x - t3); + uint8_t yx_neg = static_cast(128 - static_cast(y) * step_y + t1); + + uint8_t* row = buf + static_cast(y) * static_cast(w) * cpl; + for (lengthType x = 0; x < w; x++) { + uint8_t xs = static_cast(static_cast(x) * step_x); + uint8_t s1 = sin8(static_cast(xs + t1)); + uint8_t s3 = sin8(static_cast(xs + yx_off)); + uint8_t s4 = sin8(static_cast( + static_cast(static_cast(x) * step_y) + yx_neg)); + uint8_t hue = static_cast(((s1 + s2_y + s3 + s4) >> 2) + hue_shift); + RGB c = hsvToRgb(hue, 255, 255); + + if (cpl >= 1) row[0] = c.r; + if (cpl >= 2) row[1] = c.g; + if (cpl >= 3) row[2] = c.b; + row += cpl; + } + } + } + +private: + uint64_t phase_ = 0; + uint32_t lastElapsed_ = 0; +}; + +} // namespace mm diff --git a/src/light/PlasmaPaletteEffect.h b/src/light/PlasmaPaletteEffect.h new file mode 100644 index 00000000..9045d4d9 --- /dev/null +++ b/src/light/PlasmaPaletteEffect.h @@ -0,0 +1,101 @@ +#pragma once + +#include "light/Layer.h" +#include "core/color.h" + +namespace mm { + +class PlasmaPaletteEffect : public EffectBase { +public: + uint8_t bpm = 30; + uint8_t scale_x = 16; + uint8_t scale_y = 16; + + void onBuildControls() override { + controls_.addUint8("bpm", bpm, 1, 255); + controls_.addUint8("scale_x", scale_x, 1, 255); + controls_.addUint8("scale_y", scale_y, 1, 255); + } + + void loop() override { + uint8_t* buf = buffer(); + lengthType w = width(); + lengthType h = height(); + uint8_t cpl = channelsPerLight(); + + uint32_t now = elapsed(); + uint32_t dt = now - lastElapsed_; + lastElapsed_ = now; + phase_ += static_cast(dt) * bpm * static_cast(w) * 64 / 60000; + + uint8_t step_x = static_cast(256 / scale_x); + uint8_t step_y = static_cast(256 / scale_y); + uint8_t t1 = static_cast(phase_); + uint8_t t2 = static_cast(phase_ * 2); + uint8_t t3 = static_cast(phase_ * 3); + + for (lengthType y = 0; y < h; y++) { + uint8_t s2_y = sin8(static_cast(static_cast(y) * step_y + t2)); + uint8_t yx_off = static_cast(static_cast(y) * step_x - t3); + uint8_t yx_neg = static_cast(128 - static_cast(y) * step_y + t1); + + uint8_t* row = buf + static_cast(y) * static_cast(w) * cpl; + for (lengthType x = 0; x < w; x++) { + uint8_t xs = static_cast(static_cast(x) * step_x); + uint8_t s1 = sin8(static_cast(xs + t1)); + uint8_t s3 = sin8(static_cast(xs + yx_off)); + uint8_t s4 = sin8(static_cast( + static_cast(static_cast(x) * step_y) + yx_neg)); + uint8_t idx = static_cast(((s1 + s2_y + s3 + s4) >> 2)); + const RGB& c = palette_[idx]; + + if (cpl >= 1) row[0] = c.r; + if (cpl >= 2) row[1] = c.g; + if (cpl >= 3) row[2] = c.b; + row += cpl; + } + } + } + +private: + uint64_t phase_ = 0; + uint32_t lastElapsed_ = 0; + + // Fire-ocean gradient palette (256 RGB entries in flash) + static constexpr RGB palette_[256] = { + {0,0,0}, {4,0,2}, {8,0,4}, {12,0,6}, {16,0,8}, {20,0,10}, {24,0,12}, {28,0,14}, + {32,0,16}, {36,0,18}, {40,0,20}, {44,0,22}, {48,0,24}, {52,0,26}, {56,0,28}, {60,0,30}, + {64,0,32}, {68,0,34}, {72,0,36}, {76,0,38}, {80,0,40}, {84,0,42}, {88,0,44}, {92,0,46}, + {96,0,48}, {100,0,50}, {104,0,52}, {108,0,54}, {112,0,56}, {116,0,58}, {120,0,60}, {124,0,62}, + {128,0,64}, {132,0,66}, {136,0,68}, {140,0,70}, {144,0,72}, {148,0,74}, {152,0,76}, {156,0,78}, + {160,0,80}, {164,0,82}, {168,0,84}, {172,0,86}, {176,0,88}, {180,0,90}, {184,0,92}, {188,0,94}, + {192,0,96}, {196,0,98}, {200,0,100}, {204,0,102}, {208,0,104}, {212,0,106}, {216,0,108}, {220,0,110}, + {224,0,112}, {228,0,114}, {232,0,116}, {236,0,118}, {240,0,120}, {244,0,122}, {248,0,124}, {252,0,126}, + {255,0,0}, {255,4,0}, {255,8,0}, {255,12,0}, {255,16,0}, {255,20,0}, {255,24,0}, {255,28,0}, + {255,32,0}, {255,36,0}, {255,40,0}, {255,44,0}, {255,48,0}, {255,52,0}, {255,56,0}, {255,60,0}, + {255,64,0}, {255,68,0}, {255,72,0}, {255,76,0}, {255,80,0}, {255,84,0}, {255,88,0}, {255,92,0}, + {255,96,0}, {255,100,0}, {255,104,0}, {255,108,0}, {255,112,0}, {255,116,0}, {255,120,0}, {255,124,0}, + {255,128,0}, {255,132,0}, {255,136,0}, {255,140,0}, {255,144,0}, {255,148,0}, {255,152,0}, {255,156,0}, + {255,160,0}, {255,164,0}, {255,168,0}, {255,172,0}, {255,176,0}, {255,180,0}, {255,184,0}, {255,188,0}, + {255,192,0}, {255,196,0}, {255,200,0}, {255,204,0}, {255,208,0}, {255,212,0}, {255,216,0}, {255,220,0}, + {255,224,0}, {255,228,0}, {255,232,0}, {255,236,0}, {255,240,0}, {255,244,0}, {255,248,0}, {255,252,0}, + {255,255,0}, {253,255,2}, {251,255,4}, {249,255,6}, {247,255,8}, {245,255,10}, {243,255,12}, {241,255,14}, + {239,255,16}, {237,255,18}, {235,255,20}, {233,255,22}, {231,255,24}, {229,255,26}, {227,255,28}, {225,255,30}, + {223,255,32}, {221,255,34}, {219,255,36}, {217,255,38}, {215,255,40}, {213,255,42}, {211,255,44}, {209,255,46}, + {207,255,48}, {205,255,50}, {203,255,52}, {201,255,54}, {199,255,56}, {197,255,58}, {195,255,60}, {193,255,62}, + {191,255,64}, {189,255,66}, {187,255,68}, {185,255,70}, {183,255,72}, {181,255,74}, {179,255,76}, {177,255,78}, + {175,255,80}, {173,255,82}, {171,255,84}, {169,255,86}, {167,255,88}, {165,255,90}, {163,255,92}, {161,255,94}, + {159,255,96}, {157,255,98}, {155,255,100}, {153,255,102}, {151,255,104}, {149,255,106}, {147,255,108}, {145,255,110}, + {143,255,112}, {141,255,114}, {139,255,116}, {137,255,118}, {135,255,120}, {133,255,122}, {131,255,124}, {129,255,126}, + {0,255,255}, {0,253,255}, {0,251,255}, {0,249,255}, {0,247,255}, {0,245,255}, {0,243,255}, {0,241,255}, + {0,239,255}, {0,237,255}, {0,235,255}, {0,233,255}, {0,231,255}, {0,229,255}, {0,227,255}, {0,225,255}, + {0,223,255}, {0,221,255}, {0,219,255}, {0,217,255}, {0,215,255}, {0,213,255}, {0,211,255}, {0,209,255}, + {0,207,255}, {0,205,255}, {0,203,255}, {0,201,255}, {0,199,255}, {0,197,255}, {0,195,255}, {0,193,255}, + {0,191,255}, {0,189,255}, {0,187,255}, {0,185,255}, {0,183,255}, {0,181,255}, {0,179,255}, {0,177,255}, + {0,175,255}, {0,173,255}, {0,171,255}, {0,169,255}, {0,167,255}, {0,165,255}, {0,163,255}, {0,161,255}, + {0,159,255}, {0,157,255}, {0,155,255}, {0,153,255}, {0,151,255}, {0,149,255}, {0,147,255}, {0,145,255}, + {0,143,255}, {0,141,255}, {0,139,255}, {0,137,255}, {0,135,255}, {0,133,255}, {0,131,255}, {0,129,255} + }; +}; + +} // namespace mm diff --git a/src/light/RipplesEffect.h b/src/light/RipplesEffect.h new file mode 100644 index 00000000..0e014290 --- /dev/null +++ b/src/light/RipplesEffect.h @@ -0,0 +1,114 @@ +#pragma once + +#include "light/Layer.h" +#include "core/color.h" + +namespace mm { + +// Expanding concentric rings from random centre points. +// Each ripple grows continuously and respawns at a fresh random +// position once it leaves the visible area. Multiple ripples overlap. +class RipplesEffect : public EffectBase { +public: + static constexpr uint8_t MAX_RIPPLES = 8; + + uint8_t count = 4; + uint8_t speed = 60; + uint8_t thickness = 3; + uint8_t hue_shift = 0; + + void onBuildControls() override { + controls_.addUint8("count", count, 1, 255); + controls_.addUint8("speed", speed, 1, 255); + controls_.addUint8("thickness", thickness, 1, 255); + controls_.addUint8("hue_shift", hue_shift, 0, 255); + } + + void loop() override { + uint8_t* buf = buffer(); + lengthType w = width(); + lengthType h = height(); + uint8_t cpl = channelsPerLight(); + if (w <= 0 || h <= 0) return; + + // Visible radius limit (octagonal distance to far corner) + uint8_t maxR = dist8(static_cast(w), static_cast(h)); + + if (!initialized_) { + for (uint8_t i = 0; i < MAX_RIPPLES; i++) { + spawn(i, w, h); + // Stagger initial radii so ripples are spread across all sizes + radius_[i] = static_cast((i * maxR) / MAX_RIPPLES); + } + initialized_ = true; + } + + uint32_t now = elapsed(); + uint32_t dt = now - lastElapsed_; + lastElapsed_ = now; + // growth in radius units per frame (scaled by speed control + dt) + uint16_t growth = static_cast((static_cast(speed) * dt) >> 7); + if (growth == 0) growth = 1; + + for (uint8_t i = 0; i < count && i < MAX_RIPPLES; i++) { + uint16_t next = static_cast(radius_[i]) + growth; + if (next > maxR) { + spawn(i, w, h); + } else { + radius_[i] = static_cast(next); + } + } + + for (lengthType y = 0; y < h; y++) { + uint8_t* row = buf + static_cast(y) * static_cast(w) * cpl; + for (lengthType x = 0; x < w; x++) { + uint16_t r_acc = 0, g_acc = 0, b_acc = 0; + for (uint8_t i = 0; i < count && i < MAX_RIPPLES; i++) { + int16_t dx = static_cast(x - cx_[i]); + int16_t dy = static_cast(y - cy_[i]); + uint8_t d = dist8(dx, dy); + int16_t diff = static_cast(d) - static_cast(radius_[i]); + if (diff < 0) diff = static_cast(-diff); + if (diff < thickness) { + // Brightness peaks at ring centre, falls off with distance from ring. + uint8_t falloff = static_cast(((thickness - diff) * 255) / thickness); + // Older ripples (large radius) fade out. + uint8_t age_fade = static_cast(255 - ((radius_[i] * 255u) / maxR)); + uint8_t intensity = scale8(falloff, age_fade); + RGB c = hsvToRgb(static_cast(hue_[i] + hue_shift), 240, intensity); + r_acc = static_cast(r_acc + c.r); + g_acc = static_cast(g_acc + c.g); + b_acc = static_cast(b_acc + c.b); + } + } + if (cpl >= 1) row[0] = r_acc > 255 ? 255 : static_cast(r_acc); + if (cpl >= 2) row[1] = g_acc > 255 ? 255 : static_cast(g_acc); + if (cpl >= 3) row[2] = b_acc > 255 ? 255 : static_cast(b_acc); + row += cpl; + } + } + } + +private: + lengthType cx_[MAX_RIPPLES] = {}; + lengthType cy_[MAX_RIPPLES] = {}; + uint8_t radius_[MAX_RIPPLES] = {}; + uint8_t hue_[MAX_RIPPLES] = {}; + bool initialized_ = false; + uint32_t lastElapsed_ = 0; + uint32_t rngState_ = 0xC0DECAFEu; + + uint8_t rand8() { + rngState_ = rngState_ * 1103515245u + 12345u; + return static_cast((rngState_ >> 16) & 0xFF); + } + + void spawn(uint8_t i, lengthType w, lengthType h) { + cx_[i] = static_cast((static_cast(rand8()) * w) >> 8); + cy_[i] = static_cast((static_cast(rand8()) * h) >> 8); + radius_[i] = 0; + hue_[i] = rand8(); + } +}; + +} // namespace mm diff --git a/src/light/SpiralEffect.h b/src/light/SpiralEffect.h new file mode 100644 index 00000000..20fcd26b --- /dev/null +++ b/src/light/SpiralEffect.h @@ -0,0 +1,59 @@ +#pragma once + +#include "light/Layer.h" +#include "core/color.h" + +namespace mm { + +class SpiralEffect : public EffectBase { +public: + uint8_t bpm = 40; + uint8_t twist = 4; + uint8_t hue_shift = 0; + + void onBuildControls() override { + controls_.addUint8("bpm", bpm, 1, 255); + controls_.addUint8("twist", twist, 1, 255); + controls_.addUint8("hue_shift", hue_shift, 0, 255); + } + + void loop() override { + uint8_t* buf = buffer(); + lengthType w = width(); + lengthType h = height(); + uint8_t cpl = channelsPerLight(); + + uint32_t now = elapsed(); + uint32_t dt = now - lastElapsed_; + lastElapsed_ = now; + phase_ += static_cast(dt) * bpm * 256 / 60000; + uint8_t t = static_cast(phase_); + + int16_t cx = static_cast(w >> 1); + int16_t cy = static_cast(h >> 1); + + 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++) { + int16_t dx = static_cast(x) - cx; + uint8_t angle = atan2_8(dy, dx); + uint8_t dist = dist8(dx, dy); + uint8_t hue = static_cast( + angle + static_cast(dist * twist) - t + hue_shift); + RGB c = hsvToRgb(hue, 255, 255); + + if (cpl >= 1) row[0] = c.r; + if (cpl >= 2) row[1] = c.g; + if (cpl >= 3) row[2] = c.b; + row += cpl; + } + } + } + +private: + uint64_t phase_ = 0; + uint32_t lastElapsed_ = 0; +}; + +} // namespace mm diff --git a/src/main.cpp b/src/main.cpp index 235a28f2..3ca0b647 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,6 +2,16 @@ #include "light/GridLayout.h" #include "light/RainbowEffect.h" #include "light/NoiseEffect.h" +#include "light/PlasmaEffect.h" +#include "light/PlasmaPaletteEffect.h" +#include "light/MetaballsEffect.h" +#include "light/FireEffect.h" +#include "light/ParticlesEffect.h" +#include "light/GlowParticlesEffect.h" +#include "light/CheckerboardEffect.h" +#include "light/SpiralEffect.h" +#include "light/RipplesEffect.h" +#include "light/LavaLampEffect.h" #include "light/MirrorModifier.h" #include "light/ArtNetSendDriver.h" #include "light/PreviewDriver.h" @@ -24,6 +34,16 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("GridLayout"); mm::ModuleFactory::registerType("RainbowEffect"); mm::ModuleFactory::registerType("NoiseEffect"); + mm::ModuleFactory::registerType("PlasmaEffect"); + mm::ModuleFactory::registerType("PlasmaPaletteEffect"); + mm::ModuleFactory::registerType("MetaballsEffect"); + mm::ModuleFactory::registerType("FireEffect"); + mm::ModuleFactory::registerType("ParticlesEffect"); + mm::ModuleFactory::registerType("GlowParticlesEffect"); + mm::ModuleFactory::registerType("CheckerboardEffect"); + mm::ModuleFactory::registerType("SpiralEffect"); + mm::ModuleFactory::registerType("RipplesEffect"); + mm::ModuleFactory::registerType("LavaLampEffect"); mm::ModuleFactory::registerType("MirrorModifier"); mm::ModuleFactory::registerType("ArtNetSendDriver"); mm::ModuleFactory::registerType("PreviewDriver"); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 5873bf7e..bbc1bb4d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -14,6 +14,11 @@ add_executable(mm_tests test_movechild.cpp test_module_factory.cpp test_preview_driver.cpp + test_fire.cpp + test_metaballs.cpp + test_particles.cpp + test_plasma.cpp + test_stateless_effects.cpp ) target_include_directories(mm_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/test/test_fire.cpp b/test/test_fire.cpp new file mode 100644 index 00000000..d2455f7c --- /dev/null +++ b/test/test_fire.cpp @@ -0,0 +1,75 @@ +#include "doctest.h" +#include "light/FireEffect.h" +#include "light/GridLayout.h" + +TEST_CASE("FireEffect allocates heat buffer when enabled") { + mm::LayoutGroup layoutGroup; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layoutGroup.addChild(&grid); + + mm::Layer layer; + layer.setLayoutGroup(&layoutGroup); + layer.setChannelsPerLight(3); + + mm::FireEffect fire; + layer.addChild(&fire); + + layer.onAllocateMemory(); + CHECK(fire.dynamicBytes() == 16 * 16); +} + +TEST_CASE("FireEffect renders non-zero buffer after enough sparks") { + mm::LayoutGroup layoutGroup; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layoutGroup.addChild(&grid); + + mm::Layer layer; + layer.setLayoutGroup(&layoutGroup); + layer.setChannelsPerLight(3); + + mm::FireEffect fire; + fire.sparking = 255; + layer.addChild(&fire); + + layer.onAllocateMemory(); + + // Run several frames so sparks emerge and propagate + bool hasNonZero = false; + for (int frame = 0; frame < 50 && !hasNonZero; frame++) { + layer.loop(); + auto& buf = layer.buffer(); + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { hasNonZero = true; break; } + } + } + CHECK(hasNonZero); +} + +TEST_CASE("FireEffect frees heat buffer when disabled") { + mm::LayoutGroup layoutGroup; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 1; + layoutGroup.addChild(&grid); + + mm::Layer layer; + layer.setLayoutGroup(&layoutGroup); + layer.setChannelsPerLight(3); + + mm::FireEffect fire; + layer.addChild(&fire); + + layer.onAllocateMemory(); + CHECK(fire.dynamicBytes() > 0); + + fire.setEnabled(false); + fire.onAllocateMemory(); + CHECK(fire.dynamicBytes() == 0); +} diff --git a/test/test_metaballs.cpp b/test/test_metaballs.cpp new file mode 100644 index 00000000..eafae4e8 --- /dev/null +++ b/test/test_metaballs.cpp @@ -0,0 +1,56 @@ +#include "doctest.h" +#include "light/MetaballsEffect.h" +#include "light/GridLayout.h" + +TEST_CASE("MetaballsEffect writes non-zero RGB data to buffer") { + mm::LayoutGroup layoutGroup; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layoutGroup.addChild(&grid); + + mm::Layer layer; + layer.setLayoutGroup(&layoutGroup); + layer.setChannelsPerLight(3); + + mm::MetaballsEffect metaballs; + layer.addChild(&metaballs); + + layer.onAllocateMemory(); + layer.loop(); + + 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; } + } + CHECK(hasNonZero); +} + +TEST_CASE("MetaballsEffect produces spatial variation") { + mm::LayoutGroup layoutGroup; + mm::GridLayout grid; + grid.width = 32; + grid.height = 32; + grid.depth = 1; + layoutGroup.addChild(&grid); + + mm::Layer layer; + layer.setLayoutGroup(&layoutGroup); + layer.setChannelsPerLight(3); + + mm::MetaballsEffect metaballs; + layer.addChild(&metaballs); + + layer.onAllocateMemory(); + layer.loop(); + + auto* data = layer.buffer().data(); + uint8_t r0 = data[0], g0 = data[1], b0 = data[2]; + size_t lastIdx = (32 * 32 - 1) * 3; + uint8_t r1 = data[lastIdx], g1 = data[lastIdx + 1], b1 = data[lastIdx + 2]; + CHECK((r0 != r1 || g0 != g1 || b0 != b1)); +} diff --git a/test/test_particles.cpp b/test/test_particles.cpp new file mode 100644 index 00000000..b3936ee5 --- /dev/null +++ b/test/test_particles.cpp @@ -0,0 +1,71 @@ +#include "doctest.h" +#include "light/ParticlesEffect.h" +#include "light/GridLayout.h" + +TEST_CASE("ParticlesEffect allocates trail buffer when enabled") { + mm::LayoutGroup layoutGroup; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layoutGroup.addChild(&grid); + + mm::Layer layer; + layer.setLayoutGroup(&layoutGroup); + layer.setChannelsPerLight(3); + + mm::ParticlesEffect particles; + layer.addChild(&particles); + + layer.onAllocateMemory(); + CHECK(particles.dynamicBytes() == 16 * 16 * 3); +} + +TEST_CASE("ParticlesEffect renders non-zero buffer after one frame") { + mm::LayoutGroup layoutGroup; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layoutGroup.addChild(&grid); + + mm::Layer layer; + layer.setLayoutGroup(&layoutGroup); + layer.setChannelsPerLight(3); + + mm::ParticlesEffect particles; + layer.addChild(&particles); + + layer.onAllocateMemory(); + layer.loop(); + + auto& buf = layer.buffer(); + bool hasNonZero = false; + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) { hasNonZero = true; break; } + } + CHECK(hasNonZero); +} + +TEST_CASE("ParticlesEffect frees trail buffer when disabled") { + mm::LayoutGroup layoutGroup; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 1; + layoutGroup.addChild(&grid); + + mm::Layer layer; + layer.setLayoutGroup(&layoutGroup); + layer.setChannelsPerLight(3); + + mm::ParticlesEffect particles; + layer.addChild(&particles); + + layer.onAllocateMemory(); + CHECK(particles.dynamicBytes() > 0); + + particles.setEnabled(false); + particles.onAllocateMemory(); + CHECK(particles.dynamicBytes() == 0); +} diff --git a/test/test_plasma.cpp b/test/test_plasma.cpp new file mode 100644 index 00000000..287b2856 --- /dev/null +++ b/test/test_plasma.cpp @@ -0,0 +1,91 @@ +#include "doctest.h" +#include "light/PlasmaEffect.h" +#include "light/NoiseEffect.h" +#include "light/GridLayout.h" + +TEST_CASE("PlasmaEffect writes non-zero RGB data to buffer") { + mm::LayoutGroup layoutGroup; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 1; + layoutGroup.addChild(&grid); + + mm::Layer layer; + layer.setLayoutGroup(&layoutGroup); + layer.setChannelsPerLight(3); + + mm::PlasmaEffect plasma; + layer.addChild(&plasma); + + layer.onAllocateMemory(); + layer.loop(); + + 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; } + } + CHECK(hasNonZero); +} + +TEST_CASE("PlasmaEffect produces spatial variation") { + mm::LayoutGroup layoutGroup; + mm::GridLayout grid; + grid.width = 16; + grid.height = 16; + grid.depth = 1; + layoutGroup.addChild(&grid); + + mm::Layer layer; + layer.setLayoutGroup(&layoutGroup); + layer.setChannelsPerLight(3); + + mm::PlasmaEffect plasma; + layer.addChild(&plasma); + + layer.onAllocateMemory(); + layer.loop(); + + auto* data = layer.buffer().data(); + 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)); +} + +TEST_CASE("PlasmaEffect produces different output than NoiseEffect") { + mm::LayoutGroup layoutGroup; + mm::GridLayout grid; + grid.width = 8; + grid.height = 8; + grid.depth = 1; + layoutGroup.addChild(&grid); + + mm::Layer layer1; + layer1.setLayoutGroup(&layoutGroup); + layer1.setChannelsPerLight(3); + mm::PlasmaEffect plasma; + layer1.addChild(&plasma); + layer1.onAllocateMemory(); + layer1.loop(); + + mm::Layer layer2; + layer2.setLayoutGroup(&layoutGroup); + layer2.setChannelsPerLight(3); + mm::NoiseEffect noise; + layer2.addChild(&noise); + layer2.onAllocateMemory(); + layer2.loop(); + + 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; + } + } + CHECK(differs); +} diff --git a/test/test_stateless_effects.cpp b/test/test_stateless_effects.cpp new file mode 100644 index 00000000..6dd26fa5 --- /dev/null +++ b/test/test_stateless_effects.cpp @@ -0,0 +1,118 @@ +#include "doctest.h" +#include "light/CheckerboardEffect.h" +#include "light/SpiralEffect.h" +#include "light/PlasmaPaletteEffect.h" +#include "light/RipplesEffect.h" +#include "light/GlowParticlesEffect.h" +#include "light/LavaLampEffect.h" +#include "light/GridLayout.h" + +namespace { + +struct Ctx { + mm::LayoutGroup layoutGroup; + mm::GridLayout grid; + mm::Layer layer; + + Ctx(int w, int h) { + grid.width = static_cast(w); + grid.height = static_cast(h); + grid.depth = 1; + layoutGroup.addChild(&grid); + layer.setLayoutGroup(&layoutGroup); + layer.setChannelsPerLight(3); + } + + bool hasNonZero() { + auto& buf = layer.buffer(); + for (size_t i = 0; i < buf.bytes(); i++) { + if (buf.data()[i] != 0) return true; + } + return false; + } + + bool cornersDiffer() { + auto* data = layer.buffer().data(); + size_t last = (static_cast(grid.width) * grid.height - 1) * 3; + return data[0] != data[last] || data[1] != data[last + 1] || data[2] != data[last + 2]; + } + + // For effects with localised features (e.g. ripples): scan all pixels for + // at least two distinct RGB triplets. + bool hasTwoDistinctColors() { + auto* data = layer.buffer().data(); + size_t pixels = static_cast(grid.width) * grid.height; + for (size_t i = 1; i < pixels; i++) { + size_t a = (i - 1) * 3; + size_t b = i * 3; + if (data[a] != data[b] || data[a + 1] != data[b + 1] || data[a + 2] != data[b + 2]) { + return true; + } + } + return false; + } +}; + +} // namespace + +#define STATELESS_EFFECT_TEST(EFFECT) \ + TEST_CASE(#EFFECT " writes non-zero RGB") { \ + Ctx ctx(16, 16); \ + mm::EFFECT effect; \ + ctx.layer.addChild(&effect); \ + ctx.layer.onAllocateMemory(); \ + ctx.layer.loop(); \ + CHECK(ctx.hasNonZero()); \ + } \ + TEST_CASE(#EFFECT " spatial variation") { \ + Ctx ctx(32, 32); \ + mm::EFFECT effect; \ + ctx.layer.addChild(&effect); \ + ctx.layer.onAllocateMemory(); \ + ctx.layer.loop(); \ + CHECK(ctx.cornersDiffer()); \ + } + +TEST_CASE("CheckerboardEffect writes non-zero RGB") { + Ctx ctx(16, 16); + mm::CheckerboardEffect effect; + ctx.layer.addChild(&effect); + ctx.layer.onAllocateMemory(); + ctx.layer.loop(); + CHECK(ctx.hasNonZero()); +} + +TEST_CASE("CheckerboardEffect spatial variation") { + Ctx ctx(32, 32); + mm::CheckerboardEffect effect; + effect.cell_size = 4; + ctx.layer.addChild(&effect); + ctx.layer.onAllocateMemory(); + ctx.layer.loop(); + auto* data = ctx.layer.buffer().data(); + CHECK(data[0] != data[4 * 3]); +} +STATELESS_EFFECT_TEST(SpiralEffect) +STATELESS_EFFECT_TEST(PlasmaPaletteEffect) +STATELESS_EFFECT_TEST(GlowParticlesEffect) +STATELESS_EFFECT_TEST(LavaLampEffect) + +// RipplesEffect has localised features (thin rings); corner-pair check is +// too strict, so we scan for any two distinct pixels instead. +TEST_CASE("RipplesEffect writes non-zero RGB") { + Ctx ctx(16, 16); + mm::RipplesEffect effect; + ctx.layer.addChild(&effect); + ctx.layer.onAllocateMemory(); + ctx.layer.loop(); + CHECK(ctx.hasNonZero()); +} + +TEST_CASE("RipplesEffect spatial variation") { + Ctx ctx(32, 32); + mm::RipplesEffect effect; + ctx.layer.addChild(&effect); + ctx.layer.onAllocateMemory(); + ctx.layer.loop(); + CHECK(ctx.hasTwoDistinctColors()); +}