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.
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.
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 [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:
use macroquad::prelude::*; #[macroquad::main("Plasma")] async fn main() { loop { clear_background(BLACK); // draw here next_frame().await; } }
Four steps to plasma
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:
/// 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) }
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.
/// 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 }
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.
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.
#[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; } }
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.
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:
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]; });
opt-level = 3 in your Cargo profile will give you another 3–4× over debug.
Putting it all together
Here is the full, runnable src/main.rs. Copy it in, run cargo run --release, and enjoy.
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; } }
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.