Workflows in Three.js

So you've been through the series — you know how to set up a scene, add lights, load models, animate things, and throw in some post-processing. But when you sit down to build an actual project, a question creeps in: how do I organize all of this?
This post isn't about any single Three.js feature. It's about workflows — the patterns and habits that turn a pile of moving parts into a maintainable, performant project.
the big picture — three phases
Every Three.js project I build goes through the same three phases:
| Phase | What you're doing |
|---|---|
| Setup | Scene, camera, renderer, resize handler, clean up |
| Content | Objects, lights, models, materials, animation |
| Interactions | Raycasting, user input, state management, UI |
These phases aren't sequential — you bounce between them constantly. But keeping them mentally separate helps you decide where to put new code.
the scaffold — your starter template
Every Three.js project needs the same boilerplate. Here's the setup I copy into every project:
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
import gsap from 'gsap';
// ─── Phase 1: Scaffold ────────────────────────────────────────
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
camera.position.z = 5;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
// Resize — handles window or container resizing
function resize() {
const w = renderer.domElement.clientWidth;
const h = renderer.domElement.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
window.addEventListener('resize', resize);
// ─── Phase 2: Content ─────────────────────────────────────────
// Lighting — ambient + directional is my go-to starter
const ambient = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambient);
const sun = new THREE.DirectionalLight(0xffffff, 1);
sun.position.set(5, 10, 5);
sun.castShadow = true;
scene.add(sun);
// Asset manager — keeps everything in one place for easy cleanup
const assets = { models: new Map(), textures: new Map() };
const loader = new GLTFLoader();
async function loadModel(key, path) {
const gltf = await loader.loadAsync(path);
assets.models.set(key, gltf.scene);
scene.add(gltf.scene);
return gltf.scene;
}
// Example: animate a loaded model with GSAP
async function animateModel(key) {
const model = assets.models.get(key);
if (!model) return;
const target = { x: 0, y: 0, z: 0 };
gsap.to(target, {
x: 3,
y: 2,
duration: 2,
ease: 'power2.out',
onUpdate: () => model.position.set(target.x, target.y, target.z),
});
}
// Render loop math — continuous motion
function continuousMotion(mesh, elapsed) {
mesh.rotation.y += 0.01;
mesh.position.y = Math.sin(elapsed * 1.5) * 0.3;
mesh.scale.setScalar(1 + Math.sin(elapsed * 3) * 0.1);
}
// ─── Phase 3: Interactions ────────────────────────────────────
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const clickableObjects = [];
function makeClickable(mesh) {
clickableObjects.push(mesh);
}
renderer.domElement.addEventListener('click', (event) => {
pointer.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1;
pointer.y = -(event.clientY / renderer.domElement.clientHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(clickableObjects);
if (hits.length > 0) handleClick(hits[0].object);
});
function handleClick(obj) {
gsap.to(obj.material.color, { r: 1, g: 0, b: 0, duration: 0.3 });
}
// ─── Post-processing ──────────────────────────────────────────
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.3,
0.5,
0.1,
);
composer.addPass(bloom);
// ─── Single Render Loop ───────────────────────────────────────
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
const elapsed = clock.getElapsedTime();
// Update continuous motion for any objects
scene.children.forEach((child) => {
if (child.isMesh) continuousMotion(child, elapsed);
});
// Draw — use composer if post-processing, else plain render
composer.render();
}
animate();
The important thing isn't the exact code — it's the shape of it. Every Three.js project follows the same arc: set up the scaffold, add your content, wire up interactions, and feed everything through a single render loop. Once you internalize that pattern, you stop worrying about where to put code and start building.
