Core: Renderer, Scene, Camera
Every Three.js app needs exactly three top-level objects: a
WebGLRenderer that draws pixels, a Scene
that holds the graph, and a Camera that defines the
view. Set these up once, then reuse them for the app's lifetime.
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));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
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);
scene.fog = new THREE.Fog(0x0a0a0a, 10, 60);
// Perspective camera: fov, aspect, near, far
const camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(0, 5, 10);
camera.lookAt(0, 0, 0);
// Orthographic camera (for 2D-style / isometric views)
const frustumSize = 10;
const aspect = window.innerWidth / window.innerHeight;
const orthoCam = new THREE.OrthographicCamera(
(-frustumSize * aspect) / 2, (frustumSize * aspect) / 2,
frustumSize / 2, -frustumSize / 2,
0.1, 1000
);
| Renderer property |
Purpose |
setPixelRatio() |
Match device DPR; cap at 2 to avoid GPU overload on retina displays |
outputColorSpace |
THREE.SRGBColorSpace for correct gamma on final output |
toneMapping |
ACESFilmic or Reinhard for HDR-lit PBR scenes |
shadowMap.type |
PCFSoftShadowMap for soft edges; BasicShadowMap is cheapest |
setClearColor(color, alpha) |
Background colour when scene.background is unset |
Geometries
Geometries are BufferGeometry instances describing
vertex positions, normals, and UVs. Built-in constructors cover
the common primitives.
| Constructor |
Key arguments |
BoxGeometry |
width, height, depth, widthSeg, heightSeg, depthSeg |
SphereGeometry |
radius, widthSegments, heightSegments |
PlaneGeometry |
width, height, widthSeg, heightSeg |
CylinderGeometry |
radiusTop, radiusBottom, height, radialSegments |
TorusGeometry |
radius, tube, radialSegments, tubularSegments |
ConeGeometry |
radius, height, radialSegments |
IcosahedronGeometry |
radius, detail (0 = flat-shaded low-poly) |
ExtrudeGeometry |
Shape + { depth, bevelEnabled, steps } |
// Custom BufferGeometry from raw vertex data
const geo = new THREE.BufferGeometry();
const positions = new Float32Array([
-1, -1, 0,
1, -1, 0,
0, 1, 0,
]);
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.computeVertexNormals();
geo.computeBoundingSphere(); // required for correct frustum culling
// Merge multiple geometries into one draw call
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
const merged = mergeGeometries([geoA, geoB, geoC]);
Materials
Materials define how a surface responds to light.
MeshStandardMaterial and
MeshPhysicalMaterial are physically-based (PBR) and
the default choice for realistic lighting.
| Material |
Lighting model |
Use case |
MeshBasicMaterial |
None (unlit) |
UI overlays, wireframes, emissive-only shapes |
MeshLambertMaterial |
Diffuse only |
Cheap matte surfaces, mobile |
MeshPhongMaterial |
Diffuse + specular |
Shiny plastics without full PBR cost |
MeshStandardMaterial |
PBR metallic/roughness |
Default for realistic scenes |
MeshPhysicalMaterial |
PBR + clearcoat/transmission |
Glass, car paint, coated surfaces |
ShaderMaterial |
Custom GLSL |
Full control — custom vertex/fragment shaders |
const mat = new THREE.MeshStandardMaterial({
color: 0x3388ff,
metalness: 0.2,
roughness: 0.6,
map: colorTexture, // albedo
normalMap: normalTexture,
roughnessMap: roughTexture,
envMapIntensity: 1.0,
transparent: true,
opacity: 0.9,
side: THREE.DoubleSide, // FrontSide | BackSide | DoubleSide
});
// Custom shader material
const shaderMat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uColor: { value: new THREE.Color(0xff5500) },
},
vertexShader: /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform float uTime;
uniform vec3 uColor;
varying vec2 vUv;
void main() {
gl_FragColor = vec4(uColor * (0.5 + 0.5 * sin(uTime + vUv.x * 6.28)), 1.0);
}
`,
});
Lights
| Light |
Behaviour |
Cost |
AmbientLight |
Uniform light, no direction, no shadows |
Free |
HemisphereLight |
Sky/ground gradient — cheap approximation of outdoor light |
Free |
DirectionalLight |
Parallel rays (sun); casts shadows via orthographic frustum |
Cheap |
PointLight |
Radiates from a point; shadow uses a cube map (6 passes) |
Expensive with shadows |
SpotLight |
Cone of light with angle + penumbra |
Moderate |
RectAreaLight |
Soft light from a rectangular panel (no shadows) |
Moderate, needs LTC textures init |
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
const sun = new THREE.DirectionalLight(0xffffff, 3);
sun.position.set(5, 10, 5);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -10;
sun.shadow.camera.right = 10;
sun.shadow.camera.top = 10;
sun.shadow.camera.bottom = -10;
sun.shadow.bias = -0.0005; // reduce shadow acne
const spot = new THREE.SpotLight(0xffaa33, 5, 20, Math.PI / 6, 0.3);
spot.position.set(0, 8, 0);
scene.add(ambient, sun, spot);
Only DirectionalLight, PointLight, and
SpotLight can cast shadows. Every mesh that should
cast or receive a shadow needs
mesh.castShadow = true /
mesh.receiveShadow = true set explicitly —
renderer-level shadowMap.enabled alone is not
enough.
Objects & Scene Graph
Object3D is the base class for everything placeable
in a scene — meshes, groups, cameras, and lights all inherit its
transform (position, rotation/quaternion, scale) and parenting.
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(0, 1, 0);
mesh.rotation.set(0, Math.PI / 4, 0); // Euler, radians
mesh.scale.setScalar(1.5);
mesh.castShadow = true;
mesh.receiveShadow = true;
// Group objects to transform them together
const group = new THREE.Group();
group.add(mesh, otherMesh);
group.position.y = 2;
scene.add(group);
// Traverse the whole graph
scene.traverse((obj) => {
if (obj.isMesh) obj.material.needsUpdate = true;
});
// World-space transforms (accounting for parents)
const worldPos = new THREE.Vector3();
mesh.getWorldPosition(worldPos);
// InstancedMesh for thousands of identical objects in one draw call
const instanced = new THREE.InstancedMesh(geo, mat, 5000);
const m4 = new THREE.Matrix4();
for (let i = 0; i < 5000; i++) {
m4.makeTranslation(Math.random() * 20 - 10, 0, Math.random() * 20 - 10);
instanced.setMatrixAt(i, m4);
}
instanced.instanceMatrix.needsUpdate = true;
scene.add(instanced);
Controls
| Control |
Typical use |
OrbitControls |
Orbit/zoom/pan around a target — the default for demos and editors |
PointerLockControls |
First-person mouse-look, locks the cursor |
TrackballControls |
Free rotation with no fixed up-vector |
TransformControls |
Move/rotate/scale gizmo for editing tools |
FlyControls |
Free-flight camera (spacecraft-style) |
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.minDistance = 2;
controls.maxDistance = 50;
controls.maxPolarAngle = Math.PI / 2; // prevent camera going below ground
// Must be called every frame when damping is enabled
function tick() {
controls.update();
renderer.render(scene, camera);
}
Loaders
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
// GLTF/GLB models, optionally Draco-compressed
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/');
const gltfLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);
gltfLoader.load('model.glb', (gltf) => {
scene.add(gltf.scene);
gltf.animations; // AnimationClip[]
});
// Textures
const texLoader = new THREE.TextureLoader();
const colorMap = texLoader.load('albedo.jpg');
colorMap.colorSpace = THREE.SRGBColorSpace; // color textures need sRGB
colorMap.wrapS = colorMap.wrapT = THREE.RepeatWrapping;
colorMap.anisotropy = renderer.capabilities.getMaxAnisotropy();
// HDR environment maps
new RGBELoader().load('studio.hdr', (hdrTexture) => {
hdrTexture.mapping = THREE.EquirectangularReflectionMapping;
scene.environment = hdrTexture; // image-based lighting for PBR materials
scene.background = hdrTexture;
});
All loaders are async and callback- or Promise-based. Use
LoadingManager to track overall progress across many
assets, and always handle the onError callback —
failed asset loads should degrade gracefully, not crash the
scene.
Animation Loop
const clock = new THREE.Clock();
function tick() {
const dt = clock.getDelta(); // seconds since last frame
const elapsed = clock.getElapsedTime();
mesh.rotation.y += dt * 0.5;
shaderMat.uniforms.uTime.value = elapsed;
controls.update();
renderer.render(scene, camera);
}
// WebXR-compatible animation loop (preferred over requestAnimationFrame)
renderer.setAnimationLoop(tick);
// Stop the loop
// renderer.setAnimationLoop(null);
// Resize handling
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
Raycasting & Interaction
Raycaster casts a ray from the camera through a
normalised pointer position and reports intersections — the
standard way to click, hover, or select 3D objects.
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
window.addEventListener('pointermove', (event) => {
// Convert to Normalised Device Coordinates: -1 to +1
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
});
window.addEventListener('click', () => {
raycaster.setFromCamera(pointer, camera);
// true = check descendants recursively (needed for groups)
const hits = raycaster.intersectObjects(scene.children, true);
if (hits.length > 0) {
const { object, point, distance, face } = hits[0];
object.material.color.set(0xff0000);
console.log('Hit point:', point, 'distance:', distance);
}
});
Raycasting against thousands of objects every frame is
expensive. Restrict the candidate list (pass a specific array,
not scene.children), or use a spatial index
(octree/BVH via three-mesh-bvh) for large static
scenes.
Math Types
| Type |
Common methods |
Vector3 |
.set(), .add(),
.sub(), .multiplyScalar(),
.normalize(), .length(),
.lerp(), .cross(),
.dot(), .applyQuaternion()
|
Quaternion |
.setFromAxisAngle(),
.setFromEuler(), .slerp(),
.multiply(), .invert()
|
Euler |
x, y, z (radians) + rotation order, e.g. 'XYZ' |
Matrix4 |
.compose(), .decompose(),
.makeTranslation(),
.multiplyMatrices(), .invert()
|
Box3 |
.setFromObject(),
.containsPoint(),
.intersectsBox(), .getCenter()
|
MathUtils |
.degToRad(), .radToDeg(),
.clamp(), .lerp(),
.randFloatSpread(),
.smoothstep()
|
// All temporaries should be pre-allocated outside hot loops to avoid GC churn
const _v = new THREE.Vector3();
const _q = new THREE.Quaternion();
_q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2);
_v.set(1, 0, 0).applyQuaternion(_q); // rotate a vector by a quaternion
// Decompose a world matrix into position/quaternion/scale
const pos = new THREE.Vector3();
const quat = new THREE.Quaternion();
const scale = new THREE.Vector3();
mesh.matrixWorld.decompose(pos, quat, scale);
Prefer quaternions over Euler angles for interpolation and
composition — Euler angles suffer from gimbal lock and do not
interpolate linearly along the shortest path. Use Euler only for
authoring/reading human-friendly rotations.