ignifx
All examples

Weather

Particles3D

  • Mouse
  • Touch
  • Gamepad

A world of weather is a volume of weather over the viewer. Both documents declare `simulationSpace: "world"`, which means a drop's position is computed from where its emitter stood when it was born rather than from where the emitter is now — so a thirty-metre volume can be dragged along behind the camera and every drop already falling stays where it was. Wind is the same fact from the other side: the forces in a document are fixed once it is built, but the emitter's transform is live, so tilting the volume angles the drops it emits from now on while the ones in the air keep their own. The lit toggle hands the component a different document, because `renderer.lit` changes the generated program.

A dark yard of wooden posts standing on a grid floor in heavy rain: thousands of pale vertical streaks falling through the frame and past the posts, fading into fog towards the back.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Turn Snow on and push Wind to one side: the rain slants at once, and the flakes that are already falling do not.
  • Turn on Lit particles and orbit around the lamp-lit side — shaded drops darken as the light goes behind them.
  • Orbit far out. The storm never runs out, because the volume is small and it is standing over the camera.
Show source code

Source

main.ts
import { Camera, Environment, particleAssetFromDefinition, particles } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { readout, slider, toggle } from "../_kit/panel.ts";import { createGridGround, createLightRig, loadEnvironment } from "../_kit/stage.ts";import {  CLEAR_COLOR,  createPosts,  createVolume,  falls,  FLOOR_SIZE,  rainDefinition,  setDocument,  SHOT,  snowDefinition,  start,  VOLUME,} from "./storm.ts";import { FollowsTheViewer } from "./volume.ts";/** * Rain and snow as two volumes that follow the camera, with a wind that tilts them. * * A world of weather is a volume of weather over the viewer. Both documents declare * `simulationSpace: "world"`, which means a drop's position is computed from where its emitter * stood when it was born rather than from where the emitter is now — so the volume is dragged along * behind the camera and every drop already in the air keeps falling where it was. `volume.ts` * beside this file is the eleven-line script that does the dragging. * * Wind is the same fact from the other side. The forces in a document are fixed once it is built, * so there is no `wind` field to turn; what is live is the emitter's transform, and a world-space * document bakes it into every new record. Tilt the volume and the drops it emits from now on fall * at that angle, while the ones already falling keep their own. * * `renderer.lit` is a document field rather than a switch for the same reason: it changes the * generated program, because an unlit program never declares the light uniforms at all. The toggle * therefore hands the component a different document, which is one assignment. *//** How far a gust may tilt a volume, in degrees. */const MAX_WIND = 40;bootExample({  title: "Weather",  extensions: [particles({ maxParticles: 20_000 })],  settings: {    rendering: { clearColor: CLEAR_COLOR, msaaSamples: 4, features: { shadows: true } },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel, flags, afterStart }) {    app.registerComponents([FollowsTheViewer]);    const eye = app.world.createEntity("Main 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: 45,    });    createLightRig(app, {      focus: SHOT.target,      keyPosition: { x: -9, y: 12, z: -7 },      keyIntensity: 2.4,      fillIntensity: 0.7,      fillColor: { r: 0.6, g: 0.7, b: 0.95, a: 1 },      rimIntensity: 0.5,      shadows: true,    });    await createGridGround(app, { size: FLOOR_SIZE, color: { r: 0.16, g: 0.17, b: 0.2, a: 1 } });    createPosts(app);    // The probe lights the posts and, when "Lit particles" is on, gives every drop its ambient    // term: a lit particle takes that from the environment's spherical harmonics, and a scene with    // no probe shades the far side of every flake 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";    // Distance fades into the fog colour, which is what makes a yard read as weather rather than as    // a lit floor in a void. The drops are drawn by a generated shader material, and Babylon Lite    // gives one no fog, so the far rain stays sharp while the ground behind it goes.    sky.fog.mode = "exp2";    sky.fog.density = 0.017;    sky.fog.color = { r: 0.055, g: 0.065, b: 0.08, a: 1 };    // Four documents, both looks of both effects, built once. Swapping one onto a component is an    // assignment; a `prewarm`ed looping document fills its volume again on the first frame.    const documents = {      rain: {        plain: particleAssetFromDefinition(app, rainDefinition(false), "fx/rain"),        lit: particleAssetFromDefinition(app, rainDefinition(true), "fx/rain-lit"),      },      snow: {        plain: particleAssetFromDefinition(app, snowDefinition(false), "fx/snow"),        lit: particleAssetFromDefinition(app, snowDefinition(true), "fx/snow-lit"),      },    };    // A fixed seed, so two loads of the same URL emit the same drops in the same order.    const over = { target: eye, seed: Math.max(1, Math.trunc(flags.seed)) };    const rain = createVolume(app, { ...over, name: "Rain", definition: documents.rain.plain, ...VOLUME.rain });    const snow = createVolume(app, { ...over, name: "Snow", definition: documents.snow.plain, ...VOLUME.snow });    afterStart((): void => {      start(rain.system);      start(snow.system);    });    let lit = false;    let wind = 0;    /** Applies the wind angle and the lit choice to both volumes. */    function apply(): void {      rain.entity.transform.localEulerAngles = { x: 0, y: 0, z: wind };      snow.entity.transform.localEulerAngles = { x: 0, y: 0, z: wind };      setDocument(rain.system, lit ? documents.rain.lit : documents.rain.plain);      setDocument(snow.system, lit ? documents.snow.lit : documents.snow.plain);    }    panel({      title: "Weather",      groups: [        {          label: "Sky",          controls: [            toggle("Rain", { value: rain.system.enabled, change: falls(rain.system) }),            toggle("Snow", { value: snow.system.enabled, change: falls(snow.system) }),            slider(              "Wind",              { min: -MAX_WIND, max: MAX_WIND, step: 1, format: (value: number): string => `${value.toFixed(0)}°` },              {                value: wind,                change: (value: number): void => {                  wind = value;                  apply();                },              },            ),            toggle("Lit particles", {              value: lit,              change: (value: boolean): void => {                lit = value;                apply();              },            }),          ],        },        {          label: "Cost",          controls: [            readout("Rain alive", (): string => String(rain.system.aliveCount)),            readout("Snow alive", (): string => String(snow.system.aliveCount)),            readout("Draw calls", (): string => String(app.renderer.drawCalls)),          ],        },      ],    });  },});
storm.ts
/** * The two weather documents and the yard they fall on. * * @remarks * Both are the shipped presets with the numbers this scene wants: a bigger volume, a rate that * fills it, and `renderer.lit` set from the panel's toggle. `lit` is a document field rather than a * component one because it changes the *generated program* — a lit particle reads * `mainLightDirection`, `mainLightColor` and `ambientColor`, and a program that never reads them * does not declare them. */import {  createMaterialAsset,  MeshAsset,  MeshRenderer,  ParticleSystem,  particleDefinition,  pbrMaterialDefinition,} from "ignifx";import { FollowsTheViewer } from "./volume.ts";import type { App, AssetHandle, Entity, ParticleAsset, ParticleDefinition } from "ignifx";/** The overcast near-black the frame is cleared to. */export const CLEAR_COLOR = { r: 0.035, g: 0.042, b: 0.055, a: 1 } as const;/** The opening shot: the pose every capture is taken from. */export const SHOT = {  fov: 46,  yaw: 26,  pitch: 10,  distance: 11,  target: { x: 0, y: 2.2, z: 0 },} as const;/** How wide the ground is, in metres. */export const FLOOR_SIZE = 90;/** * How many records each volume holds: the ring size, and therefore the ceiling on how many drops or * flakes can be in the air at once. */const CAPACITY = { rain: 1600, snow: 1000 } as const;/** How high each volume hangs above the ground, in metres, and whether it opens falling. */export const VOLUME = {  rain: { height: 12, enabled: true },  snow: { height: 9, enabled: false },} as const;/** * How wide each volume is, in metres. * * @remarks * Twenty-two is a compromise the capacity forces: the volume is centred on the **camera** and the * camera looks across it, so a narrow volume leaves the far half of the frame dry — but a wide one * spreads the same thousand drops over three times the ground and the rain stops reading as rain. */const VOLUME_SIZE = { x: 22, y: 0.2, z: 22 } as const;/** * The rain document. * * @param lit - Whether the drops are shaded by the scene's main light. * @returns The document. */export function rainDefinition(lit: boolean): ParticleDefinition {  return particleDefinition("rain", {    // A rate of 900 over a 1.4-second life keeps about 1,260 drops alive, inside the capacity: a    // system that emits faster than its capacity overwrites live particles, and counts each one in    // `droppedCount`.    main: { capacity: CAPACITY.rain },    emission: { rateOverTime: 900 },    shape: { kind: "box", size: VOLUME_SIZE },    start: { lifetime: 1.4, speed: { min: -9, max: -12 }, size: 0.035, color: [0.75, 0.85, 1, 0.75] },    renderer: { lit, speedScale: 0.06, lengthScale: 1.2 },  });}/** * The snow document. * * @param lit - Whether the flakes are shaded by the scene's main light. * @returns The document. */export function snowDefinition(lit: boolean): ParticleDefinition {  return particleDefinition("snow", {    // A flake lives up to eight seconds, so sixty a second keeps about 480 of them in the air.    main: { capacity: CAPACITY.snow },    emission: { rateOverTime: 60 },    shape: { kind: "box", size: VOLUME_SIZE },    start: { lifetime: { min: 5, max: 8 }, speed: { min: -1.2, max: -2.2 }, size: { min: 0.04, max: 0.09 } },    renderer: { lit },  });}/** Where the yard's posts stand and how tall each one is, in metres. */const POSTS: readonly { readonly x: number; readonly z: number; readonly height: number }[] = [  { x: -3.2, z: 1.4, height: 3.4 },  { x: 2.6, z: -2.2, height: 2.2 },  { x: 5.4, z: 3.1, height: 4.1 },  { x: -6.1, z: -4.3, height: 2.8 },  { x: 0.4, z: 6.2, height: 3.1 },  { x: -8.4, z: 5.5, height: 2.4 },];/** * Stands a few posts in the yard, so the weather has something to fall in front of and behind. * * @param app - The app the entities belong to. */export function createPosts(app: App): void {  const mesh = MeshAsset.box(app, { size: 1 });  const material = createMaterialAsset(    app,    pbrMaterialDefinition({      name: "weather/post",      baseColor: { r: 0.21, g: 0.19, b: 0.17, a: 1 },      metallic: 0,      roughness: 0.85,    }),    [],  );  for (const post of POSTS) {    const entity = app.world.createEntity("Post", { position: { x: post.x, y: post.height / 2, z: post.z } });    entity.transform.localScale.set(0.34, post.height, 0.34);    entity.addComponent(MeshRenderer, { mesh: mesh.retain(), materials: [material.retain()], castShadows: true });  }}/** One weather volume: the entity that carries it, and the system that fills it. */export interface Volume {  /** The emitter's entity, whose rotation is the wind. */  readonly entity: Entity;  /** The system playing the document. */  readonly system: ParticleSystem;}/** What {@link createVolume} takes. */export interface VolumeOptions {  /** The entity's name. */  readonly name: string;  /** The document it opens with. */  readonly definition: AssetHandle<ParticleAsset>;  /** How high it hangs, in metres. */  readonly height: number;  /** Whose position it stands over. */  readonly target: Entity;  /** The emission seed. */  readonly seed: number;  /** Whether it is falling to begin with. */  readonly enabled: boolean;}/** * Hangs one weather volume over the viewer. * * @remarks * `playOnAwake` is off and `main.ts` plays it a frame later, because a world-space document bakes * the emitter's matrix into every record as it is written — and a `prewarm` on the very first frame * would run before the volume has been told where it is. * * @param app - The app the entity belongs to. * @param options - The document, the height, whom to follow, the seed and whether it starts on. * @returns The entity and its system. */export function createVolume(app: App, options: VolumeOptions): Volume {  const entity = app.world.createEntity(options.name, { position: { x: 0, y: options.height, z: 0 } });  const follow = entity.addComponent(FollowsTheViewer, { height: options.height });  follow.target = options.target;  const system = entity.addComponent(ParticleSystem, {    definition: options.definition,    seed: options.seed,    playOnAwake: false,  });  system.enabled = options.enabled;  return { entity, system };}/** * The toggle handler for one volume: it turns the component off and on, and starts it the first * time it is turned on. * * @param system - The volume's system. * @returns What the toggle calls. */export function falls(system: ParticleSystem): (on: boolean) => void {  return (on: boolean): void => {    system.enabled = on;    if (on) {      start(system);      return;    }    // A disabled system is frozen, not empty: its clock stops and its records stay in the ring.    // Clearing them is what makes the counter agree with the sky.    system.stop({ clear: true });  };}/** * Starts one volume if it is on and not already falling. * * @param system - The volume's system. */export function start(system: ParticleSystem): void {  if (system.enabled && !system.isPlaying) {    // A looping `prewarm` document fills its whole volume inside this call, so the storm is never    // seen filling up from an empty sky.    system.play();  }}/** * Puts one document on a volume, and starts the fresh emitter it builds. * * @param system - The volume's system. * @param definition - The document to play. */export function setDocument(system: ParticleSystem, definition: AssetHandle<ParticleAsset>): void {  if (system.definition === definition) {    return;  }  // A different document is a different effect: the component builds a new emitter for it, and the  // new one has to be told to play. The drops already in the air are the old emitter's and go with  // it.  system.definition = definition;  if (system.enabled) {    system.play();  }}
volume.ts
/** * The one script in this example: it keeps a weather volume over the camera. * * @remarks * This is the whole trick behind weather that never runs out. The volume is small — thirty metres * across — and it moves with the viewer, so the storm is always around them; and because both * documents simulate in **world space**, a drop that has already been emitted stays where it was * born instead of being dragged along. Moving the emitter therefore changes where the *next* drops * appear and nothing else. */import { f32, Script } from "ignifx";import type { Entity, ScriptCallbacks } from "ignifx";/** Keeps its entity directly above another one, at a fixed height. */export class FollowsTheViewer extends Script.define({ height: f32(12) }) implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "weather/FollowsTheViewer";  /** Whose position to stand over. Assigned in code: an entity is not a schema field. */  target: Entity | null = null;  /** Moves the volume after the camera has moved, so the two never disagree within a frame. */  lateUpdate(): void {    const target = this.target;    if (target === null) {      return;    }    const at = target.transform.position;    this.transform.localPosition.set(at.x, this.height, at.z);  }}

Uses:ParticleSystemparticleDefinitionsimulationSpace: worldEnvironment.fogScript.lateUpdate

Assets:everything in this example is created in code.