Files
ONE-OS/.agents/skills/ui-ux-pro-max/data/stacks/threejs.csv
王冕 2018e34473 feat(web): 同步当前原型页与工具配置改动
统一提交当前工作区内的页面原型调整、新增运维相关页面以及本地工具配置目录变更,便于整体同步到远端环境继续联调与演示。

Made-with: Cursor
2026-04-01 13:28:56 +08:00

44 KiB

1CategoryGuidelineDescriptionDoDon'tCode GoodCode BadSeverityDocs URL
2SetupCDN Version LockAlways 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 fileUse 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>Criticalhttps://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js
3SetupCapsuleGeometry Does Not Exist in r128THREE.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 capsCall THREE.CapsuleGeometry on r128 — it is undefined and throws immediatelyconst 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 r128Criticalhttps://threejs.org/docs/#api/en/geometries/CapsuleGeometry
4SetupOrbitControls Must Be Loaded SeparatelyOrbitControls 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 scriptExpect 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>Criticalhttps://cdnjs.com/libraries/three.js/r128
5SetupCustom 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://developer.mozilla.org/en-US/docs/Web/API/MouseEvent
6SetupESM vs CDN ImportWhen 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 pagesMix 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>Criticalhttps://threejs.org/docs/#manual/en/introduction/Installation
7SetupSingle Renderer Per PageCreate 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 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/WebGLRenderer
8SetupPixel 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.setPixelRatio
9SetupAlpha 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.setClearColor
10CameraAspect 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/PerspectiveCamera
11CameraFOV 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 45–55 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/PerspectiveCamera
12CameraExplicit 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.lookAt
13CameraOrbitControls 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.Match camera control approach to the UX intent of the sceneUse 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 completesHighhttps://threejs.org/docs/#examples/en/controls/OrbitControls
14GeometryNever 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/BufferGeometry
15GeometryShare 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/BufferGeometry
16Geometrydispose 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.dispose
17GeometrySegment Count BudgetUse 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 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/SphereGeometry
18GeometryBufferGeometry for Custom Vertex DataFor 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 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)); // ReferenceError: Geometry is not defined in r128Highhttps://threejs.org/docs/#api/en/core/BufferGeometry
19MaterialsMeshBasicMaterial 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/MeshStandardMaterial
20MaterialsShare 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/Material
21MaterialsDispose 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.dispose
22LightingAmbient 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/DirectionalLight
23LightingEnable 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.shadowMap
24LightingSelective 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.shadowMap
25LightingSkip 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/MeshBasicMaterial
26RaycastingSingle 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/Raycaster
27RaycastingNDC 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.setFromCamera
28RaycastingsetFromCamera 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/Raycaster
29RaycastingRecursive 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.intersectObjects
30RaycastingCursor 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/cursor
31AnimationrequestAnimationFrame 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.setAnimationLoop
32AnimationTHREE.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/Clock
33AnimationLerp for Smooth Pointer FollowUse 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 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.lerp
34AnimationGSAP 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://gsap.com/docs/v3/GSAP/Timeline/
35AnimationPause 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://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
36GSAPLoad 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://gsap.com/docs/v3/Installation
37GSAPRegister 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://gsap.com/docs/v3/Plugins/ScrollTrigger/
38GSAPTween 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()
39GSAPscrub for Scroll-Driven Camera PathUse 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 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://gsap.com/docs/v3/Plugins/ScrollTrigger/
40PerformanceInstancedMesh 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/InstancedMesh
41PerformanceTone Mapping and sRGB EncodingEnable 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 scenesLeave tone mapping and output encoding at defaults when the scene targets realistic visualsrenderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; renderer.outputEncoding = THREE.sRGBEncoding; // correct for r128const renderer = new THREE.WebGLRenderer(); // defaults: NoToneMapping + LinearEncoding — colors appear flat and incorrectMediumhttps://threejs.org/docs/#api/en/renderers/WebGLRenderer.toneMapping
42Performanceantialias 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/WebGLRenderer
43PerformanceFogExp2 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/FogExp2
44ParticlesBufferGeometry 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/Points
45ParticlesParticle Count CeilingStart 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 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/Points
46ParticlesneedsUpdate 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.needsUpdate
47ResponsiveCanvas 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.setSize
48ResponsiveResizeObserver 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/ResizeObserver
49ResponsiveTouch 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_events
50Accessibilityprefers-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://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
51AccessibilityCanvas 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_concerns
52ProductionBundler Stack for ProductionFor 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 projectServe raw CDN script tags in a production application that needs tree-shaking or TypeScriptnpm 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 shipsMediumhttps://threejs.org/docs/#manual/en/introduction/Installation
53ProductionGLTFLoader 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/GLTFLoader
54ProductionLOD 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); // 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 cameraMediumhttps://threejs.org/docs/#api/en/objects/LOD