pixfx

Canvas renderer

pixfx-canvas draws effects onto an HTML canvas with Canvas 2D. Mount a Stage, say which effects play where, and either let it run its own loop or drive it from yours.

Build

wasm-pack build crates/pixfx-canvas --target web --out-dir pkg

Ship pkg/pixfx_canvas.js and pkg/pixfx_canvas_bg.wasm together and serve them over HTTP. The module also exports the JS engine's effects(), meta(id) and Fx, so one wasm file covers both.

import init, { Stage, effects, meta } from "./pkg/pixfx_canvas.js";
await init();

Self-running stage

For a page that only shows effects:

const canvas = document.querySelector("canvas");
canvas.width = 320 * 4;
canvas.height = 180 * 4;

const stage = new Stage(canvas, 4);
stage.background("#13101b");
const fire = stage.play("fire/campfire", 160, 160, { seed: 3 });
stage.start();

// later
stage.stop(fire);   // fades out, then removes itself
stage.pause();      // effects keep their state; start() resumes

Each frame the stage clears the canvas (or fills the background colour), then draws the back layer of every effect, then the front layer of every effect, oldest first. Time is the real time between animation frames.

Set image-rendering: pixelated on the canvas if CSS resizes it, so the browser keeps the pixels sharp.

Host-owned loop

A game that draws its own sprites between an effect's layers owns the loop. Call update(dt), draw your ground, drawLayer("back"), draw your sprites, then drawLayer("front"). drawLayer never clears and leaves the context's compositing and smoothing as you had them.

const SCALE = 4;
const stage = new Stage(canvas, SCALE);
const ctx = canvas.getContext("2d");
stage.play("fire/campfire", 168, 160, { seed: 1 });

let last = null;
function frame(now) {
  const dt = last === null ? 0 : Math.max(0, now - last) / 1000;
  last = now;
  stage.update(dt);
  ctx.fillStyle = "#17131c";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  stage.drawLayer("back");
  drawPlayer(ctx);            // your sprites, in canvas pixels (game pixels × SCALE)
  stage.drawLayer("front");
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

tick(dt) is update(dt) plus a full draw (clear or fill, back, front), for a host that owns the loop but draws nothing between layers.

API

new Stage(canvas, scale)

Draw onto canvas (an HTMLCanvasElement), one game pixel as scale×scale canvas pixels. scale must be at least 1. Throws if the canvas has no 2D context, for example because it already has a WebGL one.

play(id, x, y, opts?) → handle

Play effect id with its origin at game pixel (x, y), rounded to a whole pixel. Returns a numeric handle; handles start at 1 and are never reused. opts takes the common options and the effect's own. Throws on an unknown id, a bad option, a non-finite position, or a margin option (the stage sizes each effect's margin itself, as meta.glowMargin).

stop(handle)

Let the effect end: loops fade out over 0.3 s, one-shots are cut short. It is removed once done. Unknown handles are ignored.

remove(handle)

Drop the effect now. Unknown handles are ignored.

start() and pause()

start() runs the stage's own requestAnimationFrame loop; calling it while running does nothing. The first frame after start() steps 0 s. pause() stops the loop; effects keep their state. If a frame fails, the loop logs pixfx Stage stopped: ... to the console and stops; pause() then start() retries.

update(dt), drawLayer(layer), tick(dt)

For host-owned loops, as above. dt is seconds, a finite number, 0 or more; anything else throws and changes nothing. layer is "back" or "front". update removes effects that are done.

setScale(scale)

Change game pixel to canvas pixels (at least 1). Resize the canvas yourself to match.

background(css)

Fill the canvas with this CSS colour before each tick or self-run frame. "" clears to transparent instead (the default). Has no effect on drawLayer.

handles()

The playing handles, oldest first. Finished effects are gone.

free()

Release the stage's wasm memory (generated by wasm-bindgen). Freeing a running stage stops its loop.

How it draws

Each effect has its own small OffscreenCanvas and pixel buffer, made once when it is played and reused every frame. For each layer, the colour half is drawn with source-over, then the light half with "lighter" (additive, saturating), each enlarged by scale with image smoothing off. Empty halves are skipped. Every effect gets glowMargin pixels of room round its canvas and no edge fade, so glow shows in full.

Light is drawn as light over transparency: it adds exactly its light to an opaque canvas, and shows as that light's colour on a transparent one.

The decisions (placement, ordering, removal, the pixels of each layer) are plain Rust in crates/pixfx-canvas/src/stage.rs, tested natively; lib.rs only blits.

Demo

wasm-pack build crates/pixfx-canvas --target web --out-dir demo/pkg
cd crates/pixfx-canvas/demo && python3 -m http.server 8766 --bind 127.0.0.1

The demo owns the loop and draws a figure between the layers. window.pixfxDemo exposes its stage and frame rate for tests.