ignifx
All examples

Particles

Particles3D

  • Mouse
  • Touch
  • Gamepad

An effect is a document. `particleDefinition("fire")` returns a complete `.particles.json` — the emission rate, the shape, the start values, the forces, the curves and the gradients — and `ParticleSystem` plays one, so the select below is nine documents handed to one component. The panel is in two halves because the split is the design: the app owns the quality scale and the gravity, and both are read fresh every frame, while the document owns everything else. A particle is computed from its spawn record by a formula, and drag and noise are constants inside the generated WGSL program — so changing one builds a new document and a new program. The lower sliders do exactly that, in coarse steps, and a combination already built is remembered.

A tall orange and yellow flame of soft overlapping particles burning on a dark grey grid floor, its light fading to deep red at the top against a near-black background.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Run Quality down to a quarter: every rate and burst in the app falls with it, which is what a settings screen does.
  • Drag Gravity up past zero on `smoke` or `sparks` — the app's gravity is a uniform, so the effect turns immediately.
  • Add drag to `explosion` and watch the blast stop dead: that slider rebuilt the document and the shader behind it.
Show source code

Source

main.ts
import { Camera, Environment, PARTICLE_PRESETS, ParticleSystem, particleAssetFromDefinition, particles } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { readout, select, slider } from "../_kit/panel.ts";import { createGridGround, createLightRig, loadEnvironment } from "../_kit/stage.ts";import {  bytes,  CLEAR_COLOR,  FLOOR_SIZE,  NO_TWEAKS,  PRESET_SHOT,  SHOT,  START_PRESET,  tweakControls,  tweakedDefinition,  tweakKey,} from "./effects.ts";import type { Tweaks } from "./effects.ts";import type { AssetHandle, ParticleAsset, ParticlePreset } from "ignifx";/** * The nine shipped presets on one emitter, and the line between what an effect can change while it * runs and what it cannot. * * `particleDefinition("fire")` returns a complete `.particles.json` — the rate, the shape, the start * values, the forces, the curves and the gradients — and `ParticleSystem` plays one, so the select * below is nine documents handed to one component. * * The panel is in two halves because the split is the design. **Live** is what the app owns: * `qualityScale` multiplies every emission rate and `gravity` is a uniform, both read fresh each * frame. **Not live** is the document: a particle is computed from its spawn record by a formula, * and drag and noise are constants inside the *generated WGSL program*, so changing one means a new * document and a new program. The lower sliders do that, in coarse steps, and a combination already * built is remembered. * * `effects.ts` beside this file holds the framing, the three document edits and their sliders. */bootExample({  title: "Particles",  extensions: [particles({ maxParticles: 50_000 })],  settings: {    // No shadow maps: a particle renderer never casts (Lite gives a shader material no shadow    // bindings), and nothing else in this scene has a silhouette worth one.    rendering: { clearColor: CLEAR_COLOR, msaaSamples: 4, features: { shadows: false } },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel, flags }) {    const opening = PRESET_SHOT[START_PRESET];    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.1, far: 200, fov: SHOT.fov });    // The orbit camera damps towards its fields, so moving the target and the distance when the    // preset changes is a pan rather than a cut.    const orbit = attachOrbit(app, eye, {      yaw: SHOT.yaw,      pitch: SHOT.pitch,      distance: opening.distance,      target: { x: 0, y: opening.target, z: 0 },      minDistance: 2,      maxDistance: 30,    });    createLightRig(app, {      focus: { x: 0, y: opening.target, z: 0 },      keyIntensity: 2.2,      fillIntensity: 0.5,      shadows: false,    });    await createGridGround(app, { size: FLOOR_SIZE, color: { r: 0.14, g: 0.16, b: 0.2, a: 1 } });    // The probe is here for the floor and for `renderer.lit`: a lit particle takes its ambient from    // the environment's spherical harmonics, and without one its unlit side 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";    // One handle per document built so far. A document is immutable, so the same preset and the    // same three numbers are always the same asset — and the generated program behind it is    // addressed by its own source, so a repeat costs no compile either.    const built = new Map<string, AssetHandle<ParticleAsset>>();    /**     * The handle for one preset and one set of edits, building it the first time it is asked for.     *     * @param name - Which preset.     * @param tweaks - The three numbers the lower sliders hold.     * @returns The document handle to hand the component.     */    function documentFor(name: ParticlePreset, tweaks: Tweaks): AssetHandle<ParticleAsset> {      const key = tweakKey(name, tweaks);      const existing = built.get(key);      if (existing !== undefined) {        return existing;      }      const handle = particleAssetFromDefinition(app, tweakedDefinition(name, tweaks), `fx/${key}`);      built.set(key, handle);      return handle;    }    let preset: ParticlePreset = START_PRESET;    let tweaks: Tweaks = NO_TWEAKS;    const emitter = app.world.createEntity("Emitter");    emitter.transform.localPosition.set(0, opening.height, 0);    // A fixed seed, so two loads of the same URL emit the same particles in the same order. `0`    // would mean "pick one at random on `play()`", which is what a game wants and a golden does not.    const system = emitter.addComponent(ParticleSystem, {      definition: documentFor(preset, tweaks),      seed: Math.max(1, Math.trunc(flags.seed)),    });    // A one-shot document stops when its last particle dies. Playing it again is how the two    // non-looping presets stay watchable; `play()` keeps the seed, so every replay is the same.    system.onStopped.connect(      (): void => {        system.play();      },      { owner: emitter },    );    /** Puts the current preset and edits on the component, and frames it. */    function apply(): void {      const shot = PRESET_SHOT[preset];      emitter.transform.localPosition.set(0, shot.height, 0);      orbit.target = { x: 0, y: shot.target, z: 0 };      orbit.distance = shot.distance;      system.definition = documentFor(preset, tweaks);    }    // The service's own diagnostics group, which the devtools overlay reads too. It is `null` in a    // production build, where the counters are not collected at all.    const counters = app.particles.counters;    const uploadIndex = counters?.index("uploadBytes") ?? 0;    panel({      title: "Particles",      groups: [        {          label: "Effect",          controls: [            select("Preset", PARTICLE_PRESETS, {              value: preset,              change: (value: string): void => {                const chosen = PARTICLE_PRESETS.find((name: ParticlePreset): boolean => name === value);                if (chosen === undefined) {                  return;                }                preset = chosen;                apply();              },            }),            readout("Alive", (): string => `${String(system.aliveCount)} of ${String(system.capacity)}`),            readout("Uploaded", (): string => (counters === null ? "" : bytes(counters.get(uploadIndex)))),            readout("Draw calls", (): string => String(app.renderer.drawCalls)),          ],        },        {          label: "Live",          controls: [            slider(              "Quality",              { min: 0, max: 1, step: 0.05, format: (value: number): string => `${(value * 100).toFixed(0)} %` },              {                value: app.particles.qualityScale,                change: (value: number): void => {                  app.particles.qualityScale = value;                },              },            ),            slider(              "Gravity",              { min: -20, max: 4, step: 0.5, format: (value: number): string => `${value.toFixed(1)} m/s²` },              {                value: app.particles.gravity.y,                change: (value: number): void => {                  app.particles.gravity = { x: 0, y: value, z: 0 };                },              },            ),            readout("Gravity ×", (): string => system.asset?.definition.forces.gravityMultiplier.toFixed(2) ?? ""),          ],        },        {          label: "Document (rebuilds the effect)",          controls: tweakControls(            (next: Tweaks): void => {              tweaks = next;              apply();            },            (): number => built.size,          ),        },      ],    });  },});
effects.ts
/** * The nine presets as this example shows them: where each one's emitter stands, and the three * document edits the panel's lower group makes. * * @remarks * A separate file for the reason `custom-shader/shot.ts` is: none of it is the lesson. `main.ts` is * the select that swaps a definition onto the component, the sliders that are live, and the sliders * that are not — which is the whole point of the example. */import { particleDefinition } from "ignifx";import { readout, slider } from "../_kit/panel.ts";import type { PanelControl } from "../_kit/panel.ts";import type { CurveKey, ParticleDefinition, ParticlePreset, ScalarValue, ScalarValueInput } from "ignifx";/** The near-black the frame is cleared to; the additive presets need somewhere dark to burn. */export const CLEAR_COLOR = { r: 0.012, g: 0.015, b: 0.023, a: 1 } as const;/** The framing every shot shares: the lens, and where the camera stands around the emitter. */export const SHOT = { fov: 34, yaw: 18, pitch: 5 } as const;/** Which preset the example opens on, and therefore what the poster shows. */export const START_PRESET: ParticlePreset = "fire";/** How wide the floor is, in metres. */export const FLOOR_SIZE = 30;/** Where one preset is played, and how the camera frames it. */export interface PresetShot {  /** How high the emitter stands, in metres. */  readonly height: number;  /** How high the camera looks, in metres. */  readonly target: number;  /** How far back the camera stands, in metres. */  readonly distance: number;}/** * The emitter height and the framing for each preset. * * @remarks * An emitter is an entity, so where an effect is played is the one thing about it a scene file * already carries: rain and snow are volumes overhead, a campfire sits on the ground, and dust * hangs in the air around head height. The camera follows, because a weather volume twelve metres * up is not in a campfire's frame. */export const PRESET_SHOT: Readonly<Record<ParticlePreset, PresetShot>> = {  fire: { height: 0.05, target: 0.85, distance: 4.6 },  smoke: { height: 0.05, target: 1.7, distance: 6.5 },  sparks: { height: 0.2, target: 1.1, distance: 6.5 },  explosion: { height: 1.2, target: 1.4, distance: 8 },  dust: { height: 1.4, target: 1.4, distance: 6 },  sparkle: { height: 1.1, target: 1.1, distance: 3.4 },  // The two weather volumes are twelve and ten metres across, and the camera stands *inside* them:  // a storm framed from outside is a box of drops with an edge.  rain: { height: 6, target: 2.5, distance: 5 },  snow: { height: 6, target: 2.5, distance: 5 },  leaves: { height: 4, target: 2.2, distance: 6 },};/** The three numbers the panel's lower group edits, each at its identity. */export interface Tweaks {  /** A multiplier on the preset's own `start.size`. `1` leaves it alone. */  readonly size: number;  /** Linear drag added to the preset's own. `0` leaves it alone. */  readonly drag: number;  /** Noise strength added to the preset's own. `0` leaves it alone. */  readonly noise: number;}/** The preset exactly as it ships. */export const NO_TWEAKS: Tweaks = { size: 1, drag: 0, noise: 0 };/** The noise a preset that declares none is given when the slider asks for some. */const DEFAULT_NOISE = { frequency: 1, scroll: { x: 0.1, y: 0.25, z: 0 }, octaves: 1 } as const;/** * Scales a start size, whatever shape the preset authored it in. * * @param value - The preset's own value. * @param factor - What to multiply it by. * @returns The scaled value, ready to override with. */function scaleSize(value: ScalarValue, factor: number): ScalarValueInput {  if (value.kind === "constant") {    return value.value * factor;  }  if (value.kind === "random") {    return { min: value.min * factor, max: value.max * factor };  }  const keys = value.curve.keys.map(([t, v, inTangent, outTangent]: CurveKey): CurveKey => [    t,    v * factor,    inTangent,    outTangent,  ]);  return { curve: { keys } };}/** * Builds one preset's document with the panel's three edits applied. * * @param name - Which preset. * @param tweaks - The three numbers, each at its identity for the preset as it ships. * @returns The complete document to build an asset from. */export function tweakedDefinition(name: ParticlePreset, tweaks: Tweaks): ParticleDefinition {  const base = particleDefinition(name);  const noise = base.forces.noise;  const strength = (noise?.strength ?? 0) + tweaks.noise;  return particleDefinition(name, {    start: { size: scaleSize(base.start.size, tweaks.size) },    forces: {      drag: base.forces.drag + tweaks.drag,      // A strength of zero is the module's absence, not a module that does nothing: the generated      // program declares the noise constants and the helper functions only when it is there.      noise:        strength <= 0          ? null          : {              strength,              frequency: noise?.frequency ?? DEFAULT_NOISE.frequency,              scroll: noise?.scroll ?? DEFAULT_NOISE.scroll,              octaves: noise?.octaves ?? DEFAULT_NOISE.octaves,            },    },  });}/** * The cache key for one preset and one set of edits. * * @param name - Which preset. * @param tweaks - The three numbers. * @returns A key that is equal exactly when the resulting document is. */export function tweakKey(name: ParticlePreset, tweaks: Tweaks): string {  return `${name}/${String(tweaks.size)}/${String(tweaks.drag)}/${String(tweaks.noise)}`;}/** * Writes a byte count for a readout cell. * * @param value - The count. * @returns The text. */export function bytes(value: number): string {  return value < 1024 ? `${String(Math.round(value))} B` : `${(value / 1024).toFixed(1)} KB`;}/** * The three sliders that edit the document, and the count of documents they have built. * * @remarks * Coarse steps on purpose: each distinct set of numbers is a distinct document and a distinct * generated program, so a slider that moved in hundredths would compile a hundred shaders. * * @param change - Called with the new numbers; `main.ts` rebuilds the effect from them. * @param built - How many documents exist so far, for the readout. * @returns The controls, in panel order. */export function tweakControls(change: (tweaks: Tweaks) => void, built: () => number): readonly PanelControl[] {  let current = NO_TWEAKS;  const edit = (next: Tweaks): void => {    current = next;    change(current);  };  return [    slider(      "Size",      { min: 0.5, max: 2, step: 0.5, format: (value: number): string => `${value.toFixed(1)}×` },      {        value: NO_TWEAKS.size,        change: (value: number): void => {          edit({ ...current, size: value });        },      },    ),    slider(      "Extra drag",      { min: 0, max: 4, step: 1, format: (value: number): string => value.toFixed(0) },      {        value: NO_TWEAKS.drag,        change: (value: number): void => {          edit({ ...current, drag: value });        },      },    ),    slider(      "Extra noise",      { min: 0, max: 1, step: 0.25, format: (value: number): string => value.toFixed(2) },      {        value: NO_TWEAKS.noise,        change: (value: number): void => {          edit({ ...current, noise: value });        },      },    ),    readout("Documents built", (): string => String(built())),  ];}

Uses:ParticleSystemparticleDefinitionparticleAssetFromDefinitionapp.particlesapp.diagnostics

Assets:everything in this example is created in code.