Examples

Particles

Sparks, smoke and motes from pools, emitted deterministically.

Starts examples/particles in this page, on WebGPU where your browser has it.

Open on its own · Source

Particles is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /particles/.

Source

main.ts

examples/particles/main.ts
/**
 * A fire in a ring of stones at night: sparks that streak as they fly and fall, smoke that rises and
 * thins as it drifts, and motes of light floating in the dark around it.
 *
 * Three pools, three particle materials. Every particle is emitted from the fixed simulation step
 * with the tick as its seed, so the same run makes the same fire.
 */
import {
  MeshBuilder,
  ParticlePool,
  SceneNode,
  createEnvironment,
  createPointLightBuffer,
  hashToUnit,
  selectPointLights,
} from '@driftengine/core';
import type { PointLightSource } from '@driftengine/core';
import { openStage } from '../common/stage';

const stage = await openStage({
  outputTransform: 'aces',
  hdrScene: true,
  bloom: 0.5,
  bloomThreshold: 1.2,
  sceneSamples: 4,
});
const { renderer, camera } = stage;

// #region pools
/** Each pool is the simulation: where its particles are, how old, how big and what colour. */
const sparks = new ParticlePool({
  capacity: 600,
  lifeSec: 1.4,
  sizeStart: 0.05,
  sizeEnd: 0.01,
  colorStart: [4, 2.2, 0.8],
  colorEnd: [1.4, 0.3, 0.05],
  gravity: 4,
  drag: 0.6,
  rise: 0,
});
const smoke = new ParticlePool({
  capacity: 300,
  lifeSec: 6,
  sizeStart: 0.5,
  sizeEnd: 2.4,
  colorStart: [0.18, 0.17, 0.17],
  colorEnd: [0.3, 0.3, 0.32],
  gravity: 0,
  drag: 0.4,
  rise: 0.7,
  alphaStart: 0.3,
  alphaEnd: 0,
});
const motes = new ParticlePool({
  capacity: 200,
  lifeSec: 8,
  sizeStart: 0.04,
  sizeEnd: 0.04,
  colorStart: [1.6, 1.4, 0.8],
  colorEnd: [0.6, 0.5, 0.3],
  gravity: 0,
  drag: 0.2,
  rise: 0.05,
  alphaStart: 1,
  alphaEnd: 0,
});
// #endregion

// #region batches
/** Each batch is how its pool is drawn: which material, how it blends, which way it faces. */
const sparkBatch = renderer.createParticles(600, {
  material: 'spark',
  blend: 'additive',
  stretchSec: 0.05,
  coreGain: 2,
});
const smokeBatch = renderer.createParticles(300, {
  material: 'smoke',
  blend: 'alpha',
  erosion: 0.6,
});
const moteBatch = renderer.createParticles(200, { material: 'mote', blend: 'additive' });
// #endregion

const env = createEnvironment({
  directionalColor: [0.05, 0.06, 0.1],
  directionalDir: [0.2, 0.8, 0.3],
  ambient: [0.02, 0.025, 0.04],
  ambientGround: [0.01, 0.01, 0.012],
  fogColor: [0.02, 0.025, 0.04],
  fogDensity: 0.02,
  nightFactor: 1,
  emissiveGain: 2,
});

const ground = new MeshBuilder();
ground.addBox([0, -0.1, 0], [30, 0.1, 30], [0.25, 0.24, 0.22]);
for (let i = 0; i < 9; i += 1) {
  const a = (i / 9) * Math.PI * 2;
  ground.addBlob([Math.cos(a) * 0.9, 0.12, Math.sin(a) * 0.9], () => 0.2, [0.4, 0.38, 0.36]);
}
ground.addBox([0, 0.08, 0], [0.45, 0.06, 0.45], [1, 0.4, 0.1], 1.5);
const groundMesh = renderer.createMesh(ground.build());
const still = new SceneNode();
still.updateWorld();

const fireLight: PointLightSource[] = [
  {
    x: 0,
    y: 0.8,
    z: 0,
    r: 4,
    g: 1.8,
    b: 0.6,
    radius: 9,
    flicker: 0.35,
    shadowNear: 0.3,
    sourceRadius: 0.3,
    castsShadow: false,
  },
];
const chosen = createPointLightBuffer(renderer.shadedLights);

let tick = 0;
let time = 0;

stage.run({
  // #region simulate
  simulate(dt) {
    tick += 1;
    time += dt;
    /* A few sparks every tick, a puff of smoke every other one, and a mote now and then. */
    for (let i = 0; i < 4; i += 1) {
      const seed = tick * 8 + i;
      const a = hashToUnit(seed) * Math.PI * 2;
      const out = hashToUnit(seed + 1) * 1.2;
      sparks.emit(
        0,
        0.3,
        0,
        Math.cos(a) * out,
        2.5 + hashToUnit(seed + 2) * 3,
        Math.sin(a) * out,
        seed,
      );
    }
    if (tick % 2 === 0) {
      const seed = tick;
      smoke.emit(
        (hashToUnit(seed) - 0.5) * 0.4,
        0.6,
        (hashToUnit(seed + 5) - 0.5) * 0.4,
        0.15,
        0.6,
        0,
        seed,
      );
    }
    if (tick % 12 === 0) {
      const seed = tick;
      const a = hashToUnit(seed) * Math.PI * 2;
      const r = 1.5 + hashToUnit(seed + 3) * 3;
      motes.emit(
        Math.cos(a) * r,
        0.3 + hashToUnit(seed + 4) * 2,
        Math.sin(a) * r,
        0,
        0.05,
        0,
        seed,
      );
    }
    sparks.update(dt);
    smoke.update(dt);
    motes.update(dt);
  },
  // #endregion
  render() {
    camera.fovYDeg = 50;
    camera.position[0] = Math.sin(time * 0.1) * 6;
    camera.position[1] = 2.2;
    camera.position[2] = Math.cos(time * 0.1) * 6;
    camera.lookAt(0, 1.2, 0);

    selectPointLights(fireLight, camera.position[0], 2, camera.position[2], chosen, time);
    env.lightCount = chosen.count;
    env.lightPositions = chosen.positions;
    env.lightColors = chosen.colors;
    env.lightRadii = chosen.radii;
    env.lightSourceRadii = chosen.sourceRadii;
    env.lightWeights = chosen.weights;

    renderer.beginFrame([0.02, 0.025, 0.04]);
    renderer.bindMeshPass(camera, env);
    renderer.drawMesh(groundMesh, still.worldMatrix);
    // #region draw
    /* After the opaque scene: what covers first, then what adds light. */
    renderer.drawParticles(smokeBatch, smoke.particles, camera, env, time);
    renderer.drawParticles(sparkBatch, sparks.particles, camera, env, time);
    renderer.drawParticles(moteBatch, motes.particles, camera, env, time);
    // #endregion
    renderer.endFrame();
  },
});