Three.js Builder
A Codex-ready framework for shipping elegant, performant Three.js web experiences with modern ES modules.
Reference Files
Read the appropriate reference when working on specific topics:
| Topic | File | Use When |
|---|---|---|
| GLTF Models | references/gltf-loading-guide.md | Loading, caching, cloning 3D models, SkeletonUtils |
| Reference Frames | references/reference-frame-contract.md | Calibration, anchoring, axis correctness, debugging |
| Game Development | references/game-patterns.md | State machines, animation switching, parallax, pooling |
| Advanced Topics | references/advanced-topics.md | Post-processing, shaders, physics, instancing |
| Calibration Helpers | scripts/README.md | GLTF calibration helper installation and usage |
Philosophy: Start from Scene Intent
Three.js work lives inside a scene graph: parents transform children, and everything in scene is rendered.
Before writing code, ask:
- What is the core visual element or metaphor (hero object, particle field, data glyph)?
- How should visitors manipulate or perceive it (static hero, orbit control, camera-relative movement)?
- What level of hardware/performance do we target (mobile hero snippet vs. desktop interactive)?
- Which animation best communicates the story (continuous rotation, bobbing, interaction-driven)?
Core principles:
- Scene graph first – model hierarchy and transforms before materials.
- Intent-driven primitives – boxes, spheres, torus, planes cover 80% of hero cases; reach for GLTF only when fidelity demands it.
- Animation as transformation – treat animation as evolving position/rotation/scale with
renderer.setAnimationLoop. - Performance through restraint – reuse geometries/materials, clamp device pixel ratio, keep draw calls low.
Foundational Principle: Lock a “Reference Frame Contract” First
Most Three.js bugs are not “rendering bugs” — they’re reference-frame bugs: wrong origin, wrong up-axis assumption, wrong forward direction, wrong “ground = y=0” semantics, wrong color space, or wrong loading environment.
Treat your scene like a contract you must prove once, then never violate:
- Axes: which way is forward for gameplay, and how do models map to it?
- Anchors: what does “on the ground” mean for each asset class?
- Units: what is 1 unit (meter-ish? pixel-ish?) and how do imported assets fit?
- Color: are textures and output color space correct?
If you don’t lock this contract early, you will chase symptoms: inverted movement, floating/sinking models, “wrongly loaded” textures, and end-state hangs caused by tangled state transitions.
The Calibration Pass (do this before building gameplay)
Do a 60-second calibration scene and lock constants (axes/anchors/scale/color) before shipping gameplay systems.
Deep guide + troubleshooting map: references/reference-frame-contract.md
Mesh Forward Verification (Do Not Guess)
Don’t “eyeball rotate until it looks right” and don’t assume “GLTF faces -Z”.
In the calibration scene, prove each imported model’s forward direction and lock it as a constant (global MODEL_YAW_OFFSET or per-asset yawOffset).
If you want a reusable helper module instead of hand-rolling arrows/box helpers each time:
- Install (repo-scoped): run
python3 .codex/skills/threejs-builder/scripts/install-gltf-calibration-helpers.py --out ./gltf-calibration-helpers.mjs - Install (from within the skill folder): run
python3 scripts/install-gltf-calibration-helpers.py --out ./gltf-calibration-helpers.mjs - Use: import and call
attachGltfCalibrationHelpers(...)(seescripts/README.md)
Minimal check:
js1// In Three.js, an Object3D’s "forward" is its local -Z axis. 2const localForward = new THREE.Vector3(0, 0, -1); 3 4// Attach a visible arrow to the model so you can *see* its forward. 5const forwardArrow = new THREE.ArrowHelper(localForward, new THREE.Vector3(0, 1.2, 0), 1.2, 0xff00ff); 6modelRoot.add(forwardArrow); 7 8// Also log the current world-forward for debugging (post-normalization and post-rotation). 9const worldForward = new THREE.Vector3(); 10modelRoot.getWorldDirection(worldForward); 11console.log('model world forward (-Z):', worldForward.toArray());
Then decide:
- If the mesh runs backward, set
yawOffset = Math.PI. - If it faces the camera (common symptom), your yaw convention is wrong—flip the offset and re-check.
Coordinate System & Camera Awareness (Critical)
Three.js is right-handed: +X right, +Y up, +Z toward the camera. Many GLTF assets are authored facing -Z, but verify per pack (don’t assume). Camera-relative controls must project the camera forward vector onto the XZ plane, normalize, then derive the right vector via a right-handed cross product to avoid inverted WASD when the camera orbits.
const forward = new THREE.Vector3();
camera.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
// Right-handed basis (DON'T swap arguments): right = forward × up
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0));
Practical rule:
- If A/D is inverted, your cross product order is wrong (or you need to flip
forwardwithforward.negate()depending on your convention). Fix the basis, not the key mapping.
Origins & “Ground” Anchors (Stop Guessing)
Anchoring is an asset-contract decision, not a “tweak until it looks right” decision.
Rule of thumb:
- Characters/props:
minY = 0 - Ground tiles: often
maxY = 0(walkable surface)
Deep guide + example code: references/reference-frame-contract.md
Build Flow
1. Frame the Experience
- Define the vibe (portfolio, gamey, data viz) and jot a mini mood board (colors, light style, animation verbs).
- Choose interaction tier: static render, auto-rotation, OrbitControls, or custom camera/gameplay loop.
- Sketch layout: do you need a ground plane, backdrop gradient, floating particles, or spotlight object?
2. Establish Runtime Foundations
- Use modern ES modules—no global
THREE. Import fromthreepackage or CDN versions (>=0.150). - Minimal HTML template:
html1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="utf-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 <title>Three.js Scene</title> 7 <style>body{margin:0;overflow:hidden;background:#010103;}canvas{display:block;}</style> 8</head> 9<body> 10 <script type="module"> 11 import * as THREE from 'https://unpkg.com/three@0.160.0/build/three.module.js'; 12 13 const scene = new THREE.Scene(); 14 const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 2000); 15 camera.position.set(0, 1.5, 4.5); 16 17 const renderer = new THREE.WebGLRenderer({ antialias: true }); 18 renderer.setSize(innerWidth, innerHeight); 19 renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); 20 renderer.outputColorSpace = THREE.SRGBColorSpace; 21 document.body.appendChild(renderer.domElement); 22 23 window.addEventListener('resize', () => { 24 camera.aspect = innerWidth / innerHeight; 25 camera.updateProjectionMatrix(); 26 renderer.setSize(innerWidth, innerHeight); 27 }); 28 </script> 29</body> 30</html>
- Plug OrbitControls from
three/addons/controls/OrbitControls.jswhen interaction is needed, enable damping, and callcontrols.update()each frame.
2.5 GLTF Loading Guardrails (When You Use Models)
- Run via a local server (not
file://) so relative resources resolve reliably. - For animated/skinned GLTF instancing, use
SkeletonUtils.clone()(plain.clone()breaks skeleton links). - Always log animation clip names after load and select by exact string (avoid “best guess” name matching unless it’s a deliberate fallback).
- If your asset pack uses an atlas, avoid “multiply tinting” materials (it can turn textured models into weird flat colors). Prefer leaving
material.colorat white and using emissive/lighting for emphasis.
3. Compose the Scene
- Geometries: prefer built-ins (Box, Sphere, Torus, Plane, Cone, Icosahedron). For particle clouds use
BufferGeometry+Float32Arraypositions. - Materials: pick
MeshStandardMaterialas default (tweakroughness/metalness),MeshPhysicalMaterialfor glass/liquids,MeshBasicMaterialfor unlit accents,PointsMaterialfor particles. - Lighting: start with Ambient (0.3–0.5) + Directional key + colored fill; enable shadows only when necessary and set
renderer.shadowMap.enabled = true+ map sizes of 1024–2048. - Color palettes: define constants (e.g.,
const COLORS = { primary: 0xff6600, accent: 0x0ff0ff }) and keep backgrounds dark to let emissive tones pop.
4. Animate & Interact
- Implement animation loops with
renderer.setAnimationLoop((time) => { ... renderer.render(scene, camera); });. - Common patterns:
- Continuous rotation with
mesh.rotation.y = time * 0.001. - Bobbing via
mesh.position.y = Math.sin(time * 0.002) * 0.4. - Mouse-reactive tilt: normalize pointer coords and lerp rotations for smoothness.
- Camera-relative character motion for interactive demos (see snippet above for direction vectors).
- Continuous rotation with
- Store state outside the loop (e.g.,
const inputState = { up: false, ... }).
4.5 State Machines & “Time Ran Out” Bugs
If your app “hangs” at time-out, it’s almost always because multiple systems are competing to transition state.
Guardrails:
- Use a single
state.modeand a single transition function. - Treat
timeLeft <= 0as terminal and clamp it once:timeLeft = Math.max(0, timeLeft - dt). - Add a one-way latch like
hasEndedso the end transition can only happen once.
5. Polish & Performance
- Cap
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))to keep 4K laptops smooth. - Reuse geometries/materials, avoid instantiating inside the animation loop.
- Use helpers (GridHelper, AxesHelper) for debugging, but remove them for production scenes.
- Separate setup helpers (e.g.,
createLights()/createMeshes()) to keep files navigable.
Anti-Patterns to Avoid
❌ Legacy global builds: Using <script src="three.js"></script> and accessing THREE globals breaks tree-shaking and OrbitControls imports. Use ES modules.
❌ Camera-blind controls: Mapping WASD directly to world axes feels wrong once the camera rotates. Always derive movement vectors from the camera orientation.
❌ Floating/sinking fixes by “random y offsets”: If you’re tweaking position.y in 5 places, your anchoring strategy is missing.
Why bad: you’ll never converge across ground tiles vs characters vs props.
Better: normalize each asset to a declared anchor (minY or maxY) at load time and only place the wrapper.
❌ Asset guessing: Assuming GLTF forward direction, scale, or animation names without a calibration pass. Better: build a 60-second calibration scene and lock constants (forward offsets, anchors, heights).
❌ Off-center canvases from transform math: Mixing translate() + scale() with the wrong transform-origin makes the render area drift.
Better: center via layout (flex/grid) and apply scale() only, with transform-origin: center.
❌ Geometry churn inside loops: Creating geometries/materials each frame tanks GC and FPS. Instantiate once and mutate transforms only.
❌ Overly dense primitives: Jumping to SphereGeometry(1, 128, 128) without purpose wastes perf; start with defaults and scale segments only when silhouetted edges demand it.
❌ Unbounded pixel ratio: Forgetting to clamp devicePixelRatio makes retina users suffer. Always clamp to ≤2 unless photorealism is mandatory.
Variation Guidance
- Purpose-specific styling:
- Portfolio hero → slow easing, glossy lighting, analogous palettes.
- Game prototype → punchy colors, contrasty lights, camera-relative controls, particles.
- Data viz → clean planes, grid helpers, consistent units, annotations.
- Background loop → subtle gradients, low-frequency motion, ambient glow.
- Mix geometry/material choices: cycle through spheres, rounded boxes, low-poly icosahedrons, tubes. Experiment with
flatShading,wireframe, emissive blooms. - Camera moods: alternate between dolly-in hero (z≈4), aerial tilt (camera.set(8, 11, -6)), or isometric (45° yaw). Pair with matching movement logic.
- Animation verbs: rotate, pulse, orbit, flock, noise-driven drift. Avoid always rotating cubes at
0.001speed.
Remember
Three.js scenes succeed when the mental model (scene graph + intent) guides the build. Lead with philosophy, respect the coordinate system, keep loops lean, and vary geometry, lighting, and animation so every experience feels purpose-built. Let these patterns unlock creativity—they are rails, not cages.
For specific topics, see the Reference Files table at the top of this document.