Skip to content
Open
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3a843fa
Added Lava Lamp effect to user_fx usermod
BobLoeffler68 Dec 31, 2025
401cafc
changed random8() to hw_random8() and added some comments to beginnin…
BobLoeffler68 Dec 31, 2025
f51357a
Allocate particle array in SEGENV.data using SEGENV.allocateData
BobLoeffler68 Jan 1, 2026
899393a
Now using SEGENV.aux0 to track the last size control value.
BobLoeffler68 Jan 1, 2026
2b3a89f
Added color macros and white channel to RGB, but just temporarily.
BobLoeffler68 Jan 1, 2026
e54ff16
Replaced qadd8() with color_add()
BobLoeffler68 Jan 2, 2026
b5a82b1
optimized distance calculation and decreased sqrt() calls in inner re…
BobLoeffler68 Jan 2, 2026
cc686a6
Moved millis() outside of particles loop
BobLoeffler68 Jan 2, 2026
4fa11fc
Use an explicit cast to float for clarity for 3 variables.
BobLoeffler68 Jan 3, 2026
3bb410e
Changed max particles to 35 and changed a couple things to be more ef…
BobLoeffler68 Jan 3, 2026
4e66fe6
A couple optimizations
BobLoeffler68 Jan 3, 2026
98903a3
changed a comment regarding attraction
BobLoeffler68 Jan 3, 2026
311fe2e
Made a change to the parameters of SEGMENT.color_from_palette
BobLoeffler68 Jan 13, 2026
2778100
Fixed a potential issue with random16(0) on small matrices, and a cou…
BobLoeffler68 Jan 13, 2026
6cab88a
Merge branch 'main' into pr-lavalamp-user-fx
BobLoeffler68 Feb 8, 2026
7becc96
Made a few changes recommended by coderabbit
BobLoeffler68 Feb 9, 2026
8648622
Removed life from the LavaParticle struct as we were not using it and…
BobLoeffler68 Feb 9, 2026
320382c
Merge branch 'pr-lavalamp-user-fx' of https://github.com/BobLoeffler6…
BobLoeffler68 Feb 9, 2026
d059d56
Adjust rise/fall velocity depending on distance from heat source (bot…
BobLoeffler68 Feb 11, 2026
52bf4ab
Removed return FRAMETIME
BobLoeffler68 Feb 11, 2026
5dc5de4
A few small tweaks but now I'm having an issue with compiling.
BobLoeffler68 Feb 12, 2026
8ffc658
Merge branch 'main' of https://github.com/Aircoookie/WLED into pr-lav…
BobLoeffler68 Feb 12, 2026
d11b524
- switched to sub-pixel accuracy animation.
BobLoeffler68 Feb 22, 2026
75a80f4
Changed the default value of the new horizontal damping slider
BobLoeffler68 Feb 22, 2026
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
294 changes: 294 additions & 0 deletions usermods/user_fx/user_fx.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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<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));

// 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 //
/////////////////////
Expand All @@ -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 //
Expand Down