THREE.js Graphics Optimizer
Version: 1.0 Focus: Performance optimization for THREE.js and graphics applications Purpose: Build smooth 60fps graphics experiences across all devices including mobile
Philosophy: Performance-First Graphics The 16ms Budget
Target: 60 FPS = 16.67ms per frame
Frame budget breakdown:
JavaScript logic: ~5-8ms Rendering (GPU): ~8-10ms Browser overhead: ~2ms
If you exceed 16ms: Frames drop, stuttering occurs.
Mobile vs Desktop Reality
Desktop: Powerful GPU, lots of VRAM, high pixel ratios Mobile: Constrained GPU, limited VRAM, battery concerns, thermal throttling
Design philosophy: Optimize for mobile, scale up for desktop (not vice versa).
Part 1: Core Optimization Principles 1. Minimize Draw Calls
The Problem: Each object = one draw call. 1000 objects = 1000 calls = slow.
Solution: Geometry Merging
// ❌ Bad: 100 draw calls for 100 cubes for (let i = 0; i < 100; i++) { const geometry = new THREE.BoxGeometry(1, 1, 1) const material = new THREE.MeshBasicMaterial({ color: 0xff0000 }) const cube = new THREE.Mesh(geometry, material) cube.position.set(i * 2, 0, 0) scene.add(cube) }
// ✅ Good: 1 draw call via InstancedMesh const geometry = new THREE.BoxGeometry(1, 1, 1) const material = new THREE.MeshBasicMaterial({ color: 0xff0000 }) const instancedMesh = new THREE.InstancedMesh(geometry, material, 100)
for (let i = 0; i < 100; i++) { const matrix = new THREE.Matrix4() matrix.setPosition(i * 2, 0, 0) instancedMesh.setMatrixAt(i, matrix) }
instancedMesh.instanceMatrix.needsUpdate = true scene.add(instancedMesh)
When to use:
Many similar objects (particles, trees, enemies) Static or semi-static positioning Shared material/geometry 2. Level of Detail (LOD)
Render simpler geometry when objects are far away:
const lod = new THREE.LOD()
// High detail (near camera) const highDetailGeo = new THREE.IcosahedronGeometry(1, 3) // Many faces const highDetailMesh = new THREE.Mesh( highDetailGeo, new THREE.MeshStandardMaterial({ color: 0x00d9ff }) ) lod.addLevel(highDetailMesh, 0) // Distance 0-10
// Medium detail const medDetailGeo = new THREE.IcosahedronGeometry(1, 1) const medDetailMesh = new THREE.Mesh( medDetailGeo, new THREE.MeshBasicMaterial({ color: 0x00d9ff }) ) lod.addLevel(medDetailMesh, 10) // Distance 10-50
// Low detail (far from camera) const lowDetailGeo = new THREE.IcosahedronGeometry(1, 0) const lowDetailMesh = new THREE.Mesh( lowDetailGeo, new THREE.MeshBasicMaterial({ color: 0x00d9ff }) ) lod.addLevel(lowDetailMesh, 50) // Distance 50+
scene.add(lod)
// Update LOD in render loop function animate() { lod.update(camera) renderer.render(scene, camera) }
- Frustum Culling (Automatic)
THREE.js automatically skips objects outside camera view. Help it:
// ❌ Bad: Unnecessarily large bounding volumes mesh.geometry.computeBoundingSphere() mesh.geometry.boundingSphere.radius = 1000 // Too large!
// ✅ Good: Accurate bounding volumes mesh.geometry.computeBoundingSphere() // Uses actual geometry size mesh.geometry.computeBoundingBox()
- Texture Optimization
Texture size matters:
4K texture (4096x4096): 64MB VRAM (uncompressed) 2K texture (2048x2048): 16MB VRAM 1K texture (1024x1024): 4MB VRAM
Rules:
Use smallest textures that look good Power-of-two dimensions (512, 1024, 2048) Compress textures (use basis/KTX2 format) const textureLoader = new THREE.TextureLoader()
// ❌ Bad: Loading 4K texture for small object const texture = textureLoader.load('texture-4k.jpg')
// ✅ Good: Appropriate size for use case const texture = textureLoader.load('texture-1k.jpg')
// ✅ Better: Set appropriate filtering texture.minFilter = THREE.LinearFilter // No mipmaps (saves VRAM) texture.anisotropy = renderer.capabilities.getMaxAnisotropy()
// ✅ Best: Dispose when done function cleanup() { texture.dispose() }
Part 2: Mobile-Specific Optimization Mobile Detection & Adaptation /* * Detect mobile device. * @returns {boolean} / export function isMobile() { return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile/i.test(navigator.userAgent) || window.innerWidth < 768 }
/* * Get optimal pixel ratio for device. * @returns {number} / export function getOptimalPixelRatio() { const mobile = isMobile() const deviceRatio = window.devicePixelRatio
// Cap pixel ratio on mobile to save performance return mobile ? Math.min(deviceRatio, 1.5) // Max 1.5x on mobile : Math.min(deviceRatio, 2) // Max 2x on desktop }
// Apply to renderer renderer.setPixelRatio(getOptimalPixelRatio())
Mobile Performance Settings /* * Configure renderer for mobile performance. / function setupMobileOptimizations(renderer, scene, camera) { const mobile = isMobile()
if (mobile) { // Disable expensive features renderer.shadowMap.enabled = false renderer.antialias = false
// Lower pixel ratio
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5))
// Simpler tone mapping
renderer.toneMapping = THREE.NoToneMapping
// Remove fog (expensive pixel shader)
scene.fog = null
// Reduce lights (expensive)
// Keep only 1-2 lights max on mobile
console.log('[Mobile] Performance optimizations applied')
} else { // Desktop: enable high-quality features renderer.shadowMap.enabled = true renderer.shadowMap.type = THREE.PCFSoftShadowMap renderer.antialias = true renderer.toneMapping = THREE.ACESFilmicToneMapping
console.log('[Desktop] High-quality features enabled')
} }
Fallback Pattern /* * Create geometry with fallback for low-end devices. / export function createOptimizedGeometry(options = {}) { const { size = 1, mobile = false } = options
if (mobile) { // Simple geometry for mobile return new THREE.SphereGeometry(size, 8, 8) // Low poly } else { // Detailed geometry for desktop return new THREE.IcosahedronGeometry(size, 2) // High poly } }
// Usage const mobile = isMobile() const geometry = createOptimizedGeometry({ size: 1, mobile }) const material = new THREE.MeshBasicMaterial({ color: 0x00d9ff }) const mesh = new THREE.Mesh(geometry, material)
Part 3: Render Loop Optimization Efficient Animation Loop class SceneManager { constructor() { this.clock = new THREE.Clock() this.animationId = null this.lastFrameTime = 0 this.fps = 60 this.frameInterval = 1000 / this.fps }
/* * Main render loop with delta time. / animate() { this.animationId = requestAnimationFrame(() => this.animate())
const now = performance.now()
const delta = now - this.lastFrameTime
// Throttle to target FPS if needed
if (delta < this.frameInterval) return
this.lastFrameTime = now - (delta % this.frameInterval)
// Update logic with delta
const deltaSeconds = this.clock.getDelta()
this.update(deltaSeconds)
// Render
this.renderer.render(this.scene, this.camera)
}
/* * Update scene objects. * @param {number} delta - Time since last frame (seconds) / update(delta) { // Update animations, physics, etc. this.animatedObjects.forEach(obj => { if (obj.update) obj.update(delta) }) }
/* * Cleanup and stop animation. / dispose() { if (this.animationId) { cancelAnimationFrame(this.animationId) } } }
Conditional Rendering /* * Only render when something changed (for static scenes). / class ConditionalRenderer { constructor(renderer, scene, camera) { this.renderer = renderer this.scene = scene this.camera = camera this.needsRender = true }
/* * Mark scene as needing re-render. / invalidate() { this.needsRender = true }
/* * Render only if needed. / render() { if (this.needsRender) { this.renderer.render(this.scene, this.camera) this.needsRender = false } }
/* * Use with controls. / connectControls(controls) { controls.addEventListener('change', () => this.invalidate()) } }
// Usage const conditionalRenderer = new ConditionalRenderer(renderer, scene, camera) conditionalRenderer.connectControls(controls)
function animate() { requestAnimationFrame(animate) controls.update() conditionalRenderer.render() // Only renders if camera moved }
Part 4: Memory Management Dispose Pattern /* * Properly dispose THREE.js resources. / export function disposeObject(object) { if (!object) return
// Traverse and dispose children object.traverse((child) => { // Dispose geometry if (child.geometry) { child.geometry.dispose() }
// Dispose materials
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(material => disposeMaterial(material))
} else {
disposeMaterial(child.material)
}
}
// Dispose textures
if (child.texture) {
child.texture.dispose()
}
})
// Remove from parent if (object.parent) { object.parent.remove(object) } }
/* * Dispose material and its textures. / function disposeMaterial(material) { material.dispose()
// Dispose textures Object.keys(material).forEach(key => { const value = material[key] if (value && typeof value === 'object' && 'minFilter' in value) { value.dispose() // It's a texture } }) }
Memory Leak Prevention class SafeSceneManager { constructor() { this.scene = new THREE.Scene() this.renderer = new THREE.WebGLRenderer() this.objects = new Set() }
/* * Add object and track it. / add(object) { this.scene.add(object) this.objects.add(object) }
/* * Remove and dispose object. / remove(object) { this.scene.remove(object) this.objects.delete(object) disposeObject(object) }
/* * Cleanup all resources. / dispose() { // Dispose all tracked objects this.objects.forEach(obj => disposeObject(obj)) this.objects.clear()
// Dispose renderer
this.renderer.dispose()
// Clear scene
this.scene.clear()
} }
Part 5: Material Optimization Material Sharing // ❌ Bad: New material for each object for (let i = 0; i < 100; i++) { const material = new THREE.MeshBasicMaterial({ color: 0xff0000 }) const mesh = new THREE.Mesh(geometry, material) scene.add(mesh) }
// ✅ Good: Share single material const sharedMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 })
for (let i = 0; i < 100; i++) { const mesh = new THREE.Mesh(geometry, sharedMaterial) scene.add(mesh) }
Cheaper Material Types
Performance ranking (fastest to slowest):
- MeshBasicMaterial - No lighting, flat shading
- MeshLambertMaterial - Simple diffuse lighting
- MeshPhongMaterial - Specular highlights
- MeshStandardMaterial - PBR (expensive)
- MeshPhysicalMaterial - Advanced PBR (very expensive)
- // Mobile: Use cheaper materials
- const material = isMobile()
- ? new THREE.MeshBasicMaterial({ color: 0x00d9ff })
- new THREE.MeshStandardMaterial({ color: 0x00d9ff, roughness: 0.5, metalness: 0.1 })
Blending Modes // Additive blending for glows (cheaper than transparent) material.blending = THREE.AdditiveBlending material.transparent = true material.depthWrite = false // Don't write to depth buffer
Part 6: Post-Processing Optimization Selective Post-Processing import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js' import { RenderPass } from 'three/addons/postprocessing/RenderPass.js' import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
function setupPostProcessing(renderer, scene, camera, mobile) { const composer = new EffectComposer(renderer)
// Always add render pass composer.addPass(new RenderPass(scene, camera))
// Bloom only on desktop if (!mobile) { const bloomPass = new UnrealBloomPass( new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, // strength 0.4, // radius 0.85 // threshold ) composer.addPass(bloomPass) }
return composer }
Part 7: General Graphics Best Practices 1. Object Pooling /* * Object pool to reuse objects instead of creating/destroying. / class ObjectPool { constructor(createFn, resetFn) { this.pool = [] this.createFn = createFn this.resetFn = resetFn }
/* * Get object from pool or create new one. / acquire() { if (this.pool.length > 0) { return this.pool.pop() } return this.createFn() }
/* * Return object to pool. / release(obj) { this.resetFn(obj) this.pool.push(obj) } }
// Usage: Particle pool const particlePool = new ObjectPool( // Create function () => { const geometry = new THREE.SphereGeometry(0.1) const material = new THREE.MeshBasicMaterial({ color: 0xffffff }) return new THREE.Mesh(geometry, material) }, // Reset function (particle) => { particle.position.set(0, 0, 0) particle.visible = false } )
// Spawn particle const particle = particlePool.acquire() particle.position.set(Math.random(), Math.random(), Math.random()) particle.visible = true scene.add(particle)
// Later: Return to pool scene.remove(particle) particlePool.release(particle)
- Visibility Culling /**
- Hide objects far from camera. */ function updateVisibility(camera, objects, maxDistance = 50) { const cameraPos = camera.position
objects.forEach(obj => { const distance = obj.position.distanceTo(cameraPos) obj.visible = distance < maxDistance }) }
- Lazy Loading /**
- Load textures on demand. */ class LazyTextureLoader { constructor() { this.loader = new THREE.TextureLoader() this.cache = new Map() }
async load(url) { // Check cache if (this.cache.has(url)) { return this.cache.get(url) }
// Load texture
return new Promise((resolve, reject) => {
this.loader.load(
url,
(texture) => {
this.cache.set(url, texture)
resolve(texture)
},
undefined,
reject
)
})
} }
Part 8: Performance Monitoring FPS Counter /* * Simple FPS monitor. / class FPSMonitor { constructor() { this.frames = 0 this.lastTime = performance.now() this.fps = 60 }
update() { this.frames++ const now = performance.now()
if (now >= this.lastTime + 1000) {
this.fps = Math.round((this.frames * 1000) / (now - this.lastTime))
this.frames = 0
this.lastTime = now
// Warn if FPS drops
if (this.fps < 30) {
console.warn(`Low FPS: ${this.fps}`)
}
}
}
getFPS() { return this.fps } }
// Usage const fpsMonitor = new FPSMonitor()
function animate() { requestAnimationFrame(animate) fpsMonitor.update() renderer.render(scene, camera) }
GPU Memory Monitoring /* * Monitor GPU memory usage. / function logMemoryUsage(renderer) { const info = renderer.info
console.log('GPU Memory:', { geometries: info.memory.geometries, textures: info.memory.textures, programs: info.programs.length, drawCalls: info.render.calls, triangles: info.render.triangles }) }
// Call periodically setInterval(() => logMemoryUsage(renderer), 5000)
Critical Optimization Checklist
Before Optimizing
Profile first (Chrome DevTools Performance tab)
Identify bottleneck (CPU or GPU?)
Set target FPS (usually 60fps = 16ms/frame)
Geometry
Use InstancedMesh for repeated objects
Implement LOD for distant objects
Merge static geometries
Use BufferGeometry (not Geometry)
Dispose unused geometries
Textures
Use smallest texture size needed
Power-of-two dimensions
Compress textures (basis/KTX2)
Set minFilter = LinearFilter if no mipmaps
Dispose unused textures
Materials
Share materials across objects
Use cheaper material types on mobile
Limit transparent objects
Use additive blending for glows
Dispose unused materials
Lighting
Limit lights (1-2 on mobile, 3-5 on desktop)
Disable shadows on mobile
Use baked lighting where possible
Prefer directional/point over spot lights
Rendering
Cap pixel ratio (1.5x mobile, 2x desktop)
Disable antialiasing on mobile
Use conditional rendering for static scenes
Implement frustum culling
Limit post-processing on mobile
Mobile-Specific
Detect mobile devices
Reduce geometry complexity
Disable expensive features
Lower pixel ratio
Test on real devices (not just desktop browser)
Common Performance Killers
Too many draw calls → Use InstancedMesh
High-resolution textures → Resize to 1K or 2K
Too many lights → Limit to 2-3
Transparent objects → Use sparingly, render last
Post-processing on mobile → Disable or simplify
Memory leaks → Always dispose geometries/materials/textures
Unnecessary re-renders → Use conditional rendering
High pixel ratio on mobile → Cap at 1.5x
Performance Testing Workflow
1. Test on Target Devices
// Detect and log device info
console.log('Device Info:', {
userAgent: navigator.userAgent,
pixelRatio: window.devicePixelRatio,
screen: ${window.screen.width}x${window.screen.height},
gpu: renderer.capabilities.getMaxAnisotropy()
})
- Profile with Chrome DevTools Open DevTools → Performance tab Record 5-10 seconds of rendering Look for: Long frames (>16ms) GPU bottlenecks Memory leaks
- A/B Test Optimizations // Feature flag for testing const ENABLE_SHADOWS = !isMobile() const ENABLE_BLOOM = !isMobile() const MAX_PARTICLE_COUNT = isMobile() ? 100 : 500
Resources THREE.js Docs: https://threejs.org/docs/ THREE.js Performance Tips: https://discoverthreejs.com/tips-and-tricks/ WebGL Fundamentals: https://webglfundamentals.org/ GPU Performance: https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices