Particle Systems

GPU-accelerated particle systems with physics simulation, texture sheet animation, and advanced behaviors for fire, smoke, rain, and custom effects.

Web Engine's particle system uses GPU instancing for high-performance rendering of thousands of particles. Each emitter supports physics, color/size interpolation, texture sheet animation, and audio playback.

Key Features#

GPU Instancing

Render 100k+ particles at 60 FPS using instanced meshes

Physics Simulation

Gravity, velocity, curl noise turbulence, and forces

Texture Animation

Sprite sheet animation with configurable tiles and speed

Emitter Shapes

Sphere, cone, and box emission shapes

Interpolation

Color, size, opacity, and rotation over lifetime

Burst Emission

Continuous and burst emission modes

Creating a Particle Emitter#

Add a ParticleEmitter component to an entity to create a particle system:

import { createEntity, addComponent } from 'bitecs';
import {
Transform,
ParticleEmitter
} from '@web-engine-dev/core/engine/ecs/components';
// Create emitter entity
const emitter = createEntity(world);
addComponent(world, emitter, Transform);
addComponent(world, emitter, ParticleEmitter);
// Position emitter
Transform.position[emitter][0] = 0;
Transform.position[emitter][1] = 5;
Transform.position[emitter][2] = 0;
// Configure emitter
ParticleEmitter.playing[emitter] = 1; // Enable
ParticleEmitter.maxParticles[emitter] = 1000; // Max particles
ParticleEmitter.emissionRate[emitter] = 50; // Particles/sec
// Particle properties
ParticleEmitter.lifetime[emitter] = 2.0; // Seconds
ParticleEmitter.speed[emitter] = 5.0; // Initial velocity
ParticleEmitter.size[emitter] = 0.5; // Start size
ParticleEmitter.sizeEnd[emitter] = 0.1; // End size
// Color (RGB 0-1)
ParticleEmitter.color[emitter][0] = 1.0; // Red
ParticleEmitter.color[emitter][1] = 0.5; // Green
ParticleEmitter.color[emitter][2] = 0.0; // Blue
ParticleEmitter.colorEnd[emitter][0] = 1.0;
ParticleEmitter.colorEnd[emitter][1] = 0.0;
ParticleEmitter.colorEnd[emitter][2] = 0.0;
// Opacity
ParticleEmitter.opacity[emitter] = 1.0;
ParticleEmitter.opacityEnd[emitter] = 0.0; // Fade out

Emission Shapes#

Control the direction and spread of emitted particles:

ShapeValueDescription
Sphere0Emit in all directions (fireworks, explosions)
Cone1Emit in a cone shape (fire, smoke)
Box2Emit upward in a box (fountains)
// Sphere emission (omnidirectional)
ParticleEmitter.shape[emitter] = 0;
// Cone emission (directional)
ParticleEmitter.shape[emitter] = 1;
ParticleEmitter.angle[emitter] = 0.5; // Cone angle in radians
// Box emission (upward)
ParticleEmitter.shape[emitter] = 2;

Physics and Forces#

Gravity#

Apply gravity or custom forces to particles:

// Gravity (world units per second squared)
ParticleEmitter.gravity[emitter][0] = 0; // X
ParticleEmitter.gravity[emitter][1] = -9.8; // Y (downward)
ParticleEmitter.gravity[emitter][2] = 0; // Z
// Or custom forces
ParticleEmitter.gravity[emitter][0] = 2; // Wind to the right
ParticleEmitter.gravity[emitter][1] = -5; // Slight downward
ParticleEmitter.gravity[emitter][2] = 0;

Curl Noise Turbulence#

Add organic, swirling motion to particles using curl noise:

// Turbulence strength
ParticleEmitter.noiseStrength[emitter] = 2.0;
// Noise frequency (higher = more detail)
ParticleEmitter.noiseFrequency[emitter] = 0.5;
// Creates divergence-free turbulence
// Perfect for smoke, fire, magic effects

Particle Rotation#

// Rotation speed (radians per second)
ParticleEmitter.rotationSpeed[emitter] = 2.0;
// Variation (randomness)
ParticleEmitter.rotationVariation[emitter] = 1.0;

Texture Sheet Animation#

Animate particles using sprite sheets for effects like fire, explosions, or magical spells:

// Load texture
const textureAssetId = AssetRegistry.loadTexture('particles/fire-sheet.png');
// Configure texture sheet
ParticleEmitter.textureAssetId[emitter] = textureAssetId;
ParticleEmitter.tilesX[emitter] = 4; // 4 columns
ParticleEmitter.tilesY[emitter] = 4; // 4 rows (16 frames total)
// Animation speed (frames per second)
ParticleEmitter.animationSpeed[emitter] = 12;
// Frames advance automatically based on particle age

Creating Sprite Sheets

Arrange animation frames in a grid (e.g., 4x4 or 8x8). The engine automatically calculates UV offsets and plays the animation over the particle's lifetime or at a fixed frame rate.

Burst Emission#

Emit particles in bursts instead of continuously:

// Enable burst mode
ParticleEmitter.emissionBursts[emitter] = 5; // Number of bursts
ParticleEmitter.emissionBurstSize[emitter] = 100; // Particles per burst
ParticleEmitter.emissionBurstInterval[emitter] = 1.0; // Seconds between bursts
// Continuous emission rate (set to 0 for burst-only)
ParticleEmitter.emissionRate[emitter] = 0;
// Bursts decrement automatically
// When emissionBursts reaches 0, emission stops

Randomization and Variation#

Add variation to make particles look more natural:

// Velocity variation (+/- random value)
ParticleEmitter.velocityVariation[emitter] = 2.0;
// Size variation
ParticleEmitter.sizeVariation[emitter] = 0.2;
// Rotation variation
ParticleEmitter.rotationVariation[emitter] = 1.0;
// Color variation
ParticleEmitter.colorVariation[emitter] = 0.1;
// Each particle gets random values within these ranges

Audio Integration#

Trigger sounds when particles emit for enhanced immersion:

// Load audio asset
const soundAssetId = AssetRegistry.loadAudio('sounds/fire-whoosh.mp3');
// Configure emitter audio
ParticleEmitter.audioAssetId[emitter] = soundAssetId;
ParticleEmitter.audioVolume[emitter] = 0.8;
ParticleEmitter.audioPitch[emitter] = 1.0;
// Spatial audio (3D positioned)
ParticleEmitter.audioSpatial[emitter] = 1;
ParticleEmitter.audioRefDistance[emitter] = 5; // Full volume distance
ParticleEmitter.audioMaxDistance[emitter] = 50; // Falloff distance
ParticleEmitter.audioRolloff[emitter] = 1.0; // Falloff factor

Example Effects#

Fire Effect#

const fireEmitter = createEntity(world);
addComponent(world, fireEmitter, Transform);
addComponent(world, fireEmitter, ParticleEmitter);
// Position
Transform.position[fireEmitter][1] = 0;
// Emission
ParticleEmitter.playing[fireEmitter] = 1;
ParticleEmitter.maxParticles[fireEmitter] = 500;
ParticleEmitter.emissionRate[fireEmitter] = 100;
ParticleEmitter.shape[fireEmitter] = 1; // Cone
ParticleEmitter.angle[fireEmitter] = 0.3;
// Physics
ParticleEmitter.lifetime[fireEmitter] = 1.5;
ParticleEmitter.speed[fireEmitter] = 3.0;
ParticleEmitter.gravity[fireEmitter][1] = 2.0; // Rise up
ParticleEmitter.noiseStrength[fireEmitter] = 1.5; // Turbulence
// Size
ParticleEmitter.size[fireEmitter] = 0.5;
ParticleEmitter.sizeEnd[fireEmitter] = 1.5; // Grow
// Color (yellow to red to black)
ParticleEmitter.color[fireEmitter][0] = 1.0; // Yellow
ParticleEmitter.color[fireEmitter][1] = 0.8;
ParticleEmitter.color[fireEmitter][2] = 0.0;
ParticleEmitter.colorEnd[fireEmitter][0] = 0.3; // Dark red
ParticleEmitter.colorEnd[fireEmitter][1] = 0.0;
ParticleEmitter.colorEnd[fireEmitter][2] = 0.0;
// Opacity
ParticleEmitter.opacity[fireEmitter] = 0.8;
ParticleEmitter.opacityEnd[fireEmitter] = 0.0;
// Rotation
ParticleEmitter.rotationSpeed[fireEmitter] = 1.0;

Rain Effect#

const rainEmitter = createEntity(world);
addComponent(world, rainEmitter, Transform);
addComponent(world, rainEmitter, ParticleEmitter);
// Position high above ground
Transform.position[rainEmitter][1] = 20;
// Emission
ParticleEmitter.playing[rainEmitter] = 1;
ParticleEmitter.maxParticles[rainEmitter] = 2000;
ParticleEmitter.emissionRate[rainEmitter] = 500;
ParticleEmitter.shape[rainEmitter] = 2; // Box
// Physics
ParticleEmitter.lifetime[rainEmitter] = 3.0;
ParticleEmitter.speed[rainEmitter] = 0.0; // Start still
ParticleEmitter.gravity[rainEmitter][1] = -20; // Fall fast
// Size (thin streaks)
ParticleEmitter.size[rainEmitter] = 0.05;
ParticleEmitter.sizeEnd[rainEmitter] = 0.05;
// Color (light blue)
ParticleEmitter.color[rainEmitter][0] = 0.7;
ParticleEmitter.color[rainEmitter][1] = 0.8;
ParticleEmitter.color[rainEmitter][2] = 1.0;
// Opacity
ParticleEmitter.opacity[rainEmitter] = 0.6;
ParticleEmitter.opacityEnd[rainEmitter] = 0.0;

Explosion Effect#

const explosionEmitter = createEntity(world);
addComponent(world, explosionEmitter, Transform);
addComponent(world, explosionEmitter, ParticleEmitter);
// Burst emission (one-shot)
ParticleEmitter.playing[explosionEmitter] = 1;
ParticleEmitter.maxParticles[explosionEmitter] = 500;
ParticleEmitter.emissionRate[explosionEmitter] = 0;
ParticleEmitter.emissionBursts[explosionEmitter] = 1;
ParticleEmitter.emissionBurstSize[explosionEmitter] = 500;
ParticleEmitter.shape[explosionEmitter] = 0; // Sphere
// Physics
ParticleEmitter.lifetime[explosionEmitter] = 2.0;
ParticleEmitter.speed[explosionEmitter] = 15.0; // Fast initial velocity
ParticleEmitter.velocityVariation[explosionEmitter] = 5.0;
ParticleEmitter.gravity[explosionEmitter][1] = -5.0;
// Size
ParticleEmitter.size[explosionEmitter] = 1.0;
ParticleEmitter.sizeEnd[explosionEmitter] = 0.1;
// Color (white to orange to black)
ParticleEmitter.color[explosionEmitter][0] = 1.0;
ParticleEmitter.color[explosionEmitter][1] = 1.0;
ParticleEmitter.color[explosionEmitter][2] = 1.0;
ParticleEmitter.colorEnd[explosionEmitter][0] = 0.0;
ParticleEmitter.colorEnd[explosionEmitter][1] = 0.0;
ParticleEmitter.colorEnd[explosionEmitter][2] = 0.0;
// Audio
ParticleEmitter.audioAssetId[explosionEmitter] = explosionSoundId;

Performance Optimization#

OptimizationBenefitTrade-off
Reduce maxParticlesLess GPU/CPU workLess dense effects
Lower emission rateFewer particles spawnedThinner effects
Disable distant emittersSkip invisible workRequires distance checks
Use simpler texturesFaster renderingLess visual detail
Reduce particle lifetimeFewer active particlesShorter effects

Distance-Based Culling#

// Disable emitters far from camera
function updateParticleEmitters(world, camera) {
const entities = emitterQuery(world);
for (let i = 0; i < entities.length; i++) {
const eid = entities[i];
const distance = camera.position.distanceTo(
new THREE.Vector3(
Transform.position[eid][0],
Transform.position[eid][1],
Transform.position[eid][2]
)
);
// Disable if too far
ParticleEmitter.playing[eid] = distance < 100 ? 1 : 0;
}
}

Accessibility#

The particle system respects the prefers-reduced-motion setting:

import { motionPreferences } from '@web-engine-dev/core/engine/accessibility';
// Check motion preferences
const prefs = motionPreferences.getPreferences();
if (!prefs.enableParticles) {
// Particles are completely disabled
// Emitters are hidden but kept alive
}
// Reduced particle density for reduced motion
const density = prefs.particleDensityMultiplier; // 0.25 for reduced motion
ParticleEmitter.emissionRate[eid] *= density;
ParticleEmitter.emissionBurstSize[eid] *= density;

Best Practices#

Effect Design#

  • Start with low particle counts and increase until satisfied
  • Use texture animation for complex effects instead of high particle counts
  • Combine multiple emitters for layered effects (inner flame + outer smoke)
  • Use color/size interpolation to create dynamic, evolving effects
  • Test effects at different distances and angles

Performance#

  • Limit total particles across all emitters to 10k-20k on mobile
  • Use burst emission for one-shot effects instead of continuous
  • Pool and reuse emitter entities instead of creating/destroying
  • Disable emitters when not visible or playing
  • Provide quality settings to adjust particle counts

Visual Quality#

  • Use additive blending for fire, sparks, and magic effects
  • Use alpha blending for smoke, clouds, and fog
  • Add variation to velocity, size, color for organic look
  • Use curl noise turbulence for realistic smoke/fire motion
  • Match particle color to environment lighting

Overdraw

Particles use transparent materials which require sorting and can cause overdraw (multiple layers rendered on top of each other). Avoid large, overlapping particles and prefer smaller particles with higher counts for better performance.

Rendering | Web Engine Docs | Web Engine Docs