Learn by changing one thing

Understand the scene behind the picture.

Four guided experiments connect Three.js code to what you see: a scene and camera, an indexed surface, materials under shared lighting, and a model driven by animation clips.

You can follow the browser experiments without writing code. For the code exercises, you should know JavaScript variables, arrays, functions, and imports. Snippets below are excerpts or small adaptations for this repository, not standalone applications. Read them alongside the linked source.

01 / Scene and camera

Moving the camera is not moving the object.

A Scene holds objects and their relationships. A Mesh combines geometry (the shape) with a material (how the surface is drawn). A camera defines the view; the renderer turns that view into pixels. Adding a mesh to a scene does not display it until the renderer draws the scene through a camera.

Open the foundations experiment →

Try it and explain the result

  1. Press Pause motion. Drag across the canvas. You can still look behind the knot because the camera controls keep updating while object animation is paused.
  2. Move closer with the scroll wheel or a pinch. Nearby parts occupy more screen space. The geometry has not gained triangles; you changed its projection.
  3. Press Reset view. This restores the camera and its target. It does not recreate the scene or change the material.

In this demo, the knot and thin halo belong to a hero group. Transforming that parent carries both children with it. The floor belongs to a separate part of the hierarchy, so it stays still. This is useful for assembling a character or vehicle from parts that should move together.

// Adapted from src/main.js: a mesh joins a parent group.
const hero = new THREE.Group();
hero.position.y = 0.95;
scene.add(hero);
const knot = new THREE.Mesh(
  new THREE.TorusKnotGeometry(1.25, 0.36, 160, 28, 2, 3),
  new THREE.MeshPhysicalMaterial({ color: 0x718aff, roughness: 0.18 })
);
hero.add(knot);
// In the render loop, after updating controls and motion:
renderer.render(scene, camera);

What the counters do—and do not—tell you

The orbit uses 28 instances of a shared cube mesh. Instancing lets repeated objects share geometry and a draw submission instead of requiring a separate mesh submission for every cube. Draw calls are not an object count: shadows and other passes can submit geometry again. Triangle counts also describe rendering work, not an exact measure of how expensive every pixel is. Compare counters under the same conditions.

If the scene is blank: check that WebGL is available, the canvas has a nonzero size, and the camera points toward objects inside its near and far range. Lit materials also need light. This scene's halo uses an unlit basic material, so it can remain visible when a lit surface would appear dark.

Code exercise: change hero.position.y in a local copy. Predict which objects will move before reloading. Then change the camera position instead and explain why the floor appears to move on screen without its own transform changing.

Inspect the complete foundations source. Next: build the shape from data.

02 / Geometry and attributes

A smooth wave is a grid of triangles.

The geometry experiment builds its surface from typed arrays rather than loading a model. A position attribute stores three numbers per vertex: x, y, and z. A color attribute stores three color components. UVs store two coordinates for mapping a texture onto the surface. Attribute sizes describe data layout; they are not a count of separate objects.

Open the geometry experiment →

  1. Leave the wireframe visible and orbit toward the edge of the wave. Notice the straight triangle edges beneath the smooth shading.
  2. Choose Hide wireframe. The underlying surface stays in place: the button hides a separate line overlay rather than deleting triangles.
  3. Compare the vertex and triangle counters with the calculation below. The triangle count in this panel comes from the surface's index buffer; the draw-call counter includes the rest of the scene.

Predict the count

The default grid has 64 cells across and 48 cells deep. A grid needs one more vertex than cell in each direction:

Vertices: (64 + 1) × (48 + 1) = 3,185.
Triangles: 64 × 48 × 2 = 6,144.
Index entries: 6,144 × 3 = 18,432.

Each cell uses two triangles. Adjacent triangles refer to shared vertices through indices, avoiding a separate position entry for every triangle corner.

// In createWaveGeometry(), after filling the arrays:
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
geometry.computeVertexNormals();

The wave height combines two trigonometric terms and a center lift. The vertex colors also depend on height. Smooth appearance comes partly from interpolated normals: normals describe surface orientation for lighting, while positions determine the silhouette. More subdivisions can refine the silhouette, but smooth shading alone cannot add geometric detail.

Change the resolution, then the height

In a local copy of src/geometry-main.js, call createWaveGeometry(16, 12) instead of the default. Before running, predict 221 vertices and 384 triangles. Compare the silhouette at a shallow angle. Restore the resolution, then change the ridge amplitude from 0.25 to 0.5. The shape becomes taller without changing the vertex count.

Common mistake: editing positions on the CPU after upload does not automatically refresh the GPU buffer. Set geometry.attributes.position.needsUpdate = true, recompute normals when the surface changes, and refresh bounds if needed. This demo constructs its wireframe as separate geometry; a later position edit would also require rebuilding that overlay. Its existing animation rotates the surface as an object, so it does not edit the vertex buffer every frame.

Inspect createWaveGeometry and the inspector overlays. Next: understand the surface response.

03 / Surfaces and lighting

Compare materials under the same light.

A convincing surface depends on its surroundings. This experiment places four spheres under the same generated room environment and lights. Their shared sphere geometry makes material differences easier to see. The samples use MeshPhysicalMaterial with different parameters; the buttons focus the camera target on a sample, rather than replacing all spheres with that material.

Open the materials experiment →

Values used by the four samples
SampleRoughnessMetalnessWhat to inspect
Ceramic0.280.02A colored body with a clearcoat highlight
Chrome0.081Sharp, strongly reflected surroundings
Glass0.060.02Transmission 0.95, thickness 0.7, IOR 1.45
Iridescent0.200.28View-dependent color with iridescence enabled
  1. Select Chrome and orbit. Watch reflected features move as your viewing direction changes.
  2. Select Glass. Compare the transmitted scene with the chrome reflection. The glass uses transmission with opacity 1; it is not simply a faded opaque sphere.
  3. Keep the same view and switch between ACES, AgX, and Neutral. Look at bright highlights and saturated areas. Tone mapping changes the displayed image even though geometry and material parameters stay fixed.

PMREMGenerator prepares the room environment for roughness-dependent reflections. Roughness broadens the reflected light; it does not remove polygons or deform the sphere. Metalness changes the balance of reflection and diffuse response. A metal object can look unexpectedly dark when there is little useful environment lighting to reflect.

// A sample definition from src/materials-main.js:
const chrome = new THREE.MeshPhysicalMaterial({
  color: 0xb7c8e8,
  roughness: 0.08,
  metalness: 1,
  envMapIntensity: 1.4
});

Isolate one variable

The provided presets change several properties at once. For a controlled comparison, edit only chrome's roughness to 0.6 in a local copy. Keep its color, lighting, exposure, and camera unchanged. Expect broader reflections, not a different silhouette. Then restore the roughness and set metalness to zero to compare a nonmetal response.

Limits: these are illustrative presets, not measured material samples. Tone mapping, display settings, and environment lighting affect the result. Screen-based transmission is not a guarantee of physically accurate glass in every scene, particularly with overlapping transparent objects. A high draw-call count alone cannot explain material cost; transmission and shading can add work beyond a simple opaque surface.

Inspect the sample definitions and tone-mapping controls. Next: make a loaded model move.

04 / Loading and animation

Loading a character does not play its animation.

A glTF file can contain a scene graph and animation clips. GLTFLoader parses those resources; an AnimationMixer advances their animation state. A clip contains tracks, while an action controls playback of a clip on a particular root. You need all of these steps to turn a loaded character into a moving one.

Open the animation experiment →

  1. Wait for the model to load and select one of its named clips. The buttons come from the file's animation list, not a hard-coded assumption about every glTF model.
  2. Move the speed slider from 1 to 0.5. The mixer advances at half speed; camera orbit controls remain responsive.
  3. Press Pause and orbit around the pose. Resume, then choose a different clip. Look for the short transition as one action fades out and another fades in.
// Simplified flow; use the full source for loading and error handling.
const model = gltf.scene;
scene.add(model);
const mixer = new THREE.AnimationMixer(model);
const actions = gltf.animations.map(clip => mixer.clipAction(clip));
if (actions.length) actions[0].play();
// In each frame, delta is elapsed time in seconds:
mixer.update(delta);

The demo fades actions over 0.25 seconds. This blends poses, but it is not a complete locomotion system: unrelated motions can still slide or jump. A production character controller may need matched phases, root-motion handling, and transition rules. Those are separate from merely playing the clips.

Why use elapsed time?

Advancing by a fixed amount on every frame ties playback speed to frame rate. This implementation uses a timer and caps a single delta at 0.05 seconds. That avoids a large jump after a slow frame, but it also means animation can run slower than wall-clock time when frames consistently exceed the cap. The demo honors reduced-motion preferences by not advancing the mixer; a stationary character can therefore be intentional.

Troubleshoot the correct stage

The included RobotExpressive asset is an external example model, not a model authored by this site. See the upstream model credits and CC0 licensing note. The lesson explains this repository's loading, framing, controls, and playback behavior.

Inspect the full loader and playback implementation. Continue with object interaction when you can distinguish camera motion from clip playback.

Make the experiment yours

Predict. Change. Observe. Explain.

Clone or download the repository, use a Node.js version supported by its Vite dependency, install the locked dependencies with npm ci --legacy-peer-deps, and run npm run dev. The legacy peer flag is currently needed because a bundled raycaster helper declares an older Three.js peer range; it does not change the locked versions. Open the localhost address printed by Vite. Opening the HTML directly from disk will not resolve the module imports correctly.

For each exercise, record the property you changed, your prediction, the visible result, and one limitation. Change one value at a time and keep an unchanged copy for comparison. The goal is to explain a result you can reproduce, not to choose the most complicated effect.

Check your understanding

Why can the camera move while an animation is paused?

Camera controls and object or mixer animation are separate updates. Pausing one does not require stopping the render loop or the controls.

Does hiding the wireframe reduce the surface triangle count?

No. It hides the separate line overlay. The indexed surface is unchanged, so its 6,144 triangles remain. Rendering work from the line overlay can decrease.

Why is a chrome preset not enough to guarantee a realistic result?

The visible result also depends on reflected surroundings, lighting, exposure, tone mapping, and geometry. Keep those fixed when comparing a material parameter.

Source, scope, and corrections

This guide was added on and describes the linked implementations using the repository's Three.js 0.185.1 dependency. Code and graphics support can change. The guide stays readable without JavaScript or a GPU; interactive demos require suitable browser graphics support.

These are learning experiments, not universal performance benchmarks. Report a mismatch between the explanation and a demo through the contact page, including the page URL, expected result, and reproduction steps. Public issue reports should not include private information.