All examples
Explosion
- Mouse
- Touch
- Gamepad
One blast is four documents played together on one entity — `ParticleSystem` is `allowMultiple` — and each shows a different renderer. The fireball is a billboard burst; the sparks are `stretched`, so every streak is aligned to its own velocity and lengthened by its speed; the shockwave is one `horizontal` particle lying flat on the floor, growing from nothing to nine metres; the debris is `mesh`, two dozen lit boxes thrown up and pulled back by the app's gravity. Nothing here runs per frame: a seed and a document are all a burst is, so the same seed is the same blast, particle for particle, whatever the frame rate. The spent entity destroys itself when `onStopped` fires.

WebGPU: checking…See browser support
Try this
- Click anywhere on the floor. The blast goes off where the ray met the ground, not where the camera is.
- Detonate twice on one seed, then change the seed and do it again — the first two are identical.
- Watch the draw calls while three blasts overlap: four systems each, and still four draws.
Show source code
Source
import { Camera, createRay, Environment, ParticleSystem, particleAssetFromDefinition, particles } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { button, readout, slider } from "../_kit/panel.ts";import { createGridGround, createLightRig, loadEnvironment } from "../_kit/stage.ts";import { aliveTotal, blastParts, CAPTURE_SECONDS, CHARGE_HEIGHT, CLEAR_COLOR, FLOOR_SIZE, groundPoint, MAX_SEED, OPENING_BLAST, SHOCKWAVE_TEXTURE, SHOT, START_SEED, WAVE_HEIGHT,} from "./blast.ts";import { BLAST_ACTIONS, ClickToDetonate } from "./click-to-detonate.ts";import type { Detonator } from "./click-to-detonate.ts";import type { AssetHandle, ParticleAsset, TextureAsset } from "ignifx";/** * Click the ground to set off a blast: a fireball, a spray of stretched sparks, a shockwave on the * floor and two dozen lit boxes of debris. * * A blast is four `.particles.json` documents played together — `blast.ts` beside this file holds * them. `ParticleSystem` is `allowMultiple`, so all four sit on one entity and go off together, and * nothing here runs per frame: a particle's position, size, colour and spin at any moment is a * formula the GPU evaluates from the spawn record the CPU wrote when it was born. * * A seed and a document are all a burst is, so two detonations on one seed are the same blast, * particle for particle, whatever the frame rate — which is why the field below is worth having; a * seed of `0` would mean "pick one at random on `play()`". The spent entity destroys itself when * the debris, the longest-lived of the four, stops. */bootExample({ title: "Explosion", extensions: [particles({ maxParticles: 20_000 })], settings: { // No shadow maps: a particle renderer never casts one (Lite gives a shader material no shadow // bindings), and there is nothing else in the scene to cast. rendering: { clearColor: CLEAR_COLOR, msaaSamples: 4, features: { shadows: false } }, time: { fixedDeltaTime: 1 / 60 }, }, async setup({ app, panel, flags, afterStart }) { app.registerComponents([ClickToDetonate]); // `loadActions` merges by map name, so the orbit camera's own map is untouched. app.input.loadActions(BLAST_ACTIONS); const eye = app.world.createEntity("Main Camera"); const camera = eye.addComponent(Camera, { near: 0.1, far: 300, fov: SHOT.fov }); attachOrbit(app, eye, { yaw: SHOT.yaw, pitch: SHOT.pitch, distance: SHOT.distance, target: SHOT.target, minDistance: 3, maxDistance: 40, }); createLightRig(app, { focus: SHOT.target, keyPosition: { x: -4, y: 6, z: -8 }, keyIntensity: 2.6, fillIntensity: 0.6, shadows: false, }); await createGridGround(app, { size: FLOOR_SIZE, color: { r: 0.13, g: 0.15, b: 0.19, a: 1 } }); // The probe is what makes `renderer.lit` worth having: a lit particle's ambient term is the // environment's spherical harmonics, and without one every chunk of debris facing away from the // key light is black. const environment = loadEnvironment(app, "studio"); await environment.promise; const sky = app.world.createEntity("Environment").addComponent(Environment, { environment, clearColor: CLEAR_COLOR, skybox: { enabled: false, size: 20 }, }); sky.imageProcessing.toneMapping = "aces"; // Awaited before `app.start()`, so the first blast already has its ring: a material binds a // texture once, and a handle that is still loading binds as the white fallback. await app.assets.loadAsync<TextureAsset>(SHOCKWAVE_TEXTURE); // One asset per document, shared by every blast: the twentieth compiles nothing. const documents: readonly AssetHandle<ParticleAsset>[] = blastParts().map((part): AssetHandle<ParticleAsset> => particleAssetFromDefinition(app, part.definition, part.name), ); // `latest` is the most recent blast's number: only that one lights again when it is spent, so // the repeat follows your clicks. let seed = START_SEED; let blasts = 0; let latest = 0; /** * Sets off one blast on the floor. * * @param x - Where, in metres. * @param z - Where, in metres. */ function detonate(x: number, z: number): void { blasts += 1; latest = blasts; const mine = blasts; // Two entities: the wave lies on the floor, and everything else sits on the charge above it. const blast = app.world.createEntity("Blast", { position: { x, y: WAVE_HEIGHT, z } }); const charge = app.world.createEntity("Charge", { parent: blast, position: { x: 0, y: CHARGE_HEIGHT, z: 0 }, }); let debris: ParticleSystem | null = null; for (let index = 0; index < documents.length; index += 1) { const definition = documents[index]; if (definition === undefined) { continue; } const host = index === 0 ? blast : charge; const system = host.addComponent(ParticleSystem, { definition, seed: seed + index }); debris = system; if (flags.isStatic) { // The capture flag stops the clock before the first frame, so a blast would be frozen at // the instant it was lit. `simulate` is the same arithmetic the frames would have run. system.play(); system.simulate(CAPTURE_SECONDS); } } // The debris is the last of the four and the longest-lived, so when it stops the blast is // over: the entity destroys itself, and the newest blast lights again where it stood. debris?.onStopped.connect( (): void => { blast.destroy(); if (mine === latest) { detonate(x, z); } }, { owner: blast }, ); } // One ray and one point, reused: a click allocates nothing. const ray = createRay(); const hit = { x: 0, z: 0 }; const detonator: Detonator = { detonateAt(x: number, y: number): void { if (groundPoint(camera, ray, x, y, hit)) { detonate(hit.x, hit.z); } }, }; app.world.createEntity("Pointer").addComponent(ClickToDetonate).detonator = detonator; // One blast before the frame is called settled, so the example opens on an explosion rather // than on an instruction. afterStart((): void => { detonate(OPENING_BLAST.x, OPENING_BLAST.z); }); panel({ title: "Explosion", groups: [ { label: "Blast", controls: [ button("Detonate", (): void => { detonate(OPENING_BLAST.x, OPENING_BLAST.z); }), slider( "Seed", { min: 1, max: MAX_SEED, step: 1, format: (value: number): string => value.toFixed(0) }, { value: START_SEED, change: (value: number): void => { seed = Math.round(value); }, }, ), readout("Blasts", (): string => String(blasts)), ], }, { label: "Cost", controls: [ readout("Alive", (): string => String(aliveTotal(app))), readout("Systems", (): string => String(app.particles.systems.length)), readout("Draw calls", (): string => String(app.renderer.drawCalls)), ], }, ], }); },});/** * The four documents one detonation plays, and the stage they are played on. * * @remarks * Four separate `.particles.json` documents rather than one, because that is how the system is * meant to be used: a document describes one population of particles with one look, and a blast is * four of them — a fireball, a spray of sparks, a wave on the ground and some solid debris. They * are built once and shared by every detonation, so the twentieth blast compiles nothing. */import { defineParticles, particleDefinition } from "ignifx";import type { App, Camera, ParticleDefinition, Ray } from "ignifx";/** The near-black the frame is cleared to; an additive blast needs somewhere dark to burn. */export const CLEAR_COLOR = { r: 0.012, g: 0.015, b: 0.023, a: 1 } as const;/** The opening shot: the pose every capture is taken from. */export const SHOT = { fov: 36, yaw: 22, pitch: 14, distance: 11, target: { x: 0, y: 1, z: 0 },} as const;/** How wide the floor is, in metres. */export const FLOOR_SIZE = 40;/** How high the fireball, the sparks and the debris sit above the point that was clicked. */export const CHARGE_HEIGHT = 0.55;/** How far the shockwave is lifted off the floor, in metres, so the two do not fight for depth. */export const WAVE_HEIGHT = 0.04;/** Where the opening detonation goes off, so a capture has something to photograph. */export const OPENING_BLAST = { x: 0, y: 0, z: 0 } as const;/** The seed the example opens on, and the one the poster is taken with. */export const START_SEED = 7;/** The highest seed the panel's field offers. */export const MAX_SEED = 24;/** How far into a blast a `?static=1` capture is taken, in seconds. */export const CAPTURE_SECONDS = 0.28;/** The ring the shockwave draws, generated by `_tools/make-particle-sprites.ts`. */export const SHOCKWAVE_TEXTURE = "textures/shockwave.png";/** One part of a blast: a document, and the name its asset is registered under. */export interface BlastPart { /** The asset address, and what the entity's component is there to do. */ readonly name: string; /** The document itself. */ readonly definition: ParticleDefinition;}/** * The four documents, in draw order. * * @remarks * The last one is the longest-lived on purpose: `main.ts` destroys a spent blast when *it* stops, * and a part that outlived it would be cut off mid-air. * * @returns The parts, built fresh. */export function blastParts(): readonly BlastPart[] { return [ { name: "fx/blast-wave", definition: shockwave() }, { name: "fx/blast-fireball", definition: fireball() }, { name: "fx/blast-sparks", definition: sparks() }, { name: "fx/blast-debris", definition: debris() }, ];}/** * The fireball: the shipped `explosion` preset, which is already a one-shot burst. * * @returns The document. */function fireball(): ParticleDefinition { return particleDefinition("explosion", { main: { capacity: 192 }, emission: { bursts: [{ time: 0, count: 120, cycles: 1 }] }, });}/** * The sparks: the `sparks` preset turned from a looping shower into one burst that arcs and falls. * * @remarks * `renderer.mode: "stretched"` is what makes a spark a streak: the quad is aligned to the * particle's own velocity and lengthened by its speed, so the fastest sparks are the longest and * they shorten as drag takes them. * * @returns The document. */function sparks(): ParticleDefinition { return particleDefinition("sparks", { main: { capacity: 128, duration: 1.2, looping: false }, emission: { rateOverTime: 0, bursts: [{ time: 0, count: 70, cycles: 1 }] }, shape: { kind: "sphere", radius: 0.12, randomDirection: 1 }, start: { lifetime: { min: 0.5, max: 1.2 }, speed: { min: 6, max: 15 }, size: 0.045 }, forces: { gravityMultiplier: 1, drag: 0.7 }, renderer: { speedScale: 0.05, lengthScale: 1.2 }, });}/** * The shockwave: one particle, lying flat on the ground, growing from nothing to nine metres. * * @remarks * `mode: "horizontal"` orients the quad in the world's XZ plane instead of facing the camera, and * the ring is a texture because a ring is the one shape the generated program's procedural disc * cannot make. * * @returns The document. */function shockwave(): ParticleDefinition { return defineParticles( { main: { capacity: 4, duration: 0.8, looping: false, renderOrder: -1 }, emission: { rateOverTime: 0, bursts: [{ time: 0, count: 1, cycles: 1 }] }, start: { lifetime: 0.7, speed: 0, size: 1, color: [1, 0.72, 0.4, 1] }, overLifetime: { size: { curve: { keys: [ [0, 0.4, 0, 14], [1, 9, 3, 0], ], }, }, color: { gradient: [ [0, 1, 1, 1, 1], [0.35, 1, 0.85, 0.6, 0.7], [1, 1, 0.5, 0.2, 0], ], }, }, renderer: { mode: "horizontal", blend: "additive", texture: SHOCKWAVE_TEXTURE }, }, "fx/blast-wave", );}/** * The debris: solid boxes, lit, thrown upward and pulled back by the app's own gravity. * * @remarks * `mode: "mesh"` draws the primitive named by `renderer.mesh` instead of a quad, and `lit: true` * shades it with ignifx's main light and ambient — which is what makes a chunk read as a chunk * rather than a grey square. * * @returns The document. */function debris(): ParticleDefinition { return defineParticles( { main: { capacity: 64, duration: 2.6, looping: false }, emission: { rateOverTime: 0, bursts: [{ time: 0, count: 24, cycles: 1 }] }, shape: { kind: "hemisphere", radius: 0.25, randomDirection: 0.35 }, start: { lifetime: { min: 1.6, max: 2.4 }, speed: { min: 3, max: 8 }, size: { min: 0.06, max: 0.17 }, rotation: { min: -180, max: 180 }, color: [0.5, 0.45, 0.4, 1], }, forces: { gravityMultiplier: 1, drag: 0.15 }, overLifetime: { rotation: { min: -240, max: 240 }, color: { gradient: [ [0, 1, 1, 1, 1], [0.85, 1, 1, 1, 1], [1, 1, 1, 1, 0], ], }, }, renderer: { mode: "mesh", mesh: "box", blend: "alpha", lit: true }, }, "fx/blast-debris", );}/** * How many particles every system in the app has alive. * * @param app - The running app. * @returns The total. */export function aliveTotal(app: App): number { let total = 0; const systems = app.particles.systems; for (let index = 0; index < systems.length; index += 1) { total += systems[index]?.aliveCount ?? 0; } return total;}/** * Where the ray through one pixel crosses the floor plane. * * @remarks * `screenToRay` writes into the ray it is given, so a click allocates nothing. The camera is always * above the floor, so the only ray with no answer is one pointing at or above the horizon. * * @param camera - The camera the pixel belongs to. * @param ray - Scratch, reused between clicks. * @param x - The backing-store pixel x. * @param y - The backing-store pixel y. * @param out - Written with the crossing. * @param out.x - Metres along X. * @param out.z - Metres along Z. * @returns Whether the ray met the floor. */export function groundPoint(camera: Camera, ray: Ray, x: number, y: number, out: { x: number; z: number }): boolean { const filled = camera.screenToRay(x, y, ray); if (filled === null || filled.direction.y >= 0) { return false; } const distance = -filled.origin.y / filled.direction.y; out.x = filled.origin.x + filled.direction.x * distance; out.z = filled.origin.z + filled.direction.z * distance; return true;}/** * The click: an action map, and the `Script` that turns a press-and-release on the canvas into one * detonation. * * @remarks * A script on `@ignifx/input` actions rather than a DOM listener, because that is what a game * writes: the same class works with a mouse, with a finger and with a gamepad-driven cursor, and * `<Pointer>/position` already reports **backing-store pixels**, which is the space * `Camera.screenToRay` takes. `app.input.uiHasPointer` is checked on the press so a drag that * starts on the parameter panel never fires, and the press position is remembered so the orbit * camera's own drag is not read as a click. * * It is `picking/click-to-pick.ts`'s script with one call changed; the comment there explains the * two details at greater length. */import { defineInputActions, Script } from "ignifx";import type { InputActionsDefinition, ScriptCallbacks } from "ignifx";/** How far the pointer may travel between press and release and still count as a click, in pixels. */const CLICK_SLOP_PIXELS = 6;/** The two actions the click needs. Its own map, so the kit camera's map is untouched. */export const BLAST_ACTIONS: InputActionsDefinition = defineInputActions({ maps: [ { name: "Explosion", actions: [ { name: "blastPress", type: "button", bindings: [{ path: "<Pointer>/press" }] }, { name: "blastPosition", type: "vector2", bindings: [{ path: "<Pointer>/position" }] }, ], }, ],});/** What a click is handed to. `main.ts` implements it; the script only calls it. */export interface Detonator { /** * Sets off a blast under one pixel. * * @param x - The backing-store pixel x, from the canvas's left edge. * @param y - The backing-store pixel y, from the canvas's top edge. */ detonateAt(x: number, y: number): void;}/** Detonates on a click that did not drag. */export class ClickToDetonate extends Script implements ScriptCallbacks { /** The namespaced registration id. */ static typeId = "explosion/ClickToDetonate"; /** What a click is handed to. A closure over the scene, so it is assigned rather than declared. */ detonator: Detonator | null = null; /** Where the pointer went down, in backing-store pixels. */ #pressX = 0; /** Where the pointer went down, in backing-store pixels. */ #pressY = 0; /** Whether the press in flight started on the canvas rather than on the parameter panel. */ #onCanvas = false; /** Reads the frame's pointer state and detonates on a release that stayed put. */ update(): void { const press = this.app.input.actions.find("blastPress"); const at = this.app.input.actions.find("blastPosition"); if (press === null || at === null) { return; } if (press.wasPressedThisFrame) { this.#pressX = at.vector.x; this.#pressY = at.vector.y; this.#onCanvas = !this.app.input.uiHasPointer; } if (!press.wasReleasedThisFrame || !this.#onCanvas) { return; } this.#onCanvas = false; if (Math.hypot(at.vector.x - this.#pressX, at.vector.y - this.#pressY) <= CLICK_SLOP_PIXELS) { this.detonator?.detonateAt(at.vector.x, at.vector.y); } }}