Home

Published

- 7 min read

Three.js Workshop - Scene, Camera, Interaction

img of Three.js Workshop - Scene, Camera, Interaction

Three.js Workshop — Scene, Camera, Interaction

This is a write-up of a hands-on Three.js workshop covering the basics: setting up a scene with a camera, adding lights and shapes, animating objects, importing 3D models and adding interaction.

Getting Started

  1. Clone the repository: github.com/TheCell/Threejs-Workshop
  2. Run npm install
  3. Run npm start

Libraries in Use


Project 1 — Building a Basic Scene

Basic Camera

A camera defines what part of the 3D world is visible. The key parameters are best explained with the concept of a frustum — a truncated pyramid that represents the visible volume.

Camera Frustum

Add a camera in the main() method:

   function main() {
  ...
  renderer.shadowMap.enabled = true;
  scene = new THREE.Scene();

  const fov = 75; 
  const aspect = 2; 
  const near = 0.01; 
  const far = 50; 
  camera = new THREE.PerspectiveCamera(fov, aspect, near, far); 
  camera.position.y = 4; 
  camera.position.z = 4; 
  camera.lookAt(0, 0, 0); 

  renderer.render(scene, camera); 
  renderer.domElement.addEventListener('pointermove', onPointerMove); 

  addLight(scene);

Tip: Once you have something to see, try another camera — for example OrthographicCamera — and compare the results.

Light

In order to see any objects we need to add light to the scene. Start with an ambient light:

   function addLight(scene: THREE.Scene) {
  ambientLight = new THREE.AmbientLight(0xFFFFFF, 1); 
  scene.add(ambientLight); 
}

Add Something to See

We have a camera but nothing to render. Add a floor plane and a TorusKnot:

   function addFloor(scene: THREE.Scene) {
  const floorSize = 10; 
  const floorGeometry = new THREE.PlaneGeometry(floorSize, floorSize); 
  const floorMaterial = new THREE.MeshStandardMaterial({ color: 0x808080, roughness: 0.8 }); 
  const floor = new THREE.Mesh(floorGeometry, floorMaterial); 
  floor.position.y = -1; 
  floor.rotation.x = -Math.PI / 2; 
  floor.receiveShadow = true; 
  scene.add(floor); 
}
   function addShape(scene: THREE.Scene) {
  const geometry = new THREE.TorusKnotGeometry(0.5, 0.2, 100, 16); 
  const material = new THREE.MeshStandardMaterial({ color: basicColor }); 
  const mesh = new THREE.Mesh(geometry, material); 
  mesh.castShadow = true; 
  scene.add(mesh); 
  meshes.push(mesh); 
  meshToGroup.set(mesh, mesh); 
}

You should now have something to see. The scene should now look like this:

Basic Scene

Directional Light

Ambient light alone makes the scene look flat — there are no shadows or highlights. A directional light adds depth:

   function addLight(scene: THREE.Scene) {
  ambientLight = new THREE.AmbientLight(0xFFFFFF, 1);
  scene.add(ambientLight);

  light = new THREE.DirectionalLight(lightColor, lightIntensity); 
  light.position.set(Math.cos(lightAngle) * lightRadius, 10, Math.sin(lightAngle) * lightRadius); 
  light.castShadow = true; 
  scene.add(light); 
}

Tip: Try different materials on your shapes: MeshBasicMaterial ignores complex lights, while MeshNormalMaterial colours based on surface normals.

Camera Movement

Listen to keyboard inputs to move the camera around:

   const keysPressed = new Set<string>();
window.addEventListener('keydown', (e) => keysPressed.add(e.code)); 
window.addEventListener('keyup', (e) => keysPressed.delete(e.code)); 

Then implement WASD + Q/E movement:

   function updateCamera() {
  if (!camera) return; 
  camera.getWorldDirection(cameraForward); 
  cameraForward.y = 0; 
  cameraForward.normalize(); 
  cameraRight.crossVectors(cameraForward, up).normalize(); 

  if (keysPressed.has('KeyW')) { 
    camera.position.addScaledVector(cameraForward, cameraSpeed); 
  } 

  if (keysPressed.has('KeyS')) { 
    camera.position.addScaledVector(cameraForward, -cameraSpeed); 
  } 

  if (keysPressed.has('KeyA')) { 
    camera.position.addScaledVector(cameraRight, -cameraSpeed); 
  } 

  if (keysPressed.has('KeyD')) { 
    camera.position.addScaledVector(cameraRight, cameraSpeed); 
  } 

  if (keysPressed.has('KeyQ')) { 
    camera.position.y -= cameraSpeed; 
  } 

  if (keysPressed.has('KeyE')) { 
    camera.position.y += cameraSpeed; 
  } 
}

Animation

Our test object is quite static — let’s add some animation:

Animation

   function animate(time: number) {
  time *= 0.001; // convert time to seconds

  if (rotateObjects) { 
    const groups = new Set(meshToGroup.values()); 
    for (let i = 0; i < groups.size; i++) { 
      const group = Array.from(groups)[i]; 
      if (i === 0) { 
        group.rotation.x = time; 
      } 
      group.rotation.y = time; 
    } 
  } 
}

3D Model Import

Primitives are fine for prototyping, but real scenes need 3D models. Set up a GLTF loader and an HTML file input:

   const gltfLoader = new GLTFLoader(); 

function importModel() {
  const input = document.createElement('input'); 
  input.type = 'file'; 
  input.accept = '.glb,.gltf'; 
  input.onchange = () => { 
    const file = input.files?.[0]; 
    if (!file) { 
      return; 
    } 

    const reader = new FileReader(); 
    reader.onload = (event) => { 
      const data = event.target!.result as ArrayBuffer; 
      gltfLoader.parse(data, '/GLB format/', (gltf) => { 
        const model = gltf.scene; 

        model.traverse((child) => { 
          if ((child as THREE.Mesh).isMesh) { 
            const mesh = child as THREE.Mesh; 
            mesh.castShadow = true; 
            mesh.receiveShadow = true; 
            meshes.push(mesh); 
            meshToGroup.set(mesh, model); 
          } 
        }); 
        scene!.add(model); 
      }); 
    }; 
    reader.readAsArrayBuffer(file); 
  }; 
  input.click(); 
}

Interaction with Objects

Mouse Events

Track pointer state for click-and-drag:

   const keysPressed = new Set<string>();
window.addEventListener('keydown', (e) => keysPressed.add(e.code));
window.addEventListener('keyup', (e) => keysPressed.delete(e.code));
let isPointerDown = false; 
let draggedGroup: THREE.Object3D | null = null; 
window.addEventListener('pointerdown', (e) => { 
  isPointerDown = true; 
}); 
window.addEventListener('pointerup', (e) => { 
  isPointerDown = false; 
  draggedGroup = null; 
}); 

Raycasting

A raycaster checks which 3D objects lie along a ray from the camera through the mouse position:

   const raycaster = new THREE.Raycaster(); 
const dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 1); 
const dragIntersection = new THREE.Vector3(); 

function handleInteraction() {
  raycaster.setFromCamera(normalizedPointerPosition, camera!); 
  const intersects = raycaster.intersectObjects(meshes); 
  const hitMesh = intersects[0]?.object as THREE.Mesh | undefined; 
  const hitGroup = hitMesh ? meshToGroup.get(hitMesh) ?? hitMesh : undefined; 
  let dragOffsetY = 0; 

Highlighting and Dragging

Swap materials to indicate hover/click, and move objects along a plane:

Interaction

     let dragOffsetY = 0;

  if (isPointerDown && !draggedGroup && hitGroup) { 
    draggedGroup = hitGroup; 
    dragOffsetY = hitGroup.position.y; 
  } 

  if (isPointerDown && draggedGroup) { 
    if (raycaster.ray.intersectPlane(dragPlane, dragIntersection)) { 
      draggedGroup.position.x = dragIntersection.x; 
      draggedGroup.position.z = dragIntersection.z; 
      draggedGroup.position.y = dragOffsetY; 
    } 
  } 

  const activeGroup = draggedGroup ?? hitGroup;
  const activeMeshes = activeGroup ? getMeshesInGroup(activeGroup) : []; 

  for (const mesh of meshes) { 
    if (activeMeshes.includes(mesh)) { 
      if (!originalMaterials.has(mesh)) { 
        originalMaterials.set(mesh, mesh.material); 
      } 
      mesh.material = isPointerDown ? pickMaterial : highlightMaterial; 
    } else if (originalMaterials.has(mesh)) { 
      mesh.material = originalMaterials.get(mesh)!; 
      originalMaterials.delete(mesh); 
    } 
  } 

Debugging

You can always add an arrow debug line to see if your vectors are what you think they are:

   const arrowHelper = new THREE.ArrowHelper(raycaster.ray.direction, raycaster.ray.origin, 10, 0xff0000);
scene?.add(arrowHelper);