Skip to Content
Quarks
DocumentationAdvanced FeaturesOptimization Techniques

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:

  1. CPU Usage: Calculating particle physics, behaviors, and state updates
  2. GPU Usage: Rendering particles and applying visual effects
  3. 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

  1. Reduce Emission Rate: Lower the emissionOverTime or emissionOverDistance values
  2. Shorter Lifetimes: Reduce the startLife value so particles die sooner
  3. Emission Bursts: Use strategic bursts instead of continuous emission
  4. 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:

  1. Implement Monitoring: Add performance monitoring to identify bottlenecks
  2. Apply Batch Rendering: Convert existing systems to use the BatchedRenderer
  3. Refine Behaviors: Audit and optimize your behavior usage
  4. Test on Target Devices: Ensure performance on your target platforms
  5. Consider Custom Components: For specialized effects, consider creating custom components
  6. 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.

Last updated on