ignifx
All examples

2D particles

Particles2D

  • Keyboard
  • Gamepad
  • Touch

`ParticleSystem2D` reads the same `.particles.json` the 3D system reads — the same presets, the same modules, the same emitter and the same evaluator — and draws it as sprites: every live particle is written into a `SpriteBatch`, a block of entity-less slots in one sorting layer. So a particle blends, sorts and pans exactly like the tiles around it, and a flame behind a roof is behind the roof. There is no Z in a 2D scene, so where an effect draws is which layer it is on: the dust is under the villager, the flames and the glint are over her and under the canopy. The dust has no script at all — `emission.rateOverDistance` spawns per metre the emitter's entity moves, so walking makes dust and standing does not.

A pixel-art village seen from above with a villager in a red tunic on the dirt lane, an orange flame burning at a brazier on either side of her, and a small gold pickup glinting with white stars on the road ahead.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Walk in a circle and stop: the puffs stay on the road behind you, because the document simulates in world space.
  • Walk behind a roof. The braziers and the villager go behind the canopy layer together, sprites and particles alike.
  • Pull Effects quality down: one number turns every rate in the app down, in 2D exactly as in 3D.
Show source code

Source

main.ts
import {  Camera2D,  Camera2DFollow,  ParticleSystem2D,  particleAssetFromDefinition,  particles,  particles2D,  physics2d,  SpriteRenderer,  Tilemap,  TilemapCollider2D,  TilemapRenderer,  twoD,  Vec2,  VirtualJoystick,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { bind, readout, slider } from "../_kit/panel.ts";import {  COIN,  footstepDefinition,  LEVEL_SIZE,  loadLevel,  REFERENCE_RESOLUTION,  SORTING_LAYERS,  sparkleDefinition,  spritesDrawn,  TORCHES,  torchDefinition,  VILLAGER_START,} from "./village-fx.ts";import { createVillager, WALK_ACTIONS, Walker } from "./walker.ts";import type { AssetHandle, ParticleAsset } from "ignifx";/** * The tilemap example's village with three particle effects on it: two braziers, dust under the * villager's feet, and a sparkle over a pickup. * * ## The same documents, drawn as sprites * * `ParticleSystem2D` reads the `.particles.json` the 3D `ParticleSystem` reads — the same presets, * the same modules, the same emitter and the same evaluator. What changes is the draw: instead of * uploading spawn records for the GPU to evaluate, it writes every live particle into a * **`SpriteBatch`**, a block of entity-less sprite slots in one sorting layer. So a particle blends, * sorts and pans exactly like the tiles around it, and a torch behind a roof is behind the roof. * * ## Sorting layers are the depth * * There is no Z here (`docs/architecture/11-2d-toolkit.md` §3), so where an effect draws is which * layer it is on: `Dust` sits under `Default` and `Sparks` above it, under `Canopy`. A large moving * effect belongs on a layer that does not Y-sort, which is why neither of those two does. * * `village-fx.ts` holds the three documents and where they stand; `walker.ts` is the villager. *//** How many particles all three effects together may hold. */const BUDGET = 4000;bootExample({  title: "2D particles",  extensions: [    // One world metre is one 16-pixel tile. `particles` owns the budget and the quality scale for    // both dimensions; `particles2D` is the renderer that draws into sprite layers.    twoD({ pixelsPerUnit: 16, ySort: { Default: true } }),    physics2d(),    particles({ maxParticles: BUDGET }),    particles2D(),  ],  settings: {    // Multisampling is off because it would soften exactly the edges pixel art exists to keep sharp.    rendering: { msaaSamples: 1 },    time: { fixedDeltaTime: 1 / 60 },    sortingLayers: { sortingLayers: SORTING_LAYERS },    layers: { layers: ["Default", "Player", "Terrain"] },    // A top-down world has no gravity: the controller goes exactly where `move` says, and a    // document that asks for the world's gravity gets none either.    physics2d: { gravity: { x: 0, y: 0 }, defaultMaterial: { friction: 0, restitution: 0 } },    particles: { gravity: { x: 0, y: 0, z: 0 } },  },  async setup({ app, panel, flags }) {    app.registerComponents([Walker]);    app.input.loadActions(WALK_ACTIONS);    const assets = await loadLevel(app);    const seed = Math.max(1, Math.trunc(flags.seed));    const level = app.world.createEntity("Level");    level.layer = app.world.layers.requireIndex("Terrain");    const map = level.addComponent(Tilemap, { map: assets.map, chunkSize: 8 });    level.addComponent(TilemapRenderer, { atlas: assets.tiles, cullChunks: true });    // Adjacent solid cells are merged into as few polygons as the tiles allow, so the villager    // walks the streets rather than through the fences.    level.addComponent(TilemapCollider2D).collisionData = map.collisionData;    const villager = createVillager(app, assets);    const walker = villager.requireComponent(Walker);    const footsteps = villager.addComponent(ParticleSystem2D, {      definition: particleAssetFromDefinition(app, footstepDefinition(), "fx/footsteps"),      atlas: assets.dust,      sortingLayer: "Dust",      seed,    });    const torchDocument: AssetHandle<ParticleAsset> = particleAssetFromDefinition(app, torchDefinition(), "fx/torch");    for (let index = 0; index < TORCHES.length; index += 1) {      const at = TORCHES[index];      if (at === undefined) {        continue;      }      const brazier = app.world.createEntity(`Brazier ${String(index)}`);      brazier.transform.position2D = new Vec2(at.x, at.y);      // Two systems, one document: the second brazier shares the first's atlas and its document,      // and its own seed is what keeps the two flames from flickering in step.      brazier.addComponent(ParticleSystem2D, {        definition: torchDocument,        atlas: assets.flame,        sortingLayer: "Sparks",        seed: seed + index + 1,      });    }    const pickup = app.world.createEntity("Pickup");    pickup.transform.position2D = new Vec2(COIN.x, COIN.y);    pickup.addComponent(SpriteRenderer, {      sprite: assets.dust,      sortingLayer: "Default",      color: { r: 1, g: 0.78, b: 0.25, a: 1 },    });    const sparkle = pickup.addComponent(ParticleSystem2D, {      definition: particleAssetFromDefinition(app, sparkleDefinition(), "fx/sparkle"),      atlas: assets.spark,      sortingLayer: "Sparks",      seed: seed + 9,    });    const eye = app.world.createEntity("Main Camera");    eye.transform.position2D = new Vec2(VILLAGER_START.x, VILLAGER_START.y);    eye.addComponent(Camera2D, {      pixelPerfect: true,      referenceResolution: REFERENCE_RESOLUTION,      follow: villager,      followDamping: 0.12,      followOffset: { x: 0, y: 0.5 },      deadZone: { x: 1.5, y: 1 },      boundsMin: { x: 0, y: 0 },      boundsMax: LEVEL_SIZE,    });    eye.addComponent(Camera2DFollow);    // On-screen controls only where there is a touch screen: on a desktop they would cover the map.    if (navigator.maxTouchPoints > 0) {      const joystick = new VirtualJoystick(app, {        control: "joystick",        ariaLabel: "Walk",        style: { left: "1.5rem", bottom: "calc(1.5rem + var(--ignifx-safe-bottom, 0px))" },      });      window.addEventListener("pagehide", (): void => {        joystick.dispose();      });    }    panel({      title: "2D particles",      groups: [        {          label: "Village",          controls: [            slider(              "Walk speed",              { min: 1, max: 8, step: 0.5, format: (value: number): string => `${value.toFixed(1)} m/s` },              bind(walker, "speed"),            ),            slider(              "Effects quality",              { min: 0, max: 1, step: 0.05, format: (value: number): string => `${(value * 100).toFixed(0)} %` },              {                value: app.particles.qualityScale,                change: (value: number): void => {                  // The one knob a settings screen turns: every rate and burst in the app, 2D and                  // 3D alike, read fresh each frame.                  app.particles.qualityScale = value;                },              },            ),          ],        },        {          label: "Particles",          controls: [            readout("Footstep dust", (): string => String(footsteps.aliveCount)),            readout("Sparkle", (): string => `${String(sparkle.aliveCount)} of ${String(sparkle.capacity)}`),            readout("Sprites in layers", (): string => String(spritesDrawn(app))),            readout("Draw calls", (): string => String(app.renderer.drawCalls)),          ],        },      ],    });  },});
village-fx.ts
/** * The three effects the village wears, and where they stand. * * @remarks * Every document here is a `.particles.json` the 3D system would play unchanged — the same file * format, the same modules, the same evaluator. What makes them 2D is the component: a * `ParticleSystem2D` writes each live particle into a sprite batch on a sorting layer instead of * uploading spawn records to the GPU, so the particles sort, blend and pan with the tiles. * * Two rules of the 2D renderer shape the numbers below (`packages/particles-2d/skills/`): * * - **Sizes are metres.** One metre is one 16-pixel tile here, so a flame of `0.5` is eight pixels. * - **Only X and Y are drawn.** A shape that spreads along Z — a `circle`, which lies in the ground *   plane — looks like a line, so the two directional effects use a wide `cone`, which spreads *   along X and travels along +Y. */import { particleDefinition } from "ignifx";import type {  App,  AssetHandle,  ParticleDefinition,  SpriteAnimationAsset,  SpriteAtlasAsset,  TilemapAsset,  Vec2Like,} from "ignifx";/** The documents and atlases the level loads, by address. */export const ADDRESSES = {  map: "2d/village.tilemap.json",  tiles: "2d/tiny-town.atlas.json",  villager: "2d/villager.atlas.json",  villagerClips: "2d/villager.spriteanim.json",  flame: "2d/fx-flame.atlas.json",  dust: "2d/fx-dust.atlas.json",  spark: "2d/fx-spark.atlas.json",} as const;/** The sorting layers, back to front. The tilemap's own three come from its document. */export const SORTING_LAYERS: readonly string[] = ["Ground", "Terrain", "Dust", "Default", "Sparks", "Canopy"];/** The map is forty by twenty cells of one metre, and the camera may not leave it. */export const LEVEL_SIZE = { x: 40, y: 20 } as const;/** The design resolution the pixel-perfect camera fits a whole-number zoom to. */export const REFERENCE_RESOLUTION = { x: 320, y: 180 } as const;/** * Where the villager starts, in metres. * * @remarks * The map's object layer says the same thing — `tilemap/main.ts` shows the `spawnTilemapObjects` * route — but reading it here would be a lesson about tilemaps in an example about particles. */export const VILLAGER_START: Vec2Like = { x: 10.5, y: 6 };/** Where the two braziers stand, in metres. */export const TORCHES: readonly Vec2Like[] = [  { x: 7.5, y: 6.2 },  { x: 13.5, y: 6.2 },];/** Where the coin floats, in metres. */export const COIN: Vec2Like = { x: 10.5, y: 9.2 };/** * The brazier flame: the `fire` preset, shrunk to a village's scale and pinned to one spot. * * @returns The document. */export function torchDefinition(): ParticleDefinition {  return particleDefinition("fire", {    main: { capacity: 64, duration: 1.5 },    emission: { rateOverTime: 16 },    shape: { kind: "cone", radius: 0.05, angle: 12 },    start: {      lifetime: { min: 0.5, max: 0.9 },      speed: { min: 0.8, max: 1.4 },      size: { min: 0.45, max: 0.75 },    },    forces: { gravityMultiplier: -0.05, drag: 1.4 },  });}/** * The footstep puff: dust emitted **per metre walked**, not per second. * * @remarks * `simulationSpace: "world"` is what leaves a puff behind on the road rather than dragging it along * under her feet, and `rateOverDistance` is why there is no script: the emitter measures how far its * entity moved this frame and spawns from that. * * @returns The document. */export function footstepDefinition(): ParticleDefinition {  return particleDefinition("dust", {    main: { capacity: 96, duration: 2, prewarm: false, simulationSpace: "world" },    emission: { rateOverTime: 0, rateOverDistance: 5 },    shape: { kind: "cone", radius: 0.12, angle: 70 },    start: {      lifetime: { min: 0.35, max: 0.75 },      speed: { min: 0.2, max: 0.6 },      size: { min: 0.18, max: 0.34 },      color: [0.86, 0.8, 0.68, 0.7],    },    forces: { drag: 3, noise: null },  });}/** * The coin glint: the `sparkle` preset, drawn from a four-frame sheet. * * @remarks * `renderer.sheet` is the one document field the 2D renderer reads differently: tile **n** of the * sheet is frame **n** of the atlas, so a four-tile sheet and a four-frame atlas line up and the * star opens and closes. * * @returns The document. */export function sparkleDefinition(): ParticleDefinition {  return particleDefinition("sparkle", {    main: { capacity: 48, duration: 1.2 },    emission: { rateOverTime: 9 },    shape: { kind: "sphere", radius: 0.35, thickness: 0.6 },    start: { lifetime: { min: 0.4, max: 0.9 }, size: { min: 0.3, max: 0.55 } },    renderer: { sheet: { tiles: { x: 4, y: 1 }, frameOverTime: { fps: 9 } } },  });}/** Everything the level is built from, once every load has settled. */export interface LevelAssets {  /** The Tiled map, as `importTiledMap` wrote it. */  readonly map: AssetHandle<TilemapAsset>;  /** The tileset the map draws from. */  readonly tiles: AssetHandle<SpriteAtlasAsset>;  /** The villager's sheet. */  readonly villager: AssetHandle<SpriteAtlasAsset>;  /** Her walk and idle clips. */  readonly clips: AssetHandle<SpriteAnimationAsset>;  /** The brazier flame's atlas. */  readonly flame: AssetHandle<SpriteAtlasAsset>;  /** The footstep puff's atlas. */  readonly dust: AssetHandle<SpriteAtlasAsset>;  /** The four-frame sparkle sheet's atlas. */  readonly spark: AssetHandle<SpriteAtlasAsset>;}/** * Loads the seven documents the level is built from. * * @remarks * Awaited before `app.start()`, where a completed load settles at once; started after it, a load is * delivered in a later frame's `PreUpdate` — and a `ParticleSystem2D` whose atlas has not arrived * reports `IGX-1754` and draws nothing. * * @param app - The app being set up. * @returns The map, the atlases and the clips. */export async function loadLevel(app: App): Promise<LevelAssets> {  const [map, tiles, villager, clips, flame, dust, spark] = await Promise.all([    app.assets.loadAsync<TilemapAsset>(ADDRESSES.map),    app.assets.loadAsync<SpriteAtlasAsset>(ADDRESSES.tiles),    app.assets.loadAsync<SpriteAtlasAsset>(ADDRESSES.villager),    app.assets.loadAsync<SpriteAnimationAsset>(ADDRESSES.villagerClips),    app.assets.loadAsync<SpriteAtlasAsset>(ADDRESSES.flame),    app.assets.loadAsync<SpriteAtlasAsset>(ADDRESSES.dust),    app.assets.loadAsync<SpriteAtlasAsset>(ADDRESSES.spark),  ]);  return { map, tiles, villager, clips, flame, dust, spark };}/** * How many sprites the 2D layers are holding: the tiles, the villager, and every live particle. * * @param app - The running app. * @returns The total. */export function spritesDrawn(app: App): number {  let total = 0;  const layers = app.twoD.layers;  for (let index = 0; index < layers.length; index += 1) {    total += layers[index]?.count ?? 0;  }  return total;}
walker.ts
/** * The villager: the walk, the facing and the clip, so `main.ts` is only the village and the three * effects on it. * * @remarks * `tilemap/villager.ts` is the same script; this copy exists because an example owns its own files * and its own component id. Input is sampled in `update` and spent in `fixedUpdate`, because those * are two different clocks: a frame carries zero, one or two fixed steps, so reading a stick inside * the step would sample the same frame twice or miss it entirely. * * Nothing here emits a particle. The dust under her feet is `emission.rateOverDistance`, which the * emitter counts from how far its entity has moved — so walking makes dust and standing does not. */import { CharacterController2D, defineInputActions, f32, Script, SpriteAnimator, SpriteRenderer, Vec2 } from "ignifx";import { VILLAGER_START } from "./village-fx.ts";import type { LevelAssets } from "./village-fx.ts";import type { App, Entity, InputAction, InputActionsDefinition, MutableVec2, ScriptCallbacks } from "ignifx";/** The four facings the sheet has, in the order a stick angle is bucketed into. */const FACINGS = ["right", "up", "left", "down"] as const;/** The actions the villager reads. Its own map, so the kit's camera actions are untouched. */export const WALK_ACTIONS: InputActionsDefinition = defineInputActions({  maps: [    {      name: "Village",      actions: [        {          name: "walk",          type: "vector2",          bindings: [            {              composite: "2DVector",              up: "<Keyboard>/w",              down: "<Keyboard>/s",              left: "<Keyboard>/a",              right: "<Keyboard>/d",            },            {              composite: "2DVector",              up: "<Keyboard>/arrowUp",              down: "<Keyboard>/arrowDown",              left: "<Keyboard>/arrowLeft",              right: "<Keyboard>/arrowRight",            },            { path: "<Gamepad>/leftStick", processors: ["deadzone(0.2)"] },            { path: "<Gamepad>/dpad" },            { path: "<Virtual>/joystick", processors: ["deadzone(0.15)"] },          ],        },      ],    },  ],});/** Walks its entity on the tilemap's collision and keeps the animator on the matching clip. */export class Walker extends Script.define({ speed: f32(4) }) implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "particles-2d/Walker";  #controller: CharacterController2D | null = null;  #animator: SpriteAnimator | null = null;  #walk: InputAction | null = null;  /** This frame's direction, already normalised so a diagonal is not 1.41 times faster. */  readonly #wish: MutableVec2 = new Vec2();  /** The displacement handed to the controller. Reused, so the fixed step allocates nothing. */  readonly #step: MutableVec2 = new Vec2();  /** Which way she faces, and the clip that is playing, so `play` is called only on a change. */  #facing = "down";  #clip = "";  awake(): void {    this.#controller = this.entity.requireComponent(CharacterController2D);    this.#animator = this.entity.getComponent(SpriteAnimator);    this.#walk = this.app.input.actions.find("walk");  }  update(): void {    const vector = this.#walk?.vector ?? null;    const length = vector === null ? 0 : Math.hypot(vector.x, vector.y);    if (vector === null || length < 0.01) {      this.#wish.set(0, 0);    } else {      const scale = length > 1 ? 1 / length : 1;      this.#wish.set(vector.x * scale, vector.y * scale);      // A stick points anywhere; the sheet has four directions, so the angle is bucketed into the      // nearest cardinal.      const quadrant = Math.round(Math.atan2(this.#wish.y, this.#wish.x) / (Math.PI / 2));      this.#facing = FACINGS[((quadrant % 4) + 4) % 4] ?? "down";    }    const clip = `${length < 0.01 ? "idle" : "walk"}_${this.#facing}`;    if (clip !== this.#clip) {      this.#clip = clip;      this.#animator?.play(clip);    }  }  fixedUpdate(dt: number): void {    this.#step.set(this.#wish.x * this.speed * dt, this.#wish.y * this.speed * dt);    this.#controller?.move(this.#step);  }}/** * Stands the villager on the road: her sheet, her clips, her collider and this script. * * @param app - The app the entity belongs to. * @param assets - The loaded level. * @returns Her entity, so the camera can follow it and the dust can be attached to it. */export function createVillager(app: App, assets: LevelAssets): Entity {  const entity = app.world.createEntity("Villager");  entity.layer = app.world.layers.requireIndex("Player");  entity.transform.position2D = new Vec2(VILLAGER_START.x, VILLAGER_START.y);  entity.addComponent(SpriteRenderer, { sprite: assets.villager, sortingLayer: "Default" });  entity.addComponent(SpriteAnimator, { animations: assets.clips, defaultClip: "idle_down", playOnAwake: true });  // A box, not the default capsule: a top-down character slides along a wall more predictably with  // square corners. The offset lifts the box off the origin, which is at her feet.  entity.addComponent(CharacterController2D, {    shape: "box",    radius: 0.3,    height: 0.5,    offset: { x: 0, y: 0.25 },    slopeLimit: 90,    snapToGround: 0,  });  entity.addComponent(Walker);  return entity;}

Uses:ParticleSystem2DSpriteBatchsorting layersrateOverDistanceTilemapCamera2D

Assets:Tiny Town tileset — CC0 1.0, KenneyVillager sprite sheet — Apache-2.0, Astrum Forge StudiosParticle sprites and the blast ring — Apache-2.0, Astrum Forge Studios