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.
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.
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.
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.
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.
Leave the wireframe visible and orbit toward the edge of the wave. Notice the straight triangle edges beneath the smooth shading.
Choose Hide wireframe. The underlying surface stays in place: the button hides a separate line overlay rather than deleting triangles.
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:
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.
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.
Select Chrome and orbit. Watch reflected features move as your viewing direction changes.
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.
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.
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.
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.
Move the speed slider from 1 to 0.5. The mixer advances at half speed; camera orbit controls remain responsive.
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
Loading fails: check the model URL and error panel. A GLB response must contain a model, not a fallback HTML page. The default asset is served from /models/RobotExpressive.glb.
The model appears but there are no clips: a valid glTF file does not have to contain animations. Check the animation count before debugging the mixer.
The model appears but stays still: check reduced motion, pause state, speed, and whether the selected clip changes the visible parts.
The character is clipped or offscreen: the demo calculates bounds to scale and frame the model. Press R to reframe. A static bounding box may not enclose every possible animated pose.
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.
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.