iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Bootcamp

A 60-minute three.js bootcamp that builds a small interactive 3D viewer with correct colour management, lighting, an orbit camera, a model loader, and a clean shutdown — the parts of a real app that turn the obligatory cube demo into something you can actually ship.

A 60-minute three.js viewer bootcamp

EXAMPLE
# ===== Objectives =====
# 1. Render a GLB model with correct colours and PBR lighting
# 2. Add OrbitControls + a resize handler
# 3. Click-pick objects with the raycaster
# 4. Dispose properly when the user leaves the page

# ===== 0-5 min: scaffold =====
# npm create vite@latest viewer -- --template vanilla-ts
# cd viewer && npm i three

# ===== 5-15 min: renderer + colour management =====
cat > src/main.ts <<'EOF'
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader }    from 'three/examples/jsm/loaders/GLTFLoader.js';
import { RGBELoader }    from 'three/examples/jsm/loaders/RGBELoader.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

const scene  = new THREE.Scene();
scene.background = new THREE.Color(0x111317);

const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.05, 100);
camera.position.set(2.4, 1.6, 3.2);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.target.set(0, 0.8, 0);
EOF

# ===== 15-30 min: lighting + model =====
cat >> src/main.ts <<'EOF'

// Environment map for PBR reflections — drop your own.hdr in /public
new RGBELoader().load('/studio.hdr', (env) => {
  env.mapping = THREE.EquirectangularReflectionMapping;
  scene.environment = env;
});

scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const key = new THREE.DirectionalLight(0xffffff, 1.6);
key.position.set(3, 5, 2); key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
scene.add(key);

const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(20, 20),
  new THREE.MeshStandardMaterial({ color: 0x222426, roughness: 1 })
);
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true;
scene.add(ground);

let model: THREE.Group | undefined;
new GLTFLoader().load('/model.glb', (gltf) => {
  model = gltf.scene;
  model.traverse((n) => { if ((n as any).isMesh) { n.castShadow = true; n.receiveShadow = true; } });
  scene.add(model);
});
EOF

# ===== 30-45 min: interaction =====
cat >> src/main.ts <<'EOF'

const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let highlighted: THREE.Object3D | null = null;

addEventListener('pointermove', (e) => {
  pointer.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1);
});

addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});

function frame(time: number) {
  raycaster.setFromCamera(pointer, camera);
  const hit = raycaster.intersectObjects(scene.children, true)[0]?.object as THREE.Mesh | undefined;
  if (hit !== highlighted) {
    if (highlighted && (highlighted as any).material?.emissive) (highlighted as any).material.emissive.setHex(0x000000);
    highlighted = hit ?? null;
    if (highlighted && (highlighted as any).material?.emissive) (highlighted as any).material.emissive.setHex(0x224488);
  }

  controls.update();
  renderer.render(scene, camera);
}
renderer.setAnimationLoop(frame);
EOF

# ===== 45-55 min: cleanup helper =====
cat >> src/main.ts <<'EOF'

function dispose(obj: THREE.Object3D) {
  obj.traverse((n: any) => {
    if (n.geometry) n.geometry.dispose();
    if (Array.isArray(n.material)) n.material.forEach((m: any) => m.dispose());
    else if (n.material) n.material.dispose();
  });
}

window.addEventListener('beforeunload', () => {
  if (model) dispose(model);
  dispose(ground);
  renderer.dispose();
  renderer.setAnimationLoop(null);
});
EOF

# ===== 55-60 min: package =====
# vite build  -> deploy to any static host
# Add a fallback <p> for browsers without WebGL2

# ===== Post-bootcamp =====
# 1. Add EffectComposer + UnrealBloomPass for a tasteful glow (see threejs/postprocess)
# 2. Lazy-load the GLB + show a progress bar via loader.onProgress
# 3. Test on iOS Safari — it dislikes >50MB textures; resize to 2k max
# 4. Add 'cmd+S' to capture a PNG via renderer.domElement.toBlob

Why it matters

Three setup decisions — outputColorSpace = SRGB, toneMapping = ACES, dispose on unload — are what separate a hobby demo from a viewer you can ship to customers. Get them in by reflex on every project so colour management and memory hygiene are not the things you have to revisit after the first deploy.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// 30-day plan in the lesson body.
Try it Yourself »

Discussion

Loading…