The emitter is a position or a mesh; its shape sets where particles are born and which way they fly. createPointEmitter(dir1, dir2) and createBoxEmitter(dir1, dir2, minBox, maxBox) pick a direction between two vectors; createSphereEmitter, createHemisphericEmitter, createCylinderEmitter and createConeEmitter emit outward from those volumes (createDirected... variants fix the directions); createMeshEmitter(mesh) uses a mesh's triangles, on the CPU system only.
All particles share one texture, tinted by their color and blended by blendMode: ONEONE (additive, the default), ADD (additive, weighted by alpha), STANDARD (alpha), MULTIPLY, MULTIPLYADD or SUBTRACT. BookNest's motes use createDotTexture(scene) from l2-particles.js, a white radial gradient painted on a canvas (Canvas 2D) and loaded from canvas.toDataURL(). A DynamicTexture version failed once a sub-emitter (Sub-Emitters) cloned the system: the clone's texture was empty and never ready.
<!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; }
</style>
<canvas id="view"></canvas>
<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'); // additive motes need a dark backdrop
const camera = new BABYLON.ArcRotateCamera('cam', -Math.PI / 2, 1.25, 9, new BABYLON.Vector3(0, 0.9, 0), scene);
camera.attachControl(canvas, true);
new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0, 1, 0), scene).intensity = 0.4;
const ui = BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI('ui');
const texture = createDotTexture(scene); // one texture for every system
const V = (x, y, z) => new BABYLON.Vector3(x, y, z);
const COLORS = ['#ffe082', '#80cbc4', '#b39ddb', '#ffab91', '#a5d6a7'];
const SHAPES = [
['Point', (ps) => ps.createPointEmitter(V(-0.3, 1, -0.3), V(0.3, 1, 0.3))],
['Box', (ps) => ps.createBoxEmitter(V(0, 1, 0), V(0, 1, 0), V(-0.4, 0, -0.4), V(0.4, 0.1, 0.4))],
['Sphere', (ps) => ps.createSphereEmitter(0.5)],
['Cone', (ps) => ps.createConeEmitter(0.4, Math.PI / 5)],
['Cylinder', (ps) => ps.createCylinderEmitter(0.35, 0.8, 0)],
];
SHAPES.forEach(([name, shape], i) => {
const x = (i - 2) * 1.7;
const ps = new BABYLON.ParticleSystem(name, 800, scene);
ps.particleTexture = texture;
ps.emitter = V(x, 0.4, 0);
shape(ps); // where particles are born, which way they fly
const color = BABYLON.Color3.FromHexString(COLORS[i]);
Object.assign(ps, { emitRate: 160, minLifeTime: 1.2, maxLifeTime: 1.8, updateSpeed: 1 / 60,
minSize: 0.04, maxSize: 0.09, minEmitPower: 0.6, maxEmitPower: 1,
color1: color.toColor4(1), color2: color.toColor4(0.7), colorDead: color.toColor4(0),
blendMode: BABYLON.ParticleSystem.BLENDMODE_ONEONE, // additive, the default
preWarmCycles: 100 });
ps.start();
const base = new BABYLON.TransformNode('base' + i, scene);
base.position.set(x, 0, 0);
const label = new BABYLON.GUI.TextBlock('', `create${name}\nEmitter()`);
Object.assign(label, { color: '#eee', fontSize: 11, resizeToFit: true });
ui.addControl(label);
label.linkWithMesh(base);
label.linkOffsetY = 30;
});
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>