2D particles
- 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.

WebGPU: checking…See browser support
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
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)), ], }, ], }); },});/** * 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;}/** * 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;}