diff --git a/usermods/user_fx/user_fx.cpp b/usermods/user_fx/user_fx.cpp index bcff5a0190..7914a1c4d3 100644 --- a/usermods/user_fx/user_fx.cpp +++ b/usermods/user_fx/user_fx.cpp @@ -2,6 +2,9 @@ // for information how FX metadata strings work see https://kno.wled.ge/interfaces/json-api/#effect-metadata +// paletteBlend: 0 - wrap when moving, 1 - always wrap, 2 - never wrap, 3 - none (undefined) +#define PALETTE_SOLID_WRAP (strip.paletteBlend == 1 || strip.paletteBlend == 3) + // static effect, used if an effect fails to initialize static void mode_static(void) { SEGMENT.fill(SEGCOLOR(0)); @@ -93,6 +96,296 @@ unsigned dataSize = cols * rows; // SEGLEN (virtual length) is equivalent to vW static const char _data_FX_MODE_DIFFUSIONFIRE[] PROGMEM = "Diffusion Fire@!,Spark rate,Diffusion Speed,Turbulence,,Use palette;;Color;;2;pal=35"; +/* +/ Lava Lamp 2D effect +* Uses particles to simulate rising blobs of "lava" or wax +* Particles slowly rise, merge to create organic flowing shapes, and then fall to the bottom to start again +* Created by Bob Loeffler using claude.ai +* The first slider sets the number of active blobs +* The second slider sets the size range of the blobs +* The third slider sets the damping value for horizontal blob movement +* The first checkbox sets the color mode (color wheel or palette) +* The second checkbox sets the attraction of blobs (checked will make the blobs attract other close blobs horizontally) +* aux0 keeps track of the blob size value +* aux1 keeps track of the number of blobs +*/ + +typedef struct LavaParticle { + float x, y; // Position + float vx, vy; // Velocity + float size; // Blob size + uint8_t hue; // Color + bool active; // will not be displayed if false + uint16_t delayTop; // number of frames to wait at top before falling again + bool idleTop; // sitting idle at the top + uint16_t delayBottom; // number of frames to wait at bottom before rising again + bool idleBottom; // sitting idle at the bottom +} LavaParticle; + +static void mode_2D_lavalamp(void) { + if (!strip.isMatrix || !SEGMENT.is2D()) FX_FALLBACK_STATIC; // not a 2D set-up + + const uint16_t cols = SEG_W; + const uint16_t rows = SEG_H; + constexpr float MAX_BLOB_RADIUS = 20.0f; // cap to prevent frame rate drops on large matrices + constexpr size_t MAX_LAVA_PARTICLES = 34; // increasing this value could cause slowness for large matrices + constexpr size_t MAX_TOP_FPS_DELAY = 900; // max delay when particles are at the top + constexpr size_t MAX_BOTTOM_FPS_DELAY = 1200; // max delay when particles are at the bottom + + // Allocate per-segment storage + if (!SEGENV.allocateData(sizeof(LavaParticle) * MAX_LAVA_PARTICLES)) FX_FALLBACK_STATIC; + LavaParticle* lavaParticles = reinterpret_cast(SEGENV.data); + + // Initialize particles on first call + if (SEGENV.call == 0) { + for (int i = 0; i < MAX_LAVA_PARTICLES; i++) { + lavaParticles[i].active = false; + } + } + + // Track particle size and particle count slider changes, re-initialize if either changes + uint8_t currentNumParticles = (SEGMENT.intensity >> 3) + 3; + uint8_t currentSize = SEGMENT.custom1; + if (currentNumParticles > MAX_LAVA_PARTICLES) currentNumParticles = MAX_LAVA_PARTICLES; + bool needsReinit = (currentSize != SEGENV.aux0) || (currentNumParticles != SEGENV.aux1); + + if (needsReinit) { + for (int i = 0; i < MAX_LAVA_PARTICLES; i++) { + lavaParticles[i].active = false; + } + SEGENV.aux0 = currentSize; + SEGENV.aux1 = currentNumParticles; + } + + uint8_t size = currentSize; + uint8_t numParticles = currentNumParticles; + + // blob size based on matrix width + const float minSize = cols * 0.15f; // Minimum 15% of width + const float maxSize = cols * 0.4f; // Maximum 40% of width + float sizeRange = (maxSize - minSize) * (size / 255.0f); + int rangeInt = max(1, (int)(sizeRange)); + + // calculate the spawning area for the particles + const float spawnXStart = cols * 0.20f; + const float spawnXWidth = cols * 0.60f; + int spawnX = max(1, (int)(spawnXWidth)); + + // Spawn new particles at the bottom near the center + for (int i = 0; i < MAX_LAVA_PARTICLES; i++) { + if (!lavaParticles[i].active && hw_random8() < 32) { // spawn when slot available + // Spawn in the middle 60% of the matrix width + lavaParticles[i].x = spawnXStart + (float)hw_random16(spawnX); + lavaParticles[i].y = rows - 1; + lavaParticles[i].vx = (hw_random16(7) - 3) / 250.0f; + lavaParticles[i].vy = -(hw_random16(20) + 10) / 100.0f * 0.3f; + + lavaParticles[i].size = minSize + (float)hw_random16(rangeInt); + if (lavaParticles[i].size > MAX_BLOB_RADIUS) lavaParticles[i].size = MAX_BLOB_RADIUS; + + lavaParticles[i].hue = hw_random8(); + lavaParticles[i].active = true; + + // Set random delays when particles are at top and bottom + lavaParticles[i].delayTop = hw_random16(MAX_TOP_FPS_DELAY); + lavaParticles[i].delayBottom = hw_random16(MAX_BOTTOM_FPS_DELAY); + lavaParticles[i].idleBottom = true; + break; + } + } + + // Fade background slightly for trailing effect + SEGMENT.fadeToBlackBy(40); + + // Update and draw particles + int activeCount = 0; + unsigned long currentMillis = strip.now; + for (int i = 0; i < MAX_LAVA_PARTICLES; i++) { + if (!lavaParticles[i].active) continue; + activeCount++; + + // Keep particle count on target by deactivating excess particles + if (activeCount > numParticles) { + lavaParticles[i].active = false; + activeCount--; + continue; + } + + LavaParticle *p = &lavaParticles[i]; + + // Physics update + p->x += p->vx; + p->y += p->vy; + + // Optional particle/blob attraction + if (SEGMENT.check2) { + for (int j = 0; j < MAX_LAVA_PARTICLES; j++) { + if (i == j || !lavaParticles[j].active) continue; + + LavaParticle *other = &lavaParticles[j]; + + // Skip attraction if moving in same vertical direction (both up or both down) + if ((p->vy < 0 && other->vy < 0) || (p->vy > 0 && other->vy > 0)) continue; + + float dx = other->x - p->x; + float dy = other->y - p->y; + + // Apply weak horizontal attraction only + float attractRange = p->size + other->size; + float distSq = dx*dx + dy*dy; + float attractRangeSq = attractRange * attractRange; + if (distSq > 0 && distSq < attractRangeSq) { + float dist = sqrt(distSq); // Only compute sqrt when needed + float force = (1.0f - (dist / attractRange)) * 0.0001f; + p->vx += (dx / dist) * force; + } + } + } + + // Horizontal oscillation (makes it more organic) + float damping= map(SEGMENT.custom2, 0, 255, 87, 97) / 100.0f; + p->vx += sin((currentMillis / 1000.0f + i) * 0.5f) * 0.002f; // Reduced oscillation + //p->vx *= 0.92f; // Stronger damping for less drift + p->vx *= damping; // damping for more or less horizontal drift + + // Bounce off sides (don't affect vertical velocity) + if (p->x < 0) { + p->x = 0; + p->vx = abs(p->vx); // reverse horizontal + } + if (p->x >= cols) { + p->x = cols - 1; + p->vx = -abs(p->vx); // reverse horizontal + } + + // Adjust rise/fall velocity depending on approx distance from heat source (at bottom) + // In top 1/4th of rows... + if (p->y < rows * .25f) { + if (p->vy >= 0) { // if going down, delay the particles so they won't go down immediately + if (p->delayTop > 0 && p->idleTop) { + p->vy = 0.0f; + p->delayTop--; + p->idleTop = true; + } else { + p->vy = 0.01f; + p->delayTop = hw_random16(MAX_TOP_FPS_DELAY); + p->idleTop = false; + } + } else if (p->vy <= 0) { // if going up, slow down the rise rate + p->vy = -0.03f; + } + } + + // In next 1/4th of rows... + if (p->y <= rows * .50f && p->y >= rows * .25f) { + if (p->vy > 0) { // if going down, speed up the fall rate + p->vy = 0.03f; + } else if (p->vy <= 0) { // if going up, speed up the rise rate a little more + p->vy = -0.05f; + } + } + + // In next 1/4th of rows... + if (p->y <= rows * .75f && p->y >= rows * .50f) { + if (p->vy > 0) { // if going down, speed up the fall rate a little more + p->vy = 0.04f; + } else if (p->vy <= 0) { // if going up, speed up the rise rate + p->vy = -0.03f; + } + } + + // In bottom 1/4th of rows... + if (p->y > rows * .75f) { + if (p->vy >= 0) { // if going down, slow down the fall rate + p->vy = 0.02f; + } else if (p->vy <= 0) { // if going up, delay the particles so they won't go up immediately + if (p->delayBottom > 0 && p->idleBottom) { + p->vy = 0.0f; + p->delayBottom--; + p->idleBottom = true; + } else { + p->vy = -0.01f; + p->delayBottom = hw_random16(MAX_BOTTOM_FPS_DELAY); + p->idleBottom = false; + } + } + } + + // Boundary handling with reversal of direction + // When reaching TOP (y=0 area), reverse to fall back down, but need to delay first + if (p->y <= 0.5f * p->size) { + p->y = 0.5f * p->size; + if (p->vy < 0) { + p->vy = 0.005f; // set to a tiny positive value to start falling very slowly + p->idleTop = true; + } + } + + // When reaching BOTTOM (y=rows-1 area), reverse to rise back up, but need to delay first + if (p->y >= rows - 0.5f * p->size) { + p->y = rows - 0.5f * p->size; + if (p->vy > 0) { + p->vy = -0.005f; // set to a tiny negative value to start rising very slowly + p->idleBottom = true; + } + } + + // Get color + uint32_t color; + if (SEGMENT.check1) { + color = SEGMENT.color_wheel(p->hue); // Random colors mode + } else { + color = SEGMENT.color_from_palette(p->hue, true, PALETTE_SOLID_WRAP, 0); // Palette mode + } + + // Extract RGB and apply life/opacity + uint8_t w = (W(color) * 255) >> 8; + uint8_t r = (R(color) * 255) >> 8; + uint8_t g = (G(color) * 255) >> 8; + uint8_t b = (B(color) * 255) >> 8; + + // Draw blob with sub-pixel accuracy using bilinear distribution + float sizeSq = p->size * p->size; + + // Get fractional offsets of particle center + float fracX = p->x - floorf(p->x); + float fracY = p->y - floorf(p->y); + int centerX = (int)floorf(p->x); + int centerY = (int)floorf(p->y); + + for (int dy = -(int)p->size - 1; dy <= (int)p->size + 1; dy++) { + for (int dx = -(int)p->size - 1; dx <= (int)p->size + 1; dx++) { + int px = centerX + dx; + int py = centerY + dy; + + if (px < 0 || px >= cols || py < 0 || py >= rows) continue; + + // Sub-pixel distance: measure from true float center to pixel center + float subDx = dx - fracX; // distance from true center to this pixel's center + float subDy = dy - fracY; + float distSq = subDx * subDx + subDy * subDy; + + if (distSq < sizeSq) { + float intensity = 1.0f - (distSq / sizeSq); + intensity = intensity * intensity; // smooth falloff + + uint8_t bw = (uint8_t)(w * intensity); + uint8_t br = (uint8_t)(r * intensity); + uint8_t bg = (uint8_t)(g * intensity); + uint8_t bb = (uint8_t)(b * intensity); + + uint32_t existing = SEGMENT.getPixelColorXY(px, py); + uint32_t newColor = RGBW32(br, bg, bb, bw); + SEGMENT.setPixelColorXY(px, py, color_add(existing, newColor)); + } + } + } + } +} +static const char _data_FX_MODE_2D_LAVALAMP[] PROGMEM = "Lava Lamp@,# of blobs,Blob size,H. Damping,,Color mode,Attract;;!;2;ix=64,c2=192,o2=1,pal=47"; + + + ///////////////////// // UserMod Class // ///////////////////// @@ -102,6 +395,7 @@ class UserFxUsermod : public Usermod { public: void setup() override { strip.addEffect(255, &mode_diffusionfire, _data_FX_MODE_DIFFUSIONFIRE); + strip.addEffect(255, &mode_2D_lavalamp, _data_FX_MODE_2D_LAVALAMP); //////////////////////////////////////// // add your effect function(s) here //