Workflows in Three.js

three.js workflow

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.