Always cooking something
โ† All posts
March 22, 2026ยท9 min read

Your First Three.js Scene: A Cube From Zero

Three.jsWebGL3D

Three.js is the most popular library for 3D on the web. It sits on top of WebGL and removes the pain of writing raw shader code while still giving you full control when you need it. In this article you'll build a rotating cube from scratch, layer in lighting and materials, and end up with an intuition for how every piece fits together.

Every section has an interactive demo you can poke at directly in the browser. The goal isn't to memorise the API. The goal is to build a mental model that makes the docs make sense.


1. The Three Pillars

Every Three.js program is built on the same three concepts: a Scene, a Camera, and a Renderer. You can't skip any of them.

js
import * as THREE from 'three'

// 1. Scene โ€” the container for everything
const scene = new THREE.Scene()

// 2. Camera โ€” the viewpoint into the scene
const camera = new THREE.PerspectiveCamera(
  50,                              // field of view (degrees)
  window.innerWidth / window.innerHeight, // aspect ratio
  0.1,                             // near clipping plane
  1000                             // far clipping plane
)
camera.position.z = 5

// 3. Renderer โ€” draws the scene onto a <canvas>
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)

// Render a single frame
renderer.render(scene, camera)

At this point the canvas exists but the scene is empty. The demo below shows exactly that: a scene with no geometry, just the coordinate axes and a grid to give you a sense of space. X is red, Y is green, Z is blue.

Interactive ยท Empty Scene

An empty scene with the coordinate axes (X=red, Y=green, Z=blue) and a grid. No geometry yet.

#0F1115
PerspectiveCamera parameters. The first argument is the vertical field of view in degrees. 50 to 75 is typical for most scenes. The second is the aspect ratio. The last two (near and far) define the clipping planes. Anything outside that range isn't rendered. Keep near as large as you can without clipping visible objects; a tiny near value causes z-fighting artefacts.

2. Adding a Cube

In Three.js, a visible object is always a Mesh: the combination of a Geometry (the shape) and a Material (the appearance). Neither is useful on its own.

js
// Geometry โ€” defines the shape
const geometry = new THREE.BoxGeometry(1, 1, 1)
// BoxGeometry(width, height, depth)

// Material โ€” defines the appearance
const material = new THREE.MeshStandardMaterial({ color: 0x7C5CFF })

// Mesh โ€” combines both into a renderable object
const cube = new THREE.Mesh(geometry, material)
scene.add(cube)

The cube is now in the scene but it won't animate by itself. Three.js doesn't have a built-in update loop. You drive it with requestAnimationFrame:

js
function tick() {
  cube.rotation.y += 0.01  // rotate ~0.57ยฐ per frame
  renderer.render(scene, camera)
  requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
Interactive ยท Hello Cube

Toggle wireframe mode to see the triangles that make up the cube's faces. Each quad face is made of two triangles, the fundamental unit of 3D rendering. Switch the rotation axis to feel the difference between tumbling on X, Y, and Z.

Colors in Three.js. You can pass a color as a hex number (0x7C5CFF), a hex string ('#7C5CFF'), a CSS color name ('violet'), or a THREE.Color instance. They're all equivalent. At runtime, you update a material color with material.color.set('#newcolor').

3. Let There Be Light

If you tried to use MeshStandardMaterial without any lights you'd see nothing, or rather a black shape. That's because physically-based materials need a light source to show any shading. Three.js ships with several light types. The two you'll use most are AmbientLight and DirectionalLight.

js
// AmbientLight โ€” illuminates all surfaces equally, no shading
const ambient = new THREE.AmbientLight(0xffffff, 0.5)
scene.add(ambient)

// DirectionalLight โ€” parallel rays from a direction, like the sun
const sun = new THREE.DirectionalLight(0xffffff, 2)
sun.position.set(4, 6, 4)
scene.add(sun)

// PointLight โ€” emits in all directions from a position, like a lamp
const lamp = new THREE.PointLight(0x7C5CFF, 4, 12)
lamp.position.set(-2, 2, 2)
scene.add(lamp)
Interactive ยท Lighting Modes

DirectionalLight acts like sunlight: it creates shading and depth.

Notice that with ambient light only, the cube looks completely flat. Every face receives the same illumination, so there's no visual depth. Adding a directional light immediately creates shading: some faces are bright, others dark, and you can read the cube as a 3D object. The point light adds a tinted glow from a specific position in space.

Light intensity and color. The second argument to any light constructor is the intensity. For DirectionalLight and PointLight it's in physical units when renderer.useLegacyLights = false. Values of 1 to 5 are typical. The first argument is the color; tinting your lights is one of the fastest ways to make a scene feel atmospheric.

4. Materials: Beyond the Default

The material determines how a surface responds to light. Three.js ships with around a dozen material types. You'll use four of them for almost everything:

js
// No lighting calculation โ€” always shows the raw color
const basic    = new THREE.MeshBasicMaterial({ color: '#7C5CFF' })

// Normals mapped to RGB โ€” great for debugging geometry
const normals  = new THREE.MeshNormalMaterial()

// Classic Phong shading with a specular highlight
const phong    = new THREE.MeshPhongMaterial({ color: '#7C5CFF', shininess: 80 })

// PBR: physically-based rendering โ€” the right default for 3D
const standard = new THREE.MeshStandardMaterial({
  color:     '#7C5CFF',
  roughness: 0.4,   // 0 = mirror, 1 = chalk
  metalness: 0.1,   // 0 = plastic, 1 = metal
})
Interactive ยท Material Explorer

PBR (physically-based rendering) material. Responds to metalness and roughness. Use this by default for realistic results.

MeshNormalMaterial is especially useful during development. Because each face renders a color based on its outward normal, you can instantly see the orientation of every polygon. If two adjacent faces look the same color when they shouldn't, you likely have a normals issue.


5. The Full Example

Here's everything together in a self-contained snippet you can drop into any project:

js
import * as THREE from 'three'

// โ”€โ”€ Setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const scene    = new THREE.Scene()
const camera   = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 1000)
const renderer = new THREE.WebGLRenderer({ antialias: true })

camera.position.set(2, 1.5, 4)
camera.lookAt(0, 0, 0)
renderer.setSize(innerWidth, innerHeight)
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
document.body.appendChild(renderer.domElement)

// โ”€โ”€ Lights โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
scene.add(new THREE.AmbientLight(0xffffff, 0.5))
const sun = new THREE.DirectionalLight(0xffffff, 2)
sun.position.set(4, 6, 4)
scene.add(sun)

// โ”€โ”€ Cube โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const cube = new THREE.Mesh(
  new THREE.BoxGeometry(1.5, 1.5, 1.5),
  new THREE.MeshStandardMaterial({ color: '#7C5CFF', roughness: 0.4 })
)
scene.add(cube)

// โ”€โ”€ Resize handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
window.addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight
  camera.updateProjectionMatrix()
  renderer.setSize(innerWidth, innerHeight)
})

// โ”€โ”€ Render loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function tick() {
  cube.rotation.y += 0.01
  renderer.render(scene, camera)
  requestAnimationFrame(tick)
}
requestAnimationFrame(tick)

Where to Go Next

A rotating cube is the foundation. From here everything builds on the same primitives: more geometry, more lights, more materials, and eventually custom GLSL shaders for effects that go beyond what the built-in materials can do.

  • โ†’Add shadows. Enable renderer.shadowMap.enabled = true, set castShadow on lights and meshes, and receiveShadow on floor planes.
  • โ†’Load a model. Use GLTFLoader from three/addons to load .glb files exported from Blender, Meshy AI, or any 3D tool.
  • โ†’Add controls. OrbitControls (also in addons) gives you free camera rotation, pan, and zoom in two lines.
  • โ†’Explore geometry. SphereGeometry, TorusGeometry, CylinderGeometry all follow the same Mesh pattern. Experiment with the segment count to control smoothness.
  • โ†’Try vertex shaders. onBeforeCompile lets you inject custom GLSL into a standard material without writing a full shader from scratch.

If you want to see these ideas taken further, the Rocky Sphere experiment on this site uses vertex shader displacement on a standard sphere geometry.

// newsletter

Stay in the loop

New experiments, articles, and tools โ€” straight to your inbox. No spam, unsubscribe anytime.