Viet Tran a38d04c3d5
feat(search): overhaul relevance and curated design data
Overhaul BM25 relevance, reasoning and data-quality contracts; refresh UI styles and framework guidance; add resilient text, chip, badge and micro-interaction guidance; strengthen release, provenance and catalog refresh gates; update bilingual documentation.
2026-08-14 00:08:23 +07:00

45 KiB
Raw Blame History

1NoCategoryGuidelineDescriptionDoDon'tCode GoodCode BadSeverityDocs URLApplies ToStatusVerified At
21SetupPin the Three.js Package VersionInstall 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 releaseUse a floating latest URL or a legacy global build as the default production setupnpm install three@0.185.1<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>Criticalhttps://www.npmjs.com/package/three/v/0.185.1threejs 0.185.1active2026-08-13
32SetupUse CapsuleGeometry for Capsule ShapesCapsuleGeometry 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.CapsuleGeometryRebuild a standard capsule from separate cylinder and sphere meshes unless custom topology is requiredconst 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 workaroundCriticalhttps://threejs.org/docs/#api/en/geometries/CapsuleGeometrythreejs 0.185.1active2026-08-13
43SetupImport OrbitControls from Three.js AddonsOrbitControls 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 controlsExpect THREE.OrbitControls to exist on the core namespaceimport * 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 namespaceCriticalhttps://threejs.org/docs/#examples/en/controls/OrbitControlsthreejs 0.185.1active2026-08-13
54SetupCustom Drag Orbit FallbackWhen 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 dragMove camera.position.x directly — vertical drag is silently ignored and the orbit is incorrectlet 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 sphereHighhttps://threejs.org/docs/#examples/en/controls/OrbitControlsthreejs 0.185.1active2026-08-13
65SetupUse ESM Imports with Bundlers or Import MapsImport 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 releaseMix 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>Criticalhttps://threejs.org/manual/en/installation.htmlthreejs 0.185.1active2026-08-13
76SetupSingle Renderer Per PageCreate 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 rendererCreate a new renderer on each component mount or scene transitionconst 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 lifetimefunction showScene() { const renderer = new THREE.WebGLRenderer(); document.body.appendChild(renderer.domElement); } showScene(); showScene(); // two GPU contexts — crashes on mobileCriticalhttps://threejs.org/docs/#api/en/renderers/WebGLRendererthreejs 0.185.1active2026-08-13
87SetupPixel Ratio Cap at 2Cap 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 3Pass window.devicePixelRatio directly without any caprenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));renderer.setPixelRatio(window.devicePixelRatio); // 3x display = 9 pixels per CSS pixel = 2.25x GPU cost for zero quality gainHighhttps://threejs.org/docs/#api/en/renderers/WebGLRenderer.setPixelRatiothreejs 0.185.1active2026-08-13
98SetupAlpha Canvas Plus CSS BackgroundSet 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 colorSet a solid renderer clear color when the canvas must composite over HTML behind itconst renderer = new THREE.WebGLRenderer({ alpha: true }); renderer.setClearColor(0x000000, 0); // fully transparent canvas // body { background: #0d0d0d; } handles the visible colorrenderer.setClearColor(0x111827); // fully opaque — HTML behind the canvas is blockedMediumhttps://threejs.org/docs/#api/en/renderers/WebGLRenderer.setClearColorthreejs 0.185.1active2026-08-13
109CameraAspect Ratio on ResizeAlways 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 resizeLet aspect ratio become stale after the browser window changes sizewindow.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 projectionHighhttps://threejs.org/docs/#api/en/cameras/PerspectiveCamerathreejs 0.185.1active2026-08-13
1110CameraFOV Range 45 to 75Use 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-upsUse FOV above 90 or below 30 without a deliberate artistic reasonconst camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000); // general const camera = new THREE.PerspectiveCamera(50, aspect, 0.1, 1000); // product shotconst camera = new THREE.PerspectiveCamera(120, aspect, 0.1, 1000); // fisheye distortion at all edgesMediumhttps://threejs.org/docs/#api/en/cameras/PerspectiveCamerathreejs 0.185.1active2026-08-13
1211CameraExplicit Position and lookAtAlways 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 renderLeave the camera at default position (0 0 0) with no lookAt — subject may be behind the camera or microscopiccamera.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 viewMediumhttps://threejs.org/docs/#api/en/cameras/Camera.lookAtthreejs 0.185.1active2026-08-13
1312CameraOrbitControls vs GSAP Camera RigUse 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 intentUse OrbitControls for a scripted reveal where users can orbit away before it completesimport { 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 revealHighhttps://threejs.org/docs/#examples/en/controls/OrbitControlsthreejs 0.185.1active2026-08-13
1413GeometryNever Create Geometry Per FrameCreating 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 neededCall any new XxxGeometry() constructor inside the animation loopconst 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 }Criticalhttps://threejs.org/docs/#api/en/core/BufferGeometrythreejs 0.185.1active2026-08-13
1514GeometryShare Geometry Across MeshesWhen 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 constructorCreate a separate identical geometry inside a loop for each objectconst 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)); }Criticalhttps://threejs.org/docs/#api/en/core/BufferGeometrythreejs 0.185.1active2026-08-13
1615Geometrydispose on Scene RemovalCall 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 callsfunction 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 foreverCriticalhttps://threejs.org/docs/#api/en/core/BufferGeometry.disposethreejs 0.185.1active2026-08-13
1716GeometrySegment Count BudgetUse 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 sceneDefault every sphere and cylinder to 64+ segments regardless of its roleconst bgSphere = new THREE.SphereGeometry(0.5, 8, 8); // background const heroSphere = new THREE.SphereGeometry(1, 64, 64); // foreground productconst particleSphere = new THREE.SphereGeometry(0.1, 64, 64); // 64 segments × 1000 particles = massive overdrawMediumhttps://threejs.org/docs/#api/en/geometries/SphereGeometrythreejs 0.185.1active2026-08-13
1817GeometryBufferGeometry for Custom Vertex DataFor 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 dataReference or instantiate the removed THREE.Geometry classconst 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 APIHighhttps://threejs.org/docs/#api/en/core/BufferGeometrythreejs 0.185.1active2026-08-13
1918MaterialsMeshBasicMaterial vs MeshStandardMaterialMeshBasicMaterial 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 objectsApply MeshStandardMaterial to flat UI elements that never receive light — lights still run for themconst 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 benefitMediumhttps://threejs.org/docs/#api/en/materials/MeshStandardMaterialthreejs 0.185.1active2026-08-13
2019MaterialsShare Material InstancesShare 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 propertiesCreate a new material inside a loop for objects that look identicalconst mat = new THREE.MeshStandardMaterial({ color: 0x4f46e5, roughness: 0.5 }); meshA.material = mat; meshB.material = mat; meshC.material = mat; // one GPU materialfor (const m of meshes) { m.material = new THREE.MeshStandardMaterial({ color: 0x4f46e5 }); } // N redundant GPU materialsHighhttps://threejs.org/docs/#api/en/materials/Materialthreejs 0.185.1active2026-08-13
2120MaterialsDispose Textures ExplicitlyTextures 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 removalLoad textures without any cleanup path — they persist in VRAM for the entire page lifetimeconst 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 reloadHighhttps://threejs.org/docs/#api/en/textures/Texture.disposethreejs 0.185.1active2026-08-13
2221LightingAmbient Plus Directional MinimumAny 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 usedUse MeshStandardMaterial without adding any lights to the scenescene.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 sceneCriticalhttps://threejs.org/docs/#api/en/lights/DirectionalLightthreejs 0.185.1active2026-08-13
2322LightingEnable shadowMap Before castShadowrenderer.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 meshesSet castShadow on a light or mesh without enabling renderer.shadowMap.enabled — shadows never renderrenderer.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 renderHighhttps://threejs.org/docs/#api/en/renderers/WebGLRenderer.shadowMapthreejs 0.185.1active2026-08-13
2423LightingSelective Shadow CastingShadow 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 meshesEnable castShadow and receiveShadow on every object in the scene including particlesrenderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; dirLight.castShadow = true; heroMesh.castShadow = true; ground.receiveShadow = true; // particles and background meshes: no shadow flagsfor (const m of allMeshes) { m.castShadow = true; m.receiveShadow = true; } // shadow map pass over particle system — expensive with no visible gainHighhttps://threejs.org/docs/#api/en/renderers/WebGLRenderer.shadowMapthreejs 0.185.1active2026-08-13
2524LightingSkip Lights for MeshBasicMaterialMeshBasicMaterial 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 MeshBasicMaterialAdd 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 definitionscene.add(new THREE.AmbientLight(0xffffff, 1.0)); // wasted per-frame light pass — BasicMaterial ignores it entirelyLowhttps://threejs.org/docs/#api/en/materials/MeshBasicMaterialthreejs 0.185.1active2026-08-13
2625RaycastingSingle Shared RaycasterCreate 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 listenerconst 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 });Criticalhttps://threejs.org/docs/#api/en/core/Raycasterthreejs 0.185.1active2026-08-13
2726RaycastingNDC Mouse CoordinatesRaycasting 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 axisForget to negate Y — raycasting appears to work but hits objects mirrored verticallymouse.x = (e.clientX / canvas.clientWidth) * 2 - 1; mouse.y = -(e.clientY / canvas.clientHeight) * 2 + 1; // Y is INVERTEDmouse.x = (e.clientX / canvas.clientWidth) * 2 - 1; mouse.y = (e.clientY / canvas.clientHeight) * 2 - 1; // BUG: Y not negated — raycasting is mirroredCriticalhttps://threejs.org/docs/#api/en/core/Raycaster.setFromCamerathreejs 0.185.1active2026-08-13
2827RaycastingsetFromCamera and intersectObjects in animateCall 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() frameCall intersectObjects without calling setFromCamera first — the raycaster uses a stale or zero rayfunction 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); }Criticalhttps://threejs.org/docs/#api/en/core/Raycasterthreejs 0.185.1active2026-08-13
2928RaycastingRecursive Flag for Groups and GLTFPass 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 modelRaycast against a parent Group without the recursive flagconst hits = raycaster.intersectObjects(scene.children, true); // catches all descendant meshesconst hits = raycaster.intersectObjects([modelGroup]); // recursive defaults to false — misses all childrenHighhttps://threejs.org/docs/#api/en/core/Raycaster.intersectObjectsthreejs 0.185.1active2026-08-13
3029RaycastingCursor Feedback on HoverSet 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 blockRun raycasting and read hits without ever updating the cursor styleif (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-interactiveMediumhttps://developer.mozilla.org/en-US/docs/Web/CSS/cursorthreejs 0.185.1active2026-08-13
3130AnimationrequestAnimationFrame Loop OnlyDrive 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 driverUse setInterval or setTimeout for render timingfunction 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 hiddenCriticalhttps://threejs.org/docs/#api/en/renderers/WebGLRenderer.setAnimationLoopthreejs 0.185.1active2026-08-13
3231AnimationTHREE.Clock for Delta TimeUse 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 frameCall 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 }Highhttps://threejs.org/docs/#api/en/core/Clockthreejs 0.185.1active2026-08-13
3332AnimationLerp for Smooth Pointer FollowUse 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 motionSnap 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 easingMediumhttps://threejs.org/docs/#api/en/math/MathUtils.lerpthreejs 0.185.1active2026-08-13
3433AnimationGSAP for Multi-Step SequencesUse 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 animationsImplement multi-step sequences with boolean flags and manual frame countersconst 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+ stepsHighhttps://www.npmjs.com/package/gsapthreejs 0.185.1active2026-08-13
3534AnimationPause Render Loop on Tab HiddenUse 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 visibilitychangeDrive with internal requestAnimationFrame and never stop the loop when the tab is hiddenrenderer.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 batteryHighhttps://threejs.org/docs/#api/en/renderers/WebGLRenderer.setAnimationLoopthreejs 0.185.1active2026-08-13
3635GSAPLoad GSAP Before Scene ScriptLoad 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 separatelyImport 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.jsCriticalhttps://www.npmjs.com/package/gsapthreejs 0.185.1active2026-08-13
3736GSAPRegister ScrollTrigger Before UseCall 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 scrollTriggerInclude scrollTrigger config in gsap.to() calls without first registering the plugingsap.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 registeredCriticalhttps://www.npmjs.com/package/gsapthreejs 0.185.1active2026-08-13
3837GSAPTween Three.js Properties DirectlyGSAP 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 opacityUse a plain proxy object then manually copy values to Three.js properties every framegsap.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 wrapperMediumhttps://gsap.com/docs/v3/GSAP/gsap.to()threejs 0.185.1active2026-08-13
3938GSAPscrub for Scroll-Driven Camera PathUse 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 movementUse onEnter or onLeave callbacks for camera motion — they snap instead of scrubbinggsap.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 scrubHighhttps://www.npmjs.com/package/gsapthreejs 0.185.1active2026-08-13
4039PerformanceInstancedMesh for Repeated ObjectsUse 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 materialCreate 50+ separate Mesh objects with the same geometry and materialconst 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 frameHighhttps://threejs.org/docs/#api/en/objects/InstancedMeshthreejs 0.185.1active2026-08-13
4140PerformanceTone Mapping and Output Color SpaceThree.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 toneMappingDisable color management or use removed outputEncoding and sRGBEncoding propertiesTHREE.ColorManagement.enabled = true; renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0;renderer.outputEncoding = THREE.sRGBEncoding; // removed legacy propertiesMediumhttps://threejs.org/manual/en/color-management.htmlthreejs 0.185.1active2026-08-13
4241Performanceantialias Set at Construction OnlyThe 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 objectConstruct the renderer without antialias then try to enable it by assigning the propertyconst renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); // antialias baked into the WebGL contextconst renderer = new THREE.WebGLRenderer(); renderer.antialias = true; // no effect — context created without AA — edges remain aliasedHighhttps://threejs.org/docs/#api/en/renderers/WebGLRendererthreejs 0.185.1active2026-08-13
4342PerformanceFogExp2 for Depth and Far CullingUse 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 cullingIgnore fog in scenes with far:1000+ and many distant objects that contribute tiny pixels per draw callscene.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 frameLowhttps://threejs.org/docs/#api/en/scenes/FogExp2threejs 0.185.1active2026-08-13
4443ParticlesBufferGeometry Plus Points for Particle SystemsBuild 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 effectsCreate hundreds of individual Mesh objects to simulate a particle systemconst 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 frameHighhttps://threejs.org/docs/#api/en/objects/Pointsthreejs 0.185.1active2026-08-13
4544ParticlesParticle Count CeilingStart 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 limitSet particle count at 100000 or higher without any mobile profilingconst 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 phoneHighhttps://threejs.org/docs/#api/en/objects/Pointsthreejs 0.185.1active2026-08-13
4645ParticlesneedsUpdate After Buffer MutationAfter 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 arrayMutate 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 moveCriticalhttps://threejs.org/docs/#api/en/core/BufferAttribute.needsUpdatethreejs 0.185.1active2026-08-13
4746ResponsiveCanvas Dimensions Not WindowSize 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 sizingHardcode renderer size to window.innerWidth/innerHeight when the canvas may be inside a containerrenderer.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 columnHighhttps://threejs.org/docs/#api/en/renderers/WebGLRenderer.setSizethreejs 0.185.1active2026-08-13
4847ResponsiveResizeObserver Over window resize EventUse 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 detectionUse only window.addEventListener('resize') for canvas sizing when the canvas is not fullscreenconst 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 UIsMediumhttps://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverthreejs 0.185.1active2026-08-13
4948ResponsiveTouch Events for Mobile InteractionAdd 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 sceneAdd only mouse event listeners and leave touch users with no interactioncanvas.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 interactionMediumhttps://developer.mozilla.org/en-US/docs/Web/API/Touch_eventsthreejs 0.185.1active2026-08-13
5049Accessibilityprefers-reduced-motionCheck 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 valueRead .matches once at startup and never update it — the scene ignores mid-session OS setting changesconst 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 entirelyHighhttps://www.w3.org/WAI/WCAG22/Techniques/css/C39.htmlthreejs 0.185.1active2026-08-13
5150AccessibilityCanvas aria-labelAdd 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 itAppend the canvas to the DOM with no accessibility attributes — invisible to screen readersrenderer.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 nothingMediumhttps://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#accessibility_concernsthreejs 0.185.1active2026-08-13
5251ProductionBundler Stack for ProductionFor 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 ESMServe legacy global scripts or import addons from deprecated examples/js pathsnpm 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 graphMediumhttps://threejs.org/manual/en/installation.htmlthreejs 0.185.1active2026-08-13
5352ProductionGLTFLoader with scene traverseLoad 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 childLoad a GLTF model and pass gltf.scene directly to scene.add without traversing child meshesimport { 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 });Mediumhttps://threejs.org/docs/#examples/en/loaders/GLTFLoaderthreejs 0.185.1active2026-08-13
5453ProductionLOD for Distance-Based DetailUse 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 automaticallyRender the same high-polygon geometry for every object regardless of its distance from the cameraconst 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 cameraMediumhttps://threejs.org/docs/#api/en/objects/LODthreejs 0.185.1active2026-08-13