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.
| Method | Draws |
|---|---|
px, dot, blend | One pixel: opaque; at an alpha faded by ordered dither; or translucent. |
line, rect, ellipse, tri, poly | Filled shapes and 1 px lines. Colour and alpha can be constants or functions of the shape's own coordinates. |
each, shade | Visit 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_ring | Light only: radial glow falling as (1 − d)² to 0 at radius r, along a line, or round a ring. |
put, add, blend_px | Write 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:
- No platform float math.
clippy.tomldisallowsf64::sin,sqrt,floor,roundand every other transcendental, rounding or fused method. Uselibm::*, andpixfx_core::math::js_roundwhere JavaScript would callMath.round. - Randomness comes from the seed. Use
math::rand,hash,noiseor a seededRng, never a system random source or the clock. - No panics.
unwrap,expectandpanicare denied; handle a missing value (let Some(..) else { return }). - No
unsafe. Every crate has#![forbid(unsafe_code)]. - Ports keep pixelfx's semantics. Where JavaScript behaves differently from Rust (NaN in
Math.min,| 0truncation,Uint8ClampedArrayrounding), use themath::js_*andto_int32/to_uint32helpers.
Register it
- Add
mod ember;andpub use ember::EMBER;to the category'smod.rs(for examplesrc/fire/mod.rs). - Add
&fire::EMBERtoEFFECTSincrates/pixfx-effects/src/lib.rs. The order there is the ordereffects()returns.
Every renderer, the CLI and the Phaser plugin pick it up from the registry; nothing else needs changing.
Test it
- Add the id to
IDSincrates/pixfx-effects/tests/determinism.rsand record its goldens on a native target:PIXFX_BLESS=1 cargo test -p pixfx-effects --test determinism - Check that the wasm build matches the same goldens:
wasm-pack test --node crates/pixfx-effects --test determinism - For a port of a pixelfx effect, run parity against pixelfx.
- Look at it:
pixfx render <id> --gif, or play it in the canvas demo. - Run the whole-repo check.