ignifx
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 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.

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

` devtools

WebGPU: checking…

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

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,  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)),          ],        },      ],    });  },});
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 { 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;}
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