AETHERComputational Visual Laboratory

AETHER FIELD GUIDE · EDITION 02

Three.js from first pixel
to living system.

A practical, twelve-chapter route into real-time 3D on the web: scenes, geometry, light, motion, interaction, particles, shaders, sound, post-processing, next-generation GPU pipelines, and production discipline.

Created and curated by Biswajit Jana12 chapters · worked examples · one complete capstone study

FLIGHT PLAN

Twelve chapters, one mental model

  1. 01The rendering loop
  2. 02Project foundation
  3. 03Geometry and meshes
  4. 04Light and cameras
  5. 05Time and animation
  6. 06Interaction
  7. 07Particle systems
  8. 08GLSL shaders
  9. 09Audio response
  10. 10Post-processing
  11. 11WebGPU and TSL
  12. 12Production
CHAPTER 01

The rendering loop

Understand the four objects behind nearly every Three.js experience, and why the pipeline is a loop rather than a single draw call.

The mental model

Three.js is a JavaScript 3D library over browser graphics APIs (WebGL2, and increasingly WebGPU). A Scene is the root of a tree; a Camera defines the view; a WebGLRenderer turns the visible scene into pixels; and a render loop repeats that work as state changes.

A mesh combines geometry (data), material (appearance), and a transform (position, rotation and scale). Keeping those responsibilities separate makes complex scenes manageable: you can swap a material without touching geometry, or reuse geometry across a thousand transforms.

Under the hood, each frame the renderer walks the scene graph, computes world matrices from local transforms, culls what the camera frustum cannot see, sorts opaque geometry front-to-back and transparent geometry back-to-front, and issues draw calls. Understanding this ordering explains why transparency artifacts appear when draw order is wrong, and why draw-call count — not triangle count alone — is often the real performance ceiling.

import * as THREE from 'three';

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, 1, .1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true });
camera.position.z = 3;
renderer.render(scene, camera);

// Inspecting the pipeline cost
console.log(renderer.info.render.calls);   // draw calls this frame
console.log(renderer.info.render.triangles);
console.log(renderer.info.memory.geometries);
CheckpointExplain why a camera can move without changing any mesh coordinates, and why draw-call count matters more than triangle count on mobile GPUs.
CHAPTER 02

Project foundation

Build a reproducible ES-module project with Vite, and structure it so it survives growth.

Start cleanly

Vite serves modules during development and produces optimised static assets for deployment. Keep node_modules out of version control; keep both package files (package.json and the lockfile) so the dependency graph can be reproduced exactly on another machine.

Use one entry module and grow into small modules for scene creation, simulation, input, audio and lifecycle. Assets that must keep their exact filenames belong in public/; assets that get bundled and hashed belong alongside source.

A study-based project (like this laboratory) benefits from a consistent per-study contract: an animation.js exporting init(canvas)/dispose(), a metadata file describing title and parameters, and a thumbnail generated from the same code path as the live scene — never a hand-drawn approximation.

npm create vite@latest aether-study -- --template vanilla
cd aether-study
npm install
npm install three
npm run dev

import * as THREE from 'three';
import { OrbitControls } from
  'three/addons/controls/OrbitControls.js';

// suggested per-study contract
export function init(canvas, opts = {}) { /* build scene */ }
export function dispose() { /* free GPU resources */ }
ExerciseCreate a canvas that fills its parent container rather than the entire browser window, and confirm it survives a container resize.
CHAPTER 03

Geometry, attributes and meshes

Move from primitives to data you control.

Vertices are structured data

BufferGeometry stores named attributes. Positions use three floats per vertex; normals influence lighting; UVs address textures; custom attributes can carry age, mass, phase or colour into a shader.

Reuse geometry and materials wherever possible — creating them per frame is a common performance mistake. For many copies of one form, use InstancedMesh with a per-instance matrix or colour attribute instead of thousands of separate mesh objects. Dispose GPU resources (geometry, material, textures) when a study is removed, or memory grows every time the user switches scenes.

Index buffers matter too: an indexed geometry reuses shared vertices between adjacent triangles, roughly halving vertex-shader work for closed surfaces like spheres or terrain grids.

const count = 10_000;
const positions = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
  positions[i*3] = (Math.random()-.5)*4;
  positions[i*3+1] = (Math.random()-.5)*4;
  positions[i*3+2] = (Math.random()-.5)*4;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position',
  new THREE.BufferAttribute(positions, 3));

// instancing 5000 identical rocks cheaply
const mesh = new THREE.InstancedMesh(rockGeo, rockMat, 5000);
const m = new THREE.Matrix4();
for (let i = 0; i < 5000; i++) {
  m.compose(randomPos(), randomQuat(), randomScale());
  mesh.setMatrixAt(i, m);
}
mesh.instanceMatrix.needsUpdate = true;
CheckpointConfirm geometry.attributes.position.count equals the particle count, and compare draw calls before/after switching to InstancedMesh.
CHAPTER 04

Cameras, light and colour

Make depth readable instead of merely present.

Compose the view

A perspective camera needs field of view, aspect, near and far clipping planes. Huge far/near ratios reduce depth-buffer precision and cause z-fighting; keep the ratio as small as the scene allows. Update aspect and projection whenever the canvas size changes, or the image will distort on resize.

Lit materials need lights; MeshBasicMaterial does not respond to any of them. Begin with one key light and a restrained ambient or hemisphere fill — this single-key-plus-fill pattern is the fastest way to make an otherwise flat render read as three-dimensional. Choose output colour space and tone mapping deliberately: physically based materials expect linear-space lighting and a filmic tone-map on output, not the raw renderer default.

Fog (THREE.Fog or FogExp2) is an inexpensive way to hide far-plane popping and add atmospheric depth cues, especially useful in particle-heavy space scenes.

const camera = new THREE.PerspectiveCamera(50,1,.1,100);
scene.add(new THREE.HemisphereLight(0x8fcfff,0x08030f,1));
const key = new THREE.DirectionalLight(0xffffff,3);
key.position.set(3,4,2); scene.add(key);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;

scene.fog = new THREE.FogExp2(0x05030a, 0.045);
ExerciseCompare normal, standard and basic materials on one sphere under the same lights, and note which ones ignore the lights entirely.
CHAPTER 05

Time, animation and simulation

Separate elapsed time, frame time and model time.

Frames are not seconds

requestAnimationFrame is not a guaranteed interval — it varies with display refresh rate, tab visibility and device load. Use delta time for speed-independent visual motion. For sensitive numerical models, accumulate real time and advance with a fixed step so frame rate does not change the physics — the classic fixed-step-with-accumulator pattern below decouples simulation correctness from rendering speed.

Seeded randomness plus a fixed initial state makes a visual, a thumbnail and a test reproducible — essential when a study needs to be re-verified later or ported into a paper figure.

For orbital or oscillatory systems, prefer a symplectic or RK4 integrator over naive Euler stepping; Euler visibly drifts energy over long runs, which shows up as an orbit that slowly spirals when physically it should be closed.

let previous=performance.now(),accumulator=0;
const step=1/120;
function frame(now){
  const delta=Math.min((now-previous)/1000,.1);
  previous=now; accumulator+=delta;
  while(accumulator>=step){simulate(step);accumulator-=step;}
  renderer.render(scene,camera);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

// visibility-aware pause (saves battery, avoids delta spikes)
document.addEventListener('visibilitychange', () => {
  if (document.hidden) previous = performance.now();
});
CheckpointThrottle the browser's CPU (DevTools performance panel) and confirm the model does not accelerate or decelerate its physical behaviour.
CHAPTER 06

Interaction and spatial input

Connect pointer, touch and keyboard gestures to camera and scene state.

Two kinds of control

OrbitControls supplies orbit, dolly and pan around a target. Enable damping and call update() each frame — without the per-frame update call, damping silently does nothing. Raycaster turns normalised pointer coordinates into a ray you can intersect against scene objects for picking and hover states.

Keep input separate from model state: a control layer should only ever write into a small, well-defined interface (target position, selected object, drag delta), never reach directly into simulation internals. This separation is what makes a study testable without a browser.

Add keyboard access (tab order, enter/space activation), visible focus rings, a prefers-reduced-motion check that disables camera auto-rotation, and a "reset view" affordance — these are accessibility requirements, not polish.

const controls=new OrbitControls(camera,renderer.domElement);
controls.enableDamping=true;
const pointer=new THREE.Vector2(),raycaster=new THREE.Raycaster();
canvas.addEventListener('pointermove',e=>{
  const r=canvas.getBoundingClientRect();
  pointer.set((e.clientX-r.left)/r.width*2-1,
    -(e.clientY-r.top)/r.height*2+1);
  raycaster.setFromCamera(pointer,camera);
});

const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
controls.autoRotate = !reduceMotion;
ExerciseHighlight the nearest raycast-selected mesh on hover, then restore its original material when the pointer leaves — verify it also works with keyboard focus.
CHAPTER 07

Particle systems

Represent structure with points, not thousands of individual meshes.

Design the distribution first

A particle system is usually one THREE.Points object backed by BufferGeometry. Meaning comes from the sampler, not the renderer: a sphere needs volume or surface sampling; a galaxy needs radial density, logarithmic spiral arms and vertical thickness; a fountain needs velocity under acceleration and a lifetime that resets.

Put static properties (birth position, base colour, random seed) in attributes and dynamic controls (time, global speed, colour shift) in uniforms. Updating one uniform is far cheaper per frame than rewriting every particle's attribute buffer on the CPU.

For genuinely large counts (100k+), move the update itself onto the GPU: encode position/velocity in a floating-point texture and step it with a render-to-texture ping-pong pass, or use GPUComputationRenderer. CPU-side loops over large typed arrays become the bottleneck well before the GPU does.

const material=new THREE.PointsMaterial({
  color:0x55ddff,size:.025,transparent:true,
  depthWrite:false,blending:THREE.AdditiveBlending
});
const cloud=new THREE.Points(geometry,material);
scene.add(cloud);
geometry.attributes.position.needsUpdate=true;

// galaxy-arm sampler sketch
function galaxyPoint(i, n, arms=3, twist=4){
  const r = Math.pow(Math.random(), 0.6) * 4;
  const arm = i % arms;
  const theta = r*twist + arm*(2*Math.PI/arms) + (Math.random()-.5)*0.3;
  return [r*Math.cos(theta), (Math.random()-.5)*0.15*(1-r/4), r*Math.sin(theta)];
}
CheckpointChange a uniform-random radius into a physically motivated density distribution, and explain in one sentence why the visual difference matters scientifically.
CHAPTER 08

GLSL shaders and uniforms

Move repeated visual computation onto the GPU, where it runs once per vertex or fragment in parallel.

One program, many vertices

A vertex shader transforms every vertex; a fragment shader colours every fragment (roughly, every pixel a triangle covers). Attributes vary per vertex; uniforms are shared across the whole draw call; varyings carry interpolated data from the vertex stage into the fragment stage.

Begin from the default transform and add one deformation at a time, checking the result after each change — shader bugs are visual and silent, with no stack trace to guide you. Keep shader source readable (named constants, commented units) and enable Three.js's shader error checking during development so a compile failure surfaces in the console instead of a blank canvas.

A ShaderMaterial or RawShaderMaterial gives full control; onBeforeCompile lets you patch an existing built-in material (keeping its lighting model) rather than reimplementing PBR lighting from scratch just to add one custom effect.

// vertex shader
uniform float uTime;
void main(){
  vec3 p=position;
  p.y+=sin(p.x*4.0+uTime)*0.15;
  vec4 mv=modelViewMatrix*vec4(p,1.0);
  gl_PointSize=3.0*(2.0/-mv.z);
  gl_Position=projectionMatrix*mv;
}
// fragment shader — soft circular sprite
void main(){
  vec2 c = gl_PointCoord - 0.5;
  float d = length(c);
  if (d > 0.5) discard;
  float alpha = smoothstep(0.5, 0.1, d);
  gl_FragColor = vec4(vec3(0.4,0.8,1.0), alpha);
}
// each frame
material.uniforms.uTime.value=elapsed;
ExerciseAdd a per-vertex phase attribute so neighbouring points pulse out of sync, then convert the hard-edged point sprite into the soft circular one above.
CHAPTER 09

Audio-reactive graphics

Translate sound energy into controlled, legible visual behaviour.

Build an audio graph

An AudioContext owns a graph of connected nodes. AnalyserNode exposes time-domain and frequency-domain samples without altering the audible signal, so it can sit anywhere in the chain as a tap. Browser autoplay policy means the context must be created or resumed after a user gesture (a click or key press), not on page load.

Compute bass, mid and high energy bands from the frequency array; normalise each against a running maximum and smooth with exponential decay so visuals read as musical rather than jittery. Map each band to a meaningful, bounded parameter (scale, hue, particle speed) rather than an unbounded one that can blow up the scene on a loud transient.

Always keep a silent, deterministic fallback state — a study that only looks correct with audio playing is not verifiable in an automated screenshot pipeline.

const context=new AudioContext();
const source=context.createMediaElementSource(audio);
const analyser=context.createAnalyser();
analyser.fftSize=2048;
source.connect(analyser).connect(context.destination);
const bins=new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(bins);
const bass=average(bins.slice(0,24))/255;

// exponential smoothing to avoid jitter
let smoothBass = 0;
function updateBass(raw){
  smoothBass += (raw - smoothBass) * 0.15;
  return smoothBass;
}
CheckpointAdd exponential smoothing and compare it side-by-side with the raw signal on the same visual parameter.
CHAPTER 10

Post-processing pipelines

Treat the final image as a sequence of explicit, costed passes rather than a single render call.

Composing an EffectComposer chain

An EffectComposer renders the scene to an off-screen target and then runs a chain of full-screen passes over it: bloom for glowing emissive geometry, a film grain or vignette for atmosphere, FXAA or SMAA for anti-aliasing when MSAA is unavailable with certain render targets, and a final output pass that applies colour-space and tone-mapping conversion.

Every pass is a full-screen fragment-shader cost proportional to canvas resolution, so treat the chain as a budget: order passes so cheap discards (like a bloom's brightness threshold) happen early, and skip a pass entirely on constrained devices rather than running it at reduced strength.

Render targets need explicit resize handling, matching the canvas's device-pixel-ratio-aware resolution, or the composited image will look soft or misaligned after a window resize.

import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
  new THREE.Vector2(innerWidth, innerHeight), 0.9, 0.4, 0.15);
composer.addPass(bloom);
composer.addPass(new OutputPass());

function resize(){
  const w = canvas.clientWidth, h = canvas.clientHeight;
  renderer.setSize(w, h, false);
  composer.setSize(w, h);
  camera.aspect = w / h; camera.updateProjectionMatrix();
}
// render via composer.render() instead of renderer.render(scene, camera)
ExerciseAdd bloom to an emissive-material mesh, then measure frame time with and without the pass using renderer.info and the browser's performance panel.
CHAPTER 11

WebGPU and the Three Shading Language

Understand the emerging alternative renderer path and when it is production-ready.

A deliberate renderer choice

Three.js provides an evolving WebGPURenderer alongside the mature WebGLRenderer. WebGPU exposes compute shaders directly (not just render passes), lower CPU overhead per draw call, and a more explicit resource-binding model — genuinely useful for large-scale GPU particle simulation and compute-heavy visual studies.

Materials and effects under the WebGPU path are increasingly authored in TSL (Three Shading Language), a JavaScript-based node system that compiles to either WGSL (WebGPU) or GLSL (WebGL) from one source graph — one material definition, two backends, rather than hand-maintaining parallel shader files.

Because this surface changes faster than the stable core scene API, feature-detect support, retain a working WebGL fallback for unsupported browsers, and pin examples to a specific Three.js release rather than following "latest" documentation blindly.

import WebGPU from 'three/addons/capabilities/WebGPU.js';
import { WebGPURenderer } from 'three/webgpu';
import { color, mix, uniform, vec3 } from 'three/tsl';

if (WebGPU.isAvailable()) {
  const renderer = new WebGPURenderer({ antialias: true });
  await renderer.init();

  // TSL node material — compiles to WGSL or GLSL as needed
  const uTime = uniform(0);
  material.colorNode = mix(
    color(0x143a5c), color(0x6fd8ff),
    uTime.sin().mul(0.5).add(0.5)
  );
} else {
  // fall back to the existing WebGLRenderer path
}
CheckpointFeature-detect WebGPU availability in your own project and confirm the WebGL fallback still renders identically for an unsupported browser or device.
CHAPTER 12

Production, testing and release

Treat lifecycle and performance as part of the artwork, not an afterthought.

Finish the system

Resize from the canvas's actual display size (not window.innerWidth, which is wrong inside a constrained container), cap device pixel ratio to control fragment-shader cost on high-DPI screens, lazy-load heavy studies, and reduce particle counts or post-processing on constrained devices detected via a rough capability check.

Dispose geometries, materials, textures, controls, composers and audio contexts when switching between studies — the disposal walk below is the minimum; anything holding a GPU or OS resource needs an explicit release, because JavaScript garbage collection does not know about the GPU.

Test creation, resize, update, render and disposal as separate concerns. Run a production build, inspect the browser console for warnings (not just errors), verify keyboard and mobile layouts, and compare every title, thumbnail and animation against the equation or dataset it claims to represent.

function disposeObject(root){
  root.traverse(object=>{
    object.geometry?.dispose();
    const list=Array.isArray(object.material)
      ?object.material:[object.material];
    list.filter(Boolean).forEach(m=>{
      Object.values(m).forEach(v=>v?.isTexture&&v.dispose());
      m.dispose();
    });
  });
}
composer?.dispose();
controls?.dispose();
audioContext?.close();
// npm test && npm run build
ExerciseSwitch between studies fifty times while watching renderer.info.memory and confirm geometry/texture counts return to baseline after each switch.

ESSENTIAL FIELD NOTES

Topics that connect the chapters

Assets and scene graphs

Load textures with TextureLoader and glTF scenes with GLTFLoader. Treat loaded models as object trees: transforms on a parent group affect every descendant. Resolve URLs through the development server, provide visible loading and error states, and dispose textures when the scene is retired.

PBR, shadows and post-processing

MeshStandardMaterial responds to physically based light parameters and environment maps. Enable shadows only for lights and objects that need them — every shadow-casting light adds a full extra depth-render pass. Build bloom, tone mapping and output conversion as an explicit EffectComposer pass chain (see Chapter 10); every pass costs render time.

Resize and diagnostics

Measure the canvas's display rectangle, cap device pixel ratio, update the camera aspect, and resize both renderer and composer together. Keep shader checks enabled in development. Inspect the console, network panel, renderer statistics and GPU frame time before changing visual code — profile before you optimise.

WebGPU and TSL

Three.js also provides an evolving WebGPU renderer and its node-based Three Shading Language (Chapter 11). Treat this as a deliberate renderer choice, feature-detect it, retain a WebGL path where required, and follow the version-matched official examples because this surface changes faster than the core scene API.

CAPSTONE

Build an equation-driven orbit study

Choose a documented system of differential equations (a two-body orbit, a Lorenz attractor, a restricted three-body problem). State parameters and initial conditions explicitly. Integrate with fixed-step RK4, store the trajectory in BufferGeometry, add a progress attribute, and move a luminous head along it with a shader uniform. Add OrbitControls, reduced-motion support, a deterministic preview render, metadata describing the model, and a stated numerical limitation.

  1. Research and record the governing equation and its physical assumptions.
  2. Test the integrator independently of any rendering code — verify against a known analytic solution first.
  3. Normalise only for display; keep the underlying state in physical units.
  4. Render points and a faint trailing line for the trajectory history.
  5. Expose visual controls (speed, trail length, colour by phase or velocity).
  6. Dispose, resize and validate — run the fifty-switch memory test from Chapter 12.
function rk4Step(state, time, h, derivative) {
  const add = (a,b,f) => a.map((v,i) => v+b[i]*f);
  const k1 = derivative(state,time);
  const k2 = derivative(add(state,k1,h/2),time+h/2);
  const k3 = derivative(add(state,k2,h/2),time+h/2);
  const k4 = derivative(add(state,k3,h),time+h);
  return state.map((v,i) =>
    v+h*(k1[i]+2*k2[i]+2*k3[i]+k4[i])/6);
}

PRIMARY READING

Official references

Three.js Fundamentals ↗Installation and modules ↗BufferGeometry ↗ShaderMaterial ↗OrbitControls ↗Web Audio AnalyserNode ↗Post-processing manual ↗WebGPU renderer ↗