WebGL Performance Optimizer
name: webgl-performance-optimizer description: Optimize Three.js UI performance, reduce GPU load, suggest fallback UI strategies, and maintain 60fps experience version: 1.0.0 author: Claude Code Skills created: 2026-01-03
Core Philosophy
WebGL performance optimization is about intelligent resource management - knowing when to render, what to render, and how to render efficiently. Every frame has a budget of ~16.67ms to maintain 60fps. Your job is to ensure the GPU never exceeds this budget through smart geometry, texture, and draw call management.
Key Principles:
- •🎯 Profile First: Measure before optimizing - identify actual bottlenecks
- •🔄 Progressive Degradation: Gracefully reduce quality when performance drops
- •📊 Frame Budget: Target 60fps desktop (16.67ms/frame), 30fps mobile (33.33ms/frame)
- •🎨 Visual Fidelity Trade-offs: Balance beauty with performance
- •🛡️ Fallback Strategies: Always have non-WebGL alternatives ready
Technology Stack
- •Three.js: Core 3D rendering library
- •React Three Fiber (@react-three/fiber): React renderer for Three.js
- •React Three Drei (@react-three/drei): Useful helpers and abstractions
- •Stats.js: Real-time performance monitoring
- •Chrome DevTools: GPU profiling and frame analysis
- •Spector.js: WebGL debugging and call inspection
When to Use This Skill
Use webgl-performance-optimizer when:
- •Three.js scenes drop below 60fps on desktop or 30fps on mobile
- •GPU usage is high (>80%) during normal operation
- •Complex 3D UI elements cause jank or stuttering
- •Users on lower-end devices report performance issues
- •Draw calls exceed 500-1000 per frame
- •Memory usage grows unbounded over time
- •You need to balance visual quality with performance
Optimization Workflow
1. Identify Bottlenecks
// Add Stats.js for real-time monitoring
import Stats from 'three/examples/jsm/libs/stats.module'
const StatsMonitor = () => {
const statsRef = useRef<Stats>()
useEffect(() => {
const stats = new Stats()
stats.showPanel(0) // 0: fps, 1: ms, 2: mb
stats.dom.style.position = 'absolute'
stats.dom.style.top = '0'
stats.dom.style.left = '0'
document.body.appendChild(stats.dom)
statsRef.current = stats
return () => document.body.removeChild(stats.dom)
}, [])
useFrame(() => {
statsRef.current?.update()
})
return null
}
// Usage in Canvas
<Canvas>
<StatsMonitor />
{/* Your scene */}
</Canvas>
Chrome DevTools GPU Profiling:
- •Open DevTools → Performance → Enable "Screenshots" and "Memory"
- •Record a session while interacting with 3D UI
- •Check for long frames (>16.67ms) in the flame chart
- •Identify draw calls, shader compilation, texture uploads
2. Reduce Draw Calls
Problem: Each mesh = 1 draw call. Too many objects = GPU bottleneck.
Solution 1: Instanced Rendering
// Bad: 1000 draw calls for 1000 spheres
{particles.map((pos, i) => (
<mesh key={i} position={pos}>
<sphereGeometry args={[0.1, 16, 16]} />
<meshStandardMaterial color="cyan" />
</mesh>
))}
// Good: 1 draw call for 1000 spheres using instancing
import { Instances, Instance } from '@react-three/drei'
<Instances limit={1000}>
<sphereGeometry args={[0.1, 16, 16]} />
<meshStandardMaterial color="cyan" />
{particles.map((pos, i) => (
<Instance key={i} position={pos} />
))}
</Instances>
Solution 2: Geometry Merging
import { mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils'
// Merge static geometries into single mesh
const MergedStaticObjects = () => {
const mergedGeometry = useMemo(() => {
const geometries = staticObjects.map(obj => {
const geo = new BoxGeometry(obj.size, obj.size, obj.size)
geo.translate(obj.position.x, obj.position.y, obj.position.z)
return geo
})
return mergeBufferGeometries(geometries)
}, [staticObjects])
return (
<mesh geometry={mergedGeometry}>
<meshStandardMaterial />
</mesh>
)
}
3. Optimize Geometry
Reduce Polygon Count:
// Bad: High poly sphere (2400 triangles)
<sphereGeometry args={[1, 64, 64]} />
// Good: Low poly sphere (300 triangles) - visually similar with proper lighting
<sphereGeometry args={[1, 16, 16]} />
// Better: Adaptive detail based on screen size
const DetailedSphere = ({ radius, screenSize }) => {
const segments = screenSize > 1920 ? 32 : screenSize > 1280 ? 24 : 16
return <sphereGeometry args={[radius, segments, segments]} />
}
Use LOD (Level of Detail):
import { LOD } from 'three'
const AdaptiveMesh = ({ position }) => {
const lodRef = useRef<LOD>()
useEffect(() => {
const lod = new LOD()
// High detail (close)
const highGeo = new SphereGeometry(1, 32, 32)
const highMesh = new Mesh(highGeo, material)
lod.addLevel(highMesh, 0)
// Medium detail
const medGeo = new SphereGeometry(1, 16, 16)
const medMesh = new Mesh(medGeo, material)
lod.addLevel(medMesh, 10)
// Low detail (far)
const lowGeo = new SphereGeometry(1, 8, 8)
const lowMesh = new Mesh(lowGeo, material)
lod.addLevel(lowMesh, 20)
lodRef.current = lod
}, [])
return <primitive object={lodRef.current} position={position} />
}
4. Optimize Textures
Texture Compression and Sizing:
import { useTexture } from '@react-three/drei'
// Bad: 4K uncompressed texture (64MB memory)
const texture = useTexture('/texture-4k.png')
// Good: Compressed, appropriately sized texture (4MB memory)
const OptimizedTexture = () => {
const texture = useTexture('/texture-1k-compressed.jpg')
// Enable mipmaps for better performance at distance
texture.generateMipmaps = true
texture.minFilter = THREE.LinearMipmapLinearFilter
texture.magFilter = THREE.LinearFilter
// Anisotropic filtering (2-4 is usually enough)
texture.anisotropy = 2
return <meshStandardMaterial map={texture} />
}
Texture Atlasing:
// Bad: 10 separate textures = 10 texture binds per frame
materials.map(mat => <meshBasicMaterial map={mat.texture} />)
// Good: 1 texture atlas = 1 texture bind
// Combine multiple textures into single atlas, use UV offsets
const AtlasMaterial = ({ uvOffset, uvScale }) => {
const atlas = useTexture('/texture-atlas.jpg')
return (
<meshBasicMaterial
map={atlas}
map-offset={uvOffset}
map-repeat={uvScale}
/>
)
}
5. Optimize Shaders
Avoid Expensive Operations:
// Bad: Expensive operations in fragment shader
varying vec3 vNormal;
varying vec3 vPosition;
void main() {
// Normalize in fragment shader (runs per-pixel)
vec3 normal = normalize(vNormal);
// Expensive calculations per pixel
float pattern = sin(vPosition.x * 100.0) * cos(vPosition.y * 100.0);
gl_FragColor = vec4(normal * pattern, 1.0);
}
// Good: Move calculations to vertex shader (runs per-vertex)
// Vertex Shader
attribute vec3 position;
attribute vec3 normal;
varying vec3 vNormal;
varying float vPattern;
void main() {
vNormal = normalize(normalMatrix * normal); // Normalize once per vertex
vPattern = sin(position.x * 100.0) * cos(position.y * 100.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// Fragment Shader
varying vec3 vNormal;
varying float vPattern;
void main() {
gl_FragColor = vec4(vNormal * vPattern, 1.0);
}
Shader Precision:
// Use appropriate precision for mobile precision mediump float; // Good for mobile (saves GPU power) precision highp float; // Use only when necessary // Mix precisions for optimal performance varying lowp vec4 vColor; // Color doesn't need high precision varying mediump vec2 vUv; // UVs need medium precision varying highp vec3 vPosition; // Positions may need high precision
6. Frustum Culling & Occlusion
import { useThree } from '@react-three/fiber'
const FrustumCulledObject = ({ position, children }) => {
const { camera } = useThree()
const meshRef = useRef()
const [visible, setVisible] = useState(true)
useFrame(() => {
if (!meshRef.current) return
// Check if object is in camera frustum
const frustum = new THREE.Frustum()
const matrix = new THREE.Matrix4().multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse
)
frustum.setFromProjectionMatrix(matrix)
const isVisible = frustum.intersectsObject(meshRef.current)
setVisible(isVisible)
})
return visible ? (
<group ref={meshRef} position={position}>
{children}
</group>
) : null
}
7. Conditional Rendering
const PerformanceAdaptiveScene = () => {
const [quality, setQuality] = useState<'high' | 'medium' | 'low'>('high')
const fpsRef = useRef(60)
useFrame((state, delta) => {
// Monitor FPS
const currentFps = 1 / delta
fpsRef.current = fpsRef.current * 0.9 + currentFps * 0.1 // Smooth average
// Adaptive quality
if (fpsRef.current < 30 && quality !== 'low') {
setQuality('low')
} else if (fpsRef.current < 50 && quality === 'high') {
setQuality('medium')
} else if (fpsRef.current > 55 && quality !== 'high') {
setQuality('high')
}
})
return (
<>
{quality === 'high' && <HighQualityEffects />}
{quality === 'medium' && <MediumQualityEffects />}
{quality === 'low' && <MinimalEffects />}
<Instances limit={quality === 'high' ? 1000 : quality === 'medium' ? 500 : 200}>
{/* Adaptive particle count */}
</Instances>
</>
)
}
8. Memory Management
// Dispose of geometries and materials when unmounting
const ManagedMesh = () => {
const meshRef = useRef<THREE.Mesh>()
useEffect(() => {
return () => {
// Clean up on unmount
if (meshRef.current) {
meshRef.current.geometry.dispose()
if (Array.isArray(meshRef.current.material)) {
meshRef.current.material.forEach(mat => mat.dispose())
} else {
meshRef.current.material.dispose()
}
}
}
}, [])
return <mesh ref={meshRef}>{/* ... */}</mesh>
}
// Dispose textures
const textureRef = useRef<THREE.Texture>()
useEffect(() => {
return () => {
textureRef.current?.dispose()
}
}, [])
Fallback Strategies
1. WebGL Detection & Fallback
const WebGLDetector = () => {
const [webglAvailable, setWebglAvailable] = useState(true)
useEffect(() => {
const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl')
setWebglAvailable(!!gl)
}, [])
if (!webglAvailable) {
return (
<div className="fallback-ui">
<h2>3D View Unavailable</h2>
<p>Your browser doesn't support WebGL. Here's a 2D alternative:</p>
<StaticImageFallback />
</div>
)
}
return <Canvas>{/* 3D Scene */}</Canvas>
}
2. Progressive Enhancement
const AdaptiveVisualization = () => {
const [mode, setMode] = useState<'3d' | '2d' | 'minimal'>('3d')
const performanceRef = useRef({ fps: 60, memory: 0 })
// Monitor performance and switch modes
useEffect(() => {
const checkPerformance = () => {
const { fps, memory } = performanceRef.current
if (fps < 20 || memory > 500) {
setMode('minimal')
} else if (fps < 40 || memory > 300) {
setMode('2d')
}
}
const interval = setInterval(checkPerformance, 5000)
return () => clearInterval(interval)
}, [])
return (
<>
{mode === '3d' && <Full3DScene />}
{mode === '2d' && <Simplified2DView />}
{mode === 'minimal' && <StaticFallback />}
</>
)
}
3. User-Controlled Quality
const QualitySettings = () => {
const [userQuality, setUserQuality] = useState<'auto' | 'high' | 'low'>('auto')
return (
<div className="quality-controls">
<select value={userQuality} onChange={e => setUserQuality(e.target.value)}>
<option value="auto">Auto</option>
<option value="high">High Quality</option>
<option value="low">Performance Mode</option>
</select>
<Canvas dpr={userQuality === 'low' ? [1, 1] : [1, 2]}>
<PerformanceOptimizedScene quality={userQuality} />
</Canvas>
</div>
)
}
Performance Targets
Desktop (60fps = 16.67ms/frame)
- •Draw Calls: < 500 per frame
- •Triangles: < 500k per frame
- •Texture Memory: < 500MB total
- •Shader Complexity: < 100 instructions per shader
- •Particles: < 10k active particles
Mobile (30fps = 33.33ms/frame)
- •Draw Calls: < 200 per frame
- •Triangles: < 100k per frame
- •Texture Memory: < 200MB total
- •Shader Complexity: < 50 instructions per shader
- •Particles: < 2k active particles
Canvas Setup for Performance
<Canvas
// Limit pixel ratio for performance
dpr={[1, 2]} // Max 2x pixel ratio
// Performance-focused GL settings
gl={{
antialias: false, // Disable AA for performance (use FXAA in post if needed)
alpha: false, // Disable transparency if not needed
stencil: false, // Disable stencil buffer if not needed
depth: true, // Keep depth buffer for proper rendering
powerPreference: 'high-performance', // Request discrete GPU
failIfMajorPerformanceCaveat: true, // Fail on software rendering
}}
// Frame loop control
frameloop="demand" // Only render when needed (for static scenes)
// frameloop="always" // Use for animated scenes
// Camera settings
camera={{
fov: 50, // Narrower FOV = less to render
near: 0.1,
far: 100, // Tighter far plane = less depth buffer precision needed
}}
>
{/* Your scene */}
</Canvas>
Quick Wins Checklist
- • Enable frustum culling (enabled by default in Three.js)
- • Use instancing for repeated objects
- • Reduce geometry segments (16 instead of 64 for spheres)
- • Compress textures and use appropriate sizes (1k-2k max for UI elements)
- • Set
dpr={[1, 2]}to limit pixel ratio - • Disable antialiasing in Canvas GL settings
- • Use
frameloop="demand"for static scenes - • Implement LOD for complex objects
- • Dispose geometries/materials on unmount
- • Add Stats.js to monitor FPS in development
- • Test on mid-range mobile devices
- • Provide fallback UI for WebGL unsupported browsers
Resources
See /references directory for:
- •
optimization-techniques.md- Advanced optimization methods - •
fallback-strategies.md- Progressive enhancement approaches - •
performance-monitoring.md- Profiling and debugging tools
Remember
"Premature optimization is the root of all evil, but so is premature de-optimization." - Profile first, optimize based on data, not assumptions. Always maintain 60fps on desktop and 30fps on mobile as your baseline targets.