pixfx

Write an effect

An effect is a Rust static with metadata and a spawn function. This page shows its parts, the drawing API, the rules that keep it deterministic, and how to register and test it.

Anatomy

Effects live in crates/pixfx-effects/src/<category>/<name>.rs. The skeleton below follows fire/candle.rs:

use pixfx_core::color::Rgba;
use pixfx_core::{Effect, EffectState, Layer, Meta, Options, Painter};

pub struct Ember;
pub static EMBER: Ember = Ember;

const META: Meta = Meta {
    id: "fire/ember",
    name: "Ember",
    desc: "A single coal glowing and dimming.",
    tags: &["fire", "prop", "light", "loop"],
    size: (24, 24),
    origin: (12, 20),   // the point that lands where the caller plays it
    duration: 2.0,      // seconds per loop
    looping: true,
    fps: 30,            // sheet frame rate
    seed: 1,
};

// the default ramp, dark to bright; callers replace it with `palette`
const PALETTE: [Rgba; 3] = [Rgba::hex24(0x4a1712), Rgba::hex24(0xf2561d), Rgba::hex24(0xffc23d)];

impl Effect for Ember {
    fn meta(&self) -> &Meta {
        &META
    }
    fn spawn(&self, opts: &Options, seed: u64) -> Box<dyn EffectState> {
        Box::new(State {
            ramp: opts.palette_or(&PALETTE),
            size: opts.num_or("size", 3.0), // an option of its own
            seed: seed as f64,
        })
    }
}

struct State {
    ramp: Vec<Rgba>,
    size: f64,
    seed: f64,
}

impl EffectState for State {
    fn draw(&self, p: &mut Painter, t: f64, layer: Layer) {
        if layer == Layer::Back {
            return; // nothing behind a character
        }
        let k = 0.5 + 0.5 * libm::sin(t * core::f64::consts::PI + self.seed); // the seed shifts the phase
        let Some(&hot) = self.ramp.last() else { return };
        p.rect(-self.size, -self.size, self.size * 2.0, self.size * 2.0, hot, 1.0);
        p.glow(0.0, -1.0, 10.0, hot, 0.6 * k);
    }
}

Draw in local space: (0, 0) is the effect's origin, y grows downward. The instance places, mirrors and rotates the painter before calling draw, and applies fade-out and the light option, so the effect never handles them.

Draw from time, or simulate

Most effects draw purely from t (seconds into the loop) and the seed, so any frame can be drawn on its own and loops close exactly. Effects with real physics (sparks that bounce, debris that settles) keep state and override EffectState::update(dt), which the instance calls once per sub-step of at most 1/120 s.

The painter

Painter (crates/pixfx-core/src/painter.rs) draws into the frame's two layers. All coordinates are local and may be fractional; shapes snap to whole pixels.

MethodDraws
px, dot, blendOne pixel: opaque; at an alpha faded by ordered dither; or translucent.
line, rect, ellipse, tri, polyFilled shapes and 1 px lines. Colour and alpha can be constants or functions of the shape's own coordinates.
each, shadeVisit every pixel in a local box and decide its colour and light yourself. Every filled shape is built on each, so shapes mirror and rotate exactly.
glow, glow_line, glow_ringLight only: radial glow falling as (1 − d)² to 0 at radius r, along a line, or round a ring.
put, add, blend_pxWrite colour or add light at a screen pixel.

Shared building blocks for fire (flame tongues, licks, sparks, embers, smoke puffs, floor light) are in crates/pixfx-effects/src/fire/lib.rs.

Rules

These keep native and wasm builds bit-identical and are enforced by the compiler and clippy:

Register it

  1. Add mod ember; and pub use ember::EMBER; to the category's mod.rs (for example src/fire/mod.rs).
  2. Add &fire::EMBER to EFFECTS in crates/pixfx-effects/src/lib.rs. The order there is the order effects() returns.

Every renderer, the CLI and the Phaser plugin pick it up from the registry; nothing else needs changing.

Test it

  1. Add the id to IDS in crates/pixfx-effects/tests/determinism.rs and record its goldens on a native target:
    PIXFX_BLESS=1 cargo test -p pixfx-effects --test determinism
  2. Check that the wasm build matches the same goldens:
    wasm-pack test --node crates/pixfx-effects --test determinism
  3. For a port of a pixelfx effect, run parity against pixelfx.
  4. Look at it: pixfx render <id> --gif, or play it in the canvas demo.
  5. Run the whole-repo check.