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:
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).
<!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>