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

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