GPU Particles

The GPU Particle System for Larger Counts

GPUParticleSystem keeps particles in GPU buffers and updates them with transform feedback on WebGL 2 (Transform Feedback) or a compute shader on WebGPUEngine (GPU Particle Updates). The API is nearly the same, but it lacks sub-emitters, mesh emitters, manualEmitCount and disposeOnStop; test GPUParticleSystem.IsSupported first. Timing both:

About 58,000 live particles, updated on the CPU and on the GPUJavaScript
scene.useConstantAnimationDeltaTime = true;
for (const make of [() => new BABYLON.ParticleSystem('cpu', 100000, scene),
  () => new BABYLON.GPUParticleSystem('gpu', { capacity: 100000 }, scene)]) {
  const system = make();                           // created one at a time
  Object.assign(system, { particleTexture: BookNestParticles.createDotTexture(scene),
    emitRate: 20000, minLifeTime: 5, maxLifeTime: 5, updateSpeed: 1 / 60 });
  system.start();
  await scene.whenReadyAsync();
  for (let frame = 0; frame < 60; frame++) scene.render();   // fill: 20,000 per second
  const start = performance.now();
  for (let frame = 0; frame < 120; frame++) scene.render();
  view.getContext('webgl2').finish();              // Babylon's context: wait for the GPU
  const ms = (performance.now() - start) / 120;
  console.log(system.name, system.getActiveCount(), `${ms.toFixed(2)} ms a frame`);
  system.dispose();
}
Output
cpu 57600 8.53 ms a frame
gpu 57600 0.27 ms a frame

Over 14 runs on a busy shared test machine (320 x 180 canvas) the GPU system needed 1/17 to 1/37 of the CPU system's frame time; trust the ratio, not the milliseconds. Use it for tens of thousands of particles.

A CPU ParticleSystem and a GPUParticleSystem side by side, the GPU one carrying ten times the particlesHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
  canvas { width: 100%; max-width: 600px; height: 320px; display: block; touch-action: none; outline: none; }
  #info { max-width: 600px; padding: 4px 8px; font-family: monospace; }
</style>
<canvas id="view"></canvas>
<div id="info"></div>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs-gui@9.28.0/babylon.gui.min.js"></script>
<script>
function createDotTexture(scene) {                  // a soft white dot painted on a canvas
  const canvas = document.createElement('canvas');
  canvas.width = canvas.height = 64;
  const g = canvas.getContext('2d');
  const glow = g.createRadialGradient(32, 32, 0, 32, 32, 32);
  glow.addColorStop(0, 'rgba(255,255,255,1)');
  glow.addColorStop(0.4, 'rgba(255,255,255,0.6)');
  glow.addColorStop(1, 'rgba(255,255,255,0)');
  g.fillStyle = glow;
  g.fillRect(0, 0, 64, 64);
  return new BABYLON.Texture(canvas.toDataURL(), scene);   // loaded like an image file
}

const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true);
const scene = new BABYLON.Scene(engine);
scene.clearColor = BABYLON.Color4.FromHexString('#2f3440ff');
const camera = new BABYLON.ArcRotateCamera('cam', -Math.PI / 2, 1.3, 7, new BABYLON.Vector3(0, 1.2, 0), scene);
camera.attachControl(canvas, true);
const ui = BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI('ui');
const texture = createDotTexture(scene);

// The same fountain twice; only the class and the numbers differ
function fountain(system, x, hex, rate) {
  Object.assign(system, { particleTexture: texture, emitter: new BABYLON.Vector3(x, 0, 0),
    emitRate: rate, minLifeTime: 2.5, maxLifeTime: 2.5, updateSpeed: 1 / 60,
    minSize: 0.015, maxSize: 0.035, minEmitPower: 3, maxEmitPower: 4.5,
    gravity: new BABYLON.Vector3(0, -3.5, 0) });
  system.createConeEmitter(0.1, Math.PI / 8);
  system.color1 = system.color2 = BABYLON.Color4.FromHexString(hex);
  system.colorDead = new BABYLON.Color4(0, 0, 0, 0);
  system.start();
  const base = new BABYLON.TransformNode('b', scene);
  base.position.x = x;
  const label = new BABYLON.GUI.TextBlock('', '');
  Object.assign(label, { color: '#eee', fontSize: 12, resizeToFit: true });
  ui.addControl(label);
  label.linkWithMesh(base);
  label.linkOffsetY = 20;
  return label;
}
const cpu = new BABYLON.ParticleSystem('cpu', 1000, scene);   // updated in JavaScript
const cpuLabel = fountain(cpu, -1.6, '#ffab91cc', 400);
let gpu = null, gpuLabel = null;
if (BABYLON.GPUParticleSystem.IsSupported) {        // transform feedback (WebGL 2) or compute (WebGPU)
  gpu = new BABYLON.GPUParticleSystem('gpu', { capacity: 10000 }, scene);
  gpuLabel = fountain(gpu, 1.6, '#80cbc4cc', 4000);
}
const info = document.getElementById('info');
scene.onAfterRenderObservable.add(() => {
  cpuLabel.text = `ParticleSystem\n${cpu.getActiveCount()} alive`;
  if (gpu) gpuLabel.text = `GPUParticleSystem\n${gpu.getActiveCount()} alive`;
  info.textContent = gpu ? `GPUParticleSystem.IsSupported: true   ${engine.getFps().toFixed(0)} fps`
    : 'GPUParticleSystem is not supported here';
});
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>