diff --git a/firmware/include/modes/MetaballsMode.h b/firmware/include/modes/MetaballsMode.h index 5b17efae..3d021034 100644 --- a/firmware/include/modes/MetaballsMode.h +++ b/firmware/include/modes/MetaballsMode.h @@ -10,14 +10,23 @@ class MetaballsMode final : public ModeModule { private: - static constexpr float radius{ + static constexpr float maxRadius{ min(static_cast(GRID_COLUMNS * PITCH_HORIZONTAL) / static_cast(PITCH_VERTICAL), - static_cast(GRID_ROWS *PITCH_VERTICAL) / static_cast(PITCH_HORIZONTAL)) / - 5.0F}; - static constexpr float radiusSq{radius * radius}; - static constexpr float speed{1e-6F * static_cast(GRID_COLUMNS * GRID_ROWS)}; - - static constexpr uint8_t multiplier{1U << 4U}; + static_cast(GRID_ROWS *PITCH_VERTICAL) / static_cast(PITCH_HORIZONTAL))}; + static inline uint8_t radiusFactor{2U}; + static inline float radius{maxRadius / static_cast(radiusFactor)}; + static inline float radiusSq{radius * radius}; + + static constexpr float baseSpeed{1e-6F * static_cast(GRID_COLUMNS * GRID_ROWS)}; + static inline uint8_t speedFactor{4U}; + static inline float speed{static_cast(speedFactor) * baseSpeed}; + + static constexpr uint8_t multiplier{1U << 3U}; + // How many discrete distance steps a ball's brightness falloff is quantized into, from its + // center (0) to its edge (falloffResolution); this is the resolution of contributions below. + static constexpr uint8_t falloffResolution{UINT8_MAX}; + // Size must stay equal to falloffResolution + 1 + std::array contributions{}; struct Ball { @@ -27,16 +36,25 @@ class MetaballsMode final : public ModeModule float yVelocity; }; - std::array contributions{}; - std::array balls{}; + static constexpr uint8_t numBallsMax{25U}; + static inline uint8_t numBalls{(GRID_COLUMNS * GRID_ROWS / 50U)}; + + std::array balls{}; + + void setSpeed(uint8_t _speed); + void setRadius(uint8_t _radius); + void updateRadius(); + void transmit(); public: static constexpr std::string_view name{"Metaballs"}; explicit MetaballsMode() : ModeModule(name) {}; + void configure() override; void begin() override; void handle() override; + void onReceive(JsonObjectConst payload, std::string_view source) override; }; #endif // MODE_METABALLS diff --git a/firmware/src/modes/MetaballsMode.cpp b/firmware/src/modes/MetaballsMode.cpp index 6802621a..a54b6e05 100644 --- a/firmware/src/modes/MetaballsMode.cpp +++ b/firmware/src/modes/MetaballsMode.cpp @@ -2,18 +2,59 @@ #include "modes/MetaballsMode.h" +#include "services/DeviceService.h" #include "services/DisplayService.h" // NOLINT(misc-include-cleaner) #include "services/ExtensionsService.h" +#include +#include + static_assert(GRID_COLUMNS * GRID_ROWS >= 50U, __STRING(MODE_METABALLS) " is not compatible with this device's display size."); +/** + * @brief Loads persisted metaballs settings and publishes the active configuration. + */ +void MetaballsMode::configure() +{ + nvs_handle_t handle{}; + if (nvs_open(name.data(), nvs_open_mode_t::NVS_READONLY, &handle) == ESP_OK) + { + uint8_t _speed{0U}; + if (nvs_get_u8(handle, "speed", &_speed) == ESP_OK) + { + speedFactor = _speed; + speed = static_cast(speedFactor) * baseSpeed; + } + uint8_t _radius{0U}; + if (nvs_get_u8(handle, "radius", &_radius) == ESP_OK) + { + radiusFactor = _radius; + updateRadius(); + } + nvs_close(handle); + } + transmit(); +} + +/** + * @brief Initializes metaballs positions and velocities. + */ void MetaballsMode::begin() { + // Builds a lookup table of a single ball's brightness contribution by distance: full brightness + // at the center (idx 0), fading quadratically to none at the edge (idx == falloffResolution). + // Multiple overlapping balls add their contributions together, so peakBrightness is kept well + // below UINT8_MAX to require several overlapping balls before a pixel reaches full brightness. + constexpr float peakBrightness{64.0F}; + constexpr float span{static_cast(falloffResolution) + 1.0F}; for (size_t idx{0U}; idx < contributions.size(); ++idx) { - contributions[idx] = ((UINT8_MAX - idx) * (UINT8_MAX - idx) * (0b1U << 6U)) >> (0b1U << 4U); + const float normalizedDistance{static_cast(falloffResolution - idx) / span}; + contributions[idx] = static_cast(peakBrightness * normalizedDistance * normalizedDistance); } + // All balls are initialized (not just the active numBalls) so they can simply be (de)activated + // by adjusting numBalls, without needing to (re)spawn any of them. for (Ball &ball : balls) { ball.x = static_cast(random(GRID_COLUMNS)); @@ -21,8 +62,15 @@ void MetaballsMode::begin() ball.xVelocity = speed * static_cast(random(1, multiplier) * ((random(2) * 2) - 1)); ball.yVelocity = speed * static_cast(random(1, multiplier) * ((random(2) * 2) - 1)); } + Display.fillFrame(0U); } +/** + * @brief Updates the metaballs positions and calculates their contributions to the display. + * + * The metaballs are represented as circles that move across the display, and their brightness + * contributions are calculated based on their distance from each pixel. + */ void MetaballsMode::handle() { #if EXTENSION_MICROPHONE @@ -38,7 +86,7 @@ void MetaballsMode::handle() const float yRatio{static_cast(2U * (rotated ? PITCH_HORIZONTAL : PITCH_VERTICAL)) / static_cast(PITCH_VERTICAL + PITCH_HORIZONTAL)}; #endif // PITCH_HORIZONTAL != PITCH_VERTICAL - for (const Ball &ball : balls) + for (const Ball &ball : std::span{balls}.first(numBalls)) { const uint8_t yMax{static_cast( min(static_cast(ceilf(ball.y + radius - min(ball.yVelocity, .0F))), GRID_ROWS - 1U))}; @@ -53,7 +101,7 @@ void MetaballsMode::handle() for (uint8_t y{yMin}; y <= yMax; ++y) { uint8_t brightness{0U}; - for (const Ball &ball : balls) + for (const Ball &ball : std::span{balls}.first(numBalls)) { #if PITCH_HORIZONTAL == PITCH_VERTICAL const float xDistance{ball.x - static_cast(x)}; @@ -65,10 +113,11 @@ void MetaballsMode::handle() const float distanceSq{(xDistance * xDistance) + (yDistance * yDistance)}; if (distanceSq < radiusSq) { - brightness = static_cast( - min(static_cast(brightness) + - contributions[static_cast(distanceSq * (0b1U << 6U) / radiusSq)], - UINT8_MAX)); + brightness = static_cast(min( + static_cast(brightness) + + contributions[static_cast(min(distanceSq * falloffResolution / radiusSq, + static_cast(falloffResolution)))], + UINT8_MAX)); if (brightness == UINT8_MAX) { break; @@ -79,7 +128,7 @@ void MetaballsMode::handle() } } } - for (Ball &ball : balls) + for (Ball &ball : std::span{balls}.first(numBalls)) { ball.x += ball.xVelocity; ball.y += ball.yVelocity; @@ -106,4 +155,115 @@ void MetaballsMode::handle() } } +/** + * @brief Sets the base speed of the metaballs and stores it in non-volatile storage. + * + * Uses arbitrary units for speed, where 1 is the slowest and 11 is the fastest. + * The actual speed is calculated based on the base speed and the speed factor. + * + * @param _speed New speed factor for the metaballs. + */ +void MetaballsMode::setSpeed(uint8_t _speed) +{ + if (_speed < 1U) + { + speedFactor = 1U; + } + else if (_speed > 11U) + { + speedFactor = 11U; + } + else + { + speedFactor = _speed; + } + + speed = baseSpeed * static_cast(speedFactor); + + nvs_handle_t handle{}; + if (nvs_open(name.data(), nvs_open_mode_t::NVS_READWRITE, &handle) == ESP_OK) + { + nvs_set_u8(handle, "speed", static_cast(speedFactor)); + nvs_commit(handle); + nvs_close(handle); + } + transmit(); +} + +/** + * @brief Sets the radius of the metaballs and stores it in non-volatile storage. + * + * Uses arbitrary units for _radius, where 1 is the smallest and 10 is the largest. + * The actual radius is calculated based on the maximum radius and the radius factor. + * + * @param _radius New radius factor for the metaballs. + */ +void MetaballsMode::setRadius(uint8_t _radius) +{ + if (_radius < 1U) + { + radiusFactor = 10U; + } + else if (_radius > 10U) + { + radiusFactor = 1U; + } + else + { + radiusFactor = 11U - _radius; + } + + updateRadius(); + /* After changing the radius, we need to clear the display to avoid visual + artifacts from the previous radius. */ + Display.fillFrame(0U); + + nvs_handle_t handle{}; + if (nvs_open(name.data(), nvs_open_mode_t::NVS_READWRITE, &handle) == ESP_OK) + { + nvs_set_u8(handle, "radius", static_cast(radiusFactor)); + nvs_commit(handle); + nvs_close(handle); + } + transmit(); +} + +/** + * @brief Updates the radius of the metaballs based on the current radius factor. + */ +void MetaballsMode::updateRadius() +{ + radius = maxRadius / static_cast(radiusFactor); + radiusSq = radius * radius; +} + +/** + * @brief Publishes the current base speed and ball radius in arbitrary units. + */ +void MetaballsMode::transmit() +{ + JsonDocument doc{}; + doc["speed"].set(speedFactor); + doc["radius"].set(11U - radiusFactor); + Device.transmit(doc.as(), name); +} + +/** + * @brief Applies speed and radius from a received payload. + * + * @param payload Received configuration fields. + * @param source Source identifier for the received payload. + */ +void MetaballsMode::onReceive(JsonObjectConst payload, std::string_view source) +{ + if (payload["speed"].is()) + { + setSpeed(payload["speed"].as()); + } + if (payload["radius"].is()) + { + setRadius(payload["radius"].as()); + } +} + #endif // MODE_METABALLS diff --git a/webapp/src/modes/Metaballs.tsx b/webapp/src/modes/Metaballs.tsx index 62d22cad..ec899505 100644 --- a/webapp/src/modes/Metaballs.tsx +++ b/webapp/src/modes/Metaballs.tsx @@ -1,8 +1,111 @@ -import { mdiBasketball } from "@mdi/js"; -import type { Component } from "solid-js"; - +import { mdiBasketball, mdiCircleExpand, mdiSpeedometer } from "@mdi/js"; +import { type Component, createSignal, For } from "solid-js"; +import { Icon } from "../components/Icon"; +import { Tooltip } from "../components/Tooltip"; +import { SidebarSection } from "../extensions/WebApp"; +import { WebSocketWS } from "../extensions/WebSocket"; import { MainComponent as ModesMainComponent } from "../services/Modes"; export const name = "Metaballs"; +const [getSpeed, setSpeed] = createSignal(4); +const [getRadius, setRadius] = createSignal(9); + +export const receiver = (json: { speed?: number; radius?: number }) => { + json?.speed !== undefined && setSpeed(json.speed); + json?.radius !== undefined && setRadius(json.radius); +}; + export const Main: Component = () => ; + +/** + * Radius values the visualization actually renders sensibly. + */ +const radiusOptions = [ + { + value: 4, + label: "Tiny", + }, + { + value: 5, + label: "Small", + }, + { + value: 6, + label: "Medium", + }, + { + value: 7, + label: "Large", + }, + { + value: 8, + label: "Huge", + }, + { + value: 9, + label: "Massive", + }, +]; + +export const Sidebar: Component = () => { + const handleSpeed = (value: number, send: boolean = false) => { + setSpeed(value); + if (send) { + WebSocketWS.send( + JSON.stringify({ + [name]: { + speed: getSpeed(), + }, + }), + ); + } + }; + + const handleRadius = (value: number) => { + setRadius(value); + WebSocketWS.send( + JSON.stringify({ + [name]: { + radius: getRadius(), + }, + }), + ); + }; + + return ( + +
+ + + + + handleSpeed(e.currentTarget.valueAsNumber, false)} + onKeyUp={() => handleSpeed(getSpeed(), true)} + onPointerUp={() => handleSpeed(getSpeed(), true)} + value={getSpeed()} + /> + +
+
+ + + + +
+
+ ); +}; diff --git a/webapp/src/services/Modes.tsx b/webapp/src/services/Modes.tsx index 25ea8786..3bd54308 100644 --- a/webapp/src/services/Modes.tsx +++ b/webapp/src/services/Modes.tsx @@ -78,7 +78,11 @@ import { } from "../modes/HomeThermometer"; import { Main as ModeLeafFallMain, name as ModeLeafFallName } from "../modes/LeafFall"; import { Main as ModeLinesMain, name as ModeLinesName } from "../modes/Lines"; -import { Main as ModeMetaballsMain, name as ModeMetaballsName } from "../modes/Metaballs"; +import { + Main as ModeMetaballsMain, + name as ModeMetaballsName, + Sidebar as ModeMetaballsSidebar, +} from "../modes/Metaballs"; import { Main as ModeNoiseMain, name as ModeNoiseName } from "../modes/Noise"; import { Main as ModePingPongMain, name as ModePingPongName, Sidebar as ModePingPongSidebar } from "../modes/PingPong"; import { Main as ModePixelSequenceMain, name as ModePixelSequenceName } from "../modes/PixelSequence"; @@ -330,6 +334,11 @@ export const Sidebar: Component = () => { )} + {MODE_METABALLS && ( + + + + )} {MODE_PINGPONG && (