mirror of
https://github.com/wled/WLED.git
synced 2026-03-13 08:29:49 +08:00
Lava Lamp FX in the user_fx usermod (#5253)
* Added Lava Lamp effect to user_fx usermod
This commit is contained in:
@@ -96,6 +96,292 @@ 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 Attract checkbox sets the attraction of blobs (checked will make the blobs attract other close blobs horizontally)
|
||||
* The Keep Color Ratio checkbox sets whether we preserve the color ratio when displaying pixels that are in 2 or more overlapping blobs
|
||||
* 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<LavaParticle*>(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));
|
||||
|
||||
bool preserveColorRatio = SEGMENT.check3;
|
||||
|
||||
// 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, 97, 87) / 100.0f;
|
||||
p->vx += sin((currentMillis / 1000.0f + i) * 0.5f) * 0.002f; // Reduced oscillation
|
||||
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;
|
||||
color = SEGMENT.color_from_palette(p->hue, true, PALETTE_SOLID_WRAP, 0);
|
||||
|
||||
// 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, preserveColorRatio ? true : false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
static const char _data_FX_MODE_2D_LAVALAMP[] PROGMEM = "Lava Lamp@,# of blobs,Blob size,H. Damping,,,Attract,Keep Color Ratio;;!;2;ix=64,c2=192,o2=1,o3=1,pal=47";
|
||||
|
||||
|
||||
/*
|
||||
/ Magma effect
|
||||
* 2D magma/lava animation
|
||||
@@ -263,17 +549,17 @@ static void mode_2D_magma(void) {
|
||||
float gravity = map(SEGMENT.custom2, 0, 255, 5, 20) / 100.0f;
|
||||
|
||||
// Number of particles (lava bombs)
|
||||
uint8_t particleCount = map(SEGMENT.custom1, 0, 255, 2, MAGMA_MAX_PARTICLES);
|
||||
particleCount = constrain(particleCount, 2, MAGMA_MAX_PARTICLES);
|
||||
uint8_t particleCount = map(SEGMENT.custom1, 0, 255, 0, MAGMA_MAX_PARTICLES);
|
||||
particleCount = constrain(particleCount, 0, MAGMA_MAX_PARTICLES);
|
||||
|
||||
// Draw lava bombs in front of magma (or behind it)
|
||||
if (SEGMENT.check2) {
|
||||
drawMagma(width, height, ff_y, ff_z, shiftHue);
|
||||
SEGMENT.fadeToBlackBy(70); // Dim the entire display to create trailing effect
|
||||
drawLavaBombs(width, height, particleData, gravity, particleCount);
|
||||
if (particleCount > 0) drawLavaBombs(width, height, particleData, gravity, particleCount);
|
||||
}
|
||||
else {
|
||||
drawLavaBombs(width, height, particleData, gravity, particleCount);
|
||||
if (particleCount > 0) drawLavaBombs(width, height, particleData, gravity, particleCount);
|
||||
SEGMENT.fadeToBlackBy(70); // Dim the entire display to create trailing effect
|
||||
drawMagma(width, height, ff_y, ff_z, shiftHue);
|
||||
}
|
||||
@@ -737,6 +1023,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);
|
||||
strip.addEffect(255, &mode_2D_magma, _data_FX_MODE_2D_MAGMA);
|
||||
strip.addEffect(255, &mode_ants, _data_FX_MODE_ANTS);
|
||||
strip.addEffect(255, &mode_morsecode, _data_FX_MODE_MORSECODE);
|
||||
|
||||
Reference in New Issue
Block a user