Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions firmware/include/modes/MetaballsMode.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,23 @@
class MetaballsMode final : public ModeModule
{
private:
static constexpr float radius{
static constexpr float maxRadius{
min<float>(static_cast<float>(GRID_COLUMNS * PITCH_HORIZONTAL) / static_cast<float>(PITCH_VERTICAL),
static_cast<float>(GRID_ROWS *PITCH_VERTICAL) / static_cast<float>(PITCH_HORIZONTAL)) /
5.0F};
static constexpr float radiusSq{radius * radius};
static constexpr float speed{1e-6F * static_cast<float>(GRID_COLUMNS * GRID_ROWS)};

static constexpr uint8_t multiplier{1U << 4U};
static_cast<float>(GRID_ROWS *PITCH_VERTICAL) / static_cast<float>(PITCH_HORIZONTAL))};
static inline uint8_t radiusFactor{2U};
static inline float radius{maxRadius / static_cast<float>(radiusFactor)};
static inline float radiusSq{radius * radius};

static constexpr float baseSpeed{1e-6F * static_cast<float>(GRID_COLUMNS * GRID_ROWS)};
static inline uint8_t speedFactor{4U};
static inline float speed{static_cast<float>(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<uint8_t, falloffResolution + 1U> contributions{};

struct Ball
{
Expand All @@ -27,16 +36,25 @@ class MetaballsMode final : public ModeModule
float yVelocity;
};

std::array<uint8_t, GRID_COLUMNS * GRID_ROWS> contributions{};
std::array<Ball, GRID_COLUMNS * GRID_ROWS / 50U> balls{};
static constexpr uint8_t numBallsMax{25U};
static inline uint8_t numBalls{(GRID_COLUMNS * GRID_ROWS / 50U)};

std::array<Ball, numBallsMax> 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
176 changes: 168 additions & 8 deletions firmware/src/modes/MetaballsMode.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,75 @@

#include "modes/MetaballsMode.h"

#include "services/DeviceService.h"
#include "services/DisplayService.h" // NOLINT(misc-include-cleaner)
#include "services/ExtensionsService.h"

#include <nvs.h>
#include <span>

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<float>(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<float>(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<float>(falloffResolution - idx) / span};
contributions[idx] = static_cast<uint8_t>(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<float>(random(GRID_COLUMNS));
ball.y = static_cast<float>(random(GRID_ROWS));
ball.xVelocity = speed * static_cast<float>(random(1, multiplier) * ((random(2) * 2) - 1));
ball.yVelocity = speed * static_cast<float>(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
Expand All @@ -38,7 +86,7 @@ void MetaballsMode::handle()
const float yRatio{static_cast<float>(2U * (rotated ? PITCH_HORIZONTAL : PITCH_VERTICAL)) /
static_cast<float>(PITCH_VERTICAL + PITCH_HORIZONTAL)};
#endif // PITCH_HORIZONTAL != PITCH_VERTICAL
for (const Ball &ball : balls)
for (const Ball &ball : std::span<const Ball>{balls}.first(numBalls))
{
const uint8_t yMax{static_cast<uint8_t>(
min<int8_t>(static_cast<int8_t>(ceilf(ball.y + radius - min(ball.yVelocity, .0F))), GRID_ROWS - 1U))};
Expand All @@ -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<const Ball>{balls}.first(numBalls))
{
#if PITCH_HORIZONTAL == PITCH_VERTICAL
const float xDistance{ball.x - static_cast<float>(x)};
Expand All @@ -65,10 +113,11 @@ void MetaballsMode::handle()
const float distanceSq{(xDistance * xDistance) + (yDistance * yDistance)};
if (distanceSq < radiusSq)
{
brightness = static_cast<uint8_t>(
min<uint16_t>(static_cast<uint16_t>(brightness) +
contributions[static_cast<uint8_t>(distanceSq * (0b1U << 6U) / radiusSq)],
UINT8_MAX));
brightness = static_cast<uint8_t>(min<uint16_t>(
static_cast<uint16_t>(brightness) +
contributions[static_cast<uint8_t>(min<float>(distanceSq * falloffResolution / radiusSq,
static_cast<float>(falloffResolution)))],
UINT8_MAX));
if (brightness == UINT8_MAX)
{
break;
Expand All @@ -79,7 +128,7 @@ void MetaballsMode::handle()
}
}
}
for (Ball &ball : balls)
for (Ball &ball : std::span<Ball>{balls}.first(numBalls))
{
ball.x += ball.xVelocity;
ball.y += ball.yVelocity;
Expand All @@ -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<float>(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<uint8_t>(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<uint8_t>(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<float>(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<JsonObjectConst>(), 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<uint8_t>())
{
setSpeed(payload["speed"].as<uint8_t>());
}
if (payload["radius"].is<uint8_t>())
{
setRadius(payload["radius"].as<uint8_t>());
}
}

#endif // MODE_METABALLS
Loading
Loading