All examples

Explosion

Particles3D
  • 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 under half a metre to nine; 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.

Off airLivePaused

Explosion

Particles · 3D

An orange fireball of overlapping particles bursting above a dark grid floor, with bright streaks of sparks thrown outward, small dark boxes of debris tumbling through them, and a glowing ring spreading across the floor beneath.
Open standalone

WebGPU: checking…

Try this

  1. Click anywhere on the floor. The blast goes off where the ray met the ground, not where the camera is.
  2. Detonate twice on one seed, then change the seed and do it again — the first two are identical.
  3. Press Detonate three times fast and watch Cost: four systems a blast, one draw per live system, no compiles.
Show source code
main.ts
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,  DEBRIS_PART,  FIREBALL_PART,  FLOOR,  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 next blast goes off when the * fireball burns out, while the last of the debris is still coming down, and 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: there is nothing in the scene but particles and the floor.    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: 24,    });    createLightRig(app, {      focus: SHOT.target,      keyPosition: { x: -4, y: 6, z: -8 },      keyIntensity: 2.6,      fillIntensity: 0.6,      shadows: false,    });    // 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,      skybox: { enabled: false, size: 20 },    });    sky.imageProcessing.toneMapping = "aces";    await createGridGround(app, FLOOR);    // 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 fireball: ParticleSystem | null = null;      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 });        if (index === FIREBALL_PART) {          fireball = system;        } else if (index === DEBRIS_PART) {          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 newest blast lights again where it stood once its fireball has burned out — the last of      // the bright parts, gone in under a second and a half. Waiting for the debris instead would      // leave the floor bare for most of every cycle, because a chunk can tumble for 2.4 s.      fireball?.onStopped.connect(        (): void => {          if (mine === latest) {            detonate(x, z);          }        },        { owner: blast },      );      // The debris is the longest-lived of the four, so when it stops the blast is over.      debris?.onStopped.connect(        (): void => {          blast.destroy();        },        { 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)),          ],        },      ],    });  },});
blast.ts
/** * 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 { clamp, defineParticles, particleDefinition } from "ignifx";import type { GridGroundOptions } from "../_kit/stage.ts";import type { App, Camera, ParticleDefinition, Ray } from "ignifx";/** The black the frame is cleared to and the floor fades into. */export const CLEAR_COLOR = { r: 0, g: 0, b: 0, 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 far a blast reaches from where it went off, in metres: the shockwave's nine-metre ring. */export const BLAST_RADIUS = 4.5;/** The grid floor: dark slate, faded into the night past the farthest the orbit can stand. */export const FLOOR: GridGroundOptions = {  size: FLOOR_SIZE,  color: { r: 0.13, g: 0.15, b: 0.19, a: 1 },  fade: { subjectRadius: BLAST_RADIUS },};/** Which of {@link blastParts} is the fireball: the brightest part, and the last of those to go out. */export const FIREBALL_PART = 1;/** Which of {@link blastParts} is the debris: the last, and the longest-lived. */export const DEBRIS_PART = 3;/** 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 under half a metre to nine. * * @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. A ray from below the * floor or pointing at or above the horizon has no answer, and a crossing past the floor's edge is * pulled back onto the floor, so a click near the horizon still goes off where it can be seen. * * @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.origin.y <= 0 || filled.direction.y >= 0) {    return false;  }  const distance = -filled.origin.y / filled.direction.y;  const edge = FLOOR_SIZE / 2 - BLAST_RADIUS;  out.x = clamp(filled.origin.x + filled.direction.x * distance, -edge, edge);  out.z = clamp(filled.origin.z + filled.direction.z * distance, -edge, edge);  return true;}
click-to-detonate.ts
/** * 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);    }  }}
Uses
ParticleSystemdefineParticlesonStoppedCamera.screenToRayapp.particles.systems
Assets
Particle sprites and the blast ring — Apache-2.0, Astrum Forge Studios