📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-08-14 17:16:04 +08:00
parent ad5eeaaf5b
commit 14e7209e78
283 changed files with 201717 additions and 13469 deletions
@@ -1,54 +1,54 @@
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
1,Setup,CDN Version Lock,Always use Three.js r128 from cdnjs. It is the stable CDN baseline. Never use a floating 'latest' URL — it silently breaks when the CDN updates without warning.,Pin to the exact r128 cdnjs URL in every HTML file,Use unpkg@latest or any unversioned CDN URL that can silently update,"<script src=""https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js""></script>","<script src=""https://unpkg.com/three@latest""></script>",Critical,https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js
2,Setup,CapsuleGeometry Does Not Exist in r128,THREE.CapsuleGeometry was introduced in r142. Using it on the r128 CDN throws 'CapsuleGeometry is not a constructor' and crashes the entire scene. Build a capsule from primitives instead.,Build a capsule from CylinderGeometry plus two SphereGeometry end caps,Call THREE.CapsuleGeometry on r128 — it is undefined and throws immediately,"const body = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 1, 16), mat); const topCap = new THREE.Mesh(new THREE.SphereGeometry(0.5, 16, 8), mat); const botCap = new THREE.Mesh(new THREE.SphereGeometry(0.5, 16, 8), mat); topCap.position.y = 0.5; botCap.position.y = -0.5; const group = new THREE.Group(); group.add(body, topCap, botCap);","const cap = new THREE.CapsuleGeometry(0.5, 1, 4, 8); // TypeError: CapsuleGeometry is not a constructor on r128",Critical,https://threejs.org/docs/#api/en/geometries/CapsuleGeometry
3,Setup,OrbitControls Must Be Loaded Separately,OrbitControls is NOT bundled in the core Three.js r128 CDN file. It lives in examples/js and must be loaded from a separate cdnjs script tag. THREE.OrbitControls is undefined without it.,Load the OrbitControls script from cdnjs examples path before your scene script,Expect THREE.OrbitControls to exist after loading only the core Three.js CDN script,"<!-- Load AFTER core Three.js script --> <script src=""https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/examples/js/controls/OrbitControls.min.js""></script>","<!-- Core only loaded — OrbitControls undefined --> <script src=""https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js""></script>",Critical,https://cdnjs.com/libraries/three.js/r128
4,Setup,Custom Drag Orbit Fallback,When OrbitControls cannot be loaded implement spherical orbit using mousedown/mousemove/mouseup. The key is rotating in spherical coordinates so both horizontal AND vertical drag work correctly.,Rotate camera in spherical coordinates so both axes respond correctly to drag,Move camera.position.x directly — vertical drag is silently ignored and the orbit is incorrect,"let dragging = false; let prev = { x: 0, y: 0 }; const radius = camera.position.length(); let theta = 0; let phi = Math.PI / 2; canvas.addEventListener('mousedown', () => dragging = true); canvas.addEventListener('mouseup', () => dragging = false); canvas.addEventListener('mousemove', e => { if (!dragging) return; theta -= (e.clientX - prev.x) * 0.005; phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi - (e.clientY - prev.y) * 0.005)); camera.position.set(radius * Math.sin(phi) * Math.sin(theta), radius * Math.cos(phi), radius * Math.sin(phi) * Math.cos(theta)); camera.lookAt(scene.position); prev = { x: e.clientX, y: e.clientY }; });","let dragging = false; let prev = { x: 0, y: 0 }; canvas.addEventListener('mousemove', e => { if (!dragging) return; camera.position.x += (e.clientX - prev.x) * 0.005; camera.lookAt(scene.position); prev = { x: e.clientX, y: e.clientY }; }); // BUG: Y-drag ignored; orbit is a horizontal slide not a sphere",High,https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent
5,Setup,ESM vs CDN Import,When using a bundler import Three.js as an ES module. When using CDN the THREE global is already available — do not import it again. Mixing both loads Three.js twice and causes subtle runtime errors.,Match import style to build environment: ESM import for bundlers; rely on the window.THREE global for CDN pages,Mix a CDN script tag with an ES module import in the same file,// Bundler project (Vite / webpack): import * as THREE from 'three'; // CDN project — no import needed; THREE is already global after the script tag,"<!-- CDN script --> <script src=""r128.cdn""></script> <script type=""module""> import * as THREE from 'three'; // loads Three.js twice — version mismatch risk </script>",Critical,https://threejs.org/docs/#manual/en/introduction/Installation
6,Setup,Single Renderer Per Page,Create one WebGLRenderer instance for the lifetime of the page. Multiple renderers compete for the browser GPU context limit (816 contexts) and cause context-lost errors especially on mobile.,Reuse a single renderer and swap scene content instead of recreating the renderer,Create a new renderer on each component mount or scene transition,"const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(canvas.clientWidth, canvas.clientHeight); // renderer lives for the page lifetime",function showScene() { const renderer = new THREE.WebGLRenderer(); document.body.appendChild(renderer.domElement); } showScene(); showScene(); // two GPU contexts — crashes on mobile,Critical,https://threejs.org/docs/#api/en/renderers/WebGLRenderer
7,Setup,Pixel Ratio Cap at 2,Cap devicePixelRatio at 2. Retina displays report 3x or higher. Going from 2x to 3x multiplies pixel count by 2.25x with no visible quality improvement at normal viewing distance.,"Apply Math.min(window.devicePixelRatio, 2) — cap is at 2 not at 3",Pass window.devicePixelRatio directly without any cap,"renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));",renderer.setPixelRatio(window.devicePixelRatio); // 3x display = 9 pixels per CSS pixel = 2.25x GPU cost for zero quality gain,High,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setPixelRatio
8,Setup,Alpha Canvas Plus CSS Background,Set alpha:true on the renderer and control the background color through CSS rather than a renderer clear color. This composites the canvas correctly over any HTML content behind it.,Set alpha:true on renderer and let body or a parent div provide the background color,Set a solid renderer clear color when the canvas must composite over HTML behind it,"const renderer = new THREE.WebGLRenderer({ alpha: true }); renderer.setClearColor(0x000000, 0); // fully transparent canvas // body { background: #0d0d0d; } handles the visible color",renderer.setClearColor(0x111827); // fully opaque — HTML behind the canvas is blocked,Medium,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setClearColor
9,Camera,Aspect Ratio on Resize,Always update camera.aspect and call camera.updateProjectionMatrix() inside every resize handler. A stale aspect ratio causes the entire scene to appear stretched or squashed horizontally.,Update camera.aspect then call updateProjectionMatrix() on every resize,Let aspect ratio become stale after the browser window changes size,"window.addEventListener('resize', () => { camera.aspect = canvas.clientWidth / canvas.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(canvas.clientWidth, canvas.clientHeight); });",// No resize handler — scene stretches to fill a wider window without correcting the projection,High,https://threejs.org/docs/#api/en/cameras/PerspectiveCamera
10,Camera,FOV Range 45 to 75,Use a field of view between 45 and 75 degrees. Below 45 creates compressed telephoto distortion. Above 90 creates visible fisheye distortion at frame edges.,Start at 75 for general interactive scenes; use 4555 for product close-ups,Use FOV above 90 or below 30 without a deliberate artistic reason,"const camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000); // general const camera = new THREE.PerspectiveCamera(50, aspect, 0.1, 1000); // product shot","const camera = new THREE.PerspectiveCamera(120, aspect, 0.1, 1000); // fisheye distortion at all edges",Medium,https://threejs.org/docs/#api/en/cameras/PerspectiveCamera
11,Camera,Explicit Position and lookAt,Always set an explicit camera position and call camera.lookAt() before the first render. The default camera at the origin pointing down -Z makes subjects at arbitrary coordinates invisible or tiny.,Set camera.position.set() and camera.lookAt() to frame the subject before the first render,Leave the camera at default position (0 0 0) with no lookAt — subject may be behind the camera or microscopic,"camera.position.set(0, 1.5, 5); camera.lookAt(new THREE.Vector3(0, 0, 0));",// No position or lookAt set — subject at y:2 is behind or above the default camera view,Medium,https://threejs.org/docs/#api/en/cameras/Camera.lookAt
12,Camera,OrbitControls vs GSAP Camera Rig,Use OrbitControls for model viewers and exploratory scenes where the user needs free-look. Use a GSAP scroll-driven camera rig for product reveals or storytelling where the camera path must stay fixed.,Match camera control approach to the UX intent of the scene,Use OrbitControls for a scripted reveal users can orbit away from the reveal before it completes,"// Scroll storytelling — GSAP controls the path: gsap.to(camera.position, { z: 2, scrollTrigger: { trigger: '.scene', scrub: 1 } }); // Free-look model viewer — OrbitControls: const controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; // call controls.update() in animate()","// Scripted product reveal: const controls = new THREE.OrbitControls(camera, renderer.domElement); // user can rotate away before the animation completes",High,https://threejs.org/docs/#examples/en/controls/OrbitControls
13,Geometry,Never Create Geometry Per Frame,Creating a new geometry inside animate() allocates a fresh GPU buffer every frame and exhausts VRAM within seconds. Create all geometry exactly once before the loop starts. Use attribute mutation if positions must change per frame.,Create all geometry before the animation loop; mutate BufferAttribute arrays in-place if needed,Call any new XxxGeometry() constructor inside the animation loop,"const geo = new THREE.SphereGeometry(1, 32, 32); // created once const mesh = new THREE.Mesh(geo, mat); scene.add(mesh); const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); mesh.rotation.y += clock.getDelta() * 0.8; // delta time renderer.render(scene, camera); }","function animate() { requestAnimationFrame(animate); const geo = new THREE.BoxGeometry(1, 1, 1); // NEW GPU buffer every frame — VRAM exhaustion }",Critical,https://threejs.org/docs/#api/en/core/BufferGeometry
14,Geometry,Share Geometry Across Meshes,When multiple objects share the same shape create one geometry instance and pass it to every Mesh. Each Mesh gets its own transform and material while all share a single GPU buffer.,Create one geometry and pass the same reference to every Mesh constructor,Create a separate identical geometry inside a loop for each object,"const geo = new THREE.BoxGeometry(1, 1, 1); // one GPU buffer for (let i = 0; i < 200; i++) { const m = new THREE.Mesh(geo, mat); m.position.set(Math.random() * 10, 0, Math.random() * 10); scene.add(m); }","for (let i = 0; i < 200; i++) { const geo = new THREE.BoxGeometry(1, 1, 1); // 200 separate GPU buffers scene.add(new THREE.Mesh(geo, mat)); }",Critical,https://threejs.org/docs/#api/en/core/BufferGeometry
15,Geometry,dispose on Scene Removal,Call geometry.dispose() and material.dispose() and texture.dispose() for every texture map when removing objects from the scene. Three.js never releases GPU resources automatically — they stay in VRAM until explicitly freed.,Dispose of geometry + material + every texture map before calling scene.remove(),Call scene.remove() alone without any dispose calls,function removeMesh(mesh) { scene.remove(mesh); mesh.geometry.dispose(); if (mesh.material.map) mesh.material.map.dispose(); if (mesh.material.normalMap) mesh.material.normalMap.dispose(); mesh.material.dispose(); },scene.remove(mesh); // geometry and all texture maps stay in GPU VRAM forever,Critical,https://threejs.org/docs/#api/en/core/BufferGeometry.dispose
16,Geometry,Segment Count Budget,Use the minimum segment count that achieves the desired silhouette quality. Hero objects: 3264 segments. Background objects: 816. Particle stand-ins: 68. High counts on background geometry waste GPU draw calls with zero visible benefit.,Apply a tiered segment budget based on the visual priority of each object in the scene,Default every sphere and cylinder to 64+ segments regardless of its role,"const bgSphere = new THREE.SphereGeometry(0.5, 8, 8); // background const heroSphere = new THREE.SphereGeometry(1, 64, 64); // foreground product","const particleSphere = new THREE.SphereGeometry(0.1, 64, 64); // 64 segments × 1000 particles = massive overdraw",Medium,https://threejs.org/docs/#api/en/geometries/SphereGeometry
17,Geometry,BufferGeometry for Custom Vertex Data,For any custom shape use BufferGeometry with setAttribute('position' ...) and a Float32Array. The legacy THREE.Geometry class was removed in r125 and throws ReferenceError in r128.,Use THREE.BufferGeometry with a Float32Array position attribute for custom vertex data,Reference or instantiate the removed THREE.Geometry class,"const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3)); geo.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(normals), 3));","const geo = new THREE.Geometry(); geo.vertices.push(new THREE.Vector3(0, 0, 0)); // ReferenceError: Geometry is not defined in r128",High,https://threejs.org/docs/#api/en/core/BufferGeometry
18,Materials,MeshBasicMaterial vs MeshStandardMaterial,MeshBasicMaterial ignores all lights and is significantly cheaper — use it for UI overlays HUDs and flat-colored decorative elements. MeshStandardMaterial is PBR-accurate and requires lights. Never use StandardMaterial where BasicMaterial suffices.,Use MeshBasicMaterial for any object that does not need lighting; use MeshStandardMaterial for physical objects,Apply MeshStandardMaterial to flat UI elements that never receive light — lights still run for them,"const uiMat = new THREE.MeshBasicMaterial({ color: 0xffffff }); // no lighting cost const physMat = new THREE.MeshStandardMaterial({ color: 0x4f46e5, roughness: 0.4, metalness: 0.6 });",const mat = new THREE.MeshStandardMaterial({ color: 0xffffff }); // on a 2D HUD card — lighting calculation runs with no visual benefit,Medium,https://threejs.org/docs/#api/en/materials/MeshStandardMaterial
19,Materials,Share Material Instances,Share one material instance across all meshes that have identical properties. Call mat.clone() only when individual meshes genuinely need different property values. Duplicate materials waste GPU VRAM.,Assign the same material reference to all meshes with identical visual properties,Create a new material inside a loop for objects that look identical,"const mat = new THREE.MeshStandardMaterial({ color: 0x4f46e5, roughness: 0.5 }); meshA.material = mat; meshB.material = mat; meshC.material = mat; // one GPU material",for (const m of meshes) { m.material = new THREE.MeshStandardMaterial({ color: 0x4f46e5 }); } // N redundant GPU materials,High,https://threejs.org/docs/#api/en/materials/Material
20,Materials,Dispose Textures Explicitly,Textures are the single largest consumer of GPU VRAM in most Three.js scenes. Call texture.dispose() when switching scenes or removing objects — Three.js does not garbage-collect GPU resources automatically.,Track all loaded textures and call dispose() on each one during scene teardown or on object removal,Load textures without any cleanup path — they persist in VRAM for the entire page lifetime,const tex = new THREE.TextureLoader().load('img.jpg'); mesh.material.map = tex; // on teardown: tex.dispose(); mesh.material.dispose();,const tex = new THREE.TextureLoader().load('img.jpg'); scene.remove(mesh); // tex occupies GPU VRAM until page reload,High,https://threejs.org/docs/#api/en/textures/Texture.dispose
21,Lighting,Ambient Plus Directional Minimum,Any scene using MeshStandardMaterial or MeshPhongMaterial requires at minimum one AmbientLight (fill) and one DirectionalLight (shading direction). Without both the objects render as solid black — the material is there but no light reaches it.,Add AmbientLight for fill and DirectionalLight for shading whenever PBR or Phong materials are used,Use MeshStandardMaterial without adding any lights to the scene,"scene.add(new THREE.AmbientLight(0xffffff, 0.4)); const dirLight = new THREE.DirectionalLight(0xffffff, 1.0); dirLight.position.set(5, 10, 7.5); scene.add(dirLight);","const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: 0x4f46e5 })); scene.add(mesh); // renders completely black — no lights in scene",Critical,https://threejs.org/docs/#api/en/lights/DirectionalLight
22,Lighting,Enable shadowMap Before castShadow,renderer.shadowMap.enabled = true must be set before any castShadow or receiveShadow flags. Without it the shadow map is never allocated and all shadow flags are silently ignored.,Set renderer.shadowMap.enabled = true first then set castShadow and receiveShadow on lights and meshes,Set castShadow on a light or mesh without enabling renderer.shadowMap.enabled — shadows never render,renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; heroMesh.castShadow = true; ground.receiveShadow = true;,dirLight.castShadow = true; heroMesh.castShadow = true; // renderer.shadowMap.enabled never set — shadows silently do not render,High,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.shadowMap
23,Lighting,Selective Shadow Casting,Shadow map rendering redraws the entire scene from the light's perspective every frame. Enable castShadow only on the primary directional light and receiveShadow only on hero meshes and the ground plane.,Enable shadows only on the key light and the most important meshes,Enable castShadow and receiveShadow on every object in the scene including particles,renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; dirLight.castShadow = true; heroMesh.castShadow = true; ground.receiveShadow = true; // particles and background meshes: no shadow flags,for (const m of allMeshes) { m.castShadow = true; m.receiveShadow = true; } // shadow map pass over particle system — expensive with no visible gain,High,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.shadowMap
24,Lighting,Skip Lights for MeshBasicMaterial,MeshBasicMaterial completely ignores all scene lights. Adding lights solely to illuminate BasicMaterial objects wastes a light pass on every frame with zero visible effect.,Omit lights entirely when every material in the scene is MeshBasicMaterial,Add AmbientLight and DirectionalLight to a scene that uses only MeshBasicMaterial,"// Scene uses only MeshBasicMaterial — no lights needed const mat = new THREE.MeshBasicMaterial({ color: 0x00ffff }); const mesh = new THREE.Mesh(geo, mat); scene.add(mesh); // MeshBasicMaterial is always fully lit by definition","scene.add(new THREE.AmbientLight(0xffffff, 1.0)); // wasted per-frame light pass — BasicMaterial ignores it entirely",Low,https://threejs.org/docs/#api/en/materials/MeshBasicMaterial
25,Raycasting,Single Shared Raycaster,Create exactly one Raycaster instance outside all event handlers. Store mouse coordinates in pointermove (cheap). Call setFromCamera and intersectObjects together inside the animate() loop — once per frame instead of once per mouse event.,Create one Raycaster; store mouse in pointermove; call setFromCamera + intersectObjects inside animate(),Create a new THREE.Raycaster() inside a mousemove handler or call setFromCamera inside the event listener,"const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); canvas.addEventListener('pointermove', e => { // only store coords — no raycasting here mouse.x = (e.clientX / canvas.clientWidth) * 2 - 1; mouse.y = -(e.clientY / canvas.clientHeight) * 2 + 1; }); // setFromCamera and intersectObjects run once per frame in animate()","window.addEventListener('mousemove', e => { const rc = new THREE.Raycaster(); // new allocation per event rc.setFromCamera(mouse, camera); rc.intersectObjects(targets, true); // fires 200+ times/sec });",Critical,https://threejs.org/docs/#api/en/core/Raycaster
26,Raycasting,NDC Mouse Coordinates,Raycasting requires mouse in Normalized Device Coordinates: X from -1 (left) to +1 (right) and Y from +1 (top) to -1 (bottom). The Y axis is inverted relative to screen space. A missing negation on Y causes all raycasts to miss or hit the wrong objects.,Apply the full NDC formula — including the negation on the Y axis,Forget to negate Y — raycasting appears to work but hits objects mirrored vertically,mouse.x = (e.clientX / canvas.clientWidth) * 2 - 1; mouse.y = -(e.clientY / canvas.clientHeight) * 2 + 1; // Y is INVERTED,mouse.x = (e.clientX / canvas.clientWidth) * 2 - 1; mouse.y = (e.clientY / canvas.clientHeight) * 2 - 1; // BUG: Y not negated — raycasting is mirrored,Critical,https://threejs.org/docs/#api/en/core/Raycaster.setFromCamera
27,Raycasting,setFromCamera and intersectObjects in animate,Call raycaster.setFromCamera(mouse camera) and then raycaster.intersectObjects(targets true) inside the animate() loop. setFromCamera must come before intersectObjects every frame — without it the raycaster uses a stale ray direction.,Call setFromCamera then intersectObjects in order inside every animate() frame,Call intersectObjects without calling setFromCamera first — the raycaster uses a stale or zero ray,"function animate() { requestAnimationFrame(animate); raycaster.setFromCamera(mouse, camera); // update ray direction first const hits = raycaster.intersectObjects(targets, true); // then test intersections if (hits.length > 0) { document.body.style.cursor = 'pointer'; } else { document.body.style.cursor = 'auto'; } renderer.render(scene, camera); }","function animate() { requestAnimationFrame(animate); const hits = raycaster.intersectObjects(targets, true); // BUG: setFromCamera never called — stale ray — hits is always empty renderer.render(scene, camera); }",Critical,https://threejs.org/docs/#api/en/core/Raycaster
28,Raycasting,Recursive Flag for Groups and GLTF,Pass true as the second argument to intersectObjects when testing Groups or GLTF loaded models. Geometry lives on child Mesh objects — without recursive:true the parent group is tested but has no geometry and hits is always empty.,Use intersectObjects(targets true) for Groups or any loaded model,Raycast against a parent Group without the recursive flag,"const hits = raycaster.intersectObjects(scene.children, true); // catches all descendant meshes",const hits = raycaster.intersectObjects([modelGroup]); // recursive defaults to false — misses all children,High,https://threejs.org/docs/#api/en/core/Raycaster.intersectObjects
29,Raycasting,Cursor Feedback on Hover,Set document.body.style.cursor = 'pointer' when intersections are found and reset to 'auto' when none are found. Without cursor feedback users cannot discover that 3D objects are interactive.,Update cursor to pointer on hit; reset to auto on miss in the same animate loop block,Run raycasting and read hits without ever updating the cursor style,if (hits.length > 0) { document.body.style.cursor = 'pointer'; } else { document.body.style.cursor = 'auto'; },"raycaster.setFromCamera(mouse, camera); raycaster.intersectObjects(targets, true); // hits ignored — cursor never changes — objects feel non-interactive",Medium,https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
30,Animation,requestAnimationFrame Loop Only,Drive the render loop exclusively with requestAnimationFrame or renderer.setAnimationLoop(). Never use setInterval or setTimeout — they are not synchronized to the display refresh rate and keep running when the tab is hidden draining battery.,Use requestAnimationFrame or renderer.setAnimationLoop() as the sole render loop driver,Use setInterval or setTimeout for render timing,"function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate();","setInterval(() => renderer.render(scene, camera), 16); // not display-synced; runs at full speed even when tab is hidden",Critical,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setAnimationLoop
31,Animation,THREE.Clock for Delta Time,"Use THREE.Clock and clock.getDelta() for all time-based motion. A hardcoded increment like += 0.01 runs at 2x speed on 120Hz displays and at unpredictable speed when frames drop under load. CRITICAL: call getDelta() exactly ONCE per animate() frame and store the result in a local dt variable. getDelta() resets the internal clock on every call — a second call in the same frame always returns ~0, silently breaking any animation block that uses it.",Call clock.getDelta() once at the top of animate(); store result in dt; reuse dt everywhere in that frame,Call clock.getDelta() more than once per frame or inside a helper called from animate(),"const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); const dt = clock.getDelta(); // called ONCE — reuse dt below mesh.rotation.y += dt * 0.8; particles.rotation.y += dt * 0.1; // reuse dt, not a second getDelta() renderer.render(scene, camera); }",function animate() { requestAnimationFrame(animate); mesh.rotation.y += 0.01; // 0.01 rad/frame — runs 2x faster on 120Hz than on 60Hz },High,https://threejs.org/docs/#api/en/core/Clock
32,Animation,Lerp for Smooth Pointer Follow,Use value += (target - value) * alpha each frame to smoothly interpolate toward a moving target. An alpha of 0.030.1 produces organic easing for camera follow pointer-tracking and hover scale effects without requiring GSAP.,Apply the lerp formula each frame with a small alpha for smooth organic motion,Snap a value directly to the target producing an instant jarring jump,// In animate(): cameraTargetX = mouse.x * 3; camera.position.x += (cameraTargetX - camera.position.x) * 0.05; camera.position.y += (cameraTargetY - camera.position.y) * 0.05; camera.lookAt(scene.position);,// In animate(): camera.position.x = mouse.x * 3; // instant snap — jarring with no easing,Medium,https://threejs.org/docs/#api/en/math/MathUtils.lerp
33,Animation,GSAP for Multi-Step Sequences,Use GSAP timelines for any animation with more than two sequential steps or for scroll-linked camera paths. GSAP timelines can be paused reversed and scrubbed — far more maintainable than boolean state machines.,Use GSAP timelines for sequences with more than two steps and for scroll-driven animations,Implement multi-step sequences with boolean flags and manual frame counters,"const tl = gsap.timeline({ defaults: { ease: 'power2.out' } }); tl.from(mesh.position, { y: -3, duration: 1 }) .to(mesh.rotation, { y: Math.PI, duration: 1 }, '-=0.3') .to(camera.position, { z: 2, duration: 1.5 });",let step = 0; let t = 0; function animate() { if (step === 0 && (t += 0.01) >= 1) step = 1; } // grows unmanageable with 3+ steps,High,https://gsap.com/docs/v3/GSAP/Timeline/
34,Animation,Pause Render Loop on Tab Hidden,Use renderer.setAnimationLoop() as the loop driver so you can pass null to pause and a function to resume. Continuous rendering in a hidden tab wastes CPU GPU and battery with no user benefit.,Use renderer.setAnimationLoop(animate) to drive the loop; pass null to pause on visibilitychange,Drive with internal requestAnimationFrame and never stop the loop when the tab is hidden,"renderer.setAnimationLoop(animate); // use setAnimationLoop as the driver — not requestAnimationFrame inside animate function animate() { const dt = clock.getDelta(); renderer.render(scene, camera); } document.addEventListener('visibilitychange', () => { if (document.hidden) renderer.setAnimationLoop(null); else renderer.setAnimationLoop(animate); });","function animate() { requestAnimationFrame(animate); // self-referencing RAF cannot be stopped externally renderer.render(scene, camera); } animate(); // runs forever in background tab — drains battery",High,https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
35,GSAP,Load GSAP Before Scene Script,Load GSAP from its own CDN script tag before your scene script. In bundler projects install via npm and import. GSAP is a completely separate library from Three.js — never try to import it from the Three.js package.,Load GSAP CDN before the scene script; or npm install gsap and import separately,Import gsap from three or expect it to be defined without a separate load,"<!-- CDN: load GSAP before your scene script --> <script src=""https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js""></script> <!-- Bundler: --> // import gsap from 'gsap'; import { ScrollTrigger } from 'gsap/ScrollTrigger';",import gsap from 'three'; // undefined — GSAP has nothing to do with Three.js,Critical,https://gsap.com/docs/v3/Installation
36,GSAP,Register ScrollTrigger Before Use,Call gsap.registerPlugin(ScrollTrigger) once at the top of your script before any scrollTrigger config object. Without registration the ScrollTrigger name is undefined and the tween throws immediately.,Call gsap.registerPlugin(ScrollTrigger) as the first line before any gsap.to/from/timeline with scrollTrigger,Include scrollTrigger config in gsap.to() calls without first registering the plugin,"gsap.registerPlugin(ScrollTrigger); gsap.to(camera.position, { z: 2, scrollTrigger: { trigger: '.hero-section', scrub: 1 } });","gsap.to(mesh.position, { scrollTrigger: { trigger: '.section', scrub: true } }); // TypeError: ScrollTrigger is not a constructor — not registered",Critical,https://gsap.com/docs/v3/Plugins/ScrollTrigger/
37,GSAP,Tween Three.js Properties Directly,GSAP can tween any numeric JavaScript property including mesh.position.x mesh.rotation.y and material.opacity. No wrapper or adaptor is needed. Note: to tween material.opacity the material must have transparent:true set before the tween starts.,Pass Three.js object properties directly to gsap.to(); set transparent:true before tweening opacity,Use a plain proxy object then manually copy values to Three.js properties every frame,"gsap.to(mesh.rotation, { y: Math.PI * 2, duration: 2, ease: 'power1.inOut' }); mesh.material.transparent = true; // required before tweening opacity gsap.to(mesh.material, { opacity: 0, duration: 1 });","const tw = { v: 0 }; gsap.to(tw, { v: Math.PI * 2, onUpdate: () => mesh.rotation.y = tw.v }); // unnecessary proxy wrapper",Medium,https://gsap.com/docs/v3/GSAP/gsap.to()
38,GSAP,scrub for Scroll-Driven Camera Path,Use scrub:true or scrub:1 to link camera movement continuously to scroll position as a 01 ratio. scrub:1 adds a 1-second lag for cinematic smoothness. onEnter/onLeave fire only once and create jarring snaps — not the right tool for a camera path.,Use scrub:1 for any scroll-controlled camera movement,Use onEnter or onLeave callbacks for camera motion — they snap instead of scrubbing,"gsap.registerPlugin(ScrollTrigger); gsap.to(camera.position, { x: 5, y: 2, z: 0, ease: 'none', scrollTrigger: { trigger: '.canvas-wrapper', start: 'top top', end: 'bottom bottom', scrub: 1 } });","gsap.to(camera.position, { z: 0, scrollTrigger: { trigger: '.section', onEnter: () => {} } }); // fires once at scroll threshold — not a continuous scrub",High,https://gsap.com/docs/v3/Plugins/ScrollTrigger/
39,Performance,InstancedMesh for Repeated Objects,Use THREE.InstancedMesh when rendering 50 or more identical objects. It submits all N transforms in one draw call instead of N draw calls and reduces CPU-GPU communication overhead dramatically.,Use InstancedMesh for any group of 50+ meshes sharing the same geometry and material,Create 50+ separate Mesh objects with the same geometry and material,"const COUNT = 500; const iMesh = new THREE.InstancedMesh(geo, mat, COUNT); const matrix = new THREE.Matrix4(); for (let i = 0; i < COUNT; i++) { matrix.setPosition(Math.random()*10, Math.random()*10, Math.random()*10); iMesh.setMatrixAt(i, matrix); } iMesh.instanceMatrix.needsUpdate = true; scene.add(iMesh);","for (let i = 0; i < 500; i++) { scene.add(new THREE.Mesh(geo, mat)); } // 500 separate draw calls per frame",High,https://threejs.org/docs/#api/en/objects/InstancedMesh
40,Performance,Tone Mapping and sRGB Encoding,Enable ACESFilmicToneMapping and sRGBEncoding on the renderer for accurate perceptual color. Without tone mapping colors appear washed out or over-saturated. These are renderer properties set after construction and take effect immediately.,Set renderer.toneMapping and renderer.outputEncoding after construction for all production scenes,Leave tone mapping and output encoding at defaults when the scene targets realistic visuals,renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; renderer.outputEncoding = THREE.sRGBEncoding; // correct for r128,const renderer = new THREE.WebGLRenderer(); // defaults: NoToneMapping + LinearEncoding — colors appear flat and incorrect,Medium,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.toneMapping
41,Performance,antialias Set at Construction Only,The antialias option can only be set at WebGLRenderer construction time. Setting renderer.antialias after construction has absolutely no effect — the WebGL context is already created without it. Decide before instantiating.,Set antialias:true inside the WebGLRenderer constructor options object,Construct the renderer without antialias then try to enable it by assigning the property,"const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); // antialias baked into the WebGL context",const renderer = new THREE.WebGLRenderer(); renderer.antialias = true; // no effect — context created without AA — edges remain aliased,High,https://threejs.org/docs/#api/en/renderers/WebGLRenderer
42,Performance,FogExp2 for Depth and Far Culling,Use scene.fog to create atmospheric depth. As a secondary benefit objects that disappear into fog before the far plane stop contributing to draw calls — useful in scenes with large view distances.,Add FogExp2 to scenes with view distances above 100 units for both visual atmosphere and implicit far culling,Ignore fog in scenes with far:1000+ and many distant objects that contribute tiny pixels per draw call,"scene.fog = new THREE.FogExp2(0x0a0a0a, 0.02); // exponential — density feels more natural than linear",// far: 2000 with no fog — hundreds of distant objects too small to see still cost draw calls per frame,Low,https://threejs.org/docs/#api/en/scenes/FogExp2
43,Particles,BufferGeometry Plus Points for Particle Systems,Build all particle systems with BufferGeometry plus a Float32Array position attribute rendered as Points. Never use individual Mesh objects as particles — they cannot scale past a few hundred with good performance.,Use Points plus BufferGeometry for all particle effects,Create hundreds of individual Mesh objects to simulate a particle system,"const COUNT = 3000; const geo = new THREE.BufferGeometry(); const pos = new Float32Array(COUNT * 3); for (let i = 0; i < COUNT * 3; i++) pos[i] = (Math.random() - 0.5) * 20; geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); const particles = new THREE.Points(geo, new THREE.PointsMaterial({ size: 0.05, color: 0xffffff })); scene.add(particles);","for (let i = 0; i < 500; i++) { scene.add(new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 8), mat)); } // 500 separate draw calls per frame",High,https://threejs.org/docs/#api/en/objects/Points
44,Particles,Particle Count Ceiling,Start particle systems at 10003000 particles. Beyond 50000 causes sustained frame drops on mid-range mobile. Always test on a real device before increasing the count — desktop and mobile GPU performance ratios can be 10:1.,Start at 3000 particles and profile on actual mobile hardware before raising the limit,Set particle count at 100000 or higher without any mobile profiling,const COUNT = 3000; // safe mobile baseline — profile before going higher const pos = new Float32Array(COUNT * 3);,const COUNT = 150000; // 60fps on desktop — 8fps on a mid-range Android phone,High,https://threejs.org/docs/#api/en/objects/Points
45,Particles,needsUpdate After Buffer Mutation,After mutating any BufferAttribute array values per frame you must set geometry.attributes.position.needsUpdate = true so Three.js re-uploads the changed buffer to the GPU. Without it the GPU still uses the old data and particles appear completely frozen.,Set needsUpdate = true on the position attribute after every per-frame mutation of the array,Mutate the Float32Array values without flagging needsUpdate — positions update in JS but not on the GPU,// In animate(): const pos = geo.attributes.position.array; for (let i = 0; i < pos.length; i += 3) { pos[i + 1] += Math.sin(clock.getElapsedTime() + i) * 0.001; // Y component } geo.attributes.position.needsUpdate = true; // GPU re-upload,// In animate(): pos[1] += 0.001; // JS array updated — GPU buffer is stale — particles do not move,Critical,https://threejs.org/docs/#api/en/core/BufferAttribute.needsUpdate
46,Responsive,Canvas Dimensions Not Window,Size the renderer and camera to the canvas element's clientWidth and clientHeight — not window.innerWidth and innerHeight. This is correct when the canvas is inside a flex or grid container that does not fill the full viewport.,Use canvas.clientWidth and canvas.clientHeight for all renderer and camera sizing,Hardcode renderer size to window.innerWidth/innerHeight when the canvas may be inside a container,"renderer.setSize(canvas.clientWidth, canvas.clientHeight); camera.aspect = canvas.clientWidth / canvas.clientHeight; camera.updateProjectionMatrix();","renderer.setSize(window.innerWidth, window.innerHeight); // wrong when canvas lives inside a sidebar or grid column",High,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setSize
47,Responsive,ResizeObserver Over window resize Event,Use ResizeObserver on the canvas container instead of the window resize event. ResizeObserver fires when the container element changes size independently of the browser window — common in split-pane layouts and sidebar collapsing.,Attach ResizeObserver to the canvas parent element for accurate container-aware resize detection,Use only window.addEventListener('resize') for canvas sizing when the canvas is not fullscreen,"const ro = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; renderer.setSize(width, height); camera.aspect = width / height; camera.updateProjectionMatrix(); }); ro.observe(canvas.parentElement);","window.addEventListener('resize', () => { renderer.setSize(window.innerWidth, window.innerHeight); }); // misses container-only resize events in split-pane UIs",Medium,https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
48,Responsive,Touch Events for Mobile Interaction,Add touchstart and touchmove listeners alongside mouse events so the scene remains interactive on mobile. Normalize touch coordinates to the same NDC range as mouse events and pass passive:false on touchmove if you call preventDefault.,Handle both mouse and touch input for any interactive 3D scene,Add only mouse event listeners and leave touch users with no interaction,"canvas.addEventListener('touchmove', e => { e.preventDefault(); const t = e.touches[0]; mouse.x = (t.clientX / canvas.clientWidth) * 2 - 1; mouse.y = -(t.clientY / canvas.clientHeight) * 2 + 1; }, { passive: false }); canvas.addEventListener('touchstart', e => { e.preventDefault(); }, { passive: false });","canvas.addEventListener('mousemove', handleMouse); // touch events unhandled — mobile users get no interaction",Medium,https://developer.mozilla.org/en-US/docs/Web/API/Touch_events
49,Accessibility,prefers-reduced-motion,"Check window.matchMedia('(prefers-reduced-motion: reduce)') before starting any auto-rotation, particle animation, or camera movement. Users who enable this OS preference have motion sickness or vestibular disorders. IMPORTANT: reading .matches once at page load is a one-time snapshot — if the user changes their OS accessibility setting mid-session the scene will not react. Attach a 'change' listener to the MediaQueryList so noMotion stays in sync at runtime.",Use matchMedia.addEventListener('change') to keep noMotion reactive; gate all auto-animation on the live value,Read .matches once at startup and never update it — the scene ignores mid-session OS setting changes,"const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); let noMotion = mq.matches; mq.addEventListener('change', e => { noMotion = e.matches; }); // In animate(): if (!noMotion) { mesh.rotation.y += dt * 0.8; particles.rotation.y += dt * 0.1; }",const noMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // one-time snapshot — mid-session OS change is ignored entirely,High,https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
50,Accessibility,Canvas aria-label,Add role='img' and a descriptive aria-label to renderer.domElement after appending it to the DOM. Screen readers receive no information from a WebGL canvas — the aria-label is the only description they can announce to users.,Set role='img' and a meaningful aria-label on renderer.domElement before or after appending it,Append the canvas to the DOM with no accessibility attributes — invisible to screen readers,"renderer.domElement.setAttribute('role', 'img'); renderer.domElement.setAttribute('aria-label', 'Interactive 3D product viewer. Drag to rotate. Scroll to zoom.'); document.body.appendChild(renderer.domElement);",document.body.appendChild(renderer.domElement); // bare canvas — screen readers announce nothing,Medium,https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#accessibility_concerns
51,Production,Bundler Stack for Production,For production use Three.js via npm plus Vite. You get full tree-shaking reduced bundle size access to the complete examples/jsm library including OrbitControls GLTFLoader and EffectComposer and TypeScript support.,Use npm install three plus Vite or Webpack for any production client-facing project,Serve raw CDN script tags in a production application that needs tree-shaking or TypeScript,npm install three gsap // then in your JS: import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';,"<!-- In a Vite/React production build: --> <script src=""https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js""></script> // no tree-shaking — entire Three.js ships",Medium,https://threejs.org/docs/#manual/en/introduction/Installation
52,Production,GLTFLoader with scene traverse,Load 3D models using GLTFLoader and traverse gltf.scene to configure castShadow receiveShadow and material overrides on all child Mesh nodes. Calling scene.add(gltf.scene) alone silently skips all shadow and material configuration.,Use GLTFLoader and traverse the entire gltf.scene graph to set up shadows and materials on every Mesh child,Load a GLTF model and pass gltf.scene directly to scene.add without traversing child meshes,"import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; const loader = new GLTFLoader(); loader.load('model.glb', gltf => { gltf.scene.traverse(child => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; } }); scene.add(gltf.scene); });","loader.load('model.glb', gltf => { scene.add(gltf.scene); // shadows and material setup silently skipped on all children });",Medium,https://threejs.org/docs/#examples/en/loaders/GLTFLoader
53,Production,LOD for Distance-Based Detail,Use THREE.LOD to automatically swap high-detail and low-detail geometry as objects move closer or farther from the camera. This maintains frame rate in scenes with many objects spread across a large depth range.,Use THREE.LOD to reduce triangle count on distant objects automatically,Render the same high-polygon geometry for every object regardless of its distance from the camera,"const lod = new THREE.LOD(); lod.addLevel(highDetailMesh, 0); // used when < 15 units away lod.addLevel(medDetailMesh, 15); // 1550 units lod.addLevel(lowDetailMesh, 50); // 50+ units scene.add(lod);",scene.add(highDetailMesh); // 64k-triangle mesh rendered at full cost whether 1 unit or 100 units from camera,Medium,https://threejs.org/docs/#api/en/objects/LOD
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL,Applies To,Status,Verified At
1,Setup,Pin the Three.js Package Version,Install the exact current Three.js release with npm for bundler projects. If a browser CDN is unavoidable use an import map pinned to the same exact release instead of a floating URL.,Pin three@0.185.1 and keep core and addon imports on that release,Use a floating latest URL or a legacy global build as the default production setup,npm install three@0.185.1,"<script src=""https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js""></script>",Critical,https://www.npmjs.com/package/three/v/0.185.1,threejs 0.185.1,active,2026-08-13
2,Setup,Use CapsuleGeometry for Capsule Shapes,CapsuleGeometry is part of the current Three.js geometry API and directly creates a capsule from radius length cap segments and radial segments.,Construct current capsule meshes with THREE.CapsuleGeometry,Rebuild a standard capsule from separate cylinder and sphere meshes unless custom topology is required,"const geometry = new THREE.CapsuleGeometry(0.5, 1, 4, 8); const capsule = new THREE.Mesh(geometry, material);","const body = new THREE.CylinderGeometry(0.5, 0.5, 1); // unnecessary multi-mesh workaround",Critical,https://threejs.org/docs/#api/en/geometries/CapsuleGeometry,threejs 0.185.1,active,2026-08-13
3,Setup,Import OrbitControls from Three.js Addons,OrbitControls is an addon and must be imported explicitly from the three/addons path while the core library is imported from three.,Import the named OrbitControls addon before constructing controls,Expect THREE.OrbitControls to exist on the core namespace,"import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const controls = new OrbitControls(camera, renderer.domElement);","const controls = new THREE.OrbitControls(camera, renderer.domElement); // not exported by the core namespace",Critical,https://threejs.org/docs/#examples/en/controls/OrbitControls,threejs 0.185.1,active,2026-08-13
4,Setup,Custom Drag Orbit Fallback,When OrbitControls cannot be loaded implement spherical orbit using mousedown/mousemove/mouseup. The key is rotating in spherical coordinates so both horizontal AND vertical drag work correctly.,Rotate camera in spherical coordinates so both axes respond correctly to drag,Move camera.position.x directly — vertical drag is silently ignored and the orbit is incorrect,"let dragging = false; let prev = { x: 0, y: 0 }; const radius = camera.position.length(); let theta = 0; let phi = Math.PI / 2; canvas.addEventListener('mousedown', () => dragging = true); canvas.addEventListener('mouseup', () => dragging = false); canvas.addEventListener('mousemove', e => { if (!dragging) return; theta -= (e.clientX - prev.x) * 0.005; phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi - (e.clientY - prev.y) * 0.005)); camera.position.set(radius * Math.sin(phi) * Math.sin(theta), radius * Math.cos(phi), radius * Math.sin(phi) * Math.cos(theta)); camera.lookAt(scene.position); prev = { x: e.clientX, y: e.clientY }; });","let dragging = false; let prev = { x: 0, y: 0 }; canvas.addEventListener('mousemove', e => { if (!dragging) return; camera.position.x += (e.clientX - prev.x) * 0.005; camera.lookAt(scene.position); prev = { x: e.clientX, y: e.clientY }; }); // BUG: Y-drag ignored; orbit is a horizontal slide not a sphere",High,https://threejs.org/docs/#examples/en/controls/OrbitControls,threejs 0.185.1,active,2026-08-13
5,Setup,Use ESM Imports with Bundlers or Import Maps,Import Three.js from three in bundler projects. For a no-build browser page define an exact-version import map for three and three/addons/ before importing modules.,Use one ESM dependency graph with core and addons pinned to the same release,Mix global script builds with ES module imports or map core and addons to different versions,"<script type=""importmap"">{""imports"":{""three"":""https://cdn.jsdelivr.net/npm/three@0.185.1/build/three.module.js"",""three/addons/"":""https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/""}}</script> <script type=""module"">import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';</script>","<script src=""https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js""></script><script type=""module"">import * as THREE from 'three';</script>",Critical,https://threejs.org/manual/en/installation.html,threejs 0.185.1,active,2026-08-13
6,Setup,Single Renderer Per Page,Create one WebGLRenderer instance for the lifetime of the page. Multiple renderers compete for the browser GPU context limit (816 contexts) and cause context-lost errors especially on mobile.,Reuse a single renderer and swap scene content instead of recreating the renderer,Create a new renderer on each component mount or scene transition,"const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(canvas.clientWidth, canvas.clientHeight); // renderer lives for the page lifetime",function showScene() { const renderer = new THREE.WebGLRenderer(); document.body.appendChild(renderer.domElement); } showScene(); showScene(); // two GPU contexts — crashes on mobile,Critical,https://threejs.org/docs/#api/en/renderers/WebGLRenderer,threejs 0.185.1,active,2026-08-13
7,Setup,Pixel Ratio Cap at 2,Cap devicePixelRatio at 2. Retina displays report 3x or higher. Going from 2x to 3x multiplies pixel count by 2.25x with no visible quality improvement at normal viewing distance.,"Apply Math.min(window.devicePixelRatio, 2) — cap is at 2 not at 3",Pass window.devicePixelRatio directly without any cap,"renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));",renderer.setPixelRatio(window.devicePixelRatio); // 3x display = 9 pixels per CSS pixel = 2.25x GPU cost for zero quality gain,High,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setPixelRatio,threejs 0.185.1,active,2026-08-13
8,Setup,Alpha Canvas Plus CSS Background,Set alpha:true on the renderer and control the background color through CSS rather than a renderer clear color. This composites the canvas correctly over any HTML content behind it.,Set alpha:true on renderer and let body or a parent div provide the background color,Set a solid renderer clear color when the canvas must composite over HTML behind it,"const renderer = new THREE.WebGLRenderer({ alpha: true }); renderer.setClearColor(0x000000, 0); // fully transparent canvas // body { background: #0d0d0d; } handles the visible color",renderer.setClearColor(0x111827); // fully opaque — HTML behind the canvas is blocked,Medium,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setClearColor,threejs 0.185.1,active,2026-08-13
9,Camera,Aspect Ratio on Resize,Always update camera.aspect and call camera.updateProjectionMatrix() inside every resize handler. A stale aspect ratio causes the entire scene to appear stretched or squashed horizontally.,Update camera.aspect then call updateProjectionMatrix() on every resize,Let aspect ratio become stale after the browser window changes size,"window.addEventListener('resize', () => { camera.aspect = canvas.clientWidth / canvas.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(canvas.clientWidth, canvas.clientHeight); });",// No resize handler — scene stretches to fill a wider window without correcting the projection,High,https://threejs.org/docs/#api/en/cameras/PerspectiveCamera,threejs 0.185.1,active,2026-08-13
10,Camera,FOV Range 45 to 75,Use a field of view between 45 and 75 degrees. Below 45 creates compressed telephoto distortion. Above 90 creates visible fisheye distortion at frame edges.,Start at 75 for general interactive scenes; use 4555 for product close-ups,Use FOV above 90 or below 30 without a deliberate artistic reason,"const camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000); // general const camera = new THREE.PerspectiveCamera(50, aspect, 0.1, 1000); // product shot","const camera = new THREE.PerspectiveCamera(120, aspect, 0.1, 1000); // fisheye distortion at all edges",Medium,https://threejs.org/docs/#api/en/cameras/PerspectiveCamera,threejs 0.185.1,active,2026-08-13
11,Camera,Explicit Position and lookAt,Always set an explicit camera position and call camera.lookAt() before the first render. The default camera at the origin pointing down -Z makes subjects at arbitrary coordinates invisible or tiny.,Set camera.position.set() and camera.lookAt() to frame the subject before the first render,Leave the camera at default position (0 0 0) with no lookAt — subject may be behind the camera or microscopic,"camera.position.set(0, 1.5, 5); camera.lookAt(new THREE.Vector3(0, 0, 0));",// No position or lookAt set — subject at y:2 is behind or above the default camera view,Medium,https://threejs.org/docs/#api/en/cameras/Camera.lookAt,threejs 0.185.1,active,2026-08-13
12,Camera,OrbitControls vs GSAP Camera Rig,Use OrbitControls for model viewers and exploratory scenes where the user needs free-look. Use a GSAP scroll-driven camera rig for product reveals or storytelling where the camera path must stay fixed.,Import OrbitControls from three/addons and match the camera control approach to the scene's UX intent,Use OrbitControls for a scripted reveal where users can orbit away before it completes,"import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; // call controls.update() in animate()","const controls = new THREE.OrbitControls(camera, renderer.domElement); // legacy global API and wrong control model for a scripted reveal",High,https://threejs.org/docs/#examples/en/controls/OrbitControls,threejs 0.185.1,active,2026-08-13
13,Geometry,Never Create Geometry Per Frame,Creating a new geometry inside animate() allocates a fresh GPU buffer every frame and exhausts VRAM within seconds. Create all geometry exactly once before the loop starts. Use attribute mutation if positions must change per frame.,Create all geometry before the animation loop; mutate BufferAttribute arrays in-place if needed,Call any new XxxGeometry() constructor inside the animation loop,"const geo = new THREE.SphereGeometry(1, 32, 32); // created once const mesh = new THREE.Mesh(geo, mat); scene.add(mesh); const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); mesh.rotation.y += clock.getDelta() * 0.8; // delta time renderer.render(scene, camera); }","function animate() { requestAnimationFrame(animate); const geo = new THREE.BoxGeometry(1, 1, 1); // NEW GPU buffer every frame — VRAM exhaustion }",Critical,https://threejs.org/docs/#api/en/core/BufferGeometry,threejs 0.185.1,active,2026-08-13
14,Geometry,Share Geometry Across Meshes,When multiple objects share the same shape create one geometry instance and pass it to every Mesh. Each Mesh gets its own transform and material while all share a single GPU buffer.,Create one geometry and pass the same reference to every Mesh constructor,Create a separate identical geometry inside a loop for each object,"const geo = new THREE.BoxGeometry(1, 1, 1); // one GPU buffer for (let i = 0; i < 200; i++) { const m = new THREE.Mesh(geo, mat); m.position.set(Math.random() * 10, 0, Math.random() * 10); scene.add(m); }","for (let i = 0; i < 200; i++) { const geo = new THREE.BoxGeometry(1, 1, 1); // 200 separate GPU buffers scene.add(new THREE.Mesh(geo, mat)); }",Critical,https://threejs.org/docs/#api/en/core/BufferGeometry,threejs 0.185.1,active,2026-08-13
15,Geometry,dispose on Scene Removal,Call geometry.dispose() and material.dispose() and texture.dispose() for every texture map when removing objects from the scene. Three.js never releases GPU resources automatically — they stay in VRAM until explicitly freed.,Dispose of geometry + material + every texture map before calling scene.remove(),Call scene.remove() alone without any dispose calls,function removeMesh(mesh) { scene.remove(mesh); mesh.geometry.dispose(); if (mesh.material.map) mesh.material.map.dispose(); if (mesh.material.normalMap) mesh.material.normalMap.dispose(); mesh.material.dispose(); },scene.remove(mesh); // geometry and all texture maps stay in GPU VRAM forever,Critical,https://threejs.org/docs/#api/en/core/BufferGeometry.dispose,threejs 0.185.1,active,2026-08-13
16,Geometry,Segment Count Budget,Use the minimum segment count that achieves the desired silhouette quality. Hero objects: 3264 segments. Background objects: 816. Particle stand-ins: 68. High counts on background geometry waste GPU draw calls with zero visible benefit.,Apply a tiered segment budget based on the visual priority of each object in the scene,Default every sphere and cylinder to 64+ segments regardless of its role,"const bgSphere = new THREE.SphereGeometry(0.5, 8, 8); // background const heroSphere = new THREE.SphereGeometry(1, 64, 64); // foreground product","const particleSphere = new THREE.SphereGeometry(0.1, 64, 64); // 64 segments × 1000 particles = massive overdraw",Medium,https://threejs.org/docs/#api/en/geometries/SphereGeometry,threejs 0.185.1,active,2026-08-13
17,Geometry,BufferGeometry for Custom Vertex Data,For custom shapes use BufferGeometry with typed BufferAttribute data for positions normals colors and other vertex attributes.,Use THREE.BufferGeometry with Float32Array-backed attributes for custom vertex data,Reference or instantiate the removed THREE.Geometry class,"const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3)); geo.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(normals), 3));","const geo = new THREE.Geometry(); geo.vertices.push(new THREE.Vector3(0, 0, 0)); // removed legacy API",High,https://threejs.org/docs/#api/en/core/BufferGeometry,threejs 0.185.1,active,2026-08-13
18,Materials,MeshBasicMaterial vs MeshStandardMaterial,MeshBasicMaterial ignores all lights and is significantly cheaper — use it for UI overlays HUDs and flat-colored decorative elements. MeshStandardMaterial is PBR-accurate and requires lights. Never use StandardMaterial where BasicMaterial suffices.,Use MeshBasicMaterial for any object that does not need lighting; use MeshStandardMaterial for physical objects,Apply MeshStandardMaterial to flat UI elements that never receive light — lights still run for them,"const uiMat = new THREE.MeshBasicMaterial({ color: 0xffffff }); // no lighting cost const physMat = new THREE.MeshStandardMaterial({ color: 0x4f46e5, roughness: 0.4, metalness: 0.6 });",const mat = new THREE.MeshStandardMaterial({ color: 0xffffff }); // on a 2D HUD card — lighting calculation runs with no visual benefit,Medium,https://threejs.org/docs/#api/en/materials/MeshStandardMaterial,threejs 0.185.1,active,2026-08-13
19,Materials,Share Material Instances,Share one material instance across all meshes that have identical properties. Call mat.clone() only when individual meshes genuinely need different property values. Duplicate materials waste GPU VRAM.,Assign the same material reference to all meshes with identical visual properties,Create a new material inside a loop for objects that look identical,"const mat = new THREE.MeshStandardMaterial({ color: 0x4f46e5, roughness: 0.5 }); meshA.material = mat; meshB.material = mat; meshC.material = mat; // one GPU material",for (const m of meshes) { m.material = new THREE.MeshStandardMaterial({ color: 0x4f46e5 }); } // N redundant GPU materials,High,https://threejs.org/docs/#api/en/materials/Material,threejs 0.185.1,active,2026-08-13
20,Materials,Dispose Textures Explicitly,Textures are the single largest consumer of GPU VRAM in most Three.js scenes. Call texture.dispose() when switching scenes or removing objects — Three.js does not garbage-collect GPU resources automatically.,Track all loaded textures and call dispose() on each one during scene teardown or on object removal,Load textures without any cleanup path — they persist in VRAM for the entire page lifetime,const tex = new THREE.TextureLoader().load('img.jpg'); mesh.material.map = tex; // on teardown: tex.dispose(); mesh.material.dispose();,const tex = new THREE.TextureLoader().load('img.jpg'); scene.remove(mesh); // tex occupies GPU VRAM until page reload,High,https://threejs.org/docs/#api/en/textures/Texture.dispose,threejs 0.185.1,active,2026-08-13
21,Lighting,Ambient Plus Directional Minimum,Any scene using MeshStandardMaterial or MeshPhongMaterial requires at minimum one AmbientLight (fill) and one DirectionalLight (shading direction). Without both the objects render as solid black — the material is there but no light reaches it.,Add AmbientLight for fill and DirectionalLight for shading whenever PBR or Phong materials are used,Use MeshStandardMaterial without adding any lights to the scene,"scene.add(new THREE.AmbientLight(0xffffff, 0.4)); const dirLight = new THREE.DirectionalLight(0xffffff, 1.0); dirLight.position.set(5, 10, 7.5); scene.add(dirLight);","const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: 0x4f46e5 })); scene.add(mesh); // renders completely black — no lights in scene",Critical,https://threejs.org/docs/#api/en/lights/DirectionalLight,threejs 0.185.1,active,2026-08-13
22,Lighting,Enable shadowMap Before castShadow,renderer.shadowMap.enabled = true must be set before any castShadow or receiveShadow flags. Without it the shadow map is never allocated and all shadow flags are silently ignored.,Set renderer.shadowMap.enabled = true first then set castShadow and receiveShadow on lights and meshes,Set castShadow on a light or mesh without enabling renderer.shadowMap.enabled — shadows never render,renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; heroMesh.castShadow = true; ground.receiveShadow = true;,dirLight.castShadow = true; heroMesh.castShadow = true; // renderer.shadowMap.enabled never set — shadows silently do not render,High,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.shadowMap,threejs 0.185.1,active,2026-08-13
23,Lighting,Selective Shadow Casting,Shadow map rendering redraws the entire scene from the light's perspective every frame. Enable castShadow only on the primary directional light and receiveShadow only on hero meshes and the ground plane.,Enable shadows only on the key light and the most important meshes,Enable castShadow and receiveShadow on every object in the scene including particles,renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; dirLight.castShadow = true; heroMesh.castShadow = true; ground.receiveShadow = true; // particles and background meshes: no shadow flags,for (const m of allMeshes) { m.castShadow = true; m.receiveShadow = true; } // shadow map pass over particle system — expensive with no visible gain,High,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.shadowMap,threejs 0.185.1,active,2026-08-13
24,Lighting,Skip Lights for MeshBasicMaterial,MeshBasicMaterial completely ignores all scene lights. Adding lights solely to illuminate BasicMaterial objects wastes a light pass on every frame with zero visible effect.,Omit lights entirely when every material in the scene is MeshBasicMaterial,Add AmbientLight and DirectionalLight to a scene that uses only MeshBasicMaterial,"// Scene uses only MeshBasicMaterial — no lights needed const mat = new THREE.MeshBasicMaterial({ color: 0x00ffff }); const mesh = new THREE.Mesh(geo, mat); scene.add(mesh); // MeshBasicMaterial is always fully lit by definition","scene.add(new THREE.AmbientLight(0xffffff, 1.0)); // wasted per-frame light pass — BasicMaterial ignores it entirely",Low,https://threejs.org/docs/#api/en/materials/MeshBasicMaterial,threejs 0.185.1,active,2026-08-13
25,Raycasting,Single Shared Raycaster,Create exactly one Raycaster instance outside all event handlers. Store mouse coordinates in pointermove (cheap). Call setFromCamera and intersectObjects together inside the animate() loop — once per frame instead of once per mouse event.,Create one Raycaster; store mouse in pointermove; call setFromCamera + intersectObjects inside animate(),Create a new THREE.Raycaster() inside a mousemove handler or call setFromCamera inside the event listener,"const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); canvas.addEventListener('pointermove', e => { // only store coords — no raycasting here mouse.x = (e.clientX / canvas.clientWidth) * 2 - 1; mouse.y = -(e.clientY / canvas.clientHeight) * 2 + 1; }); // setFromCamera and intersectObjects run once per frame in animate()","window.addEventListener('mousemove', e => { const rc = new THREE.Raycaster(); // new allocation per event rc.setFromCamera(mouse, camera); rc.intersectObjects(targets, true); // fires 200+ times/sec });",Critical,https://threejs.org/docs/#api/en/core/Raycaster,threejs 0.185.1,active,2026-08-13
26,Raycasting,NDC Mouse Coordinates,Raycasting requires mouse in Normalized Device Coordinates: X from -1 (left) to +1 (right) and Y from +1 (top) to -1 (bottom). The Y axis is inverted relative to screen space. A missing negation on Y causes all raycasts to miss or hit the wrong objects.,Apply the full NDC formula — including the negation on the Y axis,Forget to negate Y — raycasting appears to work but hits objects mirrored vertically,mouse.x = (e.clientX / canvas.clientWidth) * 2 - 1; mouse.y = -(e.clientY / canvas.clientHeight) * 2 + 1; // Y is INVERTED,mouse.x = (e.clientX / canvas.clientWidth) * 2 - 1; mouse.y = (e.clientY / canvas.clientHeight) * 2 - 1; // BUG: Y not negated — raycasting is mirrored,Critical,https://threejs.org/docs/#api/en/core/Raycaster.setFromCamera,threejs 0.185.1,active,2026-08-13
27,Raycasting,setFromCamera and intersectObjects in animate,Call raycaster.setFromCamera(mouse camera) and then raycaster.intersectObjects(targets true) inside the animate() loop. setFromCamera must come before intersectObjects every frame — without it the raycaster uses a stale ray direction.,Call setFromCamera then intersectObjects in order inside every animate() frame,Call intersectObjects without calling setFromCamera first — the raycaster uses a stale or zero ray,"function animate() { requestAnimationFrame(animate); raycaster.setFromCamera(mouse, camera); // update ray direction first const hits = raycaster.intersectObjects(targets, true); // then test intersections if (hits.length > 0) { document.body.style.cursor = 'pointer'; } else { document.body.style.cursor = 'auto'; } renderer.render(scene, camera); }","function animate() { requestAnimationFrame(animate); const hits = raycaster.intersectObjects(targets, true); // BUG: setFromCamera never called — stale ray — hits is always empty renderer.render(scene, camera); }",Critical,https://threejs.org/docs/#api/en/core/Raycaster,threejs 0.185.1,active,2026-08-13
28,Raycasting,Recursive Flag for Groups and GLTF,Pass true as the second argument to intersectObjects when testing Groups or GLTF loaded models. Geometry lives on child Mesh objects — without recursive:true the parent group is tested but has no geometry and hits is always empty.,Use intersectObjects(targets true) for Groups or any loaded model,Raycast against a parent Group without the recursive flag,"const hits = raycaster.intersectObjects(scene.children, true); // catches all descendant meshes",const hits = raycaster.intersectObjects([modelGroup]); // recursive defaults to false — misses all children,High,https://threejs.org/docs/#api/en/core/Raycaster.intersectObjects,threejs 0.185.1,active,2026-08-13
29,Raycasting,Cursor Feedback on Hover,Set document.body.style.cursor = 'pointer' when intersections are found and reset to 'auto' when none are found. Without cursor feedback users cannot discover that 3D objects are interactive.,Update cursor to pointer on hit; reset to auto on miss in the same animate loop block,Run raycasting and read hits without ever updating the cursor style,if (hits.length > 0) { document.body.style.cursor = 'pointer'; } else { document.body.style.cursor = 'auto'; },"raycaster.setFromCamera(mouse, camera); raycaster.intersectObjects(targets, true); // hits ignored — cursor never changes — objects feel non-interactive",Medium,https://developer.mozilla.org/en-US/docs/Web/CSS/cursor,threejs 0.185.1,active,2026-08-13
30,Animation,requestAnimationFrame Loop Only,Drive the render loop exclusively with requestAnimationFrame or renderer.setAnimationLoop(). Never use setInterval or setTimeout — they are not synchronized to the display refresh rate and keep running when the tab is hidden draining battery.,Use requestAnimationFrame or renderer.setAnimationLoop() as the sole render loop driver,Use setInterval or setTimeout for render timing,"function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate();","setInterval(() => renderer.render(scene, camera), 16); // not display-synced; runs at full speed even when tab is hidden",Critical,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setAnimationLoop,threejs 0.185.1,active,2026-08-13
31,Animation,THREE.Clock for Delta Time,"Use THREE.Clock and clock.getDelta() for all time-based motion. A hardcoded increment like += 0.01 runs at 2x speed on 120Hz displays and at unpredictable speed when frames drop under load. CRITICAL: call getDelta() exactly ONCE per animate() frame and store the result in a local dt variable. getDelta() resets the internal clock on every call — a second call in the same frame always returns ~0, silently breaking any animation block that uses it.",Call clock.getDelta() once at the top of animate(); store result in dt; reuse dt everywhere in that frame,Call clock.getDelta() more than once per frame or inside a helper called from animate(),"const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); const dt = clock.getDelta(); // called ONCE — reuse dt below mesh.rotation.y += dt * 0.8; particles.rotation.y += dt * 0.1; // reuse dt, not a second getDelta() renderer.render(scene, camera); }",function animate() { requestAnimationFrame(animate); mesh.rotation.y += 0.01; // 0.01 rad/frame — runs 2x faster on 120Hz than on 60Hz },High,https://threejs.org/docs/#api/en/core/Clock,threejs 0.185.1,active,2026-08-13
32,Animation,Lerp for Smooth Pointer Follow,Use value += (target - value) * alpha each frame to smoothly interpolate toward a moving target. An alpha of 0.030.1 produces organic easing for camera follow pointer-tracking and hover scale effects without requiring GSAP.,Apply the lerp formula each frame with a small alpha for smooth organic motion,Snap a value directly to the target producing an instant jarring jump,// In animate(): cameraTargetX = mouse.x * 3; camera.position.x += (cameraTargetX - camera.position.x) * 0.05; camera.position.y += (cameraTargetY - camera.position.y) * 0.05; camera.lookAt(scene.position);,// In animate(): camera.position.x = mouse.x * 3; // instant snap — jarring with no easing,Medium,https://threejs.org/docs/#api/en/math/MathUtils.lerp,threejs 0.185.1,active,2026-08-13
33,Animation,GSAP for Multi-Step Sequences,Use GSAP timelines for any animation with more than two sequential steps or for scroll-linked camera paths. GSAP timelines can be paused reversed and scrubbed — far more maintainable than boolean state machines.,Use GSAP timelines for sequences with more than two steps and for scroll-driven animations,Implement multi-step sequences with boolean flags and manual frame counters,"const tl = gsap.timeline({ defaults: { ease: 'power2.out' } }); tl.from(mesh.position, { y: -3, duration: 1 }) .to(mesh.rotation, { y: Math.PI, duration: 1 }, '-=0.3') .to(camera.position, { z: 2, duration: 1.5 });",let step = 0; let t = 0; function animate() { if (step === 0 && (t += 0.01) >= 1) step = 1; } // grows unmanageable with 3+ steps,High,https://www.npmjs.com/package/gsap,threejs 0.185.1,active,2026-08-13
34,Animation,Pause Render Loop on Tab Hidden,Use renderer.setAnimationLoop() as the loop driver so you can pass null to pause and a function to resume. Continuous rendering in a hidden tab wastes CPU GPU and battery with no user benefit.,Use renderer.setAnimationLoop(animate) to drive the loop; pass null to pause on visibilitychange,Drive with internal requestAnimationFrame and never stop the loop when the tab is hidden,"renderer.setAnimationLoop(animate); // use setAnimationLoop as the driver — not requestAnimationFrame inside animate function animate() { const dt = clock.getDelta(); renderer.render(scene, camera); } document.addEventListener('visibilitychange', () => { if (document.hidden) renderer.setAnimationLoop(null); else renderer.setAnimationLoop(animate); });","function animate() { requestAnimationFrame(animate); // self-referencing RAF cannot be stopped externally renderer.render(scene, camera); } animate(); // runs forever in background tab — drains battery",High,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setAnimationLoop,threejs 0.185.1,active,2026-08-13
35,GSAP,Load GSAP Before Scene Script,Load GSAP from its own CDN script tag before your scene script. In bundler projects install via npm and import. GSAP is a completely separate library from Three.js — never try to import it from the Three.js package.,Load GSAP CDN before the scene script; or npm install gsap and import separately,Import gsap from three or expect it to be defined without a separate load,"<!-- CDN: load GSAP before your scene script --> <script src=""https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js""></script> <!-- Bundler: --> // import gsap from 'gsap'; import { ScrollTrigger } from 'gsap/ScrollTrigger';",import gsap from 'three'; // undefined — GSAP has nothing to do with Three.js,Critical,https://www.npmjs.com/package/gsap,threejs 0.185.1,active,2026-08-13
36,GSAP,Register ScrollTrigger Before Use,Call gsap.registerPlugin(ScrollTrigger) once at the top of your script before any scrollTrigger config object. Without registration the ScrollTrigger name is undefined and the tween throws immediately.,Call gsap.registerPlugin(ScrollTrigger) as the first line before any gsap.to/from/timeline with scrollTrigger,Include scrollTrigger config in gsap.to() calls without first registering the plugin,"gsap.registerPlugin(ScrollTrigger); gsap.to(camera.position, { z: 2, scrollTrigger: { trigger: '.hero-section', scrub: 1 } });","gsap.to(mesh.position, { scrollTrigger: { trigger: '.section', scrub: true } }); // TypeError: ScrollTrigger is not a constructor — not registered",Critical,https://www.npmjs.com/package/gsap,threejs 0.185.1,active,2026-08-13
37,GSAP,Tween Three.js Properties Directly,GSAP can tween any numeric JavaScript property including mesh.position.x mesh.rotation.y and material.opacity. No wrapper or adaptor is needed. Note: to tween material.opacity the material must have transparent:true set before the tween starts.,Pass Three.js object properties directly to gsap.to(); set transparent:true before tweening opacity,Use a plain proxy object then manually copy values to Three.js properties every frame,"gsap.to(mesh.rotation, { y: Math.PI * 2, duration: 2, ease: 'power1.inOut' }); mesh.material.transparent = true; // required before tweening opacity gsap.to(mesh.material, { opacity: 0, duration: 1 });","const tw = { v: 0 }; gsap.to(tw, { v: Math.PI * 2, onUpdate: () => mesh.rotation.y = tw.v }); // unnecessary proxy wrapper",Medium,https://gsap.com/docs/v3/GSAP/gsap.to(),threejs 0.185.1,active,2026-08-13
38,GSAP,scrub for Scroll-Driven Camera Path,Use scrub:true or scrub:1 to link camera movement continuously to scroll position as a 01 ratio. scrub:1 adds a 1-second lag for cinematic smoothness. onEnter/onLeave fire only once and create jarring snaps — not the right tool for a camera path.,Use scrub:1 for any scroll-controlled camera movement,Use onEnter or onLeave callbacks for camera motion — they snap instead of scrubbing,"gsap.registerPlugin(ScrollTrigger); gsap.to(camera.position, { x: 5, y: 2, z: 0, ease: 'none', scrollTrigger: { trigger: '.canvas-wrapper', start: 'top top', end: 'bottom bottom', scrub: 1 } });","gsap.to(camera.position, { z: 0, scrollTrigger: { trigger: '.section', onEnter: () => {} } }); // fires once at scroll threshold — not a continuous scrub",High,https://www.npmjs.com/package/gsap,threejs 0.185.1,active,2026-08-13
39,Performance,InstancedMesh for Repeated Objects,Use THREE.InstancedMesh when rendering 50 or more identical objects. It submits all N transforms in one draw call instead of N draw calls and reduces CPU-GPU communication overhead dramatically.,Use InstancedMesh for any group of 50+ meshes sharing the same geometry and material,Create 50+ separate Mesh objects with the same geometry and material,"const COUNT = 500; const iMesh = new THREE.InstancedMesh(geo, mat, COUNT); const matrix = new THREE.Matrix4(); for (let i = 0; i < COUNT; i++) { matrix.setPosition(Math.random()*10, Math.random()*10, Math.random()*10); iMesh.setMatrixAt(i, matrix); } iMesh.instanceMatrix.needsUpdate = true; scene.add(iMesh);","for (let i = 0; i < 500; i++) { scene.add(new THREE.Mesh(geo, mat)); } // 500 separate draw calls per frame",High,https://threejs.org/docs/#api/en/objects/InstancedMesh,threejs 0.185.1,active,2026-08-13
40,Performance,Tone Mapping and Output Color Space,Three.js color management is enabled by default. Keep working colors in the linear-sRGB space and set the renderer output color space to SRGBColorSpace; choose tone mapping when rendering HDR lighting to a display.,Use the default ColorManagement.enabled state and set renderer.outputColorSpace plus an appropriate toneMapping,Disable color management or use removed outputEncoding and sRGBEncoding properties,"THREE.ColorManagement.enabled = true; renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0;","renderer.outputEncoding = THREE.sRGBEncoding; // removed legacy properties",Medium,https://threejs.org/manual/en/color-management.html,threejs 0.185.1,active,2026-08-13
41,Performance,antialias Set at Construction Only,The antialias option can only be set at WebGLRenderer construction time. Setting renderer.antialias after construction has absolutely no effect — the WebGL context is already created without it. Decide before instantiating.,Set antialias:true inside the WebGLRenderer constructor options object,Construct the renderer without antialias then try to enable it by assigning the property,"const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); // antialias baked into the WebGL context",const renderer = new THREE.WebGLRenderer(); renderer.antialias = true; // no effect — context created without AA — edges remain aliased,High,https://threejs.org/docs/#api/en/renderers/WebGLRenderer,threejs 0.185.1,active,2026-08-13
42,Performance,FogExp2 for Depth and Far Culling,Use scene.fog to create atmospheric depth. As a secondary benefit objects that disappear into fog before the far plane stop contributing to draw calls — useful in scenes with large view distances.,Add FogExp2 to scenes with view distances above 100 units for both visual atmosphere and implicit far culling,Ignore fog in scenes with far:1000+ and many distant objects that contribute tiny pixels per draw call,"scene.fog = new THREE.FogExp2(0x0a0a0a, 0.02); // exponential — density feels more natural than linear",// far: 2000 with no fog — hundreds of distant objects too small to see still cost draw calls per frame,Low,https://threejs.org/docs/#api/en/scenes/FogExp2,threejs 0.185.1,active,2026-08-13
43,Particles,BufferGeometry Plus Points for Particle Systems,Build all particle systems with BufferGeometry plus a Float32Array position attribute rendered as Points. Never use individual Mesh objects as particles — they cannot scale past a few hundred with good performance.,Use Points plus BufferGeometry for all particle effects,Create hundreds of individual Mesh objects to simulate a particle system,"const COUNT = 3000; const geo = new THREE.BufferGeometry(); const pos = new Float32Array(COUNT * 3); for (let i = 0; i < COUNT * 3; i++) pos[i] = (Math.random() - 0.5) * 20; geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); const particles = new THREE.Points(geo, new THREE.PointsMaterial({ size: 0.05, color: 0xffffff })); scene.add(particles);","for (let i = 0; i < 500; i++) { scene.add(new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 8), mat)); } // 500 separate draw calls per frame",High,https://threejs.org/docs/#api/en/objects/Points,threejs 0.185.1,active,2026-08-13
44,Particles,Particle Count Ceiling,Start particle systems at 10003000 particles. Beyond 50000 causes sustained frame drops on mid-range mobile. Always test on a real device before increasing the count — desktop and mobile GPU performance ratios can be 10:1.,Start at 3000 particles and profile on actual mobile hardware before raising the limit,Set particle count at 100000 or higher without any mobile profiling,const COUNT = 3000; // safe mobile baseline — profile before going higher const pos = new Float32Array(COUNT * 3);,const COUNT = 150000; // 60fps on desktop — 8fps on a mid-range Android phone,High,https://threejs.org/docs/#api/en/objects/Points,threejs 0.185.1,active,2026-08-13
45,Particles,needsUpdate After Buffer Mutation,After mutating any BufferAttribute array values per frame you must set geometry.attributes.position.needsUpdate = true so Three.js re-uploads the changed buffer to the GPU. Without it the GPU still uses the old data and particles appear completely frozen.,Set needsUpdate = true on the position attribute after every per-frame mutation of the array,Mutate the Float32Array values without flagging needsUpdate — positions update in JS but not on the GPU,// In animate(): const pos = geo.attributes.position.array; for (let i = 0; i < pos.length; i += 3) { pos[i + 1] += Math.sin(clock.getElapsedTime() + i) * 0.001; // Y component } geo.attributes.position.needsUpdate = true; // GPU re-upload,// In animate(): pos[1] += 0.001; // JS array updated — GPU buffer is stale — particles do not move,Critical,https://threejs.org/docs/#api/en/core/BufferAttribute.needsUpdate,threejs 0.185.1,active,2026-08-13
46,Responsive,Canvas Dimensions Not Window,Size the renderer and camera to the canvas element's clientWidth and clientHeight — not window.innerWidth and innerHeight. This is correct when the canvas is inside a flex or grid container that does not fill the full viewport.,Use canvas.clientWidth and canvas.clientHeight for all renderer and camera sizing,Hardcode renderer size to window.innerWidth/innerHeight when the canvas may be inside a container,"renderer.setSize(canvas.clientWidth, canvas.clientHeight); camera.aspect = canvas.clientWidth / canvas.clientHeight; camera.updateProjectionMatrix();","renderer.setSize(window.innerWidth, window.innerHeight); // wrong when canvas lives inside a sidebar or grid column",High,https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setSize,threejs 0.185.1,active,2026-08-13
47,Responsive,ResizeObserver Over window resize Event,Use ResizeObserver on the canvas container instead of the window resize event. ResizeObserver fires when the container element changes size independently of the browser window — common in split-pane layouts and sidebar collapsing.,Attach ResizeObserver to the canvas parent element for accurate container-aware resize detection,Use only window.addEventListener('resize') for canvas sizing when the canvas is not fullscreen,"const ro = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; renderer.setSize(width, height); camera.aspect = width / height; camera.updateProjectionMatrix(); }); ro.observe(canvas.parentElement);","window.addEventListener('resize', () => { renderer.setSize(window.innerWidth, window.innerHeight); }); // misses container-only resize events in split-pane UIs",Medium,https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver,threejs 0.185.1,active,2026-08-13
48,Responsive,Touch Events for Mobile Interaction,Add touchstart and touchmove listeners alongside mouse events so the scene remains interactive on mobile. Normalize touch coordinates to the same NDC range as mouse events and pass passive:false on touchmove if you call preventDefault.,Handle both mouse and touch input for any interactive 3D scene,Add only mouse event listeners and leave touch users with no interaction,"canvas.addEventListener('touchmove', e => { e.preventDefault(); const t = e.touches[0]; mouse.x = (t.clientX / canvas.clientWidth) * 2 - 1; mouse.y = -(t.clientY / canvas.clientHeight) * 2 + 1; }, { passive: false }); canvas.addEventListener('touchstart', e => { e.preventDefault(); }, { passive: false });","canvas.addEventListener('mousemove', handleMouse); // touch events unhandled — mobile users get no interaction",Medium,https://developer.mozilla.org/en-US/docs/Web/API/Touch_events,threejs 0.185.1,active,2026-08-13
49,Accessibility,prefers-reduced-motion,"Check window.matchMedia('(prefers-reduced-motion: reduce)') before starting any auto-rotation, particle animation, or camera movement. Users who enable this OS preference have motion sickness or vestibular disorders. IMPORTANT: reading .matches once at page load is a one-time snapshot — if the user changes their OS accessibility setting mid-session the scene will not react. Attach a 'change' listener to the MediaQueryList so noMotion stays in sync at runtime.",Use matchMedia.addEventListener('change') to keep noMotion reactive; gate all auto-animation on the live value,Read .matches once at startup and never update it — the scene ignores mid-session OS setting changes,"const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); let noMotion = mq.matches; mq.addEventListener('change', e => { noMotion = e.matches; }); // In animate(): if (!noMotion) { mesh.rotation.y += dt * 0.8; particles.rotation.y += dt * 0.1; }",const noMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // one-time snapshot — mid-session OS change is ignored entirely,High,https://www.w3.org/WAI/WCAG22/Techniques/css/C39.html,threejs 0.185.1,active,2026-08-13
50,Accessibility,Canvas aria-label,Add role='img' and a descriptive aria-label to renderer.domElement after appending it to the DOM. Screen readers receive no information from a WebGL canvas — the aria-label is the only description they can announce to users.,Set role='img' and a meaningful aria-label on renderer.domElement before or after appending it,Append the canvas to the DOM with no accessibility attributes — invisible to screen readers,"renderer.domElement.setAttribute('role', 'img'); renderer.domElement.setAttribute('aria-label', 'Interactive 3D product viewer. Drag to rotate. Scroll to zoom.'); document.body.appendChild(renderer.domElement);",document.body.appendChild(renderer.domElement); // bare canvas — screen readers announce nothing,Medium,https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#accessibility_concerns,threejs 0.185.1,active,2026-08-13
51,Production,Bundler Stack for Production,For production install the exact Three.js release from npm and use a bundler such as Vite. Import optional loaders controls and post-processing modules from three/addons so all modules share one version.,Use npm install three@0.185.1 and import core plus addons through ESM,Serve legacy global scripts or import addons from deprecated examples/js paths,"npm install three@0.185.1; import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';","<script src=""https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js""></script> // legacy global build with no module graph",Medium,https://threejs.org/manual/en/installation.html,threejs 0.185.1,active,2026-08-13
52,Production,GLTFLoader with scene traverse,Load 3D models using GLTFLoader and traverse gltf.scene to configure castShadow receiveShadow and material overrides on all child Mesh nodes. Calling scene.add(gltf.scene) alone silently skips all shadow and material configuration.,Use GLTFLoader and traverse the entire gltf.scene graph to set up shadows and materials on every Mesh child,Load a GLTF model and pass gltf.scene directly to scene.add without traversing child meshes,"import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; const loader = new GLTFLoader(); loader.load('model.glb', gltf => { gltf.scene.traverse(child => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; } }); scene.add(gltf.scene); });","loader.load('model.glb', gltf => { scene.add(gltf.scene); // shadows and material setup silently skipped on all children });",Medium,https://threejs.org/docs/#examples/en/loaders/GLTFLoader,threejs 0.185.1,active,2026-08-13
53,Production,LOD for Distance-Based Detail,Use THREE.LOD to automatically swap high-detail and low-detail geometry as objects move closer or farther from the camera. This maintains frame rate in scenes with many objects spread across a large depth range.,Use THREE.LOD to reduce triangle count on distant objects automatically,Render the same high-polygon geometry for every object regardless of its distance from the camera,"const lod = new THREE.LOD(); lod.addLevel(highDetailMesh, 0); // used when < 15 units away lod.addLevel(medDetailMesh, 15); // 1550 units lod.addLevel(lowDetailMesh, 50); // 50+ units scene.add(lod);",scene.add(highDetailMesh); // 64k-triangle mesh rendered at full cost whether 1 unit or 100 units from camera,Medium,https://threejs.org/docs/#api/en/objects/LOD,threejs 0.185.1,active,2026-08-13
1 No Category Guideline Description Do Don't Code Good Code Bad Severity Docs URL Applies To Status Verified At
2 1 Setup CDN Version Lock Pin the Three.js Package Version Always use Three.js r128 from cdnjs. It is the stable CDN baseline. Never use a floating 'latest' URL — it silently breaks when the CDN updates without warning. Install the exact current Three.js release with npm for bundler projects. If a browser CDN is unavoidable use an import map pinned to the same exact release instead of a floating URL. Pin to the exact r128 cdnjs URL in every HTML file Pin three@0.185.1 and keep core and addon imports on that release Use unpkg@latest or any unversioned CDN URL that can silently update Use a floating latest URL or a legacy global build as the default production setup <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> npm install three@0.185.1 <script src="https://unpkg.com/three@latest"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> Critical https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js https://www.npmjs.com/package/three/v/0.185.1 threejs 0.185.1 active 2026-08-13
3 2 Setup CapsuleGeometry Does Not Exist in r128 Use CapsuleGeometry for Capsule Shapes THREE.CapsuleGeometry was introduced in r142. Using it on the r128 CDN throws 'CapsuleGeometry is not a constructor' and crashes the entire scene. Build a capsule from primitives instead. CapsuleGeometry is part of the current Three.js geometry API and directly creates a capsule from radius length cap segments and radial segments. Build a capsule from CylinderGeometry plus two SphereGeometry end caps Construct current capsule meshes with THREE.CapsuleGeometry Call THREE.CapsuleGeometry on r128 — it is undefined and throws immediately Rebuild a standard capsule from separate cylinder and sphere meshes unless custom topology is required const body = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 1, 16), mat); const topCap = new THREE.Mesh(new THREE.SphereGeometry(0.5, 16, 8), mat); const botCap = new THREE.Mesh(new THREE.SphereGeometry(0.5, 16, 8), mat); topCap.position.y = 0.5; botCap.position.y = -0.5; const group = new THREE.Group(); group.add(body, topCap, botCap); const geometry = new THREE.CapsuleGeometry(0.5, 1, 4, 8); const capsule = new THREE.Mesh(geometry, material); const cap = new THREE.CapsuleGeometry(0.5, 1, 4, 8); // TypeError: CapsuleGeometry is not a constructor on r128 const body = new THREE.CylinderGeometry(0.5, 0.5, 1); // unnecessary multi-mesh workaround Critical https://threejs.org/docs/#api/en/geometries/CapsuleGeometry threejs 0.185.1 active 2026-08-13
4 3 Setup OrbitControls Must Be Loaded Separately Import OrbitControls from Three.js Addons OrbitControls is NOT bundled in the core Three.js r128 CDN file. It lives in examples/js and must be loaded from a separate cdnjs script tag. THREE.OrbitControls is undefined without it. OrbitControls is an addon and must be imported explicitly from the three/addons path while the core library is imported from three. Load the OrbitControls script from cdnjs examples path before your scene script Import the named OrbitControls addon before constructing controls Expect THREE.OrbitControls to exist after loading only the core Three.js CDN script Expect THREE.OrbitControls to exist on the core namespace <!-- Load AFTER core Three.js script --> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/examples/js/controls/OrbitControls.min.js"></script> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const controls = new OrbitControls(camera, renderer.domElement); <!-- Core only loaded — OrbitControls undefined --> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> const controls = new THREE.OrbitControls(camera, renderer.domElement); // not exported by the core namespace Critical https://cdnjs.com/libraries/three.js/r128 https://threejs.org/docs/#examples/en/controls/OrbitControls threejs 0.185.1 active 2026-08-13
5 4 Setup Custom Drag Orbit Fallback When OrbitControls cannot be loaded implement spherical orbit using mousedown/mousemove/mouseup. The key is rotating in spherical coordinates so both horizontal AND vertical drag work correctly. Rotate camera in spherical coordinates so both axes respond correctly to drag Move camera.position.x directly — vertical drag is silently ignored and the orbit is incorrect let dragging = false; let prev = { x: 0, y: 0 }; const radius = camera.position.length(); let theta = 0; let phi = Math.PI / 2; canvas.addEventListener('mousedown', () => dragging = true); canvas.addEventListener('mouseup', () => dragging = false); canvas.addEventListener('mousemove', e => { if (!dragging) return; theta -= (e.clientX - prev.x) * 0.005; phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi - (e.clientY - prev.y) * 0.005)); camera.position.set(radius * Math.sin(phi) * Math.sin(theta), radius * Math.cos(phi), radius * Math.sin(phi) * Math.cos(theta)); camera.lookAt(scene.position); prev = { x: e.clientX, y: e.clientY }; }); let dragging = false; let prev = { x: 0, y: 0 }; canvas.addEventListener('mousemove', e => { if (!dragging) return; camera.position.x += (e.clientX - prev.x) * 0.005; camera.lookAt(scene.position); prev = { x: e.clientX, y: e.clientY }; }); // BUG: Y-drag ignored; orbit is a horizontal slide not a sphere High https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent https://threejs.org/docs/#examples/en/controls/OrbitControls threejs 0.185.1 active 2026-08-13
6 5 Setup ESM vs CDN Import Use ESM Imports with Bundlers or Import Maps When using a bundler import Three.js as an ES module. When using CDN the THREE global is already available — do not import it again. Mixing both loads Three.js twice and causes subtle runtime errors. Import Three.js from three in bundler projects. For a no-build browser page define an exact-version import map for three and three/addons/ before importing modules. Match import style to build environment: ESM import for bundlers; rely on the window.THREE global for CDN pages Use one ESM dependency graph with core and addons pinned to the same release Mix a CDN script tag with an ES module import in the same file Mix global script builds with ES module imports or map core and addons to different versions // Bundler project (Vite / webpack): import * as THREE from 'three'; // CDN project — no import needed; THREE is already global after the script tag <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.185.1/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/"}}</script> <script type="module">import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';</script> <!-- CDN script --> <script src="r128.cdn"></script> <script type="module"> import * as THREE from 'three'; // loads Three.js twice — version mismatch risk </script> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script><script type="module">import * as THREE from 'three';</script> Critical https://threejs.org/docs/#manual/en/introduction/Installation https://threejs.org/manual/en/installation.html threejs 0.185.1 active 2026-08-13
7 6 Setup Single Renderer Per Page Create one WebGLRenderer instance for the lifetime of the page. Multiple renderers compete for the browser GPU context limit (8–16 contexts) and cause context-lost errors especially on mobile. Reuse a single renderer and swap scene content instead of recreating the renderer Create a new renderer on each component mount or scene transition const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(canvas.clientWidth, canvas.clientHeight); // renderer lives for the page lifetime function showScene() { const renderer = new THREE.WebGLRenderer(); document.body.appendChild(renderer.domElement); } showScene(); showScene(); // two GPU contexts — crashes on mobile Critical https://threejs.org/docs/#api/en/renderers/WebGLRenderer threejs 0.185.1 active 2026-08-13
8 7 Setup Pixel Ratio Cap at 2 Cap devicePixelRatio at 2. Retina displays report 3x or higher. Going from 2x to 3x multiplies pixel count by 2.25x with no visible quality improvement at normal viewing distance. Apply Math.min(window.devicePixelRatio, 2) — cap is at 2 not at 3 Pass window.devicePixelRatio directly without any cap renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setPixelRatio(window.devicePixelRatio); // 3x display = 9 pixels per CSS pixel = 2.25x GPU cost for zero quality gain High https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setPixelRatio threejs 0.185.1 active 2026-08-13
9 8 Setup Alpha Canvas Plus CSS Background Set alpha:true on the renderer and control the background color through CSS rather than a renderer clear color. This composites the canvas correctly over any HTML content behind it. Set alpha:true on renderer and let body or a parent div provide the background color Set a solid renderer clear color when the canvas must composite over HTML behind it const renderer = new THREE.WebGLRenderer({ alpha: true }); renderer.setClearColor(0x000000, 0); // fully transparent canvas // body { background: #0d0d0d; } handles the visible color renderer.setClearColor(0x111827); // fully opaque — HTML behind the canvas is blocked Medium https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setClearColor threejs 0.185.1 active 2026-08-13
10 9 Camera Aspect Ratio on Resize Always update camera.aspect and call camera.updateProjectionMatrix() inside every resize handler. A stale aspect ratio causes the entire scene to appear stretched or squashed horizontally. Update camera.aspect then call updateProjectionMatrix() on every resize Let aspect ratio become stale after the browser window changes size window.addEventListener('resize', () => { camera.aspect = canvas.clientWidth / canvas.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(canvas.clientWidth, canvas.clientHeight); }); // No resize handler — scene stretches to fill a wider window without correcting the projection High https://threejs.org/docs/#api/en/cameras/PerspectiveCamera threejs 0.185.1 active 2026-08-13
11 10 Camera FOV Range 45 to 75 Use a field of view between 45 and 75 degrees. Below 45 creates compressed telephoto distortion. Above 90 creates visible fisheye distortion at frame edges. Start at 75 for general interactive scenes; use 45–55 for product close-ups Use FOV above 90 or below 30 without a deliberate artistic reason const camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000); // general const camera = new THREE.PerspectiveCamera(50, aspect, 0.1, 1000); // product shot const camera = new THREE.PerspectiveCamera(120, aspect, 0.1, 1000); // fisheye distortion at all edges Medium https://threejs.org/docs/#api/en/cameras/PerspectiveCamera threejs 0.185.1 active 2026-08-13
12 11 Camera Explicit Position and lookAt Always set an explicit camera position and call camera.lookAt() before the first render. The default camera at the origin pointing down -Z makes subjects at arbitrary coordinates invisible or tiny. Set camera.position.set() and camera.lookAt() to frame the subject before the first render Leave the camera at default position (0 0 0) with no lookAt — subject may be behind the camera or microscopic camera.position.set(0, 1.5, 5); camera.lookAt(new THREE.Vector3(0, 0, 0)); // No position or lookAt set — subject at y:2 is behind or above the default camera view Medium https://threejs.org/docs/#api/en/cameras/Camera.lookAt threejs 0.185.1 active 2026-08-13
13 12 Camera OrbitControls vs GSAP Camera Rig Use OrbitControls for model viewers and exploratory scenes where the user needs free-look. Use a GSAP scroll-driven camera rig for product reveals or storytelling where the camera path must stay fixed. Match camera control approach to the UX intent of the scene Import OrbitControls from three/addons and match the camera control approach to the scene's UX intent Use OrbitControls for a scripted reveal — users can orbit away from the reveal before it completes Use OrbitControls for a scripted reveal where users can orbit away before it completes // Scroll storytelling — GSAP controls the path: gsap.to(camera.position, { z: 2, scrollTrigger: { trigger: '.scene', scrub: 1 } }); // Free-look model viewer — OrbitControls: const controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; // call controls.update() in animate() import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; // call controls.update() in animate() // Scripted product reveal: const controls = new THREE.OrbitControls(camera, renderer.domElement); // user can rotate away before the animation completes const controls = new THREE.OrbitControls(camera, renderer.domElement); // legacy global API and wrong control model for a scripted reveal High https://threejs.org/docs/#examples/en/controls/OrbitControls threejs 0.185.1 active 2026-08-13
14 13 Geometry Never Create Geometry Per Frame Creating a new geometry inside animate() allocates a fresh GPU buffer every frame and exhausts VRAM within seconds. Create all geometry exactly once before the loop starts. Use attribute mutation if positions must change per frame. Create all geometry before the animation loop; mutate BufferAttribute arrays in-place if needed Call any new XxxGeometry() constructor inside the animation loop const geo = new THREE.SphereGeometry(1, 32, 32); // created once const mesh = new THREE.Mesh(geo, mat); scene.add(mesh); const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); mesh.rotation.y += clock.getDelta() * 0.8; // delta time renderer.render(scene, camera); } function animate() { requestAnimationFrame(animate); const geo = new THREE.BoxGeometry(1, 1, 1); // NEW GPU buffer every frame — VRAM exhaustion } Critical https://threejs.org/docs/#api/en/core/BufferGeometry threejs 0.185.1 active 2026-08-13
15 14 Geometry Share Geometry Across Meshes When multiple objects share the same shape create one geometry instance and pass it to every Mesh. Each Mesh gets its own transform and material while all share a single GPU buffer. Create one geometry and pass the same reference to every Mesh constructor Create a separate identical geometry inside a loop for each object const geo = new THREE.BoxGeometry(1, 1, 1); // one GPU buffer for (let i = 0; i < 200; i++) { const m = new THREE.Mesh(geo, mat); m.position.set(Math.random() * 10, 0, Math.random() * 10); scene.add(m); } for (let i = 0; i < 200; i++) { const geo = new THREE.BoxGeometry(1, 1, 1); // 200 separate GPU buffers scene.add(new THREE.Mesh(geo, mat)); } Critical https://threejs.org/docs/#api/en/core/BufferGeometry threejs 0.185.1 active 2026-08-13
16 15 Geometry dispose on Scene Removal Call geometry.dispose() and material.dispose() and texture.dispose() for every texture map when removing objects from the scene. Three.js never releases GPU resources automatically — they stay in VRAM until explicitly freed. Dispose of geometry + material + every texture map before calling scene.remove() Call scene.remove() alone without any dispose calls function removeMesh(mesh) { scene.remove(mesh); mesh.geometry.dispose(); if (mesh.material.map) mesh.material.map.dispose(); if (mesh.material.normalMap) mesh.material.normalMap.dispose(); mesh.material.dispose(); } scene.remove(mesh); // geometry and all texture maps stay in GPU VRAM forever Critical https://threejs.org/docs/#api/en/core/BufferGeometry.dispose threejs 0.185.1 active 2026-08-13
17 16 Geometry Segment Count Budget Use the minimum segment count that achieves the desired silhouette quality. Hero objects: 32–64 segments. Background objects: 8–16. Particle stand-ins: 6–8. High counts on background geometry waste GPU draw calls with zero visible benefit. Apply a tiered segment budget based on the visual priority of each object in the scene Default every sphere and cylinder to 64+ segments regardless of its role const bgSphere = new THREE.SphereGeometry(0.5, 8, 8); // background const heroSphere = new THREE.SphereGeometry(1, 64, 64); // foreground product const particleSphere = new THREE.SphereGeometry(0.1, 64, 64); // 64 segments × 1000 particles = massive overdraw Medium https://threejs.org/docs/#api/en/geometries/SphereGeometry threejs 0.185.1 active 2026-08-13
18 17 Geometry BufferGeometry for Custom Vertex Data For any custom shape use BufferGeometry with setAttribute('position' ...) and a Float32Array. The legacy THREE.Geometry class was removed in r125 and throws ReferenceError in r128. For custom shapes use BufferGeometry with typed BufferAttribute data for positions normals colors and other vertex attributes. Use THREE.BufferGeometry with a Float32Array position attribute for custom vertex data Use THREE.BufferGeometry with Float32Array-backed attributes for custom vertex data Reference or instantiate the removed THREE.Geometry class const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3)); geo.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(normals), 3)); const geo = new THREE.Geometry(); geo.vertices.push(new THREE.Vector3(0, 0, 0)); // ReferenceError: Geometry is not defined in r128 const geo = new THREE.Geometry(); geo.vertices.push(new THREE.Vector3(0, 0, 0)); // removed legacy API High https://threejs.org/docs/#api/en/core/BufferGeometry threejs 0.185.1 active 2026-08-13
19 18 Materials MeshBasicMaterial vs MeshStandardMaterial MeshBasicMaterial ignores all lights and is significantly cheaper — use it for UI overlays HUDs and flat-colored decorative elements. MeshStandardMaterial is PBR-accurate and requires lights. Never use StandardMaterial where BasicMaterial suffices. Use MeshBasicMaterial for any object that does not need lighting; use MeshStandardMaterial for physical objects Apply MeshStandardMaterial to flat UI elements that never receive light — lights still run for them const uiMat = new THREE.MeshBasicMaterial({ color: 0xffffff }); // no lighting cost const physMat = new THREE.MeshStandardMaterial({ color: 0x4f46e5, roughness: 0.4, metalness: 0.6 }); const mat = new THREE.MeshStandardMaterial({ color: 0xffffff }); // on a 2D HUD card — lighting calculation runs with no visual benefit Medium https://threejs.org/docs/#api/en/materials/MeshStandardMaterial threejs 0.185.1 active 2026-08-13
20 19 Materials Share Material Instances Share one material instance across all meshes that have identical properties. Call mat.clone() only when individual meshes genuinely need different property values. Duplicate materials waste GPU VRAM. Assign the same material reference to all meshes with identical visual properties Create a new material inside a loop for objects that look identical const mat = new THREE.MeshStandardMaterial({ color: 0x4f46e5, roughness: 0.5 }); meshA.material = mat; meshB.material = mat; meshC.material = mat; // one GPU material for (const m of meshes) { m.material = new THREE.MeshStandardMaterial({ color: 0x4f46e5 }); } // N redundant GPU materials High https://threejs.org/docs/#api/en/materials/Material threejs 0.185.1 active 2026-08-13
21 20 Materials Dispose Textures Explicitly Textures are the single largest consumer of GPU VRAM in most Three.js scenes. Call texture.dispose() when switching scenes or removing objects — Three.js does not garbage-collect GPU resources automatically. Track all loaded textures and call dispose() on each one during scene teardown or on object removal Load textures without any cleanup path — they persist in VRAM for the entire page lifetime const tex = new THREE.TextureLoader().load('img.jpg'); mesh.material.map = tex; // on teardown: tex.dispose(); mesh.material.dispose(); const tex = new THREE.TextureLoader().load('img.jpg'); scene.remove(mesh); // tex occupies GPU VRAM until page reload High https://threejs.org/docs/#api/en/textures/Texture.dispose threejs 0.185.1 active 2026-08-13
22 21 Lighting Ambient Plus Directional Minimum Any scene using MeshStandardMaterial or MeshPhongMaterial requires at minimum one AmbientLight (fill) and one DirectionalLight (shading direction). Without both the objects render as solid black — the material is there but no light reaches it. Add AmbientLight for fill and DirectionalLight for shading whenever PBR or Phong materials are used Use MeshStandardMaterial without adding any lights to the scene scene.add(new THREE.AmbientLight(0xffffff, 0.4)); const dirLight = new THREE.DirectionalLight(0xffffff, 1.0); dirLight.position.set(5, 10, 7.5); scene.add(dirLight); const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: 0x4f46e5 })); scene.add(mesh); // renders completely black — no lights in scene Critical https://threejs.org/docs/#api/en/lights/DirectionalLight threejs 0.185.1 active 2026-08-13
23 22 Lighting Enable shadowMap Before castShadow renderer.shadowMap.enabled = true must be set before any castShadow or receiveShadow flags. Without it the shadow map is never allocated and all shadow flags are silently ignored. Set renderer.shadowMap.enabled = true first then set castShadow and receiveShadow on lights and meshes Set castShadow on a light or mesh without enabling renderer.shadowMap.enabled — shadows never render renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; heroMesh.castShadow = true; ground.receiveShadow = true; dirLight.castShadow = true; heroMesh.castShadow = true; // renderer.shadowMap.enabled never set — shadows silently do not render High https://threejs.org/docs/#api/en/renderers/WebGLRenderer.shadowMap threejs 0.185.1 active 2026-08-13
24 23 Lighting Selective Shadow Casting Shadow map rendering redraws the entire scene from the light's perspective every frame. Enable castShadow only on the primary directional light and receiveShadow only on hero meshes and the ground plane. Enable shadows only on the key light and the most important meshes Enable castShadow and receiveShadow on every object in the scene including particles renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; dirLight.castShadow = true; heroMesh.castShadow = true; ground.receiveShadow = true; // particles and background meshes: no shadow flags for (const m of allMeshes) { m.castShadow = true; m.receiveShadow = true; } // shadow map pass over particle system — expensive with no visible gain High https://threejs.org/docs/#api/en/renderers/WebGLRenderer.shadowMap threejs 0.185.1 active 2026-08-13
25 24 Lighting Skip Lights for MeshBasicMaterial MeshBasicMaterial completely ignores all scene lights. Adding lights solely to illuminate BasicMaterial objects wastes a light pass on every frame with zero visible effect. Omit lights entirely when every material in the scene is MeshBasicMaterial Add AmbientLight and DirectionalLight to a scene that uses only MeshBasicMaterial // Scene uses only MeshBasicMaterial — no lights needed const mat = new THREE.MeshBasicMaterial({ color: 0x00ffff }); const mesh = new THREE.Mesh(geo, mat); scene.add(mesh); // MeshBasicMaterial is always fully lit by definition scene.add(new THREE.AmbientLight(0xffffff, 1.0)); // wasted per-frame light pass — BasicMaterial ignores it entirely Low https://threejs.org/docs/#api/en/materials/MeshBasicMaterial threejs 0.185.1 active 2026-08-13
26 25 Raycasting Single Shared Raycaster Create exactly one Raycaster instance outside all event handlers. Store mouse coordinates in pointermove (cheap). Call setFromCamera and intersectObjects together inside the animate() loop — once per frame instead of once per mouse event. Create one Raycaster; store mouse in pointermove; call setFromCamera + intersectObjects inside animate() Create a new THREE.Raycaster() inside a mousemove handler or call setFromCamera inside the event listener const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); canvas.addEventListener('pointermove', e => { // only store coords — no raycasting here mouse.x = (e.clientX / canvas.clientWidth) * 2 - 1; mouse.y = -(e.clientY / canvas.clientHeight) * 2 + 1; }); // setFromCamera and intersectObjects run once per frame in animate() window.addEventListener('mousemove', e => { const rc = new THREE.Raycaster(); // new allocation per event rc.setFromCamera(mouse, camera); rc.intersectObjects(targets, true); // fires 200+ times/sec }); Critical https://threejs.org/docs/#api/en/core/Raycaster threejs 0.185.1 active 2026-08-13
27 26 Raycasting NDC Mouse Coordinates Raycasting requires mouse in Normalized Device Coordinates: X from -1 (left) to +1 (right) and Y from +1 (top) to -1 (bottom). The Y axis is inverted relative to screen space. A missing negation on Y causes all raycasts to miss or hit the wrong objects. Apply the full NDC formula — including the negation on the Y axis Forget to negate Y — raycasting appears to work but hits objects mirrored vertically mouse.x = (e.clientX / canvas.clientWidth) * 2 - 1; mouse.y = -(e.clientY / canvas.clientHeight) * 2 + 1; // Y is INVERTED mouse.x = (e.clientX / canvas.clientWidth) * 2 - 1; mouse.y = (e.clientY / canvas.clientHeight) * 2 - 1; // BUG: Y not negated — raycasting is mirrored Critical https://threejs.org/docs/#api/en/core/Raycaster.setFromCamera threejs 0.185.1 active 2026-08-13
28 27 Raycasting setFromCamera and intersectObjects in animate Call raycaster.setFromCamera(mouse camera) and then raycaster.intersectObjects(targets true) inside the animate() loop. setFromCamera must come before intersectObjects every frame — without it the raycaster uses a stale ray direction. Call setFromCamera then intersectObjects in order inside every animate() frame Call intersectObjects without calling setFromCamera first — the raycaster uses a stale or zero ray function animate() { requestAnimationFrame(animate); raycaster.setFromCamera(mouse, camera); // update ray direction first const hits = raycaster.intersectObjects(targets, true); // then test intersections if (hits.length > 0) { document.body.style.cursor = 'pointer'; } else { document.body.style.cursor = 'auto'; } renderer.render(scene, camera); } function animate() { requestAnimationFrame(animate); const hits = raycaster.intersectObjects(targets, true); // BUG: setFromCamera never called — stale ray — hits is always empty renderer.render(scene, camera); } Critical https://threejs.org/docs/#api/en/core/Raycaster threejs 0.185.1 active 2026-08-13
29 28 Raycasting Recursive Flag for Groups and GLTF Pass true as the second argument to intersectObjects when testing Groups or GLTF loaded models. Geometry lives on child Mesh objects — without recursive:true the parent group is tested but has no geometry and hits is always empty. Use intersectObjects(targets true) for Groups or any loaded model Raycast against a parent Group without the recursive flag const hits = raycaster.intersectObjects(scene.children, true); // catches all descendant meshes const hits = raycaster.intersectObjects([modelGroup]); // recursive defaults to false — misses all children High https://threejs.org/docs/#api/en/core/Raycaster.intersectObjects threejs 0.185.1 active 2026-08-13
30 29 Raycasting Cursor Feedback on Hover Set document.body.style.cursor = 'pointer' when intersections are found and reset to 'auto' when none are found. Without cursor feedback users cannot discover that 3D objects are interactive. Update cursor to pointer on hit; reset to auto on miss in the same animate loop block Run raycasting and read hits without ever updating the cursor style if (hits.length > 0) { document.body.style.cursor = 'pointer'; } else { document.body.style.cursor = 'auto'; } raycaster.setFromCamera(mouse, camera); raycaster.intersectObjects(targets, true); // hits ignored — cursor never changes — objects feel non-interactive Medium https://developer.mozilla.org/en-US/docs/Web/CSS/cursor threejs 0.185.1 active 2026-08-13
31 30 Animation requestAnimationFrame Loop Only Drive the render loop exclusively with requestAnimationFrame or renderer.setAnimationLoop(). Never use setInterval or setTimeout — they are not synchronized to the display refresh rate and keep running when the tab is hidden draining battery. Use requestAnimationFrame or renderer.setAnimationLoop() as the sole render loop driver Use setInterval or setTimeout for render timing function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate(); setInterval(() => renderer.render(scene, camera), 16); // not display-synced; runs at full speed even when tab is hidden Critical https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setAnimationLoop threejs 0.185.1 active 2026-08-13
32 31 Animation THREE.Clock for Delta Time Use THREE.Clock and clock.getDelta() for all time-based motion. A hardcoded increment like += 0.01 runs at 2x speed on 120Hz displays and at unpredictable speed when frames drop under load. CRITICAL: call getDelta() exactly ONCE per animate() frame and store the result in a local dt variable. getDelta() resets the internal clock on every call — a second call in the same frame always returns ~0, silently breaking any animation block that uses it. Call clock.getDelta() once at the top of animate(); store result in dt; reuse dt everywhere in that frame Call clock.getDelta() more than once per frame or inside a helper called from animate() const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); const dt = clock.getDelta(); // called ONCE — reuse dt below mesh.rotation.y += dt * 0.8; particles.rotation.y += dt * 0.1; // reuse dt, not a second getDelta() renderer.render(scene, camera); } function animate() { requestAnimationFrame(animate); mesh.rotation.y += 0.01; // 0.01 rad/frame — runs 2x faster on 120Hz than on 60Hz } High https://threejs.org/docs/#api/en/core/Clock threejs 0.185.1 active 2026-08-13
33 32 Animation Lerp for Smooth Pointer Follow Use value += (target - value) * alpha each frame to smoothly interpolate toward a moving target. An alpha of 0.03–0.1 produces organic easing for camera follow pointer-tracking and hover scale effects without requiring GSAP. Apply the lerp formula each frame with a small alpha for smooth organic motion Snap a value directly to the target producing an instant jarring jump // In animate(): cameraTargetX = mouse.x * 3; camera.position.x += (cameraTargetX - camera.position.x) * 0.05; camera.position.y += (cameraTargetY - camera.position.y) * 0.05; camera.lookAt(scene.position); // In animate(): camera.position.x = mouse.x * 3; // instant snap — jarring with no easing Medium https://threejs.org/docs/#api/en/math/MathUtils.lerp threejs 0.185.1 active 2026-08-13
34 33 Animation GSAP for Multi-Step Sequences Use GSAP timelines for any animation with more than two sequential steps or for scroll-linked camera paths. GSAP timelines can be paused reversed and scrubbed — far more maintainable than boolean state machines. Use GSAP timelines for sequences with more than two steps and for scroll-driven animations Implement multi-step sequences with boolean flags and manual frame counters const tl = gsap.timeline({ defaults: { ease: 'power2.out' } }); tl.from(mesh.position, { y: -3, duration: 1 }) .to(mesh.rotation, { y: Math.PI, duration: 1 }, '-=0.3') .to(camera.position, { z: 2, duration: 1.5 }); let step = 0; let t = 0; function animate() { if (step === 0 && (t += 0.01) >= 1) step = 1; } // grows unmanageable with 3+ steps High https://gsap.com/docs/v3/GSAP/Timeline/ https://www.npmjs.com/package/gsap threejs 0.185.1 active 2026-08-13
35 34 Animation Pause Render Loop on Tab Hidden Use renderer.setAnimationLoop() as the loop driver so you can pass null to pause and a function to resume. Continuous rendering in a hidden tab wastes CPU GPU and battery with no user benefit. Use renderer.setAnimationLoop(animate) to drive the loop; pass null to pause on visibilitychange Drive with internal requestAnimationFrame and never stop the loop when the tab is hidden renderer.setAnimationLoop(animate); // use setAnimationLoop as the driver — not requestAnimationFrame inside animate function animate() { const dt = clock.getDelta(); renderer.render(scene, camera); } document.addEventListener('visibilitychange', () => { if (document.hidden) renderer.setAnimationLoop(null); else renderer.setAnimationLoop(animate); }); function animate() { requestAnimationFrame(animate); // self-referencing RAF cannot be stopped externally renderer.render(scene, camera); } animate(); // runs forever in background tab — drains battery High https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setAnimationLoop threejs 0.185.1 active 2026-08-13
36 35 GSAP Load GSAP Before Scene Script Load GSAP from its own CDN script tag before your scene script. In bundler projects install via npm and import. GSAP is a completely separate library from Three.js — never try to import it from the Three.js package. Load GSAP CDN before the scene script; or npm install gsap and import separately Import gsap from three or expect it to be defined without a separate load <!-- CDN: load GSAP before your scene script --> <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script> <!-- Bundler: --> // import gsap from 'gsap'; import { ScrollTrigger } from 'gsap/ScrollTrigger'; import gsap from 'three'; // undefined — GSAP has nothing to do with Three.js Critical https://gsap.com/docs/v3/Installation https://www.npmjs.com/package/gsap threejs 0.185.1 active 2026-08-13
37 36 GSAP Register ScrollTrigger Before Use Call gsap.registerPlugin(ScrollTrigger) once at the top of your script before any scrollTrigger config object. Without registration the ScrollTrigger name is undefined and the tween throws immediately. Call gsap.registerPlugin(ScrollTrigger) as the first line before any gsap.to/from/timeline with scrollTrigger Include scrollTrigger config in gsap.to() calls without first registering the plugin gsap.registerPlugin(ScrollTrigger); gsap.to(camera.position, { z: 2, scrollTrigger: { trigger: '.hero-section', scrub: 1 } }); gsap.to(mesh.position, { scrollTrigger: { trigger: '.section', scrub: true } }); // TypeError: ScrollTrigger is not a constructor — not registered Critical https://gsap.com/docs/v3/Plugins/ScrollTrigger/ https://www.npmjs.com/package/gsap threejs 0.185.1 active 2026-08-13
38 37 GSAP Tween Three.js Properties Directly GSAP can tween any numeric JavaScript property including mesh.position.x mesh.rotation.y and material.opacity. No wrapper or adaptor is needed. Note: to tween material.opacity the material must have transparent:true set before the tween starts. Pass Three.js object properties directly to gsap.to(); set transparent:true before tweening opacity Use a plain proxy object then manually copy values to Three.js properties every frame gsap.to(mesh.rotation, { y: Math.PI * 2, duration: 2, ease: 'power1.inOut' }); mesh.material.transparent = true; // required before tweening opacity gsap.to(mesh.material, { opacity: 0, duration: 1 }); const tw = { v: 0 }; gsap.to(tw, { v: Math.PI * 2, onUpdate: () => mesh.rotation.y = tw.v }); // unnecessary proxy wrapper Medium https://gsap.com/docs/v3/GSAP/gsap.to() threejs 0.185.1 active 2026-08-13
39 38 GSAP scrub for Scroll-Driven Camera Path Use scrub:true or scrub:1 to link camera movement continuously to scroll position as a 0–1 ratio. scrub:1 adds a 1-second lag for cinematic smoothness. onEnter/onLeave fire only once and create jarring snaps — not the right tool for a camera path. Use scrub:1 for any scroll-controlled camera movement Use onEnter or onLeave callbacks for camera motion — they snap instead of scrubbing gsap.registerPlugin(ScrollTrigger); gsap.to(camera.position, { x: 5, y: 2, z: 0, ease: 'none', scrollTrigger: { trigger: '.canvas-wrapper', start: 'top top', end: 'bottom bottom', scrub: 1 } }); gsap.to(camera.position, { z: 0, scrollTrigger: { trigger: '.section', onEnter: () => {} } }); // fires once at scroll threshold — not a continuous scrub High https://gsap.com/docs/v3/Plugins/ScrollTrigger/ https://www.npmjs.com/package/gsap threejs 0.185.1 active 2026-08-13
40 39 Performance InstancedMesh for Repeated Objects Use THREE.InstancedMesh when rendering 50 or more identical objects. It submits all N transforms in one draw call instead of N draw calls and reduces CPU-GPU communication overhead dramatically. Use InstancedMesh for any group of 50+ meshes sharing the same geometry and material Create 50+ separate Mesh objects with the same geometry and material const COUNT = 500; const iMesh = new THREE.InstancedMesh(geo, mat, COUNT); const matrix = new THREE.Matrix4(); for (let i = 0; i < COUNT; i++) { matrix.setPosition(Math.random()*10, Math.random()*10, Math.random()*10); iMesh.setMatrixAt(i, matrix); } iMesh.instanceMatrix.needsUpdate = true; scene.add(iMesh); for (let i = 0; i < 500; i++) { scene.add(new THREE.Mesh(geo, mat)); } // 500 separate draw calls per frame High https://threejs.org/docs/#api/en/objects/InstancedMesh threejs 0.185.1 active 2026-08-13
41 40 Performance Tone Mapping and sRGB Encoding Tone Mapping and Output Color Space Enable ACESFilmicToneMapping and sRGBEncoding on the renderer for accurate perceptual color. Without tone mapping colors appear washed out or over-saturated. These are renderer properties set after construction and take effect immediately. Three.js color management is enabled by default. Keep working colors in the linear-sRGB space and set the renderer output color space to SRGBColorSpace; choose tone mapping when rendering HDR lighting to a display. Set renderer.toneMapping and renderer.outputEncoding after construction for all production scenes Use the default ColorManagement.enabled state and set renderer.outputColorSpace plus an appropriate toneMapping Leave tone mapping and output encoding at defaults when the scene targets realistic visuals Disable color management or use removed outputEncoding and sRGBEncoding properties renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; renderer.outputEncoding = THREE.sRGBEncoding; // correct for r128 THREE.ColorManagement.enabled = true; renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; const renderer = new THREE.WebGLRenderer(); // defaults: NoToneMapping + LinearEncoding — colors appear flat and incorrect renderer.outputEncoding = THREE.sRGBEncoding; // removed legacy properties Medium https://threejs.org/docs/#api/en/renderers/WebGLRenderer.toneMapping https://threejs.org/manual/en/color-management.html threejs 0.185.1 active 2026-08-13
42 41 Performance antialias Set at Construction Only The antialias option can only be set at WebGLRenderer construction time. Setting renderer.antialias after construction has absolutely no effect — the WebGL context is already created without it. Decide before instantiating. Set antialias:true inside the WebGLRenderer constructor options object Construct the renderer without antialias then try to enable it by assigning the property const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); // antialias baked into the WebGL context const renderer = new THREE.WebGLRenderer(); renderer.antialias = true; // no effect — context created without AA — edges remain aliased High https://threejs.org/docs/#api/en/renderers/WebGLRenderer threejs 0.185.1 active 2026-08-13
43 42 Performance FogExp2 for Depth and Far Culling Use scene.fog to create atmospheric depth. As a secondary benefit objects that disappear into fog before the far plane stop contributing to draw calls — useful in scenes with large view distances. Add FogExp2 to scenes with view distances above 100 units for both visual atmosphere and implicit far culling Ignore fog in scenes with far:1000+ and many distant objects that contribute tiny pixels per draw call scene.fog = new THREE.FogExp2(0x0a0a0a, 0.02); // exponential — density feels more natural than linear // far: 2000 with no fog — hundreds of distant objects too small to see still cost draw calls per frame Low https://threejs.org/docs/#api/en/scenes/FogExp2 threejs 0.185.1 active 2026-08-13
44 43 Particles BufferGeometry Plus Points for Particle Systems Build all particle systems with BufferGeometry plus a Float32Array position attribute rendered as Points. Never use individual Mesh objects as particles — they cannot scale past a few hundred with good performance. Use Points plus BufferGeometry for all particle effects Create hundreds of individual Mesh objects to simulate a particle system const COUNT = 3000; const geo = new THREE.BufferGeometry(); const pos = new Float32Array(COUNT * 3); for (let i = 0; i < COUNT * 3; i++) pos[i] = (Math.random() - 0.5) * 20; geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); const particles = new THREE.Points(geo, new THREE.PointsMaterial({ size: 0.05, color: 0xffffff })); scene.add(particles); for (let i = 0; i < 500; i++) { scene.add(new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 8), mat)); } // 500 separate draw calls per frame High https://threejs.org/docs/#api/en/objects/Points threejs 0.185.1 active 2026-08-13
45 44 Particles Particle Count Ceiling Start particle systems at 1000–3000 particles. Beyond 50000 causes sustained frame drops on mid-range mobile. Always test on a real device before increasing the count — desktop and mobile GPU performance ratios can be 10:1. Start at 3000 particles and profile on actual mobile hardware before raising the limit Set particle count at 100000 or higher without any mobile profiling const COUNT = 3000; // safe mobile baseline — profile before going higher const pos = new Float32Array(COUNT * 3); const COUNT = 150000; // 60fps on desktop — 8fps on a mid-range Android phone High https://threejs.org/docs/#api/en/objects/Points threejs 0.185.1 active 2026-08-13
46 45 Particles needsUpdate After Buffer Mutation After mutating any BufferAttribute array values per frame you must set geometry.attributes.position.needsUpdate = true so Three.js re-uploads the changed buffer to the GPU. Without it the GPU still uses the old data and particles appear completely frozen. Set needsUpdate = true on the position attribute after every per-frame mutation of the array Mutate the Float32Array values without flagging needsUpdate — positions update in JS but not on the GPU // In animate(): const pos = geo.attributes.position.array; for (let i = 0; i < pos.length; i += 3) { pos[i + 1] += Math.sin(clock.getElapsedTime() + i) * 0.001; // Y component } geo.attributes.position.needsUpdate = true; // GPU re-upload // In animate(): pos[1] += 0.001; // JS array updated — GPU buffer is stale — particles do not move Critical https://threejs.org/docs/#api/en/core/BufferAttribute.needsUpdate threejs 0.185.1 active 2026-08-13
47 46 Responsive Canvas Dimensions Not Window Size the renderer and camera to the canvas element's clientWidth and clientHeight — not window.innerWidth and innerHeight. This is correct when the canvas is inside a flex or grid container that does not fill the full viewport. Use canvas.clientWidth and canvas.clientHeight for all renderer and camera sizing Hardcode renderer size to window.innerWidth/innerHeight when the canvas may be inside a container renderer.setSize(canvas.clientWidth, canvas.clientHeight); camera.aspect = canvas.clientWidth / canvas.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); // wrong when canvas lives inside a sidebar or grid column High https://threejs.org/docs/#api/en/renderers/WebGLRenderer.setSize threejs 0.185.1 active 2026-08-13
48 47 Responsive ResizeObserver Over window resize Event Use ResizeObserver on the canvas container instead of the window resize event. ResizeObserver fires when the container element changes size independently of the browser window — common in split-pane layouts and sidebar collapsing. Attach ResizeObserver to the canvas parent element for accurate container-aware resize detection Use only window.addEventListener('resize') for canvas sizing when the canvas is not fullscreen const ro = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; renderer.setSize(width, height); camera.aspect = width / height; camera.updateProjectionMatrix(); }); ro.observe(canvas.parentElement); window.addEventListener('resize', () => { renderer.setSize(window.innerWidth, window.innerHeight); }); // misses container-only resize events in split-pane UIs Medium https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver threejs 0.185.1 active 2026-08-13
49 48 Responsive Touch Events for Mobile Interaction Add touchstart and touchmove listeners alongside mouse events so the scene remains interactive on mobile. Normalize touch coordinates to the same NDC range as mouse events and pass passive:false on touchmove if you call preventDefault. Handle both mouse and touch input for any interactive 3D scene Add only mouse event listeners and leave touch users with no interaction canvas.addEventListener('touchmove', e => { e.preventDefault(); const t = e.touches[0]; mouse.x = (t.clientX / canvas.clientWidth) * 2 - 1; mouse.y = -(t.clientY / canvas.clientHeight) * 2 + 1; }, { passive: false }); canvas.addEventListener('touchstart', e => { e.preventDefault(); }, { passive: false }); canvas.addEventListener('mousemove', handleMouse); // touch events unhandled — mobile users get no interaction Medium https://developer.mozilla.org/en-US/docs/Web/API/Touch_events threejs 0.185.1 active 2026-08-13
50 49 Accessibility prefers-reduced-motion Check window.matchMedia('(prefers-reduced-motion: reduce)') before starting any auto-rotation, particle animation, or camera movement. Users who enable this OS preference have motion sickness or vestibular disorders. IMPORTANT: reading .matches once at page load is a one-time snapshot — if the user changes their OS accessibility setting mid-session the scene will not react. Attach a 'change' listener to the MediaQueryList so noMotion stays in sync at runtime. Use matchMedia.addEventListener('change') to keep noMotion reactive; gate all auto-animation on the live value Read .matches once at startup and never update it — the scene ignores mid-session OS setting changes const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); let noMotion = mq.matches; mq.addEventListener('change', e => { noMotion = e.matches; }); // In animate(): if (!noMotion) { mesh.rotation.y += dt * 0.8; particles.rotation.y += dt * 0.1; } const noMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // one-time snapshot — mid-session OS change is ignored entirely High https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion https://www.w3.org/WAI/WCAG22/Techniques/css/C39.html threejs 0.185.1 active 2026-08-13
51 50 Accessibility Canvas aria-label Add role='img' and a descriptive aria-label to renderer.domElement after appending it to the DOM. Screen readers receive no information from a WebGL canvas — the aria-label is the only description they can announce to users. Set role='img' and a meaningful aria-label on renderer.domElement before or after appending it Append the canvas to the DOM with no accessibility attributes — invisible to screen readers renderer.domElement.setAttribute('role', 'img'); renderer.domElement.setAttribute('aria-label', 'Interactive 3D product viewer. Drag to rotate. Scroll to zoom.'); document.body.appendChild(renderer.domElement); document.body.appendChild(renderer.domElement); // bare canvas — screen readers announce nothing Medium https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#accessibility_concerns threejs 0.185.1 active 2026-08-13
52 51 Production Bundler Stack for Production For production use Three.js via npm plus Vite. You get full tree-shaking reduced bundle size access to the complete examples/jsm library including OrbitControls GLTFLoader and EffectComposer and TypeScript support. For production install the exact Three.js release from npm and use a bundler such as Vite. Import optional loaders controls and post-processing modules from three/addons so all modules share one version. Use npm install three plus Vite or Webpack for any production client-facing project Use npm install three@0.185.1 and import core plus addons through ESM Serve raw CDN script tags in a production application that needs tree-shaking or TypeScript Serve legacy global scripts or import addons from deprecated examples/js paths npm install three gsap // then in your JS: import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; npm install three@0.185.1; import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; <!-- In a Vite/React production build: --> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> // no tree-shaking — entire Three.js ships <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> // legacy global build with no module graph Medium https://threejs.org/docs/#manual/en/introduction/Installation https://threejs.org/manual/en/installation.html threejs 0.185.1 active 2026-08-13
53 52 Production GLTFLoader with scene traverse Load 3D models using GLTFLoader and traverse gltf.scene to configure castShadow receiveShadow and material overrides on all child Mesh nodes. Calling scene.add(gltf.scene) alone silently skips all shadow and material configuration. Use GLTFLoader and traverse the entire gltf.scene graph to set up shadows and materials on every Mesh child Load a GLTF model and pass gltf.scene directly to scene.add without traversing child meshes import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; const loader = new GLTFLoader(); loader.load('model.glb', gltf => { gltf.scene.traverse(child => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; } }); scene.add(gltf.scene); }); loader.load('model.glb', gltf => { scene.add(gltf.scene); // shadows and material setup silently skipped on all children }); Medium https://threejs.org/docs/#examples/en/loaders/GLTFLoader threejs 0.185.1 active 2026-08-13
54 53 Production LOD for Distance-Based Detail Use THREE.LOD to automatically swap high-detail and low-detail geometry as objects move closer or farther from the camera. This maintains frame rate in scenes with many objects spread across a large depth range. Use THREE.LOD to reduce triangle count on distant objects automatically Render the same high-polygon geometry for every object regardless of its distance from the camera const lod = new THREE.LOD(); lod.addLevel(highDetailMesh, 0); // used when < 15 units away lod.addLevel(medDetailMesh, 15); // 15–50 units lod.addLevel(lowDetailMesh, 50); // 50+ units scene.add(lod); scene.add(highDetailMesh); // 64k-triangle mesh rendered at full cost whether 1 unit or 100 units from camera Medium https://threejs.org/docs/#api/en/objects/LOD threejs 0.185.1 active 2026-08-13