Deep Dive · Graphics Programming

Animated Plasma
in Rust

Build mesmerising, palette-cycled plasma effects from scratch using macroquad — no GPU shaders required.

Rust 1.77+
macroquad 0.4
~120 lines
~10 min read
Plasma is one of the oldest tricks in the demoscene playbook — a field of sinusoidal waves blended together and mapped through a rotating colour palette. The effect is cheap to compute, looks hypnotic, and teaches you a lot about procedural graphics. Let's build one in Rust.
01 — Theory

What is a plasma effect?

At heart, a plasma is nothing more than a scalar field: for every pixel at screen coordinate (x, y) and at time t, we compute a single floating-point value. That value drives a lookup into a colour palette, and because we move t forward each frame the palette-lookup position shifts — creating the illusion of flowing, liquid colour.

The scalar field is built by summing several sine and cosine waves with different frequencies and phases. Mixing just three or four of these produces the characteristic swirling, organic shapes.

v(x, y, t) = sin(x·f₁ + t) + sin(y·f₂ + t) + sin((x+y)·f₃ + t) + sin(√(x²+y²)·f₄ + t)
Core plasma formula — four wave components summed into a scalar field

The result lands somewhere in [-4, 4]. We normalise it to [0, 1] and multiply by 255 to get a palette index. Offset that index by a time-dependent value and you get free animation without recomputing the geometry.

02 — Setup

Getting macroquad running

macroquad is a minimal, cross-platform Rust game library that gives you a window, an input loop, and 2-D drawing primitives with essentially zero boilerplate. Perfect for effects work.

Add it to your project:

Cargo.toml
# Cargo.toml
[package]
name    = "plasma"
version = "0.1.0"
edition = "2021"

[dependencies]
macroquad = "0.4"

macroquad's async main entry point uses a simple macro. Create src/main.rs:

Rust · src/main.rs — skeleton
use macroquad::prelude::*;

#[macroquad::main("Plasma")]
async fn main() {
    loop {
        clear_background(BLACK);
        // draw here
        next_frame().await;
    }
}
03 — Building Blocks

Four steps to plasma

01
Build a colour palette
Generate a 256-entry RGB lookup table using HSV-to-RGB conversion. Smooth, cyclable palettes make the animation feel continuous.
02
Compute the scalar field
For each pixel, evaluate the plasma formula. Normalise the result to [0, 255].
03
Map to the palette
Index into the palette with (pixel_value + time_offset) % 256. This is where "palette cycling" lives.
04
Blit to screen via Image
Write RGBA bytes into a macroquad Image, upload it as a Texture2D, and draw it fullscreen each frame.
04 — Colour

Generating the palette

We want 256 smooth colours that loop seamlessly. One of the easiest ways is to sweep hue from 0° to 360° in HSV space. Here's a small helper:

live preview
Rust · palette builder
/// Returns a 256-colour palette as an array of (r, g, b) u8 tuples.
fn build_palette() -> [(u8, u8, u8); 256] {
    let mut pal = [(0u8, 0u8, 0u8); 256];
    for i in 0..256 {
        let h = (i as f32) / 256.0;   // hue in [0,1]
        let s = 1.0_f32;
        let v = 1.0_f32;
        pal[i] = hsv_to_rgb(h, s, v);
    }
    pal
}

fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
    let i = (h * 6.0).floor() as u32;
    let f = h * 6.0 - i as f32;
    let (p, q, t) = (
        v * (1.0 - s),
        v * (1.0 - s * f),
        v * (1.0 - s * (1.0 - f)),
    );
    let (r, g, b) = match i % 6 {
        0 => (v, t, p),
        1 => (q, v, p),
        2 => (p, v, t),
        3 => (p, q, v),
        4 => (t, p, v),
        _ => (v, p, q),
    };
    ((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
}
05 — The Field

Computing the plasma value

The plasma function takes a pixel's normalised position and the current time. We normalise so that frequencies are resolution-independent — zooming the window won't change the pattern's appearance.

Rust · plasma scalar field
/// x, y are normalised to the range [-1, 1].
/// Returns a value in [-4, 4].
#[inline(always)]
fn plasma(x: f32, y: f32, t: f32) -> f32 {
    let v1 = (x * 3.0 + t).sin();
    let v2 = (y * 3.0 + t).sin();
    let v3 = ((x + y) * 2.0 + t).sin();
    let cx  = x + 0.5 * t.sin();
    let cy  = y + 0.5 * (t * 1.3).cos();
    let v4  = (cx.hypot(cy) * 6.0).sin();
    v1 + v2 + v3 + v4
}
💡
The cx/cy centre point is animated using sin(t) and cos(t). This makes the fourth (radial) wave swim around the screen, dramatically increasing visual complexity for almost no extra CPU cost.
06 — Rendering

Writing pixels to screen

macroquad's Image type holds raw RGBA bytes. We create it once at startup, overwrite its bytes every frame, upload it to a Texture2D, and draw it fullscreen with draw_texture_ex.

The key insight for palette cycling: rather than recomputing the plasma field each frame, we pre-bake a value_map of raw f32 scalars, then just re-index the palette with an advancing offset. This is fast enough for 1080p at 60 fps on a single thread.

Rust · main render loop
#[macroquad::main("Plasma")]
async fn main() {
    let w = screen_width()  as usize;
    let h = screen_height() as usize;
    let pal  = build_palette();
    let mut img = Image::gen_image_color(
        w as u16, h as u16, BLACK
    );
    let tex = Texture2D::from_image(&img);
    let mut t = 0.0_f32;

    loop {
        t += get_frame_time() * 0.8;
        let offset = (t * 80.0) as usize;

        // Fill pixel buffer
        let buf = img.get_image_data_mut();
        for py in 0..h {
            for px in 0..w {
                let nx = px as f32 / w as f32 * 2.0 - 1.0;
                let ny = py as f32 / h as f32 * 2.0 - 1.0;
                let v   = plasma(nx, ny, t);
                let idx = ((v + 4.0) / 8.0 * 255.0) as usize;
                let c   = pal[(idx + offset) % 256];
                buf[py * w + px] = [c.0, c.1, c.2, 255];
            }
        }

        tex.update(&img);
        clear_background(BLACK);
        draw_texture_ex(
            &tex, 0.0, 0.0, WHITE,
            DrawTextureParams {
                dest_size: Some(vec2(
                    screen_width(), screen_height()
                )),
                ..Default::default()
            },
        );
        next_frame().await;
    }
}
07 — Performance

Going faster

Reduce resolution

Render at half or quarter resolution and scale up with draw_texture_ex. Plasma is a low-frequency signal — you'll barely notice the difference at 4× upscale, and you get a 16× speedup.

Rust · reduced-res setup
const SCALE: usize = 4;
let rw = (w / SCALE).max(1);
let rh = (h / SCALE).max(1);
let mut img = Image::gen_image_color(rw as u16, rh as u16, BLACK);

Rayon parallelism

Pixel evaluation is embarrassingly parallel. Add rayon = "1" to your dependencies and change the inner loop to a parallel iterator:

Rust · rayon parallel iteration
use rayon::prelude::*;

buf.par_iter_mut().enumerate().for_each(|(i, pixel)| {
    let px = i % w;
    let py = i / w;
    let nx = px as f32 / w as f32 * 2.0 - 1.0;
    let ny = py as f32 / h as f32 * 2.0 - 1.0;
    let v   = plasma(nx, ny, t);
    let idx = ((v + 4.0) / 8.0 * 255.0) as usize;
    let c   = pal[(idx + offset) % 256];
    *pixel  = [c.0, c.1, c.2, 255];
});
On a quad-core laptop, combining SCALE=2 with rayon typically pushes a 1920×1080 plasma past 120 fps. Release builds with opt-level = 3 in your Cargo profile will give you another 3–4× over debug.
08 — Complete Source

Putting it all together

Here is the full, runnable src/main.rs. Copy it in, run cargo run --release, and enjoy.

src/main.rs — complete plasma ~90 lines
use macroquad::prelude::*;

// ── Palette ─────────────────────────────────────────────

fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
    let i = (h * 6.0).floor() as u32;
    let f = h * 6.0 - i as f32;
    let (p, q, t) = (
        v * (1.0 - s),
        v * (1.0 - s * f),
        v * (1.0 - s * (1.0 - f)),
    );
    let (r, g, b) = match i % 6 {
        0 => (v, t, p), 1 => (q, v, p),
        2 => (p, v, t), 3 => (p, q, v),
        4 => (t, p, v), _ => (v, p, q),
    };
    ((r*255.0) as u8, (g*255.0) as u8, (b*255.0) as u8)
}

fn build_palette() -> [(u8, u8, u8); 256] {
    let mut p = [(0u8, 0u8, 0u8); 256];
    for i in 0..256 {
        p[i] = hsv_to_rgb(i as f32 / 256.0, 1.0, 1.0);
    }
    p
}

// ── Plasma field ─────────────────────────────────────────

#[inline(always)]
fn plasma(x: f32, y: f32, t: f32) -> f32 {
    let v1  = (x * 3.0 + t).sin();
    let v2  = (y * 3.0 + t).sin();
    let v3  = ((x + y) * 2.0 + t).sin();
    let cx  = x + 0.5 * t.sin();
    let cy  = y + 0.5 * (t * 1.3).cos();
    let v4  = (cx.hypot(cy) * 6.0).sin();
    v1 + v2 + v3 + v4
}

// ── Main ─────────────────────────────────────────────────

#[macroquad::main("Plasma")]
async fn main() {
    const SCALE: usize = 2;
    let w = (screen_width()  as usize / SCALE).max(1);
    let h = (screen_height() as usize / SCALE).max(1);
    let pal = build_palette();

    let mut img = Image::gen_image_color(
        w as u16, h as u16, BLACK
    );
    let tex = Texture2D::from_image(&img);
    tex.set_filter(FilterMode::Linear); // smooth upscale

    let mut t = 0.0_f32;

    loop {
        t += get_frame_time() * 0.8;
        let offset = (t * 80.0) as usize;

        let buf = img.get_image_data_mut();
        for py in 0..h {
            let ny = py as f32 / h as f32 * 2.0 - 1.0;
            for px in 0..w {
                let nx  = px as f32 / w as f32 * 2.0 - 1.0;
                let v   = plasma(nx, ny, t);
                let idx = ((v + 4.0) / 8.0 * 255.0) as usize;
                let c   = pal[(idx + offset) % 256];
                buf[py * w + px] = [c.0, c.1, c.2, 255];
            }
        }

        tex.update(&img);
        clear_background(BLACK);
        draw_texture_ex(&tex, 0.0, 0.0, WHITE, DrawTextureParams {
            dest_size: Some(vec2(screen_width(), screen_height())),
            ..Default::default()
        });

        draw_text(&format!("FPS: {}", get_fps()), 10.0, 24.0, 20.0, WHITE);
        next_frame().await;
    }
}
09 — Going Further

Variations to explore

Once you have the basic loop, the design space is enormous. A few directions worth trying:

Custom palettes — instead of a plain HSV sweep, try combining multiple sin curves on each R, G, B channel independently. The classic "lava lamp" look uses a palette that transitions through orange, yellow, and white.

Multiple plasma layers — render two plasma fields with different frequencies and blend them with addition or XOR on the palette index. The result looks dramatically more complex.

Fragment shaders — macroquad supports GLSL via Material. Porting the scalar field to a fragment shader lets you run it at full resolution at 1000+ fps, and opens the door to per-pixel HDR bloom effects.

Interactive controls — wire up the scroll wheel to tweak frequency multipliers in real time using mouse_wheel(). Add keyboard shortcuts to cycle between pre-built palettes.