
Three Best Practices
- 805 installs
- 42 repo stars
- Updated January 28, 2026
- emalorenzo/three-agent-skills
three-best-practices is an agent enforcement skill that applies Three.js performance and memory rules automatically for developers who build 3D web apps with agent-assisted coding.
About
three-best-practices is an agent skill that automatically enforces Three.js performance and memory discipline during AI-assisted 3D web development. It organizes rules into priority tiers: Priority 0 covers modern setup with import maps, renderer choice, animation loops, and scene templates; Priority 1 mandates geometry, material, texture, render-target, and renderer dispose on unmount; Priority 2 optimizes render loops with single requestAnimationFrame, conditional rendering, delta time, frustum culling, and pixel ratio caps. Developers reach for three-best-practices when WebGL apps leak GPU memory, stutter from multiple RAF loops, or ship without proper dispose patterns. The bundled rule set spans 21+ named checks visible across setup, memory, and render sections.
- 70 prioritized rules across 7 severity tiers from Priority 0 (Modern Setup) to Priority 6 (Asset Compression)
- Covers memory-dispose-recursive, render-single-raf, geometry-instanced-mesh, material-reuse and 60+ more patterns
- Hard-gate checklist that prevents common Three.js memory leaks and draw-call explosions before code is committed
- Agent skill that returns severity-bucketed findings with remediation steps
- Next-skill handoff to automated refactoring agents once violations are fixed
Three Best Practices by the numbers
- 805 all-time installs (skills.sh)
- +40 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #176 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/emalorenzo/three-agent-skills --skill three-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 805 |
|---|---|
| repo stars | ★ 42 |
| Security audit | 3 / 3 scanners passed |
| Last updated | January 28, 2026 |
| Repository | emalorenzo/three-agent-skills ↗ |
How do you prevent Three.js memory leaks in production?
Enforce Three.js performance and memory rules automatically during agent-assisted 3D web development.
Who is it for?
Frontend developers building Three.js or WebGL 3D web apps with AI agents who need automatic memory and render-loop guardrails.
Skip if: Developers using Babylon.js or CSS-only 3D who do not maintain Three.js renderer lifecycle code.
When should I use this skill?
User builds Three.js scenes and needs dispose patterns, render loop optimization, or import-map setup enforced by the agent.
What you get
Three.js scenes with proper dispose calls, optimized render loops, and compliant import-map setup.
- dispose-compliant Three.js scenes
- optimized render loops
By the numbers
- Bundles 21+ named Three.js rules across Priority 0 setup, Priority 1 memory, and Priority 2 render tiers
- Priority 1 alone defines 8 memory-dispose rules for geometry through renderer cleanup
Files
Three.js Best Practices
Comprehensive performance optimization guide for Three.js applications. Contains 120+ rules across 18 categories, prioritized by impact.
Sources & Credits
This skill compiles best practices from multiple authoritative sources:
- Official guidelines from Three.js llms branch maintained by mrdoob- 100 Three.js Tips by Utsubo - Excellent comprehensive guide covering WebGPU, asset optimization, and performance tips
When to Apply
Reference these guidelines when:
- Setting up a new Three.js project
- Writing or reviewing Three.js code
- Optimizing performance or fixing memory leaks
- Working with custom shaders (GLSL or TSL)
- Implementing WebGPU features
- Building VR/AR experiences with WebXR
- Integrating physics engines
- Optimizing for mobile devices
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 0 | Modern Setup & Imports | FUNDAMENTAL | setup- |
| 1 | Memory Management & Dispose | CRITICAL | memory- |
| 2 | Render Loop Optimization | CRITICAL | render- |
| 3 | Draw Call Optimization | CRITICAL | drawcall- |
| 4 | Geometry & Buffer Management | HIGH | geometry- |
| 5 | Material & Texture Optimization | HIGH | material- |
| 6 | Asset Compression | HIGH | asset- |
| 7 | Lighting & Shadows | MEDIUM-HIGH | lighting- |
| 8 | Scene Graph Organization | MEDIUM | scene- |
| 9 | Shader Best Practices (GLSL) | MEDIUM | shader- |
| 10 | TSL (Three.js Shading Language) | MEDIUM | tsl- |
| 11 | WebGPU Renderer | MEDIUM | webgpu- |
| 12 | Loading & Assets | MEDIUM | loading- |
| 13 | Core Web Vitals | MEDIUM-HIGH | vitals- |
| 14 | Camera & Controls | LOW-MEDIUM | camera- |
| 15 | Animation System | MEDIUM | animation- |
| 16 | Physics Integration | MEDIUM | physics- |
| 17 | WebXR / VR / AR | MEDIUM | webxr- |
| 18 | Audio | LOW-MEDIUM | audio- |
| 19 | Post-Processing | MEDIUM | postpro- |
| 20 | Mobile Optimization | HIGH | mobile- |
| 21 | Production | HIGH | error-, migration- |
| 22 | Debug & DevTools | LOW | debug- |
Quick Reference
0. Modern Setup (FUNDAMENTAL)
setup-use-import-maps- Use Import Maps, not old CDN scriptssetup-choose-renderer- WebGLRenderer (default) vs WebGPURenderer (TSL/compute)setup-animation-loop- Userenderer.setAnimationLoop()not manual RAFsetup-basic-scene-template- Complete modern scene template
1. Memory Management (CRITICAL)
memory-dispose-geometry- Always dispose geometriesmemory-dispose-material- Always dispose materials and texturesmemory-dispose-textures- Dispose dynamically created texturesmemory-dispose-render-targets- Always dispose WebGLRenderTargetmemory-dispose-recursive- Use recursive disposal for hierarchiesmemory-dispose-on-unmount- Dispose in React cleanup/unmountmemory-renderer-dispose- Dispose renderer when destroying viewmemory-reuse-objects- Reuse geometries and materials
2. Render Loop (CRITICAL)
render-single-raf- Single requestAnimationFrame looprender-conditional- Render on demand for static scenesrender-delta-time- Use delta time for animationsrender-avoid-allocations- Never allocate in render looprender-cache-computations- Cache expensive computationsrender-frustum-culling- Enable frustum cullingrender-update-matrix-manual- Disable auto matrix updates for static objectsrender-pixel-ratio- Limit pixel ratio to 2render-antialias-wisely- Use antialiasing judiciously
3. Draw Call Optimization (CRITICAL)
draw-call-optimization- Target under 100 draw calls per framegeometry-instanced-mesh- Use InstancedMesh for identical objectsgeometry-batched-mesh- Use BatchedMesh for varied geometries (same material)geometry-merge-static- Merge static geometries with BufferGeometryUtils
4. Geometry (HIGH)
geometry-buffer-geometry- Always use BufferGeometrygeometry-merge-static- Merge static geometriesgeometry-instanced-mesh- Use InstancedMesh for identical objectsgeometry-lod- Use Level of Detail for complex modelsgeometry-index-buffer- Use indexed geometrygeometry-vertex-count- Minimize vertex countgeometry-attributes-typed- Use appropriate typed arraysgeometry-interleaved- Consider interleaved buffers
5. Materials & Textures (HIGH)
material-reuse- Reuse materials across meshesmaterial-simplest-sufficient- Use simplest material that worksmaterial-texture-size-power-of-two- Power-of-two texture dimensionsmaterial-texture-compression- Use compressed textures (KTX2/Basis)material-texture-mipmaps- Enable mipmaps appropriatelymaterial-texture-anisotropy- Use anisotropic filtering for floorsmaterial-texture-atlas- Use texture atlasesmaterial-avoid-transparency- Minimize transparent materialsmaterial-onbeforecompile- Use onBeforeCompile for shader mods (or TSL)
6. Asset Compression (HIGH)
asset-compression- Draco, Meshopt, KTX2 compression guideasset-draco- 90-95% geometry size reductionasset-ktx2- GPU-compressed textures (UASTC vs ETC1S)asset-meshopt- Alternative to Draco with faster decompressionasset-lod- Level of Detail for 30-40% frame rate improvement
7. Lighting & Shadows (MEDIUM-HIGH)
lighting-limit-lights- Limit to 3 or fewer active lightslighting-shadows-advanced- PointLight cost, CSM, fake shadowslighting-bake-static- Bake lighting for static sceneslighting-shadow-camera-tight- Fit shadow camera tightlylighting-shadow-map-size- Choose appropriate shadow resolution (512-4096)lighting-shadow-selective- Enable shadows selectivelylighting-shadow-cascade- Use CSM for large sceneslighting-shadow-auto-update- Disable autoUpdate for static sceneslighting-probe- Use Light Probeslighting-environment- Environment maps for ambient lightlighting-fake-shadows- Gradient planes for budget contact shadows
8. Scene Graph (MEDIUM)
scene-group-objects- Use Groups for organizationscene-layers- Use Layers for selective renderingscene-visible-toggle- Use visible flag, not add/removescene-flatten-static- Flatten static hierarchiesscene-name-objects- Name objects for debuggingobject-pooling- Reuse objects instead of create/destroy
9. Shaders GLSL (MEDIUM)
shader-precision- Use mediump for mobile (~2x faster)shader-mobile- Mobile-specific optimizations (varyings, branching)shader-avoid-branching- Replace conditionals with mix/stepshader-precompute-cpu- Precompute on CPUshader-avoid-discard- Avoid discard, use alphaTestshader-texture-lod- Use textureLod for known mip levelsshader-uniform-arrays- Prefer uniform arraysshader-varying-interpolation- Limit varyings to 3 for mobileshader-pack-data- Pack data into RGBA channelsshader-chunk-injection- Use Three.js shader chunks
10. TSL - Three.js Shading Language (MEDIUM)
tsl-why-use- Use TSL instead of onBeforeCompiletsl-setup-webgpu- WebGPU setup for TSLtsl-complete-reference- Full TSL type system and functionstsl-material-slots- Material node properties referencetsl-node-materials- Use NodeMaterial classestsl-basic-operations- Types, operations, swizzlingtsl-functions- Creating TSL functions with Fn()tsl-conditionals- If, select, loops in TSLtsl-textures- Textures and triplanar mappingtsl-noise- Built-in noise functions (mx_noise_float, mx_fractal_noise)tsl-post-processing- bloom, blur, dof, aotsl-compute-shaders- GPGPU and compute operationstsl-glsl-to-tsl- GLSL to TSL translation
11. WebGPU Renderer (MEDIUM)
webgpu-renderer- Setup, browser support, migration guidewebgpu-render-async- Use renderAsync for compute-heavy sceneswebgpu-feature-detection- Check adapter featureswebgpu-instanced-array- GPU-persistent bufferswebgpu-storage-textures- Read-write compute textureswebgpu-workgroup-memory- Shared memory (10-100x faster)webgpu-indirect-draws- GPU-driven rendering
12. Loading & Assets (MEDIUM)
loading-draco-compression- Use Draco for large meshesloading-gltf-preferred- Use glTF formatgltf-loading-optimization- Full loader setup with DRACO/Meshopt/KTX2loading-progress-feedback- Show loading progressloading-async-await- Use async/await for loadingloading-lazy- Lazy load non-critical assetsloading-cache-assets- Enable cachingloading-dispose-unused- Unload unused assets
13. Core Web Vitals (MEDIUM-HIGH)
core-web-vitals- LCP, FID, CLS optimization for 3Dvitals-lazy-load- Lazy load 3D below the fold with IntersectionObservervitals-code-split- Dynamic import Three.js modulesvitals-preload- Preload critical assets with link tagsvitals-progressive-loading- Low-res to high-res progressive loadvitals-placeholders- Show placeholder geometry during loadvitals-web-workers- Offload heavy work to workersvitals-streaming- Stream large scenes by chunks
14. Camera & Controls (LOW-MEDIUM)
camera-near-far- Set tight near/far planescamera-fov- Choose appropriate FOVcamera-controls-damping- Use damping for smooth controlscamera-resize-handler- Handle resize properlycamera-orbit-limits- Set orbit control limits
15. Animation (MEDIUM)
animation-system- AnimationMixer, blending, morph targets, skeletal
16. Physics (MEDIUM)
physics-integration- Rapier, Cannon-es integration patternsphysics-compute-shaders- GPU physics with compute shaders
17. WebXR (MEDIUM)
webxr-setup- VR/AR buttons, controllers, hit testing
18. Audio (LOW-MEDIUM)
audio-spatial- PositionalAudio, HRTF, spatial sound
19. Post-Processing (MEDIUM)
postprocessing-optimization- pmndrs/postprocessing guidepostpro-renderer-config- Disable AA, stencil, depth for postpostpro-merge-effects- Combine effects in single passpostpro-selective-bloom- Selective bloom for performancepostpro-resolution-scaling- Half resolution for 2x FPSpostpro-webgpu-native- TSL-based post for WebGPU
20. Optimization (HIGH)
mobile-optimization- Mobile-specific optimizations and checklistraycasting-optimization- BVH, layers, GPU picking
21. Production (HIGH)
error-handling-recovery- WebGL context loss and recoverymigration-checklist- Breaking changes by version
22. Debug & DevTools (LOW)
debug-devtools- Complete debugging toolkitdebug-stats-gl- stats-gl for WebGL/WebGPU monitoringdebug-lil-gui- lil-gui for live parameter tweakingdebug-spector- Spector.js for WebGL frame capturedebug-renderer-info- Monitor draw calls and memorydebug-three-mesh-bvh- Fast raycasting with BVHdebug-context-lost- Handle WebGL context lossdebug-animation-loop-profiling- Profile render loop sectionsdebug-conditional- Remove debug code in production
How to Use
Read individual rule files for detailed explanations and code examples:
rules/setup-use-import-maps.md
rules/memory-dispose-geometry.md
rules/tsl-complete-reference.md
rules/mobile-optimization.mdEach rule file contains:
- Brief explanation of why it matters
- BAD code example with explanation
- GOOD code example with explanation
- Additional context and references
Key Patterns
Modern Import Maps
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.182.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/",
"three/tsl": "https://cdn.jsdelivr.net/npm/three@0.182.0/build/three.tsl.js"
}
}
</script>Proper Disposal
function disposeObject(obj) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(m => m.dispose());
} else {
obj.material.dispose();
}
}
}TSL Basic Usage
import { texture, uv, color, time, sin } from 'three/tsl';
const material = new THREE.MeshStandardNodeMaterial();
material.colorNode = texture(map).mul(color(0xff0000));
material.colorNode = color(0x00ff00).mul(sin(time).mul(0.5).add(0.5));Mobile Detection
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.5 : 2));Rule Sections
Priority 0: Modern Setup & Imports (FUNDAMENTAL)
- setup-use-import-maps
- setup-choose-renderer
- setup-animation-loop
- setup-basic-scene-template
Priority 1: Memory Management & Dispose (CRITICAL)
- memory-dispose-geometry
- memory-dispose-material
- memory-dispose-textures
- memory-dispose-render-targets
- memory-dispose-recursive
- memory-dispose-on-unmount
- memory-renderer-dispose
- memory-reuse-objects
Priority 2: Render Loop Optimization (CRITICAL)
- render-single-raf
- render-conditional
- render-delta-time
- render-avoid-allocations
- render-cache-computations
- render-frustum-culling
- render-update-matrix-manual
- render-pixel-ratio
- render-antialias-wisely
Priority 3: Draw Call Optimization (CRITICAL)
- draw-call-optimization
- geometry-instanced-mesh
- geometry-batched-mesh
- geometry-merge-static
Priority 4: Geometry & Buffer Management (HIGH)
- geometry-buffer-geometry
- geometry-merge-static
- geometry-instanced-mesh
- geometry-lod
- geometry-index-buffer
- geometry-vertex-count
- geometry-attributes-typed
- geometry-interleaved
Priority 5: Material & Texture Optimization (HIGH)
- material-reuse
- material-simplest-sufficient
- material-texture-size-power-of-two
- material-texture-compression
- material-texture-mipmaps
- material-texture-anisotropy
- material-texture-atlas
- material-avoid-transparency
- material-onbeforecompile
Priority 6: Asset Compression (HIGH)
- asset-compression
- asset-draco
- asset-ktx2
- asset-meshopt
- asset-lod
Priority 7: Lighting & Shadows (MEDIUM-HIGH)
- lighting-limit-lights
- lighting-shadows-advanced
- lighting-bake-static
- lighting-shadow-camera-tight
- lighting-shadow-map-size
- lighting-shadow-selective
- lighting-shadow-cascade
- lighting-shadow-auto-update
- lighting-probe
- lighting-environment
- lighting-fake-shadows
Priority 8: Scene Graph Organization (MEDIUM)
- scene-group-objects
- scene-layers
- scene-visible-toggle
- scene-flatten-static
- scene-name-objects
- object-pooling
Priority 9: Shader Best Practices GLSL (MEDIUM)
- shader-precision
- shader-mobile
- shader-avoid-branching
- shader-precompute-cpu
- shader-avoid-discard
- shader-texture-lod
- shader-uniform-arrays
- shader-varying-interpolation
- shader-pack-data
- shader-chunk-injection
Priority 10: TSL - Three.js Shading Language (MEDIUM)
- tsl-why-use
- tsl-setup-webgpu
- tsl-complete-reference
- tsl-material-slots
- tsl-node-materials
- tsl-basic-operations
- tsl-material-nodes
- tsl-functions
- tsl-conditionals
- tsl-textures
- tsl-noise
- tsl-post-processing
- tsl-compute-shaders
- tsl-glsl-to-tsl
Priority 11: WebGPU Renderer (MEDIUM)
- webgpu-renderer
- webgpu-render-async
- webgpu-feature-detection
- webgpu-instanced-array
- webgpu-storage-textures
- webgpu-workgroup-memory
- webgpu-indirect-draws
Priority 12: Loading & Assets (MEDIUM)
- loading-draco-compression
- loading-gltf-preferred
- gltf-loading-optimization
- loading-progress-feedback
- loading-async-await
- loading-lazy
- loading-cache-assets
- loading-dispose-unused
Priority 13: Core Web Vitals (MEDIUM-HIGH)
- core-web-vitals
- vitals-lazy-load
- vitals-code-split
- vitals-preload
- vitals-progressive-loading
- vitals-placeholders
- vitals-web-workers
- vitals-streaming
Priority 14: Camera & Controls (LOW-MEDIUM)
- camera-near-far
- camera-fov
- camera-controls-damping
- camera-resize-handler
- camera-orbit-limits
Priority 15: Animation System (MEDIUM)
- animation-system
Priority 16: Physics Integration (MEDIUM)
- physics-integration
- physics-compute-shaders
Priority 17: WebXR / VR / AR (MEDIUM)
- webxr-setup
Priority 18: Audio (LOW-MEDIUM)
- audio-spatial
Priority 19: Post-Processing (MEDIUM)
- postprocessing-optimization
- postpro-renderer-config
- postpro-merge-effects
- postpro-selective-bloom
- postpro-resolution-scaling
- postpro-webgpu-native
Priority 20: Mobile Optimization (HIGH)
- mobile-optimization
- raycasting-optimization
Priority 21: Production (HIGH)
- error-handling-recovery
- migration-checklist
Priority 22: Debug & DevTools (LOW)
- debug-devtools
- debug-stats-gl
- debug-lil-gui
- debug-spector
- debug-renderer-info
- debug-three-mesh-bvh
- debug-context-lost
- debug-animation-loop-profiling
- debug-conditional
Animation System
Complete guide to Three.js animation system including AnimationMixer, blending, morph targets, and skeletal animation.
System Components
AnimationClip --- Contains KeyframeTracks
|
+-- KeyframeTrack --- Data for one animated property
| +-- times[] --- Keyframe times
| +-- values[] --- Values at each keyframe
|
AnimationMixer --- Connects clips with objects
|
+-- AnimationAction --- Controls playback
+-- play()
+-- pause()
+-- stop()
+-- reset()
+-- setLoop()
+-- setEffectiveWeight()
+-- crossFadeTo()Basic Usage
const mixer = new THREE.AnimationMixer(model);
// Play animation
const clip = THREE.AnimationClip.findByName(model.animations, 'walk');
const action = mixer.clipAction(clip);
action.play();
// In render loop
function animate() {
const delta = clock.getDelta();
mixer.update(delta);
}Animation Blending
const walkAction = mixer.clipAction(walkClip);
const runAction = mixer.clipAction(runClip);
// Start both
walkAction.play();
runAction.play();
// Crossfade from walk to run
walkAction.crossFadeTo(runAction, 0.5, true);
// Or manual weight control
walkAction.setEffectiveWeight(0.7);
runAction.setEffectiveWeight(0.3);Morph Targets
// Access morph targets
const mesh = model.getObjectByName('Face');
const morphTargetDictionary = mesh.morphTargetDictionary;
const morphTargetInfluences = mesh.morphTargetInfluences;
// Animate manually
morphTargetInfluences[morphTargetDictionary['smile']] = 0.5;
// Or use AnimationClip
const smileTrack = new THREE.NumberKeyframeTrack(
'Face.morphTargetInfluences[smile]',
[0, 1, 2], // times
[0, 1, 0] // values
);
const clip = new THREE.AnimationClip('SmileAnim', 2, [smileTrack]);Skeletal Animation
gltfLoader.load('character.glb', (gltf) => {
const model = gltf.scene;
// Find SkinnedMesh
model.traverse((child) => {
if (child.isSkinnedMesh) {
// Access bones
const skeleton = child.skeleton;
const bones = skeleton.bones;
// Manipulate bone directly
const head = bones.find(b => b.name === 'Head');
head.rotation.y = Math.PI / 4;
}
});
});Loop Modes
action.setLoop(THREE.LoopOnce); // Play once
action.setLoop(THREE.LoopRepeat); // Repeat forever (default)
action.setLoop(THREE.LoopPingPong); // Alternate direction
action.setLoop(THREE.LoopRepeat, 3); // Repeat 3 times
// Clamp at end
action.clampWhenFinished = true;Events
mixer.addEventListener('finished', (e) => {
console.log('Animation finished:', e.action.getClip().name);
});
mixer.addEventListener('loop', (e) => {
console.log('Loop:', e.action.getClip().name);
});Time Scale & Duration
// Speed control
action.setEffectiveTimeScale(2); // Double speed
action.setEffectiveTimeScale(0.5); // Half speed
action.setEffectiveTimeScale(-1); // Reverse
// Duration
const duration = clip.duration;
// Set to specific time
action.time = 1.5;
// Set to percentage
action.time = duration * 0.5; // 50%Creating Custom Animations
// Position animation
const positionTrack = new THREE.VectorKeyframeTrack(
'.position',
[0, 1, 2], // times
[0, 0, 0, 0, 5, 0, 0, 0, 0] // positions (x,y,z for each keyframe)
);
// Rotation animation (quaternion)
const rotationTrack = new THREE.QuaternionKeyframeTrack(
'.quaternion',
[0, 1],
[0, 0, 0, 1, 0, 0.707, 0, 0.707] // quaternions
);
// Color animation
const colorTrack = new THREE.ColorKeyframeTrack(
'.material.color',
[0, 1],
[1, 0, 0, 0, 0, 1] // red to blue
);
const clip = new THREE.AnimationClip('custom', 2, [
positionTrack,
rotationTrack,
colorTrack
]);Best Practices
1. Single Mixer: Use one AnimationMixer per animated object hierarchy
2. Delta Time: Always pass delta time to mixer.update()
3. Weight Management: Reset weights when switching animations
4. Cleanup: Stop actions and remove mixers when disposing objects
5. Preload: Create all AnimationActions on init, not during runtime
6. Performance: Use animation.optimize() for production
// Optimize animation data
clip.optimize();
// Cleanup
action.stop();
mixer.stopAllAction();
mixer.uncacheRoot(model);References
Asset Compression
Source: 100 Three.js Tips - Utsubo
Proper asset compression is critical for web performance. Unoptimized assets are the #1 cause of slow 3D web experiences.
Geometry Compression
Draco Compression
Achieves 90-95% size reduction with Web Worker decompression.
gltf-transform draco model.glb compressed.glb --method edgebreakerMeshopt (Alternative to Draco)
Similar compression ratios with faster decompression. Consider Meshopt when decompression speed is critical.
Setup Decoder Paths
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/');Texture Compression
Why KTX2?
PNG/JPEG decompress fully in GPU memory:
- 200KB PNG = 20MB+ VRAM
KTX2 stays compressed in GPU memory, dramatically reducing VRAM usage.
UASTC vs ETC1S
| Format | Quality | Size | Use Case |
|---|---|---|---|
| UASTC | Higher | Larger | Normal maps, hero textures |
| ETC1S | Lower | Smaller | Environment textures, backgrounds |
KTX2 Setup
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
const ktx2Loader = new KTX2Loader();
ktx2Loader.setTranscoderPath('/basis/');CLI Optimization
gltf-transform (Recommended)
gltf-transform optimize model.glb output.glb \
--texture-compress ktx2 \
--compress dracoVisual Comparison Tool
Use Shopify's gltf-compressor for interactive side-by-side compression preview (keyboard shortcut "C").
Texture Atlasing
Combine multiple textures into atlases to reduce texture binds:
// Update UV coordinates to reference atlas regions
mesh.geometry.attributes.uv.array = atlasUVs;
mesh.geometry.attributes.uv.needsUpdate = true;Level of Detail (LOD)
Can improve frame rates by 30-40%.
const lod = new THREE.LOD();
lod.addLevel(highPolyMesh, 0);
lod.addLevel(mediumPolyMesh, 50);
lod.addLevel(lowPolyMesh, 100);
scene.add(lod);Summary Checklist
- [ ] Compress geometry with Draco or Meshopt
- [ ] Convert textures to KTX2
- [ ] Use UASTC for quality-critical textures
- [ ] Use ETC1S for secondary textures
- [ ] Atlas textures where possible
- [ ] Implement LOD for complex models
- [ ] Set up decoder paths correctly
Spatial Audio
Three.js audio system for 3D positional sound.
Components
import * as THREE from 'three';
// AudioListener - represents user position (attach to camera)
const listener = new THREE.AudioListener();
camera.add(listener);
// Audio - non-positional (background music)
const sound = new THREE.Audio(listener);
// PositionalAudio - 3D spatial sound
const positionalSound = new THREE.PositionalAudio(listener);
mesh.add(positionalSound);Loading Audio
const audioLoader = new THREE.AudioLoader();
audioLoader.load('sound.mp3', (buffer) => {
sound.setBuffer(buffer);
sound.setLoop(true);
sound.setVolume(0.5);
sound.play();
});PositionalAudio Configuration
const positionalAudio = new THREE.PositionalAudio(listener);
// Reference distance (max volume distance)
positionalAudio.setRefDistance(1);
// Maximum distance
positionalAudio.setMaxDistance(100);
// Rolloff model: 'linear', 'inverse', 'exponential'
positionalAudio.setRolloffFactor(1);
positionalAudio.setDistanceModel('inverse');
// Panning model: 'HRTF' (realistic) or 'equalpower'
positionalAudio.panner.panningModel = 'HRTF';
// Directionality (cone)
positionalAudio.setDirectionalCone(180, 230, 0.1);
// innerAngle, outerAngle, outerGainDistance Models
| Model | Behavior |
|---|---|
linear | Linear decrease from ref to max distance |
inverse | Inverse relationship (realistic) |
exponential | Exponential falloff |
Audio Controls
// Play/pause
sound.play();
sound.pause();
sound.stop();
// Volume
sound.setVolume(0.5); // 0.0 to 1.0
// Playback rate
sound.setPlaybackRate(1.5);
// Loop
sound.setLoop(true);
// Check state
if (sound.isPlaying) { }
// Duration
const duration = sound.buffer.duration;
// Current time
sound.offset; // readAudio Analyzer
const analyser = new THREE.AudioAnalyser(sound, 32);
function animate() {
const data = analyser.getAverageFrequency();
// Use data for visualizations
mesh.scale.setScalar(1 + data / 256);
}Multiple Audio Sources
// Background music
const bgMusic = new THREE.Audio(listener);
audioLoader.load('music.mp3', (buffer) => {
bgMusic.setBuffer(buffer);
bgMusic.setLoop(true);
bgMusic.setVolume(0.3);
});
// Spatial sounds on objects
objects.forEach((obj, i) => {
const sound = new THREE.PositionalAudio(listener);
audioLoader.load(`sound${i}.mp3`, (buffer) => {
sound.setBuffer(buffer);
sound.setRefDistance(5);
sound.setLoop(true);
});
obj.add(sound);
});User Gesture Requirement
Audio requires user interaction to start (browser policy):
const startButton = document.getElementById('start');
startButton.addEventListener('click', () => {
// Resume AudioContext
if (listener.context.state === 'suspended') {
listener.context.resume();
}
sound.play();
startButton.style.display = 'none';
});Best Practices
1. HRTF: Use panningModel: 'HRTF' for realistic 3D audio (best with headphones)
2. User Gesture: Always require user interaction before playing audio
3. Dispose: Call audio.disconnect() when removing sounds
sound.stop();
sound.disconnect();4. Mobile: Audio consumes significant battery on mobile devices
5. Buffer Reuse: Reuse audio buffers for repeated sounds
const sharedBuffer = await loadBuffer('shoot.mp3');
function playShot(position) {
const sound = new THREE.PositionalAudio(listener);
sound.setBuffer(sharedBuffer);
sound.position.copy(position);
scene.add(sound);
sound.play();
sound.onEnded = () => {
scene.remove(sound);
sound.disconnect();
};
}6. Volume Levels: Keep sound effects around 0.3-0.7, music around 0.2-0.4
7. Ref Distance: Set refDistance to the size of the emitting object
Audio Formats
| Format | Browser Support | Notes |
|---|---|---|
| MP3 | All | Good compression, universal |
| OGG | Chrome, Firefox | Better quality at same size |
| WAV | All | Uncompressed, large files |
| AAC | All | Good for music |
References
Core Web Vitals & Loading
Source: 100 Three.js Tips - Utsubo
Optimize loading performance to improve LCP, FID, and CLS scores.
Lazy Load 3D Content Below the Fold
Don't block page load with 3D content that's not visible.
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadThreeJsScene();
observer.disconnect();
}
});
observer.observe(canvasContainer);Code-Split Three.js Modules
Dynamic imports reduce initial bundle size.
// Load Three.js only when needed
const Three = await import('three');
const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js');Preload Critical Assets
Use <link rel="preload"> for critical resources:
<link rel="preload" href="/model.glb" as="fetch" crossorigin>
<link rel="preload" href="/texture.ktx2" as="fetch" crossorigin>Progressive Loading
Show low-res content immediately, upgrade when ready:
// Show low-res immediately
const lowRes = await loadModel('low.glb');
scene.add(lowRes);
// Load and swap high-res in background
loadModel('high.glb').then(highRes => {
scene.remove(lowRes);
lowRes.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
scene.add(highRes);
});Placeholder Geometry
Show loading state with placeholder geometry:
const placeholder = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshBasicMaterial({ color: 0x808080, wireframe: true })
);
scene.add(placeholder);
loadModel().then(model => {
scene.remove(placeholder);
placeholder.geometry.dispose();
placeholder.material.dispose();
scene.add(model);
});Offload to Web Workers
Move CPU-intensive work off main thread:
// main.js
const worker = new Worker('/physics-worker.js');
worker.postMessage({ positions, velocities });
worker.onmessage = (event) => {
updatePositions(event.data.positions);
};
// physics-worker.js
self.onmessage = (event) => {
const result = computePhysics(event.data);
self.postMessage(result);
};Stream Large Scenes
Load chunks based on camera position:
function updateVisibleChunks(cameraPosition) {
const visibleChunks = getChunksNear(cameraPosition);
visibleChunks.forEach(chunk => {
if (!chunk.loaded) {
loadChunk(chunk);
}
});
// Unload distant chunks
loadedChunks.forEach(chunk => {
if (!visibleChunks.includes(chunk)) {
unloadChunk(chunk);
}
});
}React Three Fiber: Suspense
import { Suspense } from 'react';
function App() {
return (
<Canvas>
<Suspense fallback={<Loader />}>
<Model />
</Suspense>
</Canvas>
);
}Performance Budget
| Asset Type | Target |
|---|---|
| Initial JS bundle | < 150KB gzipped |
| Hero model | < 500KB compressed |
| Textures | < 1MB total (KTX2) |
| Time to interactive | < 3 seconds |
Checklist
- [ ] Lazy load 3D content below the fold
- [ ] Code-split Three.js modules
- [ ] Preload critical assets
- [ ] Implement progressive loading
- [ ] Use placeholder geometry during load
- [ ] Offload physics to Web Workers
- [ ] Stream large scenes by chunks
- [ ] Use Suspense in React
Debug & DevTools
Source: 100 Three.js Tips - Utsubo
Comprehensive debugging toolkit for Three.js applications.
stats-gl (WebGL/WebGPU)
Modern performance monitor that works with both renderers:
import Stats from 'stats-gl';
const stats = new Stats();
document.body.appendChild(stats.dom);
function animate() {
stats.begin();
// ... render
stats.end();
requestAnimationFrame(animate);
}lil-gui for Live Tweaking
import GUI from 'lil-gui';
const gui = new GUI();
gui.add(camera.position, 'x', -10, 10);
gui.add(camera.position, 'y', -10, 10);
gui.add(camera.position, 'z', -10, 10);
gui.add(light, 'intensity', 0, 2);
gui.addColor(material, 'color');
// Folders for organization
const folder = gui.addFolder('Material');
folder.add(material, 'metalness', 0, 1);
folder.add(material, 'roughness', 0, 1);renderer.info
Monitor GPU memory and draw calls:
setInterval(() => {
console.log('Calls:', renderer.info.render.calls);
console.log('Triangles:', renderer.info.render.triangles);
console.log('Geometries:', renderer.info.memory.geometries);
console.log('Textures:', renderer.info.memory.textures);
}, 1000);Spector.js for WebGL Profiling
Browser extension that captures WebGL frames with draw call visualization.
1. Install Spector.js extension 2. Click extension icon on any WebGL page 3. Click red record button 4. Inspect individual draw calls, shaders, state
three-mesh-bvh for Fast Raycasting
import { MeshBVH, acceleratedRaycast } from 'three-mesh-bvh';
// Build BVH for mesh
mesh.geometry.boundsTree = new MeshBVH(mesh.geometry);
// Replace default raycast with accelerated version
mesh.raycast = acceleratedRaycast;
// Now raycasting is much faster
raycaster.intersectObject(mesh);GPU Timing Queries (WebGPU)
const adapter = await navigator.gpu.requestAdapter();
const hasTimestamps = adapter.features.has('timestamp-query');
if (hasTimestamps) {
const device = await adapter.requestDevice({
requiredFeatures: ['timestamp-query']
});
// Use timestamp queries for GPU profiling
}Profile Animation Loop
function animate() {
const t0 = performance.now();
physics.update();
const t1 = performance.now();
controls.update();
const t2 = performance.now();
renderer.render(scene, camera);
const t3 = performance.now();
console.log(
`Physics: ${(t1-t0).toFixed(2)}ms`,
`Controls: ${(t2-t1).toFixed(2)}ms`,
`Render: ${(t3-t2).toFixed(2)}ms`
);
requestAnimationFrame(animate);
}Context Lost Handling
renderer.domElement.addEventListener('webglcontextlost', (event) => {
event.preventDefault();
console.warn('WebGL context lost');
// Stop animation loop
cancelAnimationFrame(animationId);
});
renderer.domElement.addEventListener('webglcontextrestored', () => {
console.log('WebGL context restored');
// Reinitialize and restart
init();
animate();
});Chrome WebGPU DevTools
Enable "WebGPU Developer Features" in chrome://flags for:
- Shader compilation error tracking
- Resource inspection
- Performance profiling
Browser DevTools Performance Tab
Profile real sessions to identify:
- Frame timing issues
- Garbage collection pauses
- JavaScript bottlenecks
- Layout thrashing
r3f-perf (React Three Fiber)
import { Perf } from 'r3f-perf';
<Canvas>
<Perf position="top-left" />
<Scene />
</Canvas>Debug Helpers
// Axes helper
scene.add(new THREE.AxesHelper(5));
// Grid helper
scene.add(new THREE.GridHelper(10, 10));
// Box helper for bounds
const box = new THREE.BoxHelper(mesh, 0xffff00);
scene.add(box);
// Skeleton helper
const skeleton = new THREE.SkeletonHelper(skinnedMesh);
scene.add(skeleton);
// Light helpers
const lightHelper = new THREE.DirectionalLightHelper(light, 5);
scene.add(lightHelper);
const shadowHelper = new THREE.CameraHelper(light.shadow.camera);
scene.add(shadowHelper);Clean Render Loop
Use setAnimationLoop for cleaner code:
renderer.setAnimationLoop(() => {
controls.update();
renderer.render(scene, camera);
});
// Stop when needed
renderer.setAnimationLoop(null);Debug Checklist
- [ ] Add stats-gl for FPS monitoring
- [ ] Use lil-gui for parameter tweaking
- [ ] Check renderer.info for draw calls
- [ ] Profile with Spector.js
- [ ] Use three-mesh-bvh for raycasting
- [ ] Handle context loss gracefully
- [ ] Use r3f-perf for React apps
- [ ] Remove debug code in production
Draw Call Optimization
Source: 100 Three.js Tips - Utsubo
Draw calls are the primary performance bottleneck in most Three.js applications.
Target: Under 100 Draw Calls Per Frame
Most devices maintain 60fps below 100 draw calls. Check progress via:
console.log('Draw calls:', renderer.info.render.calls);
console.log('Triangles:', renderer.info.render.triangles);Optimization Techniques
1. InstancedMesh (Identical Objects)
Reduces N draw calls to 1 for identical geometry.
const mesh = new THREE.InstancedMesh(geometry, material, 1000);
const matrix = new THREE.Matrix4();
for (let i = 0; i < 1000; i++) {
matrix.setPosition(positions[i]);
mesh.setMatrixAt(i, matrix);
}
mesh.instanceMatrix.needsUpdate = true;2. BatchedMesh (Varied Geometries)
Combines multiple geometries sharing materials into single draw call. Allows per-instance geometry variation.
const batchedMesh = new THREE.BatchedMesh(
maxGeometryCount,
maxVertexCount,
maxIndexCount,
material
);
const geoId1 = batchedMesh.addGeometry(geometry1);
const geoId2 = batchedMesh.addGeometry(geometry2);
batchedMesh.addInstance(geoId1);
batchedMesh.addInstance(geoId2);3. Merge Static Geometry
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
const merged = mergeGeometries([geo1, geo2, geo3]);
const mesh = new THREE.Mesh(merged, sharedMaterial);4. Share Materials
// BAD: Separate materials per mesh
meshes.forEach(m => {
m.material = new MeshStandardMaterial({ color: 'red' });
});
// GOOD: Shared material
const sharedMaterial = new MeshStandardMaterial({ color: 'red' });
meshes.forEach(m => {
m.material = sharedMaterial;
});5. Array Textures (Modern Browsers)
Combine multiple textures into layers, accessed by index in shaders:
const textureArray = new THREE.DataArrayTexture(data, width, height, depth);6. Frustum Culling
Enabled by default. Understand how it works:
const frustum = new THREE.Frustum();
const matrix = new THREE.Matrix4().multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse
);
frustum.setFromProjectionMatrix(matrix);
if (frustum.intersectsObject(mesh)) {
// Object is visible
}Decision Tree
Need to render many objects?
├── All identical geometry?
│ └── Use InstancedMesh
├── Different geometries, same material?
│ └── Use BatchedMesh
├── Static objects?
│ └── Merge with BufferGeometryUtils
└── Dynamic objects?
└── Consider object pooling + visibility togglingMonitoring
setInterval(() => {
const info = renderer.info.render;
console.log(`Calls: ${info.calls}, Tris: ${info.triangles}`);
}, 1000);Error Handling & Context Recovery
Handling WebGL errors and context loss gracefully.
WebGL Context Lost
const canvas = renderer.domElement;
canvas.addEventListener('webglcontextlost', (event) => {
event.preventDefault(); // Indicates we'll handle recovery
console.warn('WebGL context lost');
// Stop render loop
cancelAnimationFrame(animationId);
// Show error UI
showErrorOverlay('Graphics context lost. Recovering...');
});
canvas.addEventListener('webglcontextrestored', () => {
console.log('WebGL context restored');
// Recreate resources
initScene();
initMaterials();
initTextures();
// Restart render loop
animate();
hideErrorOverlay();
});Common Causes of Context Loss
1. Memory Leaks: Not disposing resources 2. GPU Overload: Too many draw calls 3. Browser Tab Switch: Mobile browsers free memory 4. Driver Issues: Especially on Windows 5. GPU Crash: Hardware/driver failure
Simulating Context Loss (Testing)
const ext = renderer.getContext().getExtension('WEBGL_lose_context');
// Lose context
ext.loseContext();
// Restore after delay
setTimeout(() => ext.restoreContext(), 1000);Resource Recreation Pattern
class SceneManager {
constructor() {
this.resources = [];
this.setupContextHandlers();
}
setupContextHandlers() {
const canvas = this.renderer.domElement;
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault();
this.onContextLost();
});
canvas.addEventListener('webglcontextrestored', () => {
this.onContextRestored();
});
}
registerResource(resource) {
this.resources.push(resource);
}
onContextLost() {
// Mark all resources as needing recreation
this.resources.forEach(r => r.needsRecreation = true);
}
onContextRestored() {
// Recreate all resources
this.resources.forEach(r => {
if (r.needsRecreation) {
r.recreate();
r.needsRecreation = false;
}
});
}
}React Error Boundary
class ThreeErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
console.error('Three.js error:', error);
// Report to error tracking service
reportError(error, info);
}
retry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<p>3D scene failed to load.</p>
<button onClick={this.retry}>Retry</button>
</div>
);
}
return this.props.children;
}
}
// Usage
<ThreeErrorBoundary>
<Canvas>
<Scene />
</Canvas>
</ThreeErrorBoundary>Production Logging
function logRendererInfo() {
const gl = renderer.getContext();
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
const info = {
vendor: gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL),
renderer: gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL),
maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE),
maxViewportDims: gl.getParameter(gl.MAX_VIEWPORT_DIMS),
memory: renderer.info.memory,
render: renderer.info.render
};
console.log('Renderer Info:', info);
return info;
}
// Log on errors
window.addEventListener('error', (e) => {
if (e.message.includes('WebGL') || e.message.includes('THREE')) {
logRendererInfo();
}
});Memory Monitoring
function checkMemory() {
const { geometries, textures } = renderer.info.memory;
console.log(`Geometries: ${geometries}, Textures: ${textures}`);
// Warning thresholds
if (textures > 100) {
console.warn('High texture count - check for leaks');
}
if (geometries > 500) {
console.warn('High geometry count - check for leaks');
}
}
// Check periodically
setInterval(checkMemory, 30000);Graceful Degradation
function initRenderer() {
// Try WebGPU first
if (navigator.gpu) {
try {
return new THREE.WebGPURenderer({ antialias: true });
} catch (e) {
console.warn('WebGPU failed, falling back to WebGL');
}
}
// Try WebGL2
try {
const canvas = document.createElement('canvas');
if (canvas.getContext('webgl2')) {
return new THREE.WebGLRenderer({ antialias: true });
}
} catch (e) {
console.warn('WebGL2 failed, trying WebGL1');
}
// WebGL1 fallback
try {
return new THREE.WebGLRenderer({
antialias: false,
precision: 'mediump'
});
} catch (e) {
// Show static image fallback
showStaticFallback();
return null;
}
}Recovery Strategies
| Strategy | When to Use |
|---|---|
| Prevent | Dispose aggressively, monitor memory |
| Detect | Listen for context lost events |
| Recover | Recreate resources from saved state |
| Fallback | Show static image or message |
Best Practices
1. Always Handle Context Loss: Don't assume WebGL is always available
2. Save State: Keep track of scene state for recreation
3. Dispose Properly: Prevent context loss by managing memory
4. User Feedback: Show clear messages during recovery
5. Logging: Capture device info for debugging
6. Fallbacks: Have static fallback for critical content
References
geometry-instanced-mesh
Use InstancedMesh for many identical objects.
Why It Matters
Each mesh = 1 draw call. 10,000 meshes = 10,000 draw calls = terrible performance. InstancedMesh renders multiple copies in a single draw call while allowing individual transforms and colors.
Bad Example
// BAD - 10000 draw calls
for (let i = 0; i < 10000; i++) {
const mesh = new THREE.Mesh(geometry, material);
mesh.position.random().multiplyScalar(100);
scene.add(mesh);
}This creates 10,000 separate objects, each requiring its own draw call.
Good Example
// GOOD - Single draw call for 10000 instances
const instancedMesh = new THREE.InstancedMesh(geometry, material, 10000);
const dummy = new THREE.Object3D();
const color = new THREE.Color();
for (let i = 0; i < 10000; i++) {
// Set position
dummy.position.random().multiplyScalar(100);
dummy.rotation.random();
dummy.scale.setScalar(0.5 + Math.random() * 0.5);
dummy.updateMatrix();
instancedMesh.setMatrixAt(i, dummy.matrix);
// Set color (optional)
color.setHSL(Math.random(), 0.8, 0.5);
instancedMesh.setColorAt(i, color);
}
instancedMesh.instanceMatrix.needsUpdate = true;
if (instancedMesh.instanceColor) {
instancedMesh.instanceColor.needsUpdate = true;
}
scene.add(instancedMesh);Updating Instances
// Update a single instance
function updateInstance(index, position, rotation, scale) {
dummy.position.copy(position);
dummy.rotation.copy(rotation);
dummy.scale.copy(scale);
dummy.updateMatrix();
instancedMesh.setMatrixAt(index, dummy.matrix);
instancedMesh.instanceMatrix.needsUpdate = true;
}
// Update in animation loop
function animate() {
for (let i = 0; i < 100; i++) {
instancedMesh.getMatrixAt(i, dummy.matrix);
dummy.matrix.decompose(dummy.position, dummy.quaternion, dummy.scale);
dummy.rotation.y += 0.01;
dummy.updateMatrix();
instancedMesh.setMatrixAt(i, dummy.matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true;
renderer.render(scene, camera);
}Raycasting
const raycaster = new THREE.Raycaster();
function onMouseClick(event) {
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(instancedMesh);
if (intersects.length > 0) {
const instanceId = intersects[0].instanceId;
console.log('Clicked instance:', instanceId);
// Change color of clicked instance
instancedMesh.setColorAt(instanceId, new THREE.Color(0xff0000));
instancedMesh.instanceColor.needsUpdate = true;
}
}Performance Comparison
| Method | Objects | Draw Calls | Performance |
|---|---|---|---|
| Individual Meshes | 10,000 | 10,000 | ~5 FPS |
| InstancedMesh | 10,000 | 1 | ~60 FPS |
When NOT to Use
- Different geometries needed
- Different materials needed
- Complex per-object animations
- < 100 objects (overhead not worth it)
References
GLTF Loading & Optimization
Complete setup for loading and optimizing 3D models.
Full Loader Setup
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
// DRACO decoder
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/');
// KTX2 transcoder
const ktx2Loader = new KTX2Loader();
ktx2Loader.setTranscoderPath('/basis/');
ktx2Loader.detectSupport(renderer);
// GLTF loader with all decoders
const gltfLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);
gltfLoader.setMeshoptDecoder(MeshoptDecoder);
gltfLoader.setKTX2Loader(ktx2Loader);
// Load model
gltfLoader.load('model.glb', (gltf) => {
scene.add(gltf.scene);
// Setup animations
const mixer = new THREE.AnimationMixer(gltf.scene);
gltf.animations.forEach((clip) => {
mixer.clipAction(clip).play();
});
}, onProgress, onError);Progress Tracking
function onProgress(xhr) {
if (xhr.lengthComputable) {
const percent = (xhr.loaded / xhr.total) * 100;
console.log(`Loading: ${percent.toFixed(0)}%`);
}
}
function onError(error) {
console.error('Error loading model:', error);
}glTF-Transform CLI
Installation
npm install -g @gltf-transform/cliDraco Compression
gltf-transform draco input.glb output.glb --method edgebreakerMeshopt Compression
gltf-transform meshopt input.glb output.glb --level mediumTexture Compression
# WebP (25-35% reduction)
gltf-transform webp input.glb output.glb --quality 75
# KTX2/Basis (75-85% reduction)
gltf-transform ktx input.glb output.glb --slots baseColorFull Optimization Pipeline
gltf-transform optimize input.glb output.glb \
--compress draco \
--texture-compress webp \
--texture-size 1024Compression Results
| Method | Typical Reduction |
|---|---|
| Draco | 70-90% geometry |
| Meshopt | 60-80% geometry + morph + animation |
| KTX2/Basis | 75-85% textures |
| WebP | 25-35% textures |
Draco vs Meshopt
| Feature | Draco | Meshopt |
|---|---|---|
| Compression | Higher | Lower |
| Decode Speed | Slower | Faster |
| Animation | No | Yes |
| Morph Targets | No | Yes |
| Recommendation | Static models | Animated models |
Best Practices
1. Format: Prefer GLB over GLTF (binary, single file)
2. Textures: Keep 512x512 or 1024x1024 for mobile
3. Power of Two: Always use power-of-2 dimensions
4. Compression: DRACO for geometry, KTX2 for textures
5. Host Decoders: Host decoders locally or use reliable CDN
6. Progress: Always show loading progress
CDN Decoder Paths
// Google CDN
dracoLoader.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/');
// jsDelivr
dracoLoader.setDecoderPath('https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/libs/draco/');Post-Load Processing
gltfLoader.load('model.glb', (gltf) => {
const model = gltf.scene;
// Enable shadows
model.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
// Fix materials (if needed)
model.traverse((child) => {
if (child.material) {
child.material.envMapIntensity = 1;
}
});
// Center model
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
model.position.sub(center);
scene.add(model);
});Loading Manager
const manager = new THREE.LoadingManager();
manager.onStart = (url, loaded, total) => {
console.log(`Loading: ${url}`);
};
manager.onProgress = (url, loaded, total) => {
console.log(`Progress: ${loaded}/${total}`);
};
manager.onLoad = () => {
console.log('All assets loaded');
hideLoadingScreen();
};
manager.onError = (url) => {
console.error(`Error loading: ${url}`);
};
const gltfLoader = new GLTFLoader(manager);
const textureLoader = new THREE.TextureLoader(manager);Lazy Loading
// Load critical assets first
await loadCriticalAssets();
showScene();
// Load non-critical in background
loadBackgroundAssets();
async function loadCriticalAssets() {
const hero = await gltfLoader.loadAsync('hero.glb');
scene.add(hero.scene);
}
function loadBackgroundAssets() {
gltfLoader.load('decorations.glb', (gltf) => {
scene.add(gltf.scene);
});
}References
Lighting & Shadows Advanced
Source: 100 Three.js Tips - Utsubo
Lighting is computationally expensive. Optimize carefully.
Limit Active Lights
Target: 3 or fewer active lights
Each additional light increases shader complexity. Beyond 3 lights, consider baking.
PointLight Shadow Cost
PointLights require 6 shadow map renders (cube faces):
Draw calls = objects × 6 × point_lightsA scene with 100 objects and 2 PointLights = 1,200 shadow draw calls.
Prefer DirectionalLight or SpotLight for shadows.
Shadow Map Sizing
| Platform | Recommended Size |
|---|---|
| Mobile | 512-1024 |
| Desktop | 1024-2048 |
| Quality-critical | 4096 |
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;Tight Shadow Camera Frustum
const light = new THREE.DirectionalLight(0xffffff, 1);
// Fit tightly to scene bounds
light.shadow.camera.left = -10;
light.shadow.camera.right = 10;
light.shadow.camera.top = 10;
light.shadow.camera.bottom = -10;
light.shadow.camera.near = 0.1;
light.shadow.camera.far = 50;
// Use helper to visualize
const helper = new THREE.CameraHelper(light.shadow.camera);
scene.add(helper);Disable Shadow Auto-Update for Static Scenes
renderer.shadowMap.autoUpdate = false;
// Manually trigger when needed (e.g., after moving light)
renderer.shadowMap.needsUpdate = true;Cascaded Shadow Maps (CSM) for Large Scenes
import { CSM } from 'three/addons/csm/CSM.js';
const csm = new CSM({
maxFar: camera.far,
cascades: 4, // Desktop: 4, Mobile: 2
shadowMapSize: 2048,
lightDirection: new THREE.Vector3(-1, -1, -1).normalize(),
camera: camera,
parent: scene
});
// Update in render loop
function animate() {
csm.update();
renderer.render(scene, camera);
}Bake Lightmaps for Static Scenes
Options: 1. Blender - Bake lighting in Blender, export to glTF 2. @react-three/lightmap - Runtime baking in R3F
// React Three Fiber
import { Lightmap } from '@react-three/lightmap';
<Lightmap>
<Scene />
</Lightmap>Environment Maps for Ambient Light
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
const pmremGenerator = new THREE.PMREMGenerator(renderer);
new RGBELoader().load('environment.hdr', (texture) => {
const envMap = pmremGenerator.fromEquirectangular(texture).texture;
scene.environment = envMap;
texture.dispose();
pmremGenerator.dispose();
});Fake Shadows for Simple Cases
Semi-transparent planes with radial gradients provide budget-friendly contact shadows:
const shadowTexture = createRadialGradientTexture();
const shadowMaterial = new THREE.MeshBasicMaterial({
map: shadowTexture,
transparent: true,
opacity: 0.5,
depthWrite: false
});
const shadowPlane = new THREE.Mesh(
new THREE.PlaneGeometry(2, 2),
shadowMaterial
);
shadowPlane.rotation.x = -Math.PI / 2;
shadowPlane.position.y = 0.01; // Slightly above groundLight Probes
For static scenes with complex lighting:
import { LightProbeGenerator } from 'three/addons/lights/LightProbeGenerator.js';
const lightProbe = new THREE.LightProbe();
// Generate from cube camera
const cubeRenderTarget = new THREE.WebGLCubeRenderTarget(256);
const cubeCamera = new THREE.CubeCamera(0.1, 1000, cubeRenderTarget);
cubeCamera.update(renderer, scene);
lightProbe.copy(LightProbeGenerator.fromCubeRenderTarget(renderer, cubeRenderTarget));
scene.add(lightProbe);Checklist
- [ ] Limit to 3 or fewer active lights
- [ ] Avoid PointLight shadows when possible
- [ ] Size shadow maps for target platform
- [ ] Fit shadow camera frustum tightly
- [ ] Disable shadow autoUpdate for static scenes
- [ ] Use CSM for large outdoor scenes
- [ ] Bake lighting for static geometry
- [ ] Use environment maps for ambient lighting
- [ ] Consider fake shadows for simple cases
memory-dispose-geometry
Always dispose geometries when removing objects from scene.
Why It Matters
Three.js does NOT automatically garbage collect GPU resources. Geometries allocate GPU buffer memory that persists until explicitly freed. Failing to dispose causes memory leaks that eventually crash the browser.
Bad Example
// BAD - Memory leak
scene.remove(mesh);
mesh = null; // GPU buffers still allocated!The JavaScript object is garbage collected, but the GPU memory remains allocated.
Good Example
// GOOD - Proper cleanup
scene.remove(mesh);
mesh.geometry.dispose();
mesh = null;Recursive Disposal
For complex hierarchies, use recursive disposal:
function disposeObject(obj) {
if (obj.geometry) {
obj.geometry.dispose();
}
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(disposeMaterial);
} else {
disposeMaterial(obj.material);
}
}
if (obj.children) {
obj.children.forEach(disposeObject);
}
}
function disposeMaterial(material) {
const textureKeys = [
'map', 'lightMap', 'bumpMap', 'normalMap', 'specularMap',
'envMap', 'alphaMap', 'aoMap', 'displacementMap',
'emissiveMap', 'gradientMap', 'metalnessMap', 'roughnessMap'
];
textureKeys.forEach(key => {
if (material[key]) {
material[key].dispose();
}
});
material.dispose();
}
// Usage
disposeObject(complexModel);
scene.remove(complexModel);React Example
useEffect(() => {
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial();
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
return () => {
scene.remove(mesh);
geometry.dispose();
material.dispose();
};
}, []);References
Migration Checklist
Breaking changes and migration guide for Three.js versions.
Recent Changes (r180-r183)
r182 → r183
- Shadow quality improved in WebGPURenderer; reduce/remove bias values
- RoomEnvironment scene position updated; lighting may differ
- Sky/SkyMesh legacy gamma correction removed
- MeshPostProcessingMaterial removed
r181 → r182
PCFSoftShadowMapdeprecated with WebGLRenderer; usePCFShadowMap- WebGPURenderer:
colorBufferType→outputBufferType - VOXLoader.load() restructured
r180 → r181
- Indirect specular light computation improved for PBR
- Rough PBR materials now appear brighter
renderAsync()/computeAsync()deprecated; use sync versions- TSL:
PI2→TWO_PI - New JSDoc-based API documentation
Import Changes (r170+)
BAD - Deprecated
import { WebGPURenderer } from 'three/addons/renderers/webgpu/WebGPURenderer.js';
import { MeshStandardNodeMaterial } from 'three/addons/nodes/materials/...';GOOD - Current
import * as THREE from 'three/webgpu'; // WebGPURenderer and NodeMaterials
import { ... } from 'three/tsl'; // TSL functionsColor Management (r151+)
BAD - Old API
renderer.outputEncoding = THREE.sRGBEncoding;
texture.encoding = THREE.sRGBEncoding;GOOD - Current API
renderer.outputColorSpace = THREE.SRGBColorSpace;
texture.colorSpace = THREE.SRGBColorSpace;
// ColorManagement.enabled = true by defaultGeometry Changes (r125+)
Removed
// REMOVED - use BufferGeometry
const geometry = new THREE.Geometry();Aliases Removed
// These no longer exist
BoxBufferGeometry → BoxGeometry
SphereBufferGeometry → SphereGeometry
PlaneBufferGeometry → PlaneGeometry
// Just use BoxGeometry, SphereGeometry, etc.Light Decay (r147+)
// Default decay is now 2 (physically correct)
const light = new THREE.PointLight(0xffffff, 1);
light.decay = 2; // DEFAULT
// To restore old behavior:
light.decay = 1;Material Changes (r147+)
// vertexColors is now boolean
material.vertexColors = true; // CORRECT
// OLD:
// material.vertexColors = THREE.VertexColors; // REMOVEDRenderer Changes
setAnimationLoop
// GOOD - Modern way
renderer.setAnimationLoop(animate);
// OLD - Still works but less preferred
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();XR Animation Loop
// For WebXR, MUST use setAnimationLoop
renderer.xr.enabled = true;
renderer.setAnimationLoop(animate);Version Check
console.log('Three.js version:', THREE.REVISION);
// Example: "182"
if (parseInt(THREE.REVISION) < 170) {
console.warn('Please update Three.js to r170+');
}Common Migration Issues
Issue: "X is not a constructor"
// Check import path
// OLD:
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
// NEW:
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';Issue: Colors look different
// Color management is now enabled by default
// If colors look washed out:
THREE.ColorManagement.enabled = false; // Revert to old behavior (not recommended)
// Better: Fix your color values for linear workflowIssue: Materials appear different
// PBR materials changed in r180+
// Rough surfaces are now brighter
// Adjust roughness values if needed
material.roughness = 0.6; // May need to increaseIssue: Shadows look wrong
// Shadow bias may need adjustment
light.shadow.bias = -0.0001; // r183+ may need smaller valuesQuick Migration Checklist
- [ ] Update import paths (
addons/instead ofexamples/jsm/) - [ ] Replace
encodingwithcolorSpace - [ ] Remove BufferGeometry suffix (BoxGeometry, not BoxBufferGeometry)
- [ ] Check light decay values
- [ ] Update vertexColors to boolean
- [ ] For WebGPU: use
three/webgpuandthree/tslimports - [ ] Test shadows and adjust bias if needed
- [ ] Verify PBR material appearance
References
Mobile Optimization
Essential optimizations for Three.js on mobile devices.
Device Detection
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
const isLowEnd = navigator.hardwareConcurrency <= 4;
const hasLimitedMemory = navigator.deviceMemory && navigator.deviceMemory < 4;Renderer Configuration
const renderer = new THREE.WebGLRenderer({
antialias: !isMobile, // Disable AA on mobile
powerPreference: 'high-performance',
precision: isMobile ? 'mediump' : 'highp'
});
// Limit pixel ratio (CRITICAL)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.5 : 2));Material Hierarchy (Fast to Slow)
// 1. MeshBasicMaterial (no lighting) - FASTEST
const basic = new THREE.MeshBasicMaterial({ color: 0xff0000 });
// 2. MeshLambertMaterial (per-vertex lighting)
const lambert = new THREE.MeshLambertMaterial({ color: 0xff0000 });
// 3. MeshPhongMaterial (per-pixel lighting)
const phong = new THREE.MeshPhongMaterial({ color: 0xff0000 });
// 4. MeshStandardMaterial (PBR) - SLOWEST
const standard = new THREE.MeshStandardMaterial({ color: 0xff0000 });On iOS, Phong can drop FPS from 60 to 15. Prefer Lambert or Basic for mobile.
Texture Optimization
// Max recommended: 1024x1024 for mobile, ideal: 512x512
texture.minFilter = THREE.LinearFilter; // Avoid mipmaps if not needed
texture.generateMipmaps = false;
// Use compressed formats - KTX2/Basis reduces memory 75%+
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
const ktx2Loader = new KTX2Loader();
ktx2Loader.setTranscoderPath('/basis/');
ktx2Loader.detectSupport(renderer);Shader Optimization
BAD - Multiple passes
composer.addPass(bloomPass);
composer.addPass(filmPass);
composer.addPass(colorPass);
// 3 draw calls, 3 framebuffer switchesGOOD - Combined SuperShader
const superShader = {
uniforms: { /* all uniforms combined */ },
fragmentShader: `
// Combine all effects in single shader
void main() {
vec4 color = texture2D(tDiffuse, vUv);
color = applyBloom(color);
color = applyFilm(color);
color = applyColorGrade(color);
gl_FragColor = color;
}
`
};
// 1 draw call, 1 framebuffer switchMemory Management
// Pre-allocate everything at init
const tempVec3 = new THREE.Vector3();
const tempMatrix = new THREE.Matrix4();
// NEVER allocate in render loop
function animate() {
// BAD: new THREE.Vector3() here
// GOOD: tempVec3.set(x, y, z)
}
// Dispose aggressively
texture.dispose();
geometry.dispose();
material.dispose();
// Monitor memory
console.log(renderer.info.memory);Draw Calls
// Check draw calls
console.log(renderer.info.render.calls);
// Target: < 100 draw calls on mobile
// Reduce with:
// - InstancedMesh for repeated objects
// - Merged geometries for static objects
// - Texture atlases
// - Material sharingShadows on Mobile
// Option 1: Disable shadows entirely
renderer.shadowMap.enabled = false;
// Option 2: Baked shadows (texture on ground plane)
const shadowTexture = textureLoader.load('baked-shadow.png');
const shadowMaterial = new THREE.MeshBasicMaterial({
map: shadowTexture,
transparent: true
});
// Option 3: Simple shadow map (if needed)
renderer.shadowMap.type = THREE.BasicShadowMap;
light.shadow.mapSize.set(512, 512); // Lower resolutionLevel of Detail
const lod = new THREE.LOD();
// High detail (close)
lod.addLevel(highDetailMesh, 0);
// Medium detail
lod.addLevel(mediumDetailMesh, 10);
// Low detail (far)
lod.addLevel(lowDetailMesh, 30);
// Mobile distances
if (isMobile) {
lod.levels[0].distance = 5; // Switch sooner
lod.levels[1].distance = 15;
}Mobile Checklist
- [ ] Pixel ratio ≤ 1.5
- [ ] Textures ≤ 1024px
- [ ] No antialiasing (or FXAA)
- [ ] Simple materials (Basic/Lambert)
- [ ] Post-processing minimal or combined
- [ ] Dispose aggressively
- [ ] Draw calls < 100
- [ ] No shadows or baked shadows
- [ ] LOD for complex models
- [ ] Precision mediump
Performance Profiles
const profiles = {
high: {
pixelRatio: 2,
textureSize: 2048,
shadows: true,
postprocessing: true,
antialias: true
},
medium: {
pixelRatio: 1.5,
textureSize: 1024,
shadows: true,
postprocessing: false,
antialias: false
},
low: {
pixelRatio: 1,
textureSize: 512,
shadows: false,
postprocessing: false,
antialias: false
}
};
const profile = isMobile ?
(isLowEnd ? profiles.low : profiles.medium) :
profiles.high;
applyProfile(profile);Touch Controls
// OrbitControls works on touch by default
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// Disable zoom pinch if needed
controls.enableZoom = false;
// Touch-friendly hit areas
// Make interactive elements at least 44x44 CSS pixelsReferences
Object Pooling
Source: 100 Three.js Tips - Utsubo
Object pooling prevents garbage collection pauses by reusing objects instead of creating/destroying them.
When to Use
- Bullets, particles, projectiles
- Spawned enemies/NPCs
- Collectibles
- Any frequently created/destroyed objects
Implementation
class ObjectPool {
constructor(factory, reset, initialSize = 20) {
this.factory = factory;
this.reset = reset;
this.pool = [];
// Pre-warm the pool
for (let i = 0; i < initialSize; i++) {
const obj = factory();
obj.visible = false;
this.pool.push(obj);
}
}
acquire() {
const obj = this.pool.pop() || this.factory();
obj.visible = true;
return obj;
}
release(obj) {
this.reset(obj);
obj.visible = false;
this.pool.push(obj);
}
}Example: Bullet Pool
const bulletGeometry = new THREE.SphereGeometry(0.1);
const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 });
const bulletPool = new ObjectPool(
// Factory: create new bullet
() => {
const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial);
scene.add(bullet);
return bullet;
},
// Reset: return bullet to initial state
(bullet) => {
bullet.position.set(0, 0, 0);
bullet.userData.velocity = null;
},
50 // Initial pool size
);
// Spawn bullet
function fireBullet(position, direction) {
const bullet = bulletPool.acquire();
bullet.position.copy(position);
bullet.userData.velocity = direction.clone().multiplyScalar(10);
activeBullets.add(bullet);
}
// Despawn bullet
function removeBullet(bullet) {
activeBullets.delete(bullet);
bulletPool.release(bullet);
}
// Update loop
function updateBullets(delta) {
for (const bullet of activeBullets) {
bullet.position.add(
bullet.userData.velocity.clone().multiplyScalar(delta)
);
// Check bounds
if (bullet.position.length() > 100) {
removeBullet(bullet);
}
}
}Key Benefits
1. No GC pauses - Objects are reused, not collected 2. Predictable memory - Pool size is bounded 3. Faster spawning - No allocation overhead
Best Practices
1. Pre-warm pools - Create objects during loading, not gameplay 2. Share geometry/material - All pooled objects use same resources 3. Reset completely - Clear all state when releasing 4. Size appropriately - Match pool size to max concurrent objects 5. Use visibility - Toggle visible instead of add/remove from scene
Anti-Pattern
// BAD: Creates garbage every frame
function spawnParticle() {
const particle = new THREE.Mesh(
new THREE.SphereGeometry(0.1), // New geometry!
new THREE.MeshBasicMaterial() // New material!
);
scene.add(particle);
setTimeout(() => {
scene.remove(particle);
// Memory leak: geometry/material not disposed
}, 1000);
}Physics Integration
Guide to integrating physics engines with Three.js.
Engine Comparison
| Engine | Language | Characteristics | Performance |
|---|---|---|---|
| Rapier | Rust/WASM | Deterministic, modern | Very High |
| Cannon-es | JavaScript | Easy to use, maintained fork | High |
| Ammo.js | C++/WASM | Bullet port, softbody support | Medium-High |
Rapier (Recommended for 2025+)
Setup
npm install @dimforge/rapier3dVite Configuration
// vite.config.js
import wasm from 'vite-plugin-wasm';
import topLevelAwait from 'vite-plugin-top-level-await';
export default {
plugins: [wasm(), topLevelAwait()]
};Basic Usage
import RAPIER from '@dimforge/rapier3d';
await RAPIER.init();
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
// Create rigid body
const rigidBodyDesc = RAPIER.RigidBodyDesc.dynamic()
.setTranslation(0, 5, 0);
const rigidBody = world.createRigidBody(rigidBodyDesc);
// Add collider
const colliderDesc = RAPIER.ColliderDesc.ball(0.5);
world.createCollider(colliderDesc, rigidBody);
// Sync with Three.js mesh
function animate() {
world.step();
const position = rigidBody.translation();
const rotation = rigidBody.rotation();
mesh.position.set(position.x, position.y, position.z);
mesh.quaternion.set(rotation.x, rotation.y, rotation.z, rotation.w);
}Collider Shapes
RAPIER.ColliderDesc.ball(radius)
RAPIER.ColliderDesc.cuboid(hx, hy, hz)
RAPIER.ColliderDesc.capsule(halfHeight, radius)
RAPIER.ColliderDesc.cylinder(halfHeight, radius)
RAPIER.ColliderDesc.cone(halfHeight, radius)
RAPIER.ColliderDesc.convexHull(vertices)
RAPIER.ColliderDesc.trimesh(vertices, indices)Cannon-es
Setup
npm install cannon-esBasic Usage
import * as CANNON from 'cannon-es';
const world = new CANNON.World();
world.gravity.set(0, -9.82, 0);
const sphereBody = new CANNON.Body({
mass: 1,
shape: new CANNON.Sphere(0.5),
position: new CANNON.Vec3(0, 5, 0)
});
world.addBody(sphereBody);
function animate() {
world.step(1/60);
mesh.position.copy(sphereBody.position);
mesh.quaternion.copy(sphereBody.quaternion);
}Body Types
// Dynamic (affected by forces)
new CANNON.Body({ mass: 1 })
// Static (immovable, mass = 0)
new CANNON.Body({ mass: 0 })
// Kinematic (controlled programmatically)
new CANNON.Body({
mass: 0,
type: CANNON.Body.KINEMATIC
})Shapes
new CANNON.Sphere(radius)
new CANNON.Box(new CANNON.Vec3(hx, hy, hz))
new CANNON.Cylinder(radiusTop, radiusBottom, height, segments)
new CANNON.Plane()
new CANNON.ConvexPolyhedron({ vertices, faces })
new CANNON.Trimesh(vertices, indices)Sync Pattern
BAD - Bidirectional sync
// Physics affects mesh
mesh.position.copy(body.position);
// Mesh affects physics (creates feedback loop)
body.position.copy(mesh.position);GOOD - Physics -> Visual only
function syncPhysicsToMesh(body, mesh) {
mesh.position.copy(body.position);
mesh.quaternion.copy(body.quaternion);
}
// For kinematic bodies controlled by Three.js
function syncMeshToKinematic(mesh, body) {
body.position.copy(mesh.position);
body.quaternion.copy(mesh.quaternion);
}Best Practices
1. Simple Shapes: Use simple colliders (box, sphere) even for complex meshes
2. Fixed Timestep: Use fixed timestep for physics (1/60)
world.step(1/60, deltaTime, 3); // Fixed step, max substeps3. Sleep: Enable sleep for static bodies
body.allowSleep = true;
body.sleepSpeedLimit = 0.1;4. Broad Phase: Configure appropriate broad phase
world.broadphase = new CANNON.SAPBroadphase(world);5. Material Friction: Define physics materials
const material = new CANNON.Material('default');
const contact = new CANNON.ContactMaterial(material, material, {
friction: 0.3,
restitution: 0.3
});
world.addContactMaterial(contact);6. Sync Direction: Always sync physics -> visual, not reverse
7. Compound Shapes: Use compound shapes for complex objects
const body = new CANNON.Body({ mass: 1 });
body.addShape(new CANNON.Box(size1), offset1);
body.addShape(new CANNON.Sphere(radius), offset2);Debug Visualization
import CannonDebugger from 'cannon-es-debugger';
const cannonDebugger = CannonDebugger(scene, world);
function animate() {
world.step(1/60);
cannonDebugger.update();
}References
Post-Processing Optimization
Source: 100 Three.js Tips - Utsubo
Post-processing can significantly impact performance. Optimize carefully.
Use pmndrs/postprocessing (WebGL)
The pmndrs library is more performant than Three.js default EffectComposer:
import { EffectComposer, Bloom, Vignette, EffectPass, RenderPass } from 'postprocessing';
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new EffectPass(camera, new Bloom(), new Vignette()));
// In render loop
composer.render();Configure Renderer for Post-Processing
const renderer = new THREE.WebGLRenderer({
powerPreference: 'high-performance',
antialias: false, // AA handled by post-processing
stencil: false, // Disable if not needed
depth: false // Disable if not needed
});Disable Multisampling When Not Needed
// React Three Fiber
<EffectComposer multisampling={0}>
<Bloom />
</EffectComposer>Apply Tone Mapping at Pipeline End
renderer.toneMapping = THREE.NoToneMapping;
// Add ToneMappingEffect as the LAST effect
composer.addPass(new EffectPass(camera, new ToneMappingEffect()));Add Antialiasing at the End
import { SMAAEffect } from 'postprocessing';
// SMAA as the final pass
composer.addPass(new EffectPass(camera, new SMAAEffect()));Merge Compatible Effects
Reduce passes by combining effects:
// BAD: Multiple passes
composer.addPass(new EffectPass(camera, new Bloom()));
composer.addPass(new EffectPass(camera, new Vignette()));
composer.addPass(new EffectPass(camera, new ChromaticAberration()));
// GOOD: Single pass with multiple effects
composer.addPass(new EffectPass(
camera,
new Bloom(),
new Vignette(),
new ChromaticAberration()
));Resolution Scaling
Half resolution can double frame rate:
// Render at half resolution
composer.setSize(window.innerWidth / 2, window.innerHeight / 2);Selective Bloom
Only bloom objects that need it:
import { SelectiveBloomEffect } from 'postprocessing';
const bloom = new SelectiveBloomEffect(scene, camera, {
luminanceThreshold: 0.9,
luminanceSmoothing: 0.3
});
// Add objects to bloom selection
bloom.selection.add(glowingObject);Bloom Parameter Tuning
| Parameter | Range | Description |
|---|---|---|
| intensity | 0.5-2.0 | Overall strength |
| luminanceThreshold | 0.8-1.0 | Minimum brightness to bloom |
| radius | 0.5-1.0 | Spread size |
WebGPU Native Post-Processing
For WebGPU, use Three.js native TSL-based post-processing:
import { pass, bloom, fxaa } from 'three/tsl';
const postProcessing = new THREE.PostProcessing(renderer);
const scenePass = pass(scene, camera);
postProcessing.outputNode = scenePass
.pipe(bloom({ threshold: 0.8, intensity: 1.0 }))
.pipe(fxaa());
// In render loop
postProcessing.render();Performance Checklist
- [ ] Use pmndrs/postprocessing for WebGL
- [ ] Disable renderer AA, stencil, depth when using post-processing
- [ ] Merge compatible effects into single pass
- [ ] Add AA (SMAA/FXAA) as final effect
- [ ] Apply tone mapping at end
- [ ] Consider resolution scaling for mobile
- [ ] Use selective bloom instead of full-screen
- [ ] Disable multisampling when not needed
- [ ] Use TSL post-processing for WebGPU
Raycasting Optimization
Efficient picking and intersection testing in Three.js.
Basic Raycaster
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
function onPointerMove(event) {
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
function checkIntersections() {
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
const hit = intersects[0];
console.log('Object:', hit.object.name);
console.log('Point:', hit.point);
console.log('Face:', hit.face);
console.log('Distance:', hit.distance);
}
}Layers for Filtering
// Define layers
const LAYER_INTERACTIVE = 1;
const LAYER_DECORATIVE = 2;
// Assign objects to layers
interactiveObject.layers.set(LAYER_INTERACTIVE);
decorativeObject.layers.set(LAYER_DECORATIVE);
// Raycaster only checks specific layer
raycaster.layers.set(LAYER_INTERACTIVE);
// Multiple layers
raycaster.layers.enable(LAYER_INTERACTIVE);
raycaster.layers.enable(2);three-mesh-bvh (High Performance)
For complex meshes (80k+ polygons), use BVH acceleration:
npm install three-mesh-bvhimport { MeshBVH, acceleratedRaycast } from 'three-mesh-bvh';
// Extend Mesh prototype
THREE.Mesh.prototype.raycast = acceleratedRaycast;
// Generate BVH for mesh
mesh.geometry.boundsTree = new MeshBVH(mesh.geometry);
// Now raycasting is ~100x faster
const intersects = raycaster.intersectObject(mesh);
// Dispose when done
mesh.geometry.boundsTree = null;BVH Options
mesh.geometry.boundsTree = new MeshBVH(mesh.geometry, {
maxLeafTris: 10, // Triangles per leaf node
maxDepth: 40, // Max tree depth
strategy: CENTER // SAH, CENTER, or AVERAGE
});Throttling
// Don't raycast on every mousemove
let lastRaycast = 0;
const RAYCAST_INTERVAL = 50; // ms
function onPointerMove(event) {
const now = performance.now();
if (now - lastRaycast < RAYCAST_INTERVAL) return;
lastRaycast = now;
updatePointer(event);
checkIntersections();
}Octree for Scenes
import { Octree } from 'three/addons/math/Octree.js';
const octree = new Octree();
// Add meshes to octree
scene.traverse((object) => {
if (object.isMesh) {
octree.fromGraphNode(object);
}
});
// Optimized raycast
const result = octree.rayIntersect(ray);
// Capsule collision (for character controllers)
const capsuleInfo = {
radius: 0.5,
segment: new THREE.Line3(
new THREE.Vector3(0, 0.5, 0),
new THREE.Vector3(0, 1.5, 0)
)
};
const collision = octree.capsuleIntersect(capsuleInfo);GPU Picking
For skinned meshes or when BVH isn't enough:
// Concept:
// 1. Render each object with unique color
// 2. Read pixel under mouse
// 3. Map color -> object
const pickingScene = new THREE.Scene();
const pickingTexture = new THREE.WebGLRenderTarget(1, 1);
const idToObject = new Map();
// Assign unique colors
let id = 1;
scene.traverse((object) => {
if (object.isMesh) {
const color = new THREE.Color(id);
const pickingMaterial = new THREE.MeshBasicMaterial({
color: color
});
const pickingMesh = object.clone();
pickingMesh.material = pickingMaterial;
pickingScene.add(pickingMesh);
idToObject.set(id, object);
id++;
}
});
function gpuPick(x, y) {
camera.setViewOffset(
renderer.domElement.width, renderer.domElement.height,
x, y, 1, 1
);
renderer.setRenderTarget(pickingTexture);
renderer.render(pickingScene, camera);
camera.clearViewOffset();
const pixelBuffer = new Uint8Array(4);
renderer.readRenderTargetPixels(pickingTexture, 0, 0, 1, 1, pixelBuffer);
const id = (pixelBuffer[0] << 16) | (pixelBuffer[1] << 8) | pixelBuffer[2];
return idToObject.get(id);
}Best Practices
1. Layers: Use layers to filter non-interactive objects
2. BVH: Use three-mesh-bvh for complex meshes
3. Throttle: Don't raycast on every mouse event
4. Bounding Box: Three.js already optimizes with bounding box/sphere checks
5. Recursive: Use recursive=true only when needed
6. First Hit: If you only need first hit, check intersects[0]
7. Near/Far: Set raycaster.near and raycaster.far to limit range
raycaster.near = 0.1;
raycaster.far = 100;Spatial Partitioning Comparison
| Structure | Best For |
|---|---|
| BVH | Ray-mesh intersection |
| Octree | Scene queries, collision |
| KD-Tree | Point queries |
| Grid | Uniform distribution |
References
render-delta-time
Use delta time for frame-rate independent animation.
Why It Matters
Without delta time, animations run faster on high refresh rate displays (144Hz) and slower on low frame rate devices. Delta time ensures consistent animation speed regardless of frame rate.
Bad Example
// BAD - Animation speed varies with frame rate
function animate() {
requestAnimationFrame(animate);
object.rotation.y += 0.01; // Fast on 144hz, slow on 30hz
renderer.render(scene, camera);
}At 60fps: rotates ~0.6 rad/sec At 144fps: rotates ~1.44 rad/sec At 30fps: rotates ~0.3 rad/sec
Good Example
// GOOD - Consistent speed regardless of frame rate
const clock = new THREE.Clock();
function animate() {
const delta = clock.getDelta(); // Time since last frame in seconds
object.rotation.y += 1.0 * delta; // 1 radian per second, always
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);Now the rotation is always 1 radian per second, regardless of frame rate.
Common Patterns
Movement
const speed = 5; // units per second
function animate() {
const delta = clock.getDelta();
object.position.x += speed * delta;
}Lerp with Delta
function animate() {
const delta = clock.getDelta();
const lerpFactor = 1 - Math.pow(0.001, delta); // Smooth, frame-rate independent
camera.position.lerp(targetPosition, lerpFactor);
}Time-based Effects
function animate() {
const elapsed = clock.getElapsedTime(); // Total time since start
object.position.y = Math.sin(elapsed * 2) * 2; // Oscillate at 2 Hz
}References
setup-use-import-maps
Use Import Maps instead of old CDN script tags.
Why It Matters
The old CDN pattern (<script src="...three.min.js">) is outdated and causes:
- Module resolution issues
- No tree shaking
- Global namespace pollution
- Version conflicts
Bad Example
<!-- WRONG - Outdated pattern (DO NOT USE) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// THREE is global, no modules
const scene = new THREE.Scene();
</script>Good Example
<!-- CORRECT - Modern Import Maps pattern -->
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.182.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
</script>WebGPU Import Map
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.182.0/build/three.webgpu.js",
"three/tsl": "https://cdn.jsdelivr.net/npm/three@0.182.0/build/three.tsl.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/"
}
}
</script>References
Shader Optimization for Mobile
Source: 100 Three.js Tips - Utsubo
Mobile GPUs have specific constraints that require careful shader optimization.
Use mediump Precision
Mobile processes mediump ~2x faster than highp:
precision mediump float;
// Or per-variable
mediump vec3 color;
highp float depth; // Use highp only when necessaryMinimize Varying Variables
Keep under 3 varyings for mobile GPUs by packing data:
// BAD: 5 varyings
varying vec3 vPosition;
varying vec3 vNormal;
varying vec2 vUv;
varying vec3 vColor;
varying float vAlpha;
// GOOD: 2 varyings with packed data
varying vec4 vPositionAlpha; // xyz = position, w = alpha
varying vec4 vNormalUv; // xy = normal.xy, zw = uv
// Reconstruct normal.z in fragment shaderReplace Conditionals with mix() and step()
Branching is expensive on mobile GPUs:
// BAD: Conditional
if (value > 0.5) {
color = colorA;
} else {
color = colorB;
}
// GOOD: Branchless
color = mix(colorB, colorA, step(0.5, value));Pack Data into RGBA Channels
Reduces texture fetches by 75%:
// BAD: 4 texture fetches
float value1 = texture2D(tex1, uv).r;
float value2 = texture2D(tex2, uv).r;
float value3 = texture2D(tex3, uv).r;
float value4 = texture2D(tex4, uv).r;
// GOOD: 1 texture fetch
vec4 data = texture2D(dataTex, uv);
float value1 = data.r;
float value2 = data.g;
float value3 = data.b;
float value4 = data.a;Avoid Dynamic Loops
Use fixed loop bounds to enable compiler optimization:
// BAD: Dynamic loop
for (int i = 0; i < numLights; i++) {
// ...
}
// GOOD: Fixed loop with early exit
#define MAX_LIGHTS 4
for (int i = 0; i < MAX_LIGHTS; i++) {
if (i >= numLights) break;
// ...
}Avoid discard
Use alphaTest instead:
// Instead of discard in shader
material.alphaTest = 0.5;
material.transparent = false; // Avoid transparent sortingUse TSL for Cross-Platform
TSL automatically optimizes for the target platform:
import { color, positionLocal, sin, time } from 'three/tsl';
const material = new THREE.MeshStandardNodeMaterial();
material.colorNode = color(1, 0, 0).mul(sin(time).mul(0.5).add(0.5));Precompute on CPU
Move constant calculations out of shaders:
// CPU: Compute once
const inverseViewMatrix = camera.matrixWorld.clone();
material.uniforms.uInverseView = { value: inverseViewMatrix };
// Update only when camera moves
camera.addEventListener('change', () => {
material.uniforms.uInverseView.value.copy(camera.matrixWorld);
});Mobile Shader Checklist
- [ ] Use
mediumpprecision by default - [ ] Limit varyings to 3 or fewer
- [ ] Pack data into vec4 where possible
- [ ] Replace conditionals with mix/step
- [ ] Use fixed loop bounds
- [ ] Pack multiple values into single texture
- [ ] Avoid
discard, usealphaTest - [ ] Precompute constants on CPU
- [ ] Use TSL for automatic optimization
TSL Complete Reference
Comprehensive reference for Three.js Shading Language (TSL) - the modern way to create shaders in Three.js.
Type System
// Scalar conversions
float(), int(), uint(), bool()
// Vector conversions
color(), vec2(), vec3(), vec4()
// Matrix conversions
mat2(), mat3(), mat4()
// Method-based conversion
positionWorld.toVec2() // Access xy components
value.toFloat() // Explicit conversion
value.toColor() // Convert to colorUniforms with Update Events
import { uniform } from 'three/tsl';
const myColor = uniform(new THREE.Color(0x0066FF));
material.colorNode = myColor;
// Update events
const posY = uniform(0);
posY.onObjectUpdate(({ object }) => object.position.y); // Per object
posY.onRenderUpdate(() => value); // Per render pass
posY.onFrameUpdate(() => value); // Per frameFunctions with Fn()
import { Fn, vec3, float, time } from 'three/tsl';
// Basic function
const oscSine = Fn(([t = time]) => {
return t.add(0.75).mul(Math.PI * 2).sin().mul(0.5).add(0.5);
});
// Named parameters
const customColor = Fn(({ r, g, b }) => {
return vec3(r, g, b);
});
material.colorNode = customColor({ r: 1, g: 0, b: 0 });Conditionals
import { Fn, If, select, vec3, float } from 'three/tsl';
// Ternary (inline) - GOOD for simple cases
const result = select(value.greaterThan(1), 1.0, value);
// If-Else (inside Fn) - NOTE: If with capital I
const limitedPosition = Fn(({ position }) => {
const limit = 10;
const result = vec3(position);
If(result.y.greaterThan(limit), () => {
result.y.assign(limit);
});
return result;
});
// Switch-Case
const col = color();
Switch(0)
.Case(0, () => col.assign(color(1, 0, 0)))
.Case(1, () => col.assign(color(0, 1, 0)))
.Default(() => col.assign(color(1, 1, 1)));Loops
// Basic loop
Loop(count, ({ i }) => {
// Loop body
});
// Advanced configuration
Loop({ start: int(0), end: int(10), type: 'int', condition: '<' },
({ i }) => {}
);
// Nested loops
Loop(10, 5, ({ i, j }) => {});
// Boolean condition loop
const value = float(0);
Loop(value.lessThan(10), () => {
value.addAssign(1);
});
// Flow control
Break(); // Exit loop
Continue(); // Next iterationArrays
// Creation
const colors = array([vec3(1, 0, 0), vec3(0, 1, 0)]);
const a = array('vec3', 2); // Fixed size
const a = vec3(0, 0, 1).toArray(2); // Fill with value
const a = array([0, 1, 2], 'uint'); // Explicit type
// Access
const greenColor = colors.element(1); // Dynamic index
const first = colors[0]; // Constant index
// Uniform arrays
const tintColors = uniformArray(
[new Color(1, 0, 0), new Color(0, 1, 0)],
'color'
);Position Nodes
| Node | Description | Type |
|---|---|---|
positionGeometry | Raw geometry position | vec3 |
positionLocal | Local transformed (post-skinning) | vec3 |
positionWorld | World space position | vec3 |
positionWorldDirection | Normalized world direction | vec3 |
positionView | View space position | vec3 |
positionViewDirection | Normalized view direction | vec3 |
Normal Nodes
| Node | Type |
|---|---|
normalGeometry | vec3 |
normalLocal | vec3 |
normalView | vec3 normalized |
normalWorld | vec3 normalized |
normalViewGeometry | vec3 |
normalWorldGeometry | vec3 |
Camera Data
| Variable | Type |
|---|---|
cameraNear, cameraFar | float |
cameraProjectionMatrix, cameraViewMatrix, cameraWorldMatrix | mat4 |
cameraProjectionMatrixInverse | mat4 |
cameraNormalMatrix | mat3 |
cameraPosition | vec3 |
Screen & Viewport
// Screen (frame buffer in physical pixels)
screenUV // Normalized coordinate (0-1)
screenCoordinate // Physical pixels
screenSize // Dimensions in physical pixels
screenDPR // Device pixel ratio
// Viewport (from renderer.setViewport())
viewportUV // Normalized coordinate
viewport // vec4 dimensions
viewportCoordinate // Physical pixel coordinate
viewportSize // Dimensions
viewportSharedTexture() // Previously rendered content
viewportDepthTexture() // Depth access
viewportLinearDepth // Orthographic depthTexture Operations
texture(texture, uv, level) // vec4 with interpolation
textureLoad(texture, uv, level) // vec4 without interpolation
textureStore(texture, uv, value) // void
textureSize(texture, level) // ivec2
textureBicubic(node, strength) // vec4 bicubic filtering
cubeTexture(texture, uvw) // vec4 from cube map
texture3D(texture, uvw) // vec4 from 3D texture
triplanarTexture(texX, texY, texZ, scale, pos, normal) // Triplanar mappingUV Utilities
matcapUV // vec2 for matcap
rotateUV(uv, rotation, center) // vec2 rotated
spherizeUV(uv, strength, center) // vec2 spherical distortion
spritesheetUV(count, uv, frame) // vec2 for sprite animation
equirectUV(direction) // vec2 for equirectangular mappingColor Adjustments
luminance(node) // Perceived brightness (float)
saturation(node, adjustment) // Adjust saturation (color)
vibrance(node, adjustment) // Enhance less saturated colors (color)
hue(node, adjustment) // Rotate hue in radians (color)
posterize(node, steps) // Reduce color levels (color)
material.colorNode = saturation(texture(map), 1.5);
material.colorNode = hue(texture(map), Math.PI / 2);Fog
fog(color, factor)
rangeFogFactor(near, far) // Linear fog
densityFogFactor(density) // Exponential squared fog
scene.fogNode = fog(color(0x000000), rangeFogFactor(10, 100));Flow Control
Discard() // Discard current fragment
Return() // Return from function
Break() // Exit loop
Continue() // Next iteration
const customFragment = Fn(() => {
If(uv().x.lessThan(0.5), () => {
Discard();
});
return vec4(1, 0, 0, 1);
});Utilities
billboarding({ position, horizontal, vertical }) // Face camera
checker(coord) // Checker pattern
// Full billboarding
material.vertexNode = billboarding();
// Horizontal only (for trees)
material.vertexNode = billboarding({ horizontal: true, vertical: false });Structs
const BoundingBox = struct({ min: 'vec3', max: 'vec3' });
// Create instance
const bb = BoundingBox(vec3(0), vec3(1));
const bb2 = BoundingBox({ min: vec3(0), max: vec3(1) });
// Access members
const min = bb.get('min');
min.assign(vec3(-1));References
TSL Compute Shaders
GPU compute shaders using TSL for WebGPU - enables GPGPU operations like particle systems, physics, and simulations.
Basic Compute Shader
import { Fn, instancedArray, instanceIndex, deltaTime } from 'three/tsl';
const COUNT = 1000000; // 1 million particles
// Storage buffers
const positionBuffer = instancedArray(COUNT, 'vec3');
const velocityBuffer = instancedArray(COUNT, 'vec3');
// Define compute shader
const computeParticles = Fn(() => {
const position = positionBuffer.element(instanceIndex);
const velocity = velocityBuffer.element(instanceIndex);
position.addAssign(velocity.mul(deltaTime));
})().compute(COUNT);
// Execute in render loop
renderer.compute(computeParticles);Atomic Operations
// Available atomic functions
atomicAdd(buffer, value)
atomicSub(buffer, value)
atomicMax(buffer, value)
atomicMin(buffer, value)
atomicAnd(buffer, value)
atomicOr(buffer, value)
atomicXor(buffer, value)
atomicStore(buffer, value)
atomicLoad(buffer)Barriers
// Synchronization barriers
workgroupBarrier() // Sync within workgroup
storageBarrier() // Sync storage buffer access
textureBarrier() // Sync texture access
barrier() // Full barrierCompute Variables
| Variable | Description |
|---|---|
workgroupId | ID of current workgroup |
localId | Local invocation ID |
globalId | Global invocation ID |
numWorkgroups | Total number of workgroups |
subgroupSize | Size of subgroup |
Particle System Example
import {
Fn, instancedArray, instanceIndex, deltaTime,
If, float, vec3
} from 'three/tsl';
const COUNT = 100000;
const positionBuffer = instancedArray(COUNT, 'vec3');
const velocityBuffer = instancedArray(COUNT, 'vec3');
// Initialize positions
for (let i = 0; i < COUNT; i++) {
positionBuffer.array[i * 3 + 0] = (Math.random() - 0.5) * 100;
positionBuffer.array[i * 3 + 1] = Math.random() * 100;
positionBuffer.array[i * 3 + 2] = (Math.random() - 0.5) * 100;
velocityBuffer.array[i * 3 + 0] = 0;
velocityBuffer.array[i * 3 + 1] = -9.81; // Gravity
velocityBuffer.array[i * 3 + 2] = 0;
}
// Compute shader with physics
const computeParticles = Fn(() => {
const position = positionBuffer.element(instanceIndex);
const velocity = velocityBuffer.element(instanceIndex);
// Apply velocity
position.addAssign(velocity.mul(deltaTime));
// Bounce off ground
If(position.y.lessThan(0), () => {
velocity.y.assign(velocity.y.negate().mul(0.8)); // Damping
position.y.assign(0);
});
})().compute(COUNT);
// In render loop
function animate() {
renderer.compute(computeParticles);
renderer.render(scene, camera);
}GPGPU with Render Targets (WebGL Fallback)
For WebGL without compute shaders:
// Create position texture
const size = 256; // 256x256 = 65536 particles
const data = new Float32Array(size * size * 4);
for (let i = 0; i < size * size; i++) {
data[i * 4 + 0] = Math.random() * 100 - 50; // x
data[i * 4 + 1] = Math.random() * 100 - 50; // y
data[i * 4 + 2] = Math.random() * 100 - 50; // z
data[i * 4 + 3] = 1; // w
}
const positionTexture = new THREE.DataTexture(
data, size, size,
THREE.RGBAFormat, THREE.FloatType
);
positionTexture.needsUpdate = true;
// Ping-pong render targets
const rtA = new THREE.WebGLRenderTarget(size, size, {
type: THREE.FloatType,
format: THREE.RGBAFormat
});
const rtB = rtA.clone();
// Update shader
const updateMaterial = new THREE.ShaderMaterial({
uniforms: {
positions: { value: positionTexture },
time: { value: 0 }
},
fragmentShader: `
uniform sampler2D positions;
uniform float time;
varying vec2 vUv;
void main() {
vec4 pos = texture2D(positions, vUv);
pos.y += sin(time + pos.x * 0.1) * 0.01;
gl_FragColor = pos;
}
`
});Performance Comparison
| Method | Particles at 60fps |
|---|---|
| CPU | ~50,000 |
| GPGPU (WebGL) | ~500,000 |
| Compute Shaders (WebGPU) | 1,000,000+ (<1ms update) |
Best Practices
1. Workgroup Size: Use multiples of 64 for optimal GPU utilization
2. Memory Access: Coalesce memory access patterns when possible
3. Barriers: Only use barriers when synchronization is necessary
4. Buffer Types: Use appropriate buffer types for your data
5. Fallback: Provide WebGL fallback for browsers without WebGPU support
References
TSL Material Node Slots
Complete reference for all material node slots available in TSL.
Core Slots
| Slot | Description | Type |
|---|---|---|
.fragmentNode | Replace fragment shader logic | vec4 |
.vertexNode | Replace vertex shader logic | vec4 |
.geometryNode | Execute geometry operations | Fn() |
Basic Slots
| Slot | Description | Reference | Type |
|---|---|---|---|
.colorNode | Base color x map | materialColor | vec4 |
.depthNode | Depth output | depth | float |
.opacityNode | Opacity x alphaMap | materialOpacity | float |
.alphaTestNode | Alpha threshold | materialAlphaTest | float |
.positionNode | Vertex position + displacement | positionLocal | vec3 |
Lighting Slots
| Slot | Type |
|---|---|
.emissiveNode | color |
.normalNode | vec3 |
.lightsNode | Lighting model |
.envNode | color |
Shadow Slots
| Slot | Description | Type |
|---|---|---|
.castShadowNode | Shadow color/opacity | vec4 |
.maskShadowNode | Shadow mask | bool |
.receivedShadowNode | Shadow reception | Fn() |
.receivedShadowPositionNode | Shadow projection position | vec3 |
.aoNode | Ambient occlusion | float |
Output Slots
| Slot | Description | Type |
|---|---|---|
.maskNode | Fragment mask | bool |
.mrtNode | Custom MRT config | mrt() |
.outputNode | Final output | vec4 |
MeshPhysicalNodeMaterial Specific
| Slot | Type |
|---|---|
.clearcoatNode | float |
.clearcoatRoughnessNode | float |
.clearcoatNormalNode | vec3 |
.sheenNode | color |
.iridescenceNode | float |
.iridescenceIORNode | float |
.iridescenceThicknessNode | float |
.specularIntensityNode | float |
.specularColorNode | color |
.iorNode | float |
.transmissionNode | color |
.thicknessNode | float |
.attenuationDistanceNode | float |
.attenuationColorNode | color |
.dispersionNode | float |
.anisotropyNode | vec2 |
Usage Examples
Color with Time Animation
import { color, time, sin } from 'three/tsl';
const material = new MeshStandardNodeMaterial();
material.colorNode = color(0xff0000).mul(sin(time).mul(0.5).add(0.5));Custom Normal Mapping
import { normalMap, texture, normalLocal } from 'three/tsl';
material.normalNode = normalMap(texture(normalTexture));Vertex Displacement
import { positionLocal, normalLocal, sin, time } from 'three/tsl';
material.positionNode = positionLocal.add(
normalLocal.mul(sin(time.add(positionLocal.y)).mul(0.1))
);Alpha Cutout
import { texture, float } from 'three/tsl';
material.opacityNode = texture(alphaMap).r;
material.alphaTestNode = float(0.5);PBR Properties
import { float, color } from 'three/tsl';
const material = new MeshPhysicalNodeMaterial();
material.clearcoatNode = float(1.0);
material.clearcoatRoughnessNode = float(0.1);
material.transmissionNode = float(0.9);
material.iorNode = float(1.5);
material.thicknessNode = float(0.5);TSL Post-Processing
Modern post-processing using TSL (Three.js Shading Language) for WebGPU and WebGL.
TSL Post-Processing Setup
import { pass, bloom, gaussianBlur, grayscale } from 'three/tsl';
const scenePass = pass(scene, camera);
const beauty = scenePass.getTextureNode();
// Chain effects
postProcessing.outputNode = bloom(grayscale(beauty), 1, 0.4, 0.85);Available Effects
| Effect | Signature |
|---|---|
| After Image | afterImage(node, damp) |
| Anamorphic Flare | anamorphic(node, threshold, scale, samples) |
| Bloom | bloom(node, strength, radius, threshold) |
| Box Blur | boxBlur(textureNode, options) |
| Chromatic Aberration | chromaticAberration(node, strength, center, scale) |
| Denoise | denoise(node, depthNode, normalNode, camera) |
| Depth of Field | dof(node, viewZ, focusDistance, focalLength, bokehScale) |
| Dot Screen | dotScreen(node, angle, scale) |
| Film Grain | film(inputNode, intensity, uv) |
| FXAA | fxaa(node) |
| Gaussian Blur | gaussianBlur(node, direction, sigma, options) |
| Grayscale | grayscale(color) |
| Hash Blur | hashBlur(textureNode, blurAmount, options) |
| LUT Grading | lut3D(node, lut, size, intensity) |
| Motion Blur | motionBlur(inputNode, velocity, samples) |
| Outline | outline(scene, camera, params) |
| RGB Shift | rgbShift(node, amount, angle) |
| Sepia | sepia(color) |
| SMAA | smaa(node) |
| Sobel | sobel(node) |
| SSR | ssr(colorNode, depthNode, normalNode, metalness, roughness, camera) |
| SSGI | ssgi(beautyNode, depthNode, normalNode, camera) |
| AO | ao(depthNode, normalNode, camera) |
| Transition | transition(nodeA, nodeB, mixTexture, ratio, threshold, useTexture) |
| TRAA | traa(beautyNode, depthNode, velocityNode, camera) |
Common Patterns
Bloom
import { pass, bloom } from 'three/tsl';
const scenePass = pass(scene, camera);
const beauty = scenePass.getTextureNode();
postProcessing.outputNode = bloom(beauty, 1, 0.4, 0.85);
// Parameters: node, strength, radius, thresholdGaussian Blur
import { gaussianBlur, pass } from 'three/tsl';
const scenePass = pass(scene, camera);
const beauty = scenePass.getTextureNode();
postProcessing.outputNode = gaussianBlur(beauty, 4);
// Parameters: node, sigmaColor Grading Pipeline
import { pass, grayscale, saturation, hue, bloom } from 'three/tsl';
const scenePass = pass(scene, camera);
const beauty = scenePass.getTextureNode();
// Chain multiple effects
const graded = saturation(hue(beauty, 0.1), 1.2);
const final = bloom(graded, 0.5, 0.4, 0.9);
postProcessing.outputNode = final;Depth of Field
import { pass, dof } from 'three/tsl';
const scenePass = pass(scene, camera);
const beauty = scenePass.getTextureNode();
const depth = scenePass.getDepthNode();
postProcessing.outputNode = dof(
beauty,
depth,
5, // focusDistance
0.02, // focalLength
0.025 // bokehScale
);TSL vs EffectComposer
EffectComposer (WebGL Traditional)
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(resolution, strength, radius, threshold));
// In render loop
composer.render();TSL Post-Processing (Modern)
import { pass, bloom } from 'three/tsl';
const scenePass = pass(scene, camera);
const beauty = scenePass.getTextureNode();
postProcessing.outputNode = bloom(beauty, 1, 0.4, 0.85);Best Practices
1. Tone Mapping: When using postprocessing, set renderer.toneMapping = NoToneMapping and add tone mapping as last effect
2. Precision: Use HalfFloatType for high precision frame buffers
3. Anti-Aliasing: WebGL AA is bypassed with postprocessing; add FXAA/SMAA at the end
4. Performance: Combine multiple effects into single pass when possible
5. Order: RenderPass always first, AA always last
Alternative: pmndrs/postprocessing
High-performance post-processing library from Poimandres ecosystem:
- Better performance than built-in EffectComposer
- Optimized effects
- Better selective bloom support
npm install postprocessingReferences
tsl-why-use
Use TSL instead of onBeforeCompile hacks for custom materials.
Why It Matters
TSL (Three.js Shading Language) is the modern approach to shader creation in Three.js:
- Works with both WebGL and WebGPU backends
- No string manipulation or onBeforeCompile hacks
- Type-safe, composable shader nodes
- Automatic optimization and tree shaking
- Easier to maintain and debug
Bad Example
// OLD - onBeforeCompile (fragile, hard to maintain)
const material = new THREE.MeshStandardMaterial();
material.map = colorMap;
material.onBeforeCompile = (shader) => {
shader.uniforms.detailMap = { value: detailMap };
let token = '#define STANDARD';
let insert = `uniform sampler2D detailMap;`;
shader.fragmentShader = shader.fragmentShader.replace(token, token + insert);
token = '#include <map_fragment>';
insert = `diffuseColor *= texture2D(detailMap, vMapUv * 10.0);`;
shader.fragmentShader = shader.fragmentShader.replace(token, token + insert);
};Problems:
- String manipulation is error-prone
- Breaks with Three.js updates
- Hard to combine multiple modifications
- No type safety
- Difficult to debug
Good Example
// NEW - TSL (clean, composable)
import { texture, uv } from 'three/tsl';
const detail = texture(detailMap, uv().mul(10));
const material = new THREE.MeshStandardNodeMaterial();
material.colorNode = texture(colorMap).mul(detail);Benefits:
- Clean, readable code
- Composable nodes
- Works with WebGL and WebGPU
- Type-safe with TypeScript
- Automatic optimization
More TSL Examples
Animated Color
import { color, time, sin } from 'three/tsl';
const material = new THREE.MeshStandardNodeMaterial();
material.colorNode = color(0x00ff00).mul(sin(time).mul(0.5).add(0.5));Vertex Displacement
import { positionLocal, sin, time } from 'three/tsl';
const material = new THREE.MeshStandardNodeMaterial();
material.positionNode = positionLocal.add(
sin(time.add(positionLocal.y)).mul(0.1)
);Custom Function
import { Fn, vec3, float } from 'three/tsl';
const oscSine = Fn(([t = time]) => {
return t.add(0.75).mul(Math.PI * 2).sin().mul(0.5).add(0.5);
});
material.colorNode = vec3(oscSine(), 0, 0);References
WebGPU Renderer
Source: 100 Three.js Tips - Utsubo
WebGPU is the next-generation graphics API. Three.js provides zero-config WebGPU with automatic WebGL 2 fallback.
Setup
import { WebGPURenderer } from 'three/webgpu';
const renderer = new WebGPURenderer();
await renderer.init(); // Required before first render
function animate() {
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();Browser Support Matrix
| Browser | Version | Notes |
|---|---|---|
| Chrome/Edge | v113+ | Full support |
| Firefox | v141+ (Windows), v145+ (macOS ARM) | Requires flags in older versions |
| Safari | v26+ (September 2025) | WebKit support |
Key Tips
1. Use renderAsync for Compute-Heavy Scenes
async function animate() {
await renderer.renderAsync(scene, camera);
requestAnimationFrame(animate);
}Ensures compute passes complete before dependent render passes.
2. Force WebGL for Testing
const renderer = new WebGPURenderer({ forceWebGL: true });Useful for testing fallback behavior and debugging shader differences.
3. Feature Detection
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) return; // Fallback to WebGL
const hasFloat32Filtering = adapter.features.has('float32-filterable');
const hasTimestamps = adapter.features.has('timestamp-query');4. GPU-Persistent Buffers with instancedArray
import { instancedArray } from 'three/tsl';
const positions = instancedArray(particleCount, 'vec3');
const velocities = instancedArray(particleCount, 'vec3');CPU-based particle updates plateau around 50,000 particles; compute shaders enable millions.
5. Storage Textures for Read-Write Compute
import { storageTexture, textureStore, uvec2 } from 'three/tsl';
const outputTexture = new StorageTexture(width, height);
const store = textureStore(outputTexture, uvec2(x, y), computedColor);6. Workgroup Shared Memory
import { workgroupArray, workgroupBarrier } from 'three/tsl';
const sharedData = workgroupArray('float', 256);
sharedData.element(localIndex).assign(inputData);
workgroupBarrier();Shared memory operates 10-100x faster than global memory.
7. Indirect Draws for GPU-Driven Rendering
Let GPU determine what renders based on compute shader output, enabling frustum culling on GPU.
When to Migrate to WebGPU
Prioritize migration when hitting performance walls in:
- Draw-call-heavy scenes
- Complex particle systems (>50k particles)
- Compute-intensive effects
- Complex shader pipelines
Expect 2-10x performance gains in these specific scenarios.
Best Practices
1. Minimize buffer updates per frame - Batch multiple small updates into single operations 2. Group frequently-updated uniforms - WebGPU batches resources into bind groups; separate static from dynamic data 3. Use compute shaders for physics - Move CPU-bound physics to GPU compute 4. Debug with Chrome WebGPU DevTools - Enable "WebGPU Developer Features" in chrome://flags
WebXR Setup
Guide to implementing VR and AR experiences with Three.js WebXR.
Basic Setup
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { ARButton } from 'three/addons/webxr/ARButton.js';
// Enable XR
renderer.xr.enabled = true;
// VR
document.body.appendChild(VRButton.createButton(renderer));
// AR
document.body.appendChild(ARButton.createButton(renderer, {
requiredFeatures: ['hit-test']
}));
// Animation loop for XR (required)
renderer.setAnimationLoop(function() {
renderer.render(scene, camera);
});Reference Spaces
// AR - local space
renderer.xr.setReferenceSpaceType('local');
// VR - room-scale
renderer.xr.setReferenceSpaceType('local-floor');
// VR - seated
renderer.xr.setReferenceSpaceType('local');
// VR - unbounded (large areas)
renderer.xr.setReferenceSpaceType('unbounded');Controllers
const controller1 = renderer.xr.getController(0);
const controller2 = renderer.xr.getController(1);
scene.add(controller1, controller2);
// Events
controller1.addEventListener('selectstart', onSelectStart);
controller1.addEventListener('selectend', onSelectEnd);
controller1.addEventListener('squeeze', onSqueeze);
// Controller models
import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
const controllerModelFactory = new XRControllerModelFactory();
const controllerGrip1 = renderer.xr.getControllerGrip(0);
controllerGrip1.add(controllerModelFactory.createControllerModel(controllerGrip1));
scene.add(controllerGrip1);
const controllerGrip2 = renderer.xr.getControllerGrip(1);
controllerGrip2.add(controllerModelFactory.createControllerModel(controllerGrip2));
scene.add(controllerGrip2);Hand Tracking
import { XRHandModelFactory } from 'three/addons/webxr/XRHandModelFactory.js';
const handModelFactory = new XRHandModelFactory();
const hand1 = renderer.xr.getHand(0);
hand1.add(handModelFactory.createHandModel(hand1, 'mesh'));
scene.add(hand1);
const hand2 = renderer.xr.getHand(1);
hand2.add(handModelFactory.createHandModel(hand2, 'mesh'));
scene.add(hand2);AR Hit Testing
let hitTestSource = null;
let hitTestSourceRequested = false;
renderer.xr.addEventListener('sessionstart', async () => {
const session = renderer.xr.getSession();
const viewerSpace = await session.requestReferenceSpace('viewer');
hitTestSource = await session.requestHitTestSource({ space: viewerSpace });
});
renderer.xr.addEventListener('sessionend', () => {
hitTestSource = null;
hitTestSourceRequested = false;
});
function animate(timestamp, frame) {
if (frame && hitTestSource) {
const referenceSpace = renderer.xr.getReferenceSpace();
const hitTestResults = frame.getHitTestResults(hitTestSource);
if (hitTestResults.length > 0) {
const hit = hitTestResults[0];
const pose = hit.getPose(referenceSpace);
reticle.visible = true;
reticle.matrix.fromArray(pose.transform.matrix);
} else {
reticle.visible = false;
}
}
renderer.render(scene, camera);
}AR Features
// Request specific AR features
document.body.appendChild(ARButton.createButton(renderer, {
requiredFeatures: ['hit-test'],
optionalFeatures: ['dom-overlay', 'light-estimation'],
domOverlay: { root: document.body }
}));
// Light estimation
renderer.xr.addEventListener('sessionstart', () => {
const session = renderer.xr.getSession();
session.requestLightProbe().then((lightProbe) => {
// Use light probe data
});
});Teleportation (VR)
const marker = new THREE.Mesh(
new THREE.CircleGeometry(0.25, 32).rotateX(-Math.PI / 2),
new THREE.MeshBasicMaterial({ color: 0x00ff00 })
);
controller.addEventListener('selectstart', () => {
const intersects = getIntersections(controller);
if (intersects.length > 0) {
const point = intersects[0].point;
// Move user to point
baseReferenceSpace = renderer.xr.getReferenceSpace();
const offsetPosition = { x: -point.x, y: 0, z: -point.z, w: 1 };
const transform = new XRRigidTransform(offsetPosition);
const teleportSpace = baseReferenceSpace.getOffsetReferenceSpace(transform);
renderer.xr.setReferenceSpace(teleportSpace);
}
});Session Events
renderer.xr.addEventListener('sessionstart', () => {
console.log('XR session started');
});
renderer.xr.addEventListener('sessionend', () => {
console.log('XR session ended');
});
// Check if in XR
if (renderer.xr.isPresenting) {
// Currently in XR
}Browser Support (2025)
| Browser | VR | AR |
|---|---|---|
| Chrome (Android) | Yes | Yes |
| Samsung Internet | Yes | Yes |
| Meta Quest Browser | Yes | Limited |
| Safari (iOS) | No | No |
| Firefox Reality | Yes | Yes |
Best Practices
1. setAnimationLoop: Always use renderer.setAnimationLoop() for XR, not manual RAF
2. Reference Space: Choose appropriate reference space for your experience
3. Controller Fallback: Provide gaze/pointer fallback for devices without controllers
4. Performance: Target 72-90 fps for comfortable VR experience
5. Comfort: Avoid artificial locomotion; prefer teleportation
6. UI: Place UI at comfortable distance (1-3 meters)
7. Testing: Test on actual devices, not just emulators
References
Related skills
How it compares
Pick three-best-practices over generic frontend lint skills when agents write Three.js code needing GPU memory and RAF loop guardrails.
FAQ
What memory rules does three-best-practices enforce?
three-best-practices mandates dispose for geometry, materials, textures, render targets, and the renderer on unmount, plus recursive dispose and object reuse patterns in Priority 1 rules.
How does three-best-practices optimize render loops?
three-best-practices requires a single requestAnimationFrame loop, conditional rendering, delta-time updates, frustum culling, cached computations, and controlled device pixel ratio settings.
When should developers enable three-best-practices?
Enable three-best-practices during agent-assisted Three.js development when WebGL apps show memory growth, multiple RAF loops, or missing dispose calls on scene teardown.
Is Three Best Practices safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.