Concepts
The ideas every pixfx package shares: effects and instances, the colour and light layers, back and front, origins and margins, time, and seeds.
Effects and instances
An effect is a static description: its metadata plus a way to spawn state. Effects are listed in one registry (pixfx_effects::EFFECTS) and looked up by id, a category/name string such as fire/candle.
An instance is one playing copy of an effect, with its own options, seed and clock. You can spawn any number of instances of the same effect.
Metadata
Every effect has a Meta. In JavaScript, meta(id) returns it as an object.
| Field (JS name) | Meaning |
|---|---|
id | category/name. |
name, desc, tags | Display name, one-sentence description, search tags. |
size | [w, h]: the effect's canvas in pixels. |
origin | [x, y]: where the effect's local (0, 0) sits on its canvas. For a fire, the point on the ground under it. |
duration | Seconds: one loop, or the whole one-shot. |
loop (Rust looping) | Whether it loops. A one-shot holds its last moment and is done at duration. |
fps | Frame rate of exported sheets and GIFs. Live playback runs at any rate. |
seed | The seed used when you give none. |
glowMargin (Rust glow_margin()) | Empty pixels worth adding round the canvas so glow that spills past it is not cut: half the canvas's longer side. |
Colour and light
A frame holds two layers of the same size:
- Colour: ordinary pixels with alpha, drawn normally (source-over). Stored as one ABGR word per pixel, which is the byte order of
ImageDatain memory: R, G, B, A. - Light: additive glow, one xBGR word per pixel with the top byte unused. Light is added to whatever is underneath, saturating at 255 per channel.
Keeping light separate is what lets a fire light up the ground and the sprites standing next to it. Every live renderer draws colour first, then light with additive blending (Canvas 2D "lighter", Phaser BlendModes.ADD, gpui SpriteBlend::Additive).
Additive light only shows over an opaque, darker background. Over a transparent target there is nothing to add to: the canvas renderer and the exports then turn light into colour whose alpha is its brightest channel, which reads correctly once composited over a dark ground. Phaser's WebGL renderer adds nothing over a transparent render target; see Phaser.
Compositing
To flatten both layers into RGBA bytes, composite draws colour over an optional background, then adds light:
- Opaque colour, or colour over a background: light is added channel by channel, clamped to 255.
- Over transparency: the pixel's existing colour is premultiplied, the light added, and the result divided by the new alpha, which is at least the light's brightest channel. Light alone over nothing therefore becomes the straight-alpha pixel that adds that light when drawn additively.
The composite sheet the CLI writes and Fx.rgba() in JS both use this.
Back and front layers
An effect draws in two passes:
- Back: what goes behind a character standing in the effect.
- Front: everything else, drawn over the character.
When drawing Both, back is drawn first, then front, into the same frame. An effect with nothing behind a character draws nothing for back. The three effects shipped today (candle, campfire, brazier) draw everything in front; the split is part of the effect contract, so an effect can wrap around a character.
To put your own sprite inside an effect, draw the back layer, your sprite, then the front layer:
stage.update(dt);
drawGround();
stage.drawLayer("back");
drawPlayer();
stage.drawLayer("front");
The Phaser plugin does the same with depths: its back images sit at one depth and front images at another, and you put your sprite between them.
Origin, placement and margin
When you play an effect "at" a point, that point is where the effect's origin lands. Its canvas extends from there by -origin to size - origin. The candle is 56×76 with origin (28, 64): its canvas reaches 28 pixels left, 28 right, 64 up and 12 down from the point you give.
Positions are rounded to whole pixels as JavaScript's Math.round does.
An instance can draw on a canvas bigger than size by a margin of empty pixels on every side. Light often spills past an effect's canvas, so:
- Margin 0 eases light out over the last 8 pixels at the canvas edge (an edge fade), so a glow cut off by the edge does not show as a box. Exports use margin 0, matching pixelfx sheets. Full-screen effects (16:9, at least 320 wide) keep light to the edge.
- A margin leaves room for the glow and fades nothing. The canvas renderer, Phaser plugin and gpui crate all use
glowMargin.
Time
Instances advance in seconds: update(dt).
- The simulation runs in equal sub-steps of at most 1/120 s, whatever your frame rate, so 30 fps and 144 fps hosts see the same motion.
- One call advances at most 0.25 s (
MAX_DT). A tab that was in the background resumes where it left off instead of simulating the whole gap. dtmust be a finite number, 0 or more. In JS, any other value (NaN, a missing argument, a negative step) throws and leaves the instance unchanged. In Rust,Instance::updateignores it.- Loops wrap at
duration; one-shots hold their last moment.
Stopping
stop() lets an instance end: a loop fades out over 0.3 s (FADE), a one-shot is cut short the same way. An instance is done when that fade has finished, or when a one-shot reaches its duration. The canvas renderer and Phaser plugin remove done instances on their own. Later stop() calls keep the first stop time.
Options
Every instance takes the same common options, all optional:
| Option | Type | Default | Meaning |
|---|---|---|---|
seed | non-negative integer | meta.seed | Picks the random variation. Same seed and same steps give the same pixels. |
flip | boolean | false | Mirror left-right about the origin. |
angle | number, radians | 0 | Rotate about the origin. |
light | number | 1 | Glow strength. 0 draws no light; values above 1 brighten it. |
palette | array of hex colours | the effect's ramp | The effect's main colour ramp, dark to bright. |
Effects also take options of their own, each a number or an array of hex colours. The Effect catalog lists them. An option an effect does not know is ignored; a number given where a ramp is expected is ignored too.
Palettes and ramps
A ramp is a list of colours from dark to bright. Colours are "#rgb", "#rrggbb" or "#rrggbbaa", with or without the #; they are always opaque (alpha digits are ignored, as in pixelfx). A ramp of a different length from the default is resampled to the default's length, so a 3-colour ramp works on a 9-colour slot. An empty ramp falls back to the default.
stage.play("fire/campfire", 64, 112, {
palette: ["#0d1033", "#3a62e0", "#7fb4ff", "#ffffff"], // a blue fire
wood: ["#1a1a1a", "#4a4a4a"], // the campfire's own ramp
height: 30, // the campfire's own number
});
Determinism
Everything an effect draws comes from its seed, its options and the steps it was given. pixfx reproduces JavaScript's number semantics (rounding, min/max with NaN, 32-bit integer conversions, clamped byte stores) and uses its own math functions instead of the platform's, so a native build and a wasm build of the same effect produce the same pixels, bit for bit. A frame-hash test checks this; see Parity and tests.