Boilerplate Setup
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js';
// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // cap at 2x
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);
// Camera
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 5, 10);
// Lights
const ambient = new THREE.AmbientLight(0xffffff, 0.5);
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(5, 10, 5);
scene.add(ambient, sun);
Responsive Resize
Use ResizeObserver on the canvas container for accurate
size tracking; avoid window.onresize which fires on
scroll on mobile.
// ResizeObserver approach — fires only on actual size change
const ro = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false); // false = don't override canvas CSS size
});
ro.observe(renderer.domElement.parentElement ?? document.body);
// Alternative: resize on canvas element itself
const ro2 = new ResizeObserver(() => {
const w = renderer.domElement.clientWidth;
const h = renderer.domElement.clientHeight;
if (renderer.domElement.width !== w || renderer.domElement.height !== h) {
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
});
ro2.observe(renderer.domElement);
Memory Disposal
Three.js objects allocate GPU memory. Always dispose when removing objects from the scene to prevent memory leaks.
// Dispose a single mesh completely
function disposeMesh(mesh) {
mesh.geometry.dispose();
// Material can be shared — only dispose if not reused elsewhere
if (Array.isArray(mesh.material)) {
mesh.material.forEach(m => disposeMaterial(m));
} else {
disposeMaterial(mesh.material);
}
mesh.removeFromParent();
}
function disposeMaterial(mat) {
// Dispose all texture maps
for (const key of Object.keys(mat)) {
const val = mat[key];
if (val?.isTexture) val.dispose();
}
mat.dispose();
}
// Dispose entire scene recursively
function disposeScene(scene) {
scene.traverse(obj => {
if (obj.isMesh) disposeMesh(obj);
});
}
// Dispose the renderer itself (call when unmounting)
renderer.dispose();
renderer.forceContextLoss();
Call
renderer.info.memory before and after disposal to
verify objects are being freed. geometries and
textures counts should go down.
Object Pooling
Creating and destroying Vector3, Matrix4,
and Quaternion inside hot loops triggers GC. Reuse them
instead.
// Pre-allocate temporaries outside loops
const _v = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _m = new THREE.Matrix4();
const _box = new THREE.Box3();
function updateObjects(objects) {
for (const obj of objects) {
// Use _ prefixed temporaries — no allocation inside loop
_v.set(obj.x, obj.y, obj.z);
_box.setFromCenterAndSize(_v, _v.setScalar(obj.size));
obj.mesh.position.copy(_v.set(obj.x, obj.y, obj.z));
}
}
// Pool pattern for mesh instances
class MeshPool {
constructor(geo, mat, maxSize) {
this.im = new THREE.InstancedMesh(geo, mat, maxSize);
this.im.count = 0;
this._free = Array.from({length: maxSize}, (_, i) => i).reverse();
this._active = new Set();
}
acquire() {
const id = this._free.pop();
this._active.add(id);
this.im.count = Math.max(this.im.count, id + 1);
return id;
}
release(id) {
this._active.delete(id);
this._free.push(id);
// Hide the instance by sending it off-screen
_m.makeTranslation(99999, 0, 0);
this.im.setMatrixAt(id, _m);
this.im.instanceMatrix.needsUpdate = true;
}
}
Animation Loop
let lastTime = 0;
function tick(now) {
const dt = Math.min((now - lastTime) / 1000, 0.1); // seconds, capped at 100ms
lastTime = now;
// 1. Update systems (physics, particles, animations)
updatePhysics(dt);
updateParticles(dt);
controls.update(); // OrbitControls damping
// 2. Render
renderer.render(scene, camera);
// 3. Stats (dev mode only)
// stats.update();
}
// Three.js animation loop (handles XR too)
renderer.setAnimationLoop(tick);
// To stop:
// renderer.setAnimationLoop(null);
Prefer
renderer.setAnimationLoop() over
requestAnimationFrame() — it is WebXR-compatible and
automatically calls the callback at the display refresh rate.
Performance Idioms
Mark buffers dirty only when changed
Three.js does not know when you mutate attribute arrays. Tell it
explicitly.
geo.attributes.position.needsUpdate = true; geo.attributes.color.needsUpdate = true; geo.computeBoundingSphere(); // needed for correct frustum culling
Share geometries and materials
Multiple meshes can share the same
BufferGeometry and
Material instance — Three.js handles instanced
uniforms. Sharing reduces GPU state switches.
const geo = new THREE.BoxGeometry();
const mat = new THREE.MeshStandardMaterial({ color: 0xff0000 });
for (let i = 0; i < 1000; i++) {
scene.add(new THREE.Mesh(geo, mat)); // shared geo + mat
}
frustumCulled flag for particle systems
Large particle
Points objects may be incorrectly
culled if the bounding sphere is stale. Set
mesh.frustumCulled = false or always call
computeBoundingSphere() after updates.
points.frustumCulled = false; // disable culling entirely for dynamic particles
Texture power-of-2 and mipmap generation
Non-power-of-2 textures cannot use mipmapping in WebGL 1. In WebGL
2 they work but still generate power-of-2 allocation internally.
Set
generateMipmaps = false for data textures.
const dataTexture = new THREE.DataTexture(buffer, 200, 100); dataTexture.type = THREE.FloatType; dataTexture.format = THREE.RGBAFormat; dataTexture.minFilter = THREE.LinearFilter; dataTexture.generateMipmaps = false; // not needed for data / simulation textures dataTexture.needsUpdate = true;