Skip to content

Instantly share code, notes, and snippets.

@iErcann
Created February 22, 2025 20:21
Show Gist options
  • Save iErcann/2a9dfa51ed9fc44854375796c8c24d92 to your computer and use it in GitHub Desktop.
Save iErcann/2a9dfa51ed9fc44854375796c8c24d92 to your computer and use it in GitHub Desktop.
Three JS Performance Guide

Three.js Performance Guide

Performance Monitoring

To monitor CPU usage while running your Three.js application, you can utilize Chrome DevTools : PerformanceThreeJS

Monitoring Draw Calls

You can easily check the number of draw calls made by the renderer using the following code:

// Monitor draw calls
console.log(renderer.info.render.calls)

Using stats.js for Performance Metrics

For a more detailed performance analysis, you can integrate stats.js into your application. This library provides real-time performance statistics, including frame rates and rendering times. Here’s how to set it up:

import Stats from 'three/examples/jsm/libs/stats.module';

// Create a new Stats instance
const stats = new Stats();
document.body.appendChild(stats.dom);

// Animation loop to update stats
function animate() {
  stats.begin(); // Start measuring
  // Render your scene here
  stats.end(); // Stop measuring
  requestAnimationFrame(animate); // Continue the animation loop
}

// Start the animation
animate();

Lighting & Shadows Performance

Understanding the performance impact of lights and shadows is crucial for optimizing Three.js applications.

Shadow Performance Hierarchy

From best to worst performance:

  1. No Shadows (Fastest)

    • Use for non-essential objects
    • Best for mobile/low-end devices
  2. DirectionalLight with shadows

    • One additional scene render
    • Good for sun/moon effects
    • Best choice for general shadow casting
  3. SpotLight with shadows

    • One additional scene render
    • Good for focused areas like flashlights
    • More expensive than DirectionalLight
  4. PointLight with shadows (Most Expensive)

    • Six additional scene renders!
    • Extremely costly for performance
    • Use very sparingly, if at all

Understanding Draw Calls

Each shadow-casting light adds significant draw calls:

// DirectionalLight/SpotLight
drawCalls = numberOfMeshes * 1

// PointLight (6x more expensive!)
drawCalls = numberOfMeshes * 6

// Example with 5 meshes and 2 point lights:
totalDrawCalls = baseCalls + (meshes * 6 * pointLights)
// 5 base + (5 * 6 * 2) = 65 draw calls

Good draw calls for web-games : <200

Optimization Strategies

1. Light Management

// GOOD - Main shadow casting light
const mainLight = new THREE.DirectionalLight()
mainLight.castShadow = true
mainLight.position.set(50, 30, 50)
mainLight.intensity = 1.5

// GOOD - Ambient fill light for overall scene brightness
const ambientLight = new THREE.AmbientLight('#ffffff', 0.4)

// GOOD - Hemisphere light for sky/ground color variation
const hemiLight = new THREE.HemisphereLight(
  '#skyColor',   // Sky color
  '#groundColor', // Ground color
  0.5            // Intensity
)

// ACCEPTABLE - Non-shadow point light for local highlights
const accentLight = new THREE.PointLight('#ffffff', 1.0)
accentLight.castShadow = false  // Important!

// BAD - Point light with shadows (extremely expensive!)
const badLight = new THREE.PointLight('#ffffff', 1.0)
badLight.castShadow = true  // Will cause 6 additional renders!
Light Type Performance Guide

From best to worst performance:

  1. AmbientLight

    • No shadows possible
    • Extremely cheap
    • Good for base illumination
    • Use to reduce shadow darkness
  2. HemisphereLight

    • No shadows possible
    • Very performant
    • Great for outdoor scenes
    • Provides subtle color variation
  3. DirectionalLight (without shadows)

    • Good for sun/moon simulation
    • Relatively cheap
    • Consistent shadows across scene
  4. DirectionalLight (with shadows)

    • One additional render pass
    • Best choice for main shadow caster
    • Good balance of quality/performance
  5. SpotLight (without shadows)

    • Good for focused lighting
    • Moderate performance impact
    • Use for highlights/accents
  6. SpotLight (with shadows)

    • One additional render pass
    • More expensive than DirectionalLight
    • Use sparingly
  7. PointLight (without shadows)

    • Expensive but manageable
    • Good for local lighting
    • Keep radius small when possible
  8. PointLight (with shadows) ⚠️

    • EXTREMELY expensive (6 render passes!)
    • Should almost never be used
    • Consider alternatives:
      • SpotLight with shadows
      • Multiple non-shadow PointLights
      • Baked shadows/lighting
Recommended Light Setups
// Basic Outdoor Scene
const lights = {
  main: new THREE.DirectionalLight('#ffffff', 1.5),  // Sun
  hemi: new THREE.HemisphereLight('#skyblue', '#groundcolor', 0.5),
  ambient: new THREE.AmbientLight('#ffffff', 0.2)
}
lights.main.castShadow = true

// Indoor Scene
const lights = {
  main: new THREE.DirectionalLight('#ffffff', 0.5),  // Window light
  spots: [
    new THREE.SpotLight('#ffffff', 0.7),  // Key light
    new THREE.SpotLight('#ffffff', 0.3)   // Fill light
  ],
  ambient: new THREE.AmbientLight('#ffffff', 0.3)
}
lights.main.castShadow = true

// Not needed since it's by default at false, but just to show usage
lights.spots.forEach(light => light.castShadow = false)

// Night Scene
const lights = {
  moon: new THREE.DirectionalLight('#blue', 0.5),
  points: Array(5).fill(0).map(() => new THREE.PointLight('#orange', 0.4)),
  ambient: new THREE.AmbientLight('#000033', 0.2)
}
lights.moon.castShadow = true

// Not needed since it's by default at false, but just to show usage
lights.points.forEach(light => light.castShadow = false)  // Important!
Tips for Light Performance
  • Use ONE main shadow-casting light (usually DirectionalLight)
  • Combine AmbientLight and HemisphereLight for base illumination
  • Use non-shadow PointLights for accent lighting
  • Avoid shadow-casting SpotLights when possible
  • NEVER use shadow-casting PointLights in production
  • Consider light radius/distance for PointLights and SpotLights
  • Use light helpers during development to visualize coverage

2. Shadow Map Optimization

const directionalLight = new THREE.DirectionalLight()

// Reduce shadow map size for better performance
// Set the resolution of the shadow map texture (higher = sharper shadows but more expensive)
directionalLight.shadow.mapSize.height = 1024
directionalLight.shadow.mapSize.width = 1024

3. Selective Shadow Casting

// Only enable shadows on important objects
mainObject.traverse((child) => {
  if (child.isMesh) {
    child.castShadow = false       // Disable casting
    child.receiveShadow = true     // Still receive shadows
  }
})

Best Practices

Lighting Setup

  • Use ONE DirectionalLight for main shadows
  • Add non-shadow PointLights for ambient lighting
  • Avoid shadow-casting PointLights
  • Consider baking shadows for static scenes

Shadow Quality

  • Use smaller shadow maps (512x512) by default
  • Increase only for close-up shadows
  • Disable autoUpdate for static scenes
  • Consider shadow bias adjustment for quality/performance balance

Mesh Optimization

  • Combine small meshes where possible
  • Use instancing for repeated objects
  • Target < 200 draw calls for web
  • Group static objects

Materials

// Shadows require appropriate materials
const material = new THREE.MeshStandardMaterial({
  receiveShadow: true,
  castShadow: true
})

// BasicMaterial doesn't support shadows
const basicMaterial = new THREE.MeshBasicMaterial()  // No shadows

Manual Updates of Shadows maps

  • autoUpdate: Enables automatic updates to the shadows in the scene

    • Default is true
  • When autoUpdate is false, you need to manually update the shadows maps with needsUpdate

  • needsUpdate: Forces shadow maps to update on next render

    • Default is false
    • Required when autoUpdate = false to manually trigger shadow updates
    • Must be set to true followed by a render call to update shadows
Example : Only when a specific object moves
renderer.shadowMap.autoUpdate = false

// Update shadows only when necessary
function onObjectMove() {
  renderer.shadowMap.needsUpdate = true
}
Example : Only 1 time per second
renderer.shadowMap.autoUpdate = false

// Update shadows only once per second
function updateShadows() {
  renderer.shadowMap.needsUpdate = true
}

setInterval(updateShadows, 1000) 
// Of course, you can use requestAnimationFrame instead of setInterval with proper delta time logic

Common Pitfalls

  1. Too Many Shadow Casters

    • Every shadow-casting light multiplies render calls
    • Each mesh with castShadow = true adds to the cost
  2. Unnecessary Shadow Updates

    • Static scenes don't need autoUpdate
    • Update shadows only when the scene changes
  3. Oversized Shadow Maps

    • Large shadow maps impact memory and performance
    • Start small and increase only if needed
  4. Point Light Shadows

    • Extremely expensive (6x normal shadow cost)
    • Consider alternatives like SpotLights

Testing & Profiling

Always test on target devices:

  • Mobile performance differs significantly
  • Monitor FPS and draw calls
  • Use Chrome DevTools Performance tab
  • Test with varying numbers of objects and lights

Additional Resources

Advanced: Cascaded Shadow Maps (THREE-CSM)

THREE-CSM is a solution for rendering high-quality directional light shadows over large distances. It works by splitting the view frustum into several segments, each with its own shadow map.

Benefits

  • Better shadow quality over large distances
  • More efficient than increasing shadow map resolution
  • Good for open world scenes

Basic Setup

import { CSM } from 'three-csm'

// Create CSM instance
const csm = new CSM({
  maxFar: 1000,           // How far shadows are rendered
  cascades: 4,            // Number of shadow cascades
  shadowMapSize: 1024,    // Resolution of each cascade
  lightDirection: new THREE.Vector3(-1, -1, -1),
  camera: camera,         // Your THREE.Camera instance
  parent: scene           // Your THREE.Scene instance
})

// Update in animation loop
function animate() {
  csm.update(camera.matrix)
  renderer.render(scene, camera)
}

Performance Considerations

  • Each cascade adds one render pass
  • Balance cascade count with performance needs
  • Typically 3-4 cascades is sufficient
  • Adjust maxFar and shadowMapSize based on scene scale

When to Use CSM

  • Large outdoor environments
  • When shadow quality at distance is important
  • Games with terrain or large landscapes
  • When standard shadows show obvious cutoff points

Example Configuration for Different Scenarios

// High-end desktop setup
const csmHigh = new CSM({
  cascades: 4,
  shadowMapSize: 2048,
  maxFar: 2000,
  mode: 'practical'
})

// Mobile-friendly setup
const csmLow = new CSM({
  cascades: 2,
  shadowMapSize: 512,
  maxFar: 500,
  mode: 'uniform'
})

Note: THREE-CSM might need updates for newer Three.js versions. Check compatibility before implementation.


Example Project: Enari Engine

A previous project demonstrating good performance practices:

Also, the default scene uses baked shadows (found it on Sketchfab) You can make your own worlds with static baked shadows (Blender)

@iErcann
Copy link
Author

iErcann commented Feb 22, 2025

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment