Plazma Demo

The Great Plasma Effect Optimization Journey: LUTs, Speed, and the Art of Going in Circles

Or: How I learned to stop worrying and love lookup tables

What’s a Plasma Effect Anyway?

If you’ve never seen a plasma effect, imagine what happens when you stare at a lava lamp while having mathematical fever dreams. It’s one of those classic demoscene effects that makes people go “ooh, pretty colors” without realizing there’s actually some elegant math underneath.

The plasma effect creates flowing, organic-looking patterns by combining multiple sine waves with different frequencies and phases. It’s named “plasma” because it resembles the swirling patterns you might see in, well, plasma - though thankfully without the part where everything gets ionized and really, really hot.

A Brief History Lesson (Don’t Worry, It’s Short)

The plasma effect became a staple of the demoscene in the late 80s and early 90s. Back when CPUs were slower than a caffeinated sloth and memory was more precious than your grandmother’s china, demo coders had to be creative. They couldn’t just throw more hardware at the problem - they had to outsmart it.

The basic plasma formula is deceptively simple:

float plasma1 = sin(distance * 0.1f + time * 2.0f);
float plasma2 = sin(x * 0.035f + time);
float plasma3 = sin(y * 0.035f + time * 1.5f);  
float plasma4 = sin((x + y) * 0.035f + time * 0.5f);
float result = (plasma1 + plasma2 + plasma3 + plasma4) / 4.0f;

Each sine wave creates ripples across the screen, and when you combine them, you get those mesmerizing interference patterns that make your brain happy.

The Modern Problem: WASM and the Need for Speed

Fast forward to 2025, and we’re running this stuff in browsers via WebAssembly. At 640x480 resolution, that’s 307,200 pixels that need four sine calculations each, every frame. Even with modern JavaScript engines, that’s a lot of sin() calls.

So naturally, I thought: “How hard can it be to optimize this?”

Narrator: It was harder than he thought.

Optimization Round 1: The Distance LUT

The first bottleneck was calculating the distance from each pixel to the center of the screen:

float getDistanceToCenter(int x, int y) {
    float dx = x - CANVAS_WIDTH / 2.0f;
    float dy = y - CANVAS_HEIGHT / 2.0f;
    return sqrt(dx * dx + dy * dy);
}

That sqrt() call was murder on performance. The fix? Pre-calculate everything into a lookup table:

void initDistanceLUT() {
    for (int y = 0; y < CANVAS_HEIGHT; y++) {
        for (int x = 0; x < CANVAS_WIDTH; x++) {
            distanceLUT[y * CANVAS_WIDTH + x] = getDistanceToCenter(x, y);
        }
    }
}

Result: +15 FPS. Not bad for trading some memory for speed!

Optimization Round 2: The Sine LUT

Next target: those expensive sin() calls. Four per pixel, every frame. Time for another LUT:

#define SIN_LUT_SIZE 4096
#define SIN_SCALE (SIN_LUT_SIZE / (2.0f * M_PI))
static float sinLUT[SIN_LUT_SIZE];

float sinLut(float x) {
    return sinLUT[((int)(x * SIN_SCALE)) & SIN_LUT_MASK];
}

Result: +30 FPS. Now we’re cooking with gas! The combination of distance and sine LUTs gave us a solid 45 FPS improvement.

The Rabbit Hole: Palette LUTs and the Art of Overthinking

But wait, there’s more! What if we could eliminate all the math per pixel? Classic demoscene effects often used palette cycling - pre-calculate everything into color indices, then just shift the palette over time.

The idea was tantalizing: instead of four sine lookups per pixel, just one palette lookup. Should be blazing fast, right?

// The dream: pre-calculate everything
static uint8_t plasma_indices[CANVAS_WIDTH * CANVAS_HEIGHT];

void renderPaletteLUT(uint8_t* buffer) {
    int time_offset = (int)(g_plasmaTime * 32.0f) & PALETTE_MASK;
    
    for (int i = 0; i < CANVAS_WIDTH * CANVAS_HEIGHT; i++) {
        buffer[i] = palette[(plasma_indices[i] + time_offset) & PALETTE_MASK];
    }
}

Where Everything Went Sideways

Here’s where I learned an important lesson about the difference between phase modulation and amplitude modulation.

In the original effect, time affects the phase of each sine wave:

  • sin(distance + time) creates waves that flow inward and outward
  • Each component has independent timing (×2.0, ×1.5, ×0.5) creating complex interference

But with simple palette shifting, you’re doing amplitude modulation - just shifting colors uniformly. The result? A much less dynamic effect that loses the beautiful “breathing” patterns.

We tried various approaches:

  • Storing separate LUTs for each component
  • Adding time offsets to LUT indices
  • Converting everything to palette space

Each attempt either broke the visual effect or ended up being basically the same as the regular LUT version, just with extra steps.

The Fundamental Tradeoff

After going in circles (sometimes literally - those sine waves are round), we hit a fundamental limitation:

You can’t easily convert phase-modulated interference patterns into simple palette lookups without losing the magic.

The complex, organic motion comes from the way multiple sine waves interfere with each other over time. When you try to bake that into a static lookup table, you lose the very thing that makes plasma effects mesmerizing.

WASM-Specific Optimization Tips

Through this journey, I learned some WASM-specific lessons:

  1. LUTs are your friend: Memory is cheap, computation is expensive
  2. Bit operations are fast: Use & MASK instead of modulo
  3. Pre-calculate constants: Move any math you can outside the inner loops
  4. uint32_t pixel buffers: Write 4 bytes at once instead of individual RGBA
  5. Don’t overthink it: Sometimes the “obvious” optimization is the right one
// Fast: Direct 32-bit write
uint32_t* pixel_buffer = (uint32_t*)buffer;
pixel_buffer[i] = palette[color];

// Slower: Individual byte writes
buffer[i*4 + 0] = r;
buffer[i*4 + 1] = g;
buffer[i*4 + 2] = b;
buffer[i*4 + 3] = 255;

The Final Wisdom

Sometimes the best optimization is knowing when to stop. Our “renderLUT” version with distance and sine LUTs hit the sweet spot:

  • 45 FPS improvement from smart LUTs
  • Exact visual fidelity preserved
  • Simple, maintainable code

The “perfect” palette LUT optimization would have been faster, but it would have killed the soul of the effect. And in graphics programming, the soul matters.

Try It Yourself!

For a full screen version of the plasma effect go here.

JS FPS: 0
C++ FPS: 0

plasma - Press 1 for renderRaw, 2 for renderLUT, 3 for renderPaletteLUT (Current: renderRaw)

Use the buttons to switch between rendering modes and see the performance difference. Notice how the optimized LUT version looks identical to the raw version but runs much smoother?

Conclusion

Optimization is part art, part science, and part knowing when you’re chasing diminishing returns down a rabbit hole. The plasma effect taught me that sometimes the journey is more educational than the destination - even when the destination turns out to be exactly where you started, just with better LUTs.

And hey, at least we can all agree that watching colorful sine waves is way more fun than reading documentation about WebAssembly memory management.

Now excuse me while I go optimize a fire effect by pre-calculating every possible flame particle position. What could go wrong?


Performance Summary:

  • Raw version: Baseline (lots of sqrt() and sin() calls)
  • LUT version: +45 FPS (distance LUT + sine LUT)
  • Palette LUT version: Fast but breaks the visual magic ❌

Lesson learned: Don’t fix what ain’t broken, and if it is broken, LUTs probably aren’t the problem.

Performance Results

As you can see from the demo above, the WASM implementation achieves:

  • C++ FPS: ~100 FPS (logic/rendering)
  • JavaScript FPS: ~60 FPS (display)
  • Memory usage: Fixed 1.2MB pixel buffer

The key insight here is that we’re running the rendering logic at 120 FPS while only displaying at 60 FPS. This gives us headroom for more complex calculations.

Technical Implementation

The core rendering function is surprisingly simple:

void render(uint8_t* buffer) {
    for (int i = 0; i < CANVAS_WIDTH * CANVAS_HEIGHT * 4; i += 4) {
        buffer[i] = rand() % 256;     // Red
        buffer[i + 1] = rand() % 256; // Green
        buffer[i + 2] = rand() % 256; // Blue
        buffer[i + 3] = 255;          // Alpha
    }
}
tags: programming - wasm - graphics - development - effects - plasma

💬 Comments

Post comment
Loading...