3D Scenes

The preview can draw 3D scenes as well as 2D. 3D is built with THREE, which is the three.js 3D toolkit driven from Python, 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, in Python; 3D functions lists every geometry, material, light and more, with pictures.

The three things every scene needs

A 3D picture needs a renderer (it draws onto the preview), 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 preview's canvas
renderer = THREE.WebGLRenderer(canvas=canvas, antialias=True)
renderer.setPixelRatio(pixel_ratio)
renderer.setSize(width, height, False)

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

# 3. The camera: field of view in degrees, shape, nearest and farthest distance it sees
camera = 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
cube = THREE.Mesh(THREE.BoxGeometry(1, 1, 1), THREE.MeshStandardMaterial(color="tomato"))
scene.add(cube)
scene.add(THREE.AmbientLight("white", 0.4))
sun = THREE.DirectionalLight("white", 2)
sun.position.set(3, 4, 5)
scene.add(sun)

def draw():
    cube.rotation.y += 0.01
    renderer.render(scene, camera)

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

  • canvas=canvas tells the renderer to draw on the preview. antialias=True smooths the edges.
  • setPixelRatio(pixel_ratio) keeps the picture sharp on high-resolution screens.
  • setSize(width, height, False) sizes the drawing to the preview.
  • Resizing is automatic. When the preview changes size, the renderer and every perspective camera are refitted for you, so the scene never looks squashed.

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.

Calling three.js from Python

The rules from How a program is shaped apply:

  • A capitalised name makes something new: THREE.Mesh(...), THREE.Vector3(1, 2, 3). You never write new.
  • Keyword arguments become an options object: THREE.MeshStandardMaterial(color="gold", metalness=0.8).
  • Colours can be a name or a hex string ("tomato", "#ff6347") or a number (0xff6347).
  • Changing values in place works: mesh.rotation.y += 0.01.
  • snake_case works here too (set_pixel_ratio), but this page uses three.js's own spelling so that it matches its documentation.

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
THREE.BoxGeometry(w, h, d)A box
THREE.SphereGeometry(radius, 32, 16)A ball
THREE.CylinderGeometry(top, bottom, height, 32)A cylinder, or a cone if one radius is 0
THREE.ConeGeometry(radius, height, 32)A cone
THREE.TorusGeometry(radius, tube, 16, 64)A ring doughnut
THREE.TorusKnotGeometry(radius, tube, 128, 16)A knotted tube
THREE.PlaneGeometry(w, h)A flat rectangle, good for floors
THREE.CircleGeometry(radius, 32)A flat disc
THREE.IcosahedronGeometry(radius, 0)A 20-sided gem (raise the 0 to make it rounder)
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: THREE.Mesh(geometry, material).

MaterialLooks
THREE.MeshBasicMaterial(color=…)Flat colour. Ignores lights, so it always shows. Good for glowing things.
THREE.MeshLambertMaterial(color=…)Matte, like paper or chalk. Needs light.
THREE.MeshPhongMaterial(color=…, shininess=60)Shiny highlights, like plastic. Needs light.
THREE.MeshStandardMaterial(color=…, roughness=0.5, metalness=0)Realistic. roughness 0 is a mirror finish, 1 is rough; metalness 1 is metal. Needs light.
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).

renderer = THREE.WebGLRenderer(canvas=canvas, antialias=True)
renderer.setPixelRatio(pixel_ratio)
renderer.setSize(width, height, False)
scene = THREE.Scene()
scene.background = THREE.Color("#1b1b24")
camera = THREE.PerspectiveCamera(50, width / height, 0.1, 100)
camera.position.set(0, 0, 8)
camera.lookAt(0, 0, 0)

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

shapes = [
    THREE.Mesh(THREE.BoxGeometry(1, 1, 1), THREE.MeshStandardMaterial(color="tomato")),
    THREE.Mesh(THREE.SphereGeometry(0.6, 32, 16), THREE.MeshStandardMaterial(color="gold", metalness=0.4, roughness=0.3)),
    THREE.Mesh(THREE.ConeGeometry(0.6, 1.2, 32), THREE.MeshLambertMaterial(color="mediumseagreen")),
    THREE.Mesh(THREE.TorusGeometry(0.5, 0.2, 16, 64), THREE.MeshPhongMaterial(color="deepskyblue", shininess=80)),
    THREE.Mesh(THREE.IcosahedronGeometry(0.6, 0), THREE.MeshNormalMaterial(flatShading=True)),
    THREE.Mesh(THREE.TorusKnotGeometry(0.4, 0.12, 128, 16), THREE.MeshBasicMaterial(color="hotpink", wireframe=True)),
]
for i, mesh in enumerate(shapes):
    mesh.position.x = (i % 3 - 1) * 2      # three across
    mesh.position.y = 1 - (i // 3) * 2     # two rows
    scene.add(mesh)

def draw():
    for mesh in 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 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).

import math

renderer = THREE.WebGLRenderer(canvas=canvas, antialias=True)
renderer.setPixelRatio(pixel_ratio)
renderer.setSize(width, height, False)
scene = THREE.Scene()
camera = THREE.PerspectiveCamera(50, width / height, 0.1, 100)
camera.position.set(0, 3, 7)
camera.lookAt(0, 0, 0)
scene.add(THREE.HemisphereLight("white", "#223", 2))

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

blades = THREE.Group()
blades.position.set(0, 1.5, 0.45)
scene.add(blades)
for i in range(4):
    blade = THREE.Mesh(THREE.BoxGeometry(0.25, 1.6, 0.05), THREE.MeshStandardMaterial(color="firebrick"))
    blade.position.y = 0.8           # sticks out from the centre
    holder = THREE.Group()           # turned to space the blades out
    holder.rotation.z = i * math.pi / 2
    holder.add(blade)
    blades.add(holder)

def draw():
    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).

import random

renderer = THREE.WebGLRenderer(canvas=canvas, antialias=True)
renderer.setPixelRatio(pixel_ratio)
renderer.setSize(width, height, False)
scene = THREE.Scene()
camera = THREE.PerspectiveCamera(60, width / height, 0.1, 100)
camera.position.z = 6

positions = []
for _ in range(3000):
    positions += [random.uniform(-5, 5), random.uniform(-5, 5), random.uniform(-5, 5)]

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

def draw():
    stars.rotation.y += 0.002
    renderer.render(scene, camera)

Numbers from numpy work too: pass array.ravel() where the list goes.

Looking around: orbit controls

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.

renderer = THREE.WebGLRenderer(canvas=canvas, antialias=True)
renderer.setPixelRatio(pixel_ratio)
renderer.setSize(width, height, False)
scene = THREE.Scene()
scene.background = THREE.Color("#0e1116")
camera = THREE.PerspectiveCamera(50, width / height, 0.1, 100)
camera.position.set(3, 3, 5)

controls = 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(THREE.GridHelper(10, 10))
scene.add(THREE.AxesHelper(2))
knot = THREE.Mesh(THREE.TorusKnotGeometry(0.6, 0.2, 128, 16), THREE.MeshNormalMaterial())
knot.position.y = 1
scene.add(knot)

def draw():
    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. Finding out needs an answer back from the preview, so the click handler is async and uses await (reading a value back).

import random

renderer = THREE.WebGLRenderer(canvas=canvas, antialias=True)
renderer.setPixelRatio(pixel_ratio)
renderer.setSize(width, height, False)
scene = THREE.Scene()
scene.background = THREE.Color("#202020")
camera = THREE.PerspectiveCamera(50, width / height, 0.1, 100)
camera.position.set(0, 0, 8)
scene.add(THREE.HemisphereLight("white", "#444", 2.5))

boxes = []
for i in range(5):
    box = THREE.Mesh(THREE.BoxGeometry(1, 1, 1), THREE.MeshStandardMaterial(color="lightgray"))
    box.position.x = (i - 2) * 1.6
    box.name = f"box {i + 1}"
    scene.add(box)
    boxes.append(box)

ray = THREE.Raycaster()

async def clicked(event):
    # the click as -1..1 across and up the preview
    point = THREE.Vector2(event.x / width * 2 - 1, -(event.y / height) * 2 + 1)
    ray.setFromCamera(point, camera)
    hits = ray.intersectObjects(boxes)
    if await hits.length > 0:
        hit = hits[0].object
        hit.material.color.setHSL(random.random(), 0.8, 0.55)
        print("You clicked", await hit.name)

canvas.addEventListener("pointerdown", clicked)

def draw():
    for box in 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.

← Drawing in 2D · Tutorial: your first drawing →