Sub-Emitters

Sub-Emitters and Particle Animation

A sub-emitter clones a template system per particle: SubEmitterType.ATTACHED at birth (a trail) or END at death (a burst). Gradients animate properties over a particle's life (t from 0 to 1): addColorGradient(t, color), addSizeGradient, addVelocityGradient and more. Here sparks fade from warm white to transparent orange and burst into 20-particle puffs:

Sparks that burst into puffs when they dieJavaScript
scene.useConstantAnimationDeltaTime = true;
const make = (name, capacity, props) => Object.assign(new BABYLON.ParticleSystem(name,
  capacity, scene), { particleTexture: BookNestParticles.createDotTexture(scene),
  updateSpeed: 1 / 60 }, props);
const puff = make('puff', 20, { manualEmitCount: 20, targetStopDuration: 0.5,
  disposeOnStop: true, minLifeTime: 0.5, maxLifeTime: 0.5 });   // a one-shot template
const sparks = make('sparks', 10, { emitRate: 2, minLifeTime: 1, maxLifeTime: 1 });
sparks.addColorGradient(0, new BABYLON.Color4(1, 0.9, 0.5, 1));   // born warm white
sparks.addColorGradient(1, new BABYLON.Color4(0.9, 0.3, 0.1, 0));  // dies clear orange
const burst = new BABYLON.SubEmitter(puff);
burst.type = BABYLON.SubEmitterType.END;           // clone the puff where a spark dies
sparks.subEmitters = [burst];
sparks.start();
for (let frame = 1; frame <= 180; frame++) {
  scene.render();
  await new Promise((next) => requestAnimationFrame(next));   // lets clones get ready
  const puffs = sparks.activeSubSystems;           // the live clones
  if (frame % 60 === 0) console.log(frame, sparks.getActiveCount(), puffs.length,
    puffs.reduce((sum, system) => sum + system.getActiveCount(), 0));
}
Output
60 1 0 0
120 2 1 20
180 2 1 20

Columns: frame, live sparks, live puffs, particles in puffs; one puff is alive at a time. Sprite sheets animate a particle's picture (isAnimationSheetEnabled, startSpriteCellID, endSpriteCellID).

Sparks fading from warm white to clear orange through color and size gradients, each bursting into a puff when it diesHTMLLive
<!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>
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, 5, new BABYLON.Vector3(0, 1.2, 0), scene);
camera.attachControl(canvas, true);

const make = (name, capacity, props) => Object.assign(new BABYLON.ParticleSystem(name, capacity, scene),
  { particleTexture: createDotTexture(scene), updateSpeed: 1 / 60 }, props);
// The template: a one-shot puff of 20 particles, cloned wherever a spark dies
const puff = make('puff', 20, { manualEmitCount: 20, targetStopDuration: 0.5, disposeOnStop: true,
  minLifeTime: 0.5, maxLifeTime: 0.5, minSize: 0.03, maxSize: 0.06, minEmitPower: 0.6, maxEmitPower: 1.2,
  color1: new BABYLON.Color4(1, 0.7, 0.3, 1), color2: new BABYLON.Color4(1, 0.5, 0.2, 1),
  colorDead: new BABYLON.Color4(0.6, 0.2, 0.1, 0) });
puff.createSphereEmitter(0.05);
const sparks = make('sparks', 20, { emitRate: 4, minLifeTime: 1, maxLifeTime: 1,
  minEmitPower: 2.2, maxEmitPower: 3, gravity: new BABYLON.Vector3(0, -3, 0) });
sparks.emitter = new BABYLON.Vector3(0, 0.2, 0);
sparks.createConeEmitter(0.05, Math.PI / 6);
sparks.addColorGradient(0, new BABYLON.Color4(1, 0.9, 0.5, 1));   // born warm white
sparks.addColorGradient(1, new BABYLON.Color4(0.9, 0.3, 0.1, 0));  // dies clear orange
sparks.addSizeGradient(0, 0.12);                     // and shrinks as it ages
sparks.addSizeGradient(1, 0.03);
const burst = new BABYLON.SubEmitter(puff);
burst.type = BABYLON.SubEmitterType.END;             // clone the puff where a spark dies
sparks.subEmitters = [burst];
sparks.start();

const info = document.getElementById('info');
scene.onAfterRenderObservable.add(() => {
  const puffs = sparks.activeSubSystems;             // the live clones
  info.textContent = `live sparks ${sparks.getActiveCount()}, live puffs ${puffs.length}, ` +
    `particles in puffs ${puffs.reduce((sum, system) => sum + system.getActiveCount(), 0)}`;
});
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>