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 entityconst emitter = createEntity(world);addComponent(world, emitter, Transform);addComponent(world, emitter, ParticleEmitter); // Position emitterTransform.position[emitter][0] = 0;Transform.position[emitter][1] = 5;Transform.position[emitter][2] = 0; // Configure emitterParticleEmitter.playing[emitter] = 1; // EnableParticleEmitter.maxParticles[emitter] = 1000; // Max particlesParticleEmitter.emissionRate[emitter] = 50; // Particles/sec // Particle propertiesParticleEmitter.lifetime[emitter] = 2.0; // SecondsParticleEmitter.speed[emitter] = 5.0; // Initial velocityParticleEmitter.size[emitter] = 0.5; // Start sizeParticleEmitter.sizeEnd[emitter] = 0.1; // End size // Color (RGB 0-1)ParticleEmitter.color[emitter][0] = 1.0; // RedParticleEmitter.color[emitter][1] = 0.5; // GreenParticleEmitter.color[emitter][2] = 0.0; // Blue ParticleEmitter.colorEnd[emitter][0] = 1.0;ParticleEmitter.colorEnd[emitter][1] = 0.0;ParticleEmitter.colorEnd[emitter][2] = 0.0; // OpacityParticleEmitter.opacity[emitter] = 1.0;ParticleEmitter.opacityEnd[emitter] = 0.0; // Fade outEmission Shapes#
Control the direction and spread of emitted particles:
| Shape | Value | Description |
|---|---|---|
| Sphere | 0 | Emit in all directions (fireworks, explosions) |
| Cone | 1 | Emit in a cone shape (fire, smoke) |
| Box | 2 | Emit 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; // XParticleEmitter.gravity[emitter][1] = -9.8; // Y (downward)ParticleEmitter.gravity[emitter][2] = 0; // Z // Or custom forcesParticleEmitter.gravity[emitter][0] = 2; // Wind to the rightParticleEmitter.gravity[emitter][1] = -5; // Slight downwardParticleEmitter.gravity[emitter][2] = 0;Curl Noise Turbulence#
Add organic, swirling motion to particles using curl noise:
// Turbulence strengthParticleEmitter.noiseStrength[emitter] = 2.0; // Noise frequency (higher = more detail)ParticleEmitter.noiseFrequency[emitter] = 0.5; // Creates divergence-free turbulence// Perfect for smoke, fire, magic effectsParticle 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 textureconst textureAssetId = AssetRegistry.loadTexture('particles/fire-sheet.png'); // Configure texture sheetParticleEmitter.textureAssetId[emitter] = textureAssetId;ParticleEmitter.tilesX[emitter] = 4; // 4 columnsParticleEmitter.tilesY[emitter] = 4; // 4 rows (16 frames total) // Animation speed (frames per second)ParticleEmitter.animationSpeed[emitter] = 12; // Frames advance automatically based on particle ageCreating 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 modeParticleEmitter.emissionBursts[emitter] = 5; // Number of burstsParticleEmitter.emissionBurstSize[emitter] = 100; // Particles per burstParticleEmitter.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 stopsRandomization and Variation#
Add variation to make particles look more natural:
// Velocity variation (+/- random value)ParticleEmitter.velocityVariation[emitter] = 2.0; // Size variationParticleEmitter.sizeVariation[emitter] = 0.2; // Rotation variationParticleEmitter.rotationVariation[emitter] = 1.0; // Color variationParticleEmitter.colorVariation[emitter] = 0.1; // Each particle gets random values within these rangesAudio Integration#
Trigger sounds when particles emit for enhanced immersion:
// Load audio assetconst soundAssetId = AssetRegistry.loadAudio('sounds/fire-whoosh.mp3'); // Configure emitter audioParticleEmitter.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 distanceParticleEmitter.audioMaxDistance[emitter] = 50; // Falloff distanceParticleEmitter.audioRolloff[emitter] = 1.0; // Falloff factorExample Effects#
Fire Effect#
const fireEmitter = createEntity(world);addComponent(world, fireEmitter, Transform);addComponent(world, fireEmitter, ParticleEmitter); // PositionTransform.position[fireEmitter][1] = 0; // EmissionParticleEmitter.playing[fireEmitter] = 1;ParticleEmitter.maxParticles[fireEmitter] = 500;ParticleEmitter.emissionRate[fireEmitter] = 100;ParticleEmitter.shape[fireEmitter] = 1; // ConeParticleEmitter.angle[fireEmitter] = 0.3; // PhysicsParticleEmitter.lifetime[fireEmitter] = 1.5;ParticleEmitter.speed[fireEmitter] = 3.0;ParticleEmitter.gravity[fireEmitter][1] = 2.0; // Rise upParticleEmitter.noiseStrength[fireEmitter] = 1.5; // Turbulence // SizeParticleEmitter.size[fireEmitter] = 0.5;ParticleEmitter.sizeEnd[fireEmitter] = 1.5; // Grow // Color (yellow to red to black)ParticleEmitter.color[fireEmitter][0] = 1.0; // YellowParticleEmitter.color[fireEmitter][1] = 0.8;ParticleEmitter.color[fireEmitter][2] = 0.0; ParticleEmitter.colorEnd[fireEmitter][0] = 0.3; // Dark redParticleEmitter.colorEnd[fireEmitter][1] = 0.0;ParticleEmitter.colorEnd[fireEmitter][2] = 0.0; // OpacityParticleEmitter.opacity[fireEmitter] = 0.8;ParticleEmitter.opacityEnd[fireEmitter] = 0.0; // RotationParticleEmitter.rotationSpeed[fireEmitter] = 1.0;Rain Effect#
const rainEmitter = createEntity(world);addComponent(world, rainEmitter, Transform);addComponent(world, rainEmitter, ParticleEmitter); // Position high above groundTransform.position[rainEmitter][1] = 20; // EmissionParticleEmitter.playing[rainEmitter] = 1;ParticleEmitter.maxParticles[rainEmitter] = 2000;ParticleEmitter.emissionRate[rainEmitter] = 500;ParticleEmitter.shape[rainEmitter] = 2; // Box // PhysicsParticleEmitter.lifetime[rainEmitter] = 3.0;ParticleEmitter.speed[rainEmitter] = 0.0; // Start stillParticleEmitter.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; // OpacityParticleEmitter.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 // PhysicsParticleEmitter.lifetime[explosionEmitter] = 2.0;ParticleEmitter.speed[explosionEmitter] = 15.0; // Fast initial velocityParticleEmitter.velocityVariation[explosionEmitter] = 5.0;ParticleEmitter.gravity[explosionEmitter][1] = -5.0; // SizeParticleEmitter.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; // AudioParticleEmitter.audioAssetId[explosionEmitter] = explosionSoundId;Performance Optimization#
| Optimization | Benefit | Trade-off |
|---|---|---|
| Reduce maxParticles | Less GPU/CPU work | Less dense effects |
| Lower emission rate | Fewer particles spawned | Thinner effects |
| Disable distant emitters | Skip invisible work | Requires distance checks |
| Use simpler textures | Faster rendering | Less visual detail |
| Reduce particle lifetime | Fewer active particles | Shorter effects |
Distance-Based Culling#
// Disable emitters far from camerafunction 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 preferencesconst prefs = motionPreferences.getPreferences(); if (!prefs.enableParticles) { // Particles are completely disabled // Emitters are hidden but kept alive} // Reduced particle density for reduced motionconst 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.