Optimization Techniques
Particle systems can be resource-intensive, especially when rendering thousands of particles simultaneously. This guide provides strategies and best practices to optimize your three.quarks particle systems for better performance.
Understanding Performance Bottlenecks
Before diving into optimization techniques, it’s important to understand the main performance bottlenecks in particle systems:
- CPU Usage: Calculating particle physics, behaviors, and state updates
- GPU Usage: Rendering particles and applying visual effects
- Memory Usage: Storing particle data and resources
Most performance issues fall into one of these categories. Let’s look at techniques to address each of them.
Batching Renderers
The most significant optimization in three.quarks is the BatchedRenderer
system, which drastically reduces draw calls by batching particles into fewer render operations.
import { BatchedRenderer, ParticleSystem } from "three.quarks";
import { Scene } from "three";
// Create a scene
const scene = new Scene();
// Create a batched renderer
const batchedRenderer = new BatchedRenderer();
scene.add(batchedRenderer);
// Create particle systems
const particleSystem1 = new ParticleSystem({
/* ... */
});
const particleSystem2 = new ParticleSystem({
/* ... */
});
// Add systems to the batched renderer instead of directly to the scene
batchedRenderer.addSystem(particleSystem1.emitter.system);
batchedRenderer.addSystem(particleSystem2.emitter.system);
How Batching Works
The BatchedRenderer
groups similar particles together based on their visual properties (same material, texture, blend mode, etc.). This allows the renderer to issue fewer draw calls to the GPU, which is a major performance boost.
When to Use Batching
Always use batching when you have:
- Multiple particle systems in the same scene
- Similar visual properties across systems
- More than a few hundred particles
You can use the QuarksUtil
helper to batch all particle systems in your scene:
import { QuarksUtil } from "three.quarks";
// Add all particle systems in the scene to the batched renderer
QuarksUtil.addToBatchRenderer(scene, batchedRenderer);
Limit Particle Count
One of the simplest and most effective optimizations is to limit the number of particles:
const particleSystem = new ParticleSystem({
// Emit fewer particles over time
emissionOverTime: new ConstantValue(50), // Reduced from 100
// Use shorter lifetimes
startLife: new ConstantValue(2), // Reduced from 5
});
Strategies for Limiting Particles
- Reduce Emission Rate: Lower the
emissionOverTime
oremissionOverDistance
values - Shorter Lifetimes: Reduce the
startLife
value so particles die sooner - Emission Bursts: Use strategic bursts instead of continuous emission
- LOD (Level of Detail): Reduce particle count based on distance or screen space
Optimize Behaviors
Behaviors are processed for each particle every frame, making them a potential bottleneck:
Use Simpler Behaviors
// Instead of complex noise behaviors
new TurbulenceField(/* complex parameters */),
// Use simpler force behaviors
new ApplyForce(new Vector3(0, 1, 0), new ConstantValue(1));
Limit the Number of Behaviors
// Before: Many behaviors
particleSystem.behaviors = [
new ColorOverLife(/* ... */),
new SizeOverLife(/* ... */),
new RotationOverLife(/* ... */),
new SpeedOverLife(/* ... */),
new TurbulenceField(/* ... */),
new Noise(/* ... */),
// Many more behaviors...
];
// After: Only essential behaviors
particleSystem.behaviors = [
new ColorOverLife(/* ... */),
new SizeOverLife(/* ... */),
// Just the most visually important ones
];
Use Efficient Value Generators
Value generators that require less computation will improve performance:
// More expensive (calculated each frame)
new PiecewiseBezier(/* ... */),
// Less expensive (constant or pre-calculated)
new ConstantValue(1);
Use Efficient Emitter Shapes
Some emitter shapes are more compute-intensive than others:
// More expensive
new MeshSurfaceEmitter(complexMesh),
// Less expensive
new SphereEmitter();
Simpler geometric shapes (spheres, cones, circles) are generally more efficient than mesh-based emitters.
Texture Optimizations
Use Texture Atlases
Instead of separate textures for different particles, use a single texture atlas:
const particleSystem = new ParticleSystem({
// Use texture atlas
uTileCount: 4,
vTileCount: 4,
// Frame animation behavior
behaviors: [new FrameOverLife(new PiecewiseBezier(/* ... */))],
});
Optimize Texture Size and Format
- Use power-of-two textures (256×256, 512×512, etc.)
- Use compressed textures when possible
- Reduce texture size for distant or smaller particles
GPU Instancing
three.quarks uses GPU instancing internally, but you can optimize it further:
const particleSystem = new ParticleSystem({
// Use simple instancing geometry
instancingGeometry: new PlaneGeometry(1, 1, 1, 1),
// Or use a more efficient custom geometry
// instancingGeometry: optimizedGeometry,
});
When to Use Custom Instancing Geometry
- For unique particle shapes that aren’t just quads
- When you need particles with fewer vertices
- For specialized visual effects
Culling and Visibility
Use Frustum Culling
Ensure particles outside the camera view are culled:
const particleSystem = new ParticleSystem({
// Enable frustum culling
frustumCulled: true,
});
Use Layers for Selective Rendering
Use Three.js layers to control which particle systems are rendered:
import { Layers } from "three";
const particleSystem = new ParticleSystem({
// Set custom layers
layers: new Layers().set(1),
});
// Then configure your camera to only render specific layers
camera.layers.enable(1);
Memory Management
Dispose Systems Properly
Always dispose of particle systems when they’re no longer needed:
// When a particle system is no longer needed
particleSystem.dispose();
Use Auto-Destroy for Temporary Effects
For one-shot effects, use the auto-destroy option:
const explosionEffect = new ParticleSystem({
autoDestroy: true,
looping: false,
duration: 2,
// Other parameters...
});
Reuse Particle Systems
Instead of creating new systems, reuse existing ones:
// Instead of creating a new system
// const newSystem = new ParticleSystem({ /* ... */ });
// Reuse an existing system
existingSystem.restart();
Profiling and Monitoring
Use Performance Monitoring
Regularly profile your application to identify bottlenecks:
// Add simple performance monitoring
let particleCount = 0;
function updateStats() {
particleCount = batchedRenderer.systems.reduce(
(count, system) => count + system.particleNum,
0,
);
console.log(`Active particles: ${particleCount}`);
}
Use Chrome DevTools or PerformanceMonitor
For more detailed profiling:
import { PerformanceMonitor } from "three/examples/jsm/utils/PerformanceMonitor.js";
const performanceMonitor = new PerformanceMonitor();
performanceMonitor.watch((delta) => {
// Adjust particle count based on framerate
if (delta > 16.6) {
// Less than 60 FPS
particleSystem.emissionOverTime = new ConstantValue(
particleSystem.emissionOverTime.value * 0.8,
);
}
});
Dynamic LOD System
Implement a dynamic Level of Detail system:
function updateLOD() {
const distance = camera.position.distanceTo(particleSystem.emitter.position);
// Far away - use fewer particles
if (distance > 100) {
particleSystem.emissionOverTime = new ConstantValue(10);
}
// Medium distance - use more particles
else if (distance > 50) {
particleSystem.emissionOverTime = new ConstantValue(50);
}
// Close - use full detail
else {
particleSystem.emissionOverTime = new ConstantValue(100);
}
}
Optimizing Trail Renderers
Trail renderers can be particularly expensive. Optimize them by:
const trailSystem = new ParticleSystem({
renderMode: RenderMode.Trail,
rendererEmitterSettings: {
// Use fewer segments for the trail
startLength: new ConstantValue(15), // Reduced from 30
// Only follow important particles
followLocalOrigin: false,
},
});
Mesh Particle Optimization
When using RenderMode.Mesh
:
const meshParticleSystem = new ParticleSystem({
renderMode: RenderMode.Mesh,
// Use a low-poly mesh
instancingGeometry: lowPolyGeometry,
// Avoid complex rotations
startRotation: new ConstantValue(0),
});
WebGL Context Settings
Adjust the WebGL context for better performance:
const renderer = new WebGLRenderer({
powerPreference: "high-performance",
antialias: false, // Disable for performance
});
// Use lower precision when possible
renderer.capabilities.precision = "mediump";
CPU vs GPU Workload Balance
Understand whether your bottleneck is CPU or GPU:
- If CPU-bound: Reduce behaviors, particle count, and physics calculations
- If GPU-bound: Use simpler shaders, smaller textures, and fewer draw calls
Mobile Optimization
For mobile devices:
function detectMobile() {
// Simple mobile detection
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
if (isMobile) {
// Apply aggressive optimizations
particleSystem.emissionOverTime = new ConstantValue(
particleSystem.emissionOverTime.value * 0.3,
);
// Use simpler behaviors
particleSystem.behaviors = particleSystem.behaviors.slice(0, 2);
// Disable soft particles
particleSystem.softParticles = false;
}
}
Optimization Checklist
✅ Use BatchedRenderer
for all particle systems
✅ Limit particle count and lifetime
✅ Minimize the number of behaviors
✅ Use simple emitter shapes when possible
✅ Optimize textures (atlas, compression, size)
✅ Implement proper culling and visibility
✅ Dispose unused systems and resources
✅ Profile regularly and identify bottlenecks
✅ Implement dynamic LOD for distance-based optimization
✅ Optimize for mobile when necessary
Advanced Techniques
Custom Shaders
For specialized effects that don’t require individual particle behavior, consider using custom shaders instead of particle systems:
import { ShaderMaterial } from "three";
const customEffect = new ShaderMaterial({
uniforms: {
time: { value: 0 },
// Other uniforms...
},
vertexShader: `
// Custom vertex shader...
`,
fragmentShader: `
// Custom fragment shader...
`,
});
WebWorkers for Physics
For extremely complex systems, consider offloading physics calculations to a WebWorker:
// In main thread
const worker = new Worker("particle-physics.js");
worker.onmessage = function (e) {
// Update particle positions from worker results
updateParticlesFromData(e.data);
};
// In worker
self.onmessage = function (e) {
// Perform physics calculations
const results = calculateParticlePhysics(e.data);
self.postMessage(results);
};
Conclusion
Optimizing particle systems is a balance between visual quality and performance. Start with the techniques that give the biggest performance improvements (batching, limiting particle count) and then fine-tune with more specific optimizations as needed.
Remember that perceived quality often matters more than raw particle counts. A well-designed system with fewer particles can look better than a poorly optimized one with many more particles.
Next Steps
Now that you understand how to optimize your particle systems:
- Implement Monitoring: Add performance monitoring to identify bottlenecks
- Apply Batch Rendering: Convert existing systems to use the
BatchedRenderer
- Refine Behaviors: Audit and optimize your behavior usage
- Test on Target Devices: Ensure performance on your target platforms
- Consider Custom Components: For specialized effects, consider creating custom components
- Explore Advanced Features: Learn about Custom Behaviors to extend functionality while maintaining performance
By applying these optimization techniques, you’ll be able to create rich, visually impressive particle effects that perform well across a wide range of devices.