3D Scenes

The canvas can draw 3D scenes as well as 2D. 3D is built with THREE, which is the three.js 3D toolkit itself, so the names here are three.js's own names. Everything three.js can do is there, and its documentation at threejs.org/docs describes every part in full. This page covers the parts you need most.

A scene is 3D when its code uses THREE. or addons. anywhere. Then three.js is loaded for it, and there is no ctx: a canvas is either 2D or 3D, not both.

The three things every scene needs

A 3D picture needs a renderer (it draws onto the canvas), a scene (the world you put things in) and a camera (where you look from). Then, every frame, the renderer draws the scene as the camera sees it.

// 1. The renderer draws on the canvas
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
renderer.setPixelRatio(pixelRatio)
renderer.setSize(width, height, false)

// 2. The scene holds everything
const scene = new THREE.Scene()
scene.background = new THREE.Color("#101820")

// 3. The camera: field of view in degrees, shape, nearest and farthest distance it sees
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 100)
camera.position.set(0, 1.5, 4)
camera.lookAt(0, 0, 0)

// Something to look at, and light to see it by
const cube = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: "tomato" }))
scene.add(cube)
scene.add(new THREE.AmbientLight("white", 0.4))
const sun = new THREE.DirectionalLight("white", 2)
sun.position.set(3, 4, 5)
scene.add(sun)

function draw() {
  // follow the canvas when it changes size
  camera.aspect = width / height
  camera.updateProjectionMatrix()
  renderer.setSize(width, height, false)

  cube.rotation.y += 0.01
  renderer.render(scene, camera)
}

Start every 3D program with those first lines. What they do:

  • { canvas, antialias: true } tells the renderer to draw on the canvas and to smooth the edges. { canvas } is short for { canvas: canvas }.
  • setPixelRatio(pixelRatio) keeps the picture sharp on high-resolution screens.
  • setSize(width, height, false) sizes the drawing to the canvas. The false leaves the canvas's place on the page alone.
  • Resizing is yours to do, and it is the three lines at the top of draw(). width and height follow the canvas, so each frame the camera is given the canvas's shape and the renderer its size. Leave them out and the scene looks squashed, or blurred, after the preview changes size.

Distances are in whatever unit you like; most scenes treat 1 as one metre. The y axis points up, x to the right, and z towards you.

Writing three.js in a scene

This is three.js as its own documentation writes it, with the setting up done for you:

  • new makes something new: new THREE.Mesh(...), new THREE.Vector3(1, 2, 3).
  • Options are an object: new THREE.MeshStandardMaterial({ color: "gold", metalness: 0.8 }).
  • Colours can be a name or a hex string ("tomato", "#ff6347") or a number (0xff6347).
  • Values are ordinary values: mesh.rotation.y += 0.01 changes one, and mesh.position.x > 3 reads one.
  • There is nothing to import. THREE is ready, and so is addons.OrbitControls. A scene is not a module, so an import line would be an error.

Shapes: geometries

A geometry is the shape of an object. These are the common ones; the numbers are sizes, and the optional last numbers say how smooth the surface is.

GeometryShape
new THREE.BoxGeometry(w, h, d)A box
new THREE.SphereGeometry(radius, 32, 16)A ball
new THREE.CylinderGeometry(top, bottom, height, 32)A cylinder, or a cone if one radius is 0
new THREE.ConeGeometry(radius, height, 32)A cone
new THREE.TorusGeometry(radius, tube, 16, 64)A ring doughnut
new THREE.TorusKnotGeometry(radius, tube, 128, 16)A knotted tube
new THREE.PlaneGeometry(w, h)A flat rectangle, good for floors
new THREE.CircleGeometry(radius, 32)A flat disc
new THREE.IcosahedronGeometry(radius, 0)A 20-sided gem (raise the 0 to make it rounder)
new THREE.CapsuleGeometry(radius, length, 8, 16)A pill

Looks: materials

A material is how the surface looks. A mesh is a geometry plus a material: new THREE.Mesh(geometry, material).

MaterialLooks
new THREE.MeshBasicMaterial({ color: … })Flat colour. Ignores lights, so it always shows. Good for glowing things.
new THREE.MeshLambertMaterial({ color: … })Matte, like paper or chalk. Needs light.
new THREE.MeshPhongMaterial({ color: …, shininess: 60 })Shiny highlights, like plastic. Needs light.
new THREE.MeshStandardMaterial({ color: …, roughness: 0.5, metalness: 0 })Realistic. roughness 0 is a mirror finish, 1 is rough; metalness 1 is metal. Needs light.
new THREE.MeshNormalMaterial()Rainbow colours by direction. Needs no light; handy for testing.

Useful options on any material: wireframe: true (show the edges only), transparent: true, opacity: 0.5 (see-through), side: THREE.DoubleSide (show the back of flat shapes), flatShading: true (show the facets).

const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
renderer.setPixelRatio(pixelRatio)
renderer.setSize(width, height, false)
const scene = new THREE.Scene()
scene.background = new THREE.Color("#1b1b24")
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100)
camera.position.set(0, 0, 8)
camera.lookAt(0, 0, 0)

scene.add(new THREE.HemisphereLight("white", "#334", 1.5))
const light = new THREE.DirectionalLight("white", 2)
light.position.set(2, 5, 4)
scene.add(light)

const shapes = [
  new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: "tomato" })),
  new THREE.Mesh(new THREE.SphereGeometry(0.6, 32, 16), new THREE.MeshStandardMaterial({ color: "gold", metalness: 0.4, roughness: 0.3 })),
  new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.2, 32), new THREE.MeshLambertMaterial({ color: "mediumseagreen" })),
  new THREE.Mesh(new THREE.TorusGeometry(0.5, 0.2, 16, 64), new THREE.MeshPhongMaterial({ color: "deepskyblue", shininess: 80 })),
  new THREE.Mesh(new THREE.IcosahedronGeometry(0.6, 0), new THREE.MeshNormalMaterial({ flatShading: true })),
  new THREE.Mesh(new THREE.TorusKnotGeometry(0.4, 0.12, 128, 16), new THREE.MeshBasicMaterial({ color: "hotpink", wireframe: true })),
]
for (let i = 0; i < shapes.length; i++) {
  shapes[i].position.x = (i % 3 - 1) * 2             // three across
  shapes[i].position.y = 1 - Math.floor(i / 3) * 2   // two rows
  scene.add(shapes[i])
}

function draw() {
  camera.aspect = width / height
  camera.updateProjectionMatrix()
  renderer.setSize(width, height, false)

  for (const mesh of shapes) {
    mesh.rotation.x += 0.01
    mesh.rotation.y += 0.015
  }
  renderer.render(scene, camera)
}

Placing things: position, rotation and scale

Every object has a position, a rotation and a scale, each with x, y and z:

CodeWhat it does
mesh.position.set(1, 0, -2)Moves it to that spot
mesh.position.y = 2Moves it up to height 2
mesh.rotation.y = Math.PI / 4Turns it 45 degrees around the up axis (radians)
mesh.rotation.x += 0.01Keeps turning it, a little each frame
mesh.scale.set(2, 1, 1)Stretches it to twice as wide
mesh.lookAt(0, 0, 0)Turns it to face a point
mesh.visible = falseHides it
scene.remove(mesh)Takes it out of the scene

Groups

A new THREE.Group() holds other objects so they move together. Turn the group and everything in it turns around the group's centre. Groups inside groups are how you build things like a planet with a moon going round it (solar system tutorial).

const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
renderer.setPixelRatio(pixelRatio)
renderer.setSize(width, height, false)
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100)
camera.position.set(0, 3, 7)
camera.lookAt(0, 0, 0)
scene.add(new THREE.HemisphereLight("white", "#223", 2))

// a little windmill: a tower, and a group of blades that turns
const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.4, 3, 16), new THREE.MeshStandardMaterial({ color: "beige" }))
scene.add(tower)

const blades = new THREE.Group()
blades.position.set(0, 1.5, 0.45)
scene.add(blades)
for (let i = 0; i < 4; i++) {
  const blade = new THREE.Mesh(new THREE.BoxGeometry(0.25, 1.6, 0.05), new THREE.MeshStandardMaterial({ color: "firebrick" }))
  blade.position.y = 0.8                 // sticks out from the centre
  const holder = new THREE.Group()       // turned to space the blades out
  holder.rotation.z = i * Math.PI / 2
  holder.add(blade)
  blades.add(holder)
}

function draw() {
  camera.aspect = width / height
  camera.updateProjectionMatrix()
  renderer.setSize(width, height, false)

  blades.rotation.z += 0.03
  renderer.render(scene, camera)
}

Clouds of points

THREE.Points draws one dot per position, which suits stars, particles and scatter plots. Give it a list of numbers, three for each point (x, y, z).

const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
renderer.setPixelRatio(pixelRatio)
renderer.setSize(width, height, false)
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 100)
camera.position.z = 6

const positions = []
for (let i = 0; i < 3000; i++) {
  positions.push(Math.random() * 10 - 5, Math.random() * 10 - 5, Math.random() * 10 - 5)
}

const geometry = new THREE.BufferGeometry()
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3))
const stars = new THREE.Points(geometry, new THREE.PointsMaterial({ color: "white", size: 0.04 }))
scene.add(stars)

function draw() {
  camera.aspect = width / height
  camera.updateProjectionMatrix()
  renderer.setSize(width, height, false)

  stars.rotation.y += 0.002
  renderer.render(scene, camera)
}

A Float32Array works too, where the list goes.

Looking around: orbit controls

new addons.OrbitControls(camera, canvas) lets you turn the camera around the scene by dragging: drag to orbit, pinch or scroll to zoom, and drag with two fingers (or right-drag) to pan. Call controls.update() every frame.

const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
renderer.setPixelRatio(pixelRatio)
renderer.setSize(width, height, false)
const scene = new THREE.Scene()
scene.background = new THREE.Color("#0e1116")
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100)
camera.position.set(3, 3, 5)

const controls = new addons.OrbitControls(camera, canvas)
controls.enableDamping = true       // glides to a stop when you let go
controls.target.set(0, 0.5, 0)      // the point it orbits around

scene.add(new THREE.GridHelper(10, 10))
scene.add(new THREE.AxesHelper(2))
const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(0.6, 0.2, 128, 16), new THREE.MeshNormalMaterial())
knot.position.y = 1
scene.add(knot)

function draw() {
  camera.aspect = width / height
  camera.updateProjectionMatrix()
  renderer.setSize(width, height, false)

  controls.update()
  renderer.render(scene, camera)
}

GridHelper and AxesHelper draw a floor grid and the three axes (red x, green y, blue z), which help you find your way while building a scene.

Picking: what did I click?

A THREE.Raycaster finds which objects are under a point on the screen. The answer comes straight back, so an ordinary onMouseDown can use it.

const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
renderer.setPixelRatio(pixelRatio)
renderer.setSize(width, height, false)
const scene = new THREE.Scene()
scene.background = new THREE.Color("#202020")
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100)
camera.position.set(0, 0, 8)
scene.add(new THREE.HemisphereLight("white", "#444", 2.5))

const boxes = []
for (let i = 0; i < 5; i++) {
  const box = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: "lightgray" }))
  box.position.x = (i - 2) * 1.6
  box.name = `box ${i + 1}`
  scene.add(box)
  boxes.push(box)
}

const ray = new THREE.Raycaster()

function onMouseDown(x, y) {
  // the click as -1..1 across and up the canvas
  const point = new THREE.Vector2(x / width * 2 - 1, -(y / height) * 2 + 1)
  ray.setFromCamera(point, camera)
  const hits = ray.intersectObjects(boxes)
  if (hits.length > 0) {
    const hit = hits[0].object
    hit.material.color.setHSL(Math.random(), 0.8, 0.55)
    console.log("You clicked", hit.name)
  }
}

function draw() {
  camera.aspect = width / height
  camera.updateProjectionMatrix()
  renderer.setSize(width, height, false)

  for (const box of boxes) box.rotation.y += 0.01
  renderer.render(scene, camera)
}

Lights and shadows

Most materials need light to be seen. Lights, and how to turn on shadows, have a page of their own: lighting a scene.