Why Three.js Is More Than a Renderer
Three.js is a JavaScript library for creating 3D graphics in the browser. This opening asks a more useful question than “how do I draw a cube?”: what does Three.js actually stand between, and why does that boundary matter? By the end, you should be able to name the parts of a minimal Three.js application, understand how they relate, and see why the library is valuable precisely because it does not erase every rendering decision. It gives you a usable model first; it leaves room to take control when the application reaches a real limit.
That distinction matters because Three.js is often confused with WebGL. WebGL is the browser interface that lets code send low-level drawing commands for points, lines, and triangles to a graphics processor. It does not, by itself, give an application a scene, a light, a camera, a material, a texture, or a way to express that the wheels of a car should follow its body. Three.js supplies those concepts and connects them to the underlying graphics API. Its current project also includes a WebGPU path, while other renderers and many practical tools are available as separately imported addons. The subject, then, is not one renderer. It is a layered runtime for turning a structured description of a 3D world into an interactive image.
The first cube exposes the whole system
A first Three.js scene can be small enough to fit in a few objects, but it is not conceptually small. You need a Scene, a camera, a renderer, a geometry, a material, and a mesh. The scene is the root that contains what should be drawn. The camera defines the portion of that world that can be seen. The renderer turns the selected view into pixels on a canvas. The geometry describes a shape; the material describes how that shape should look; and the mesh joins those resources to a position, orientation, and scale.
The order matters. Creating a box does not make it visible. Creating a material does not place a surface in the world. Adding a mesh to the scene does not draw it until a renderer is asked to render that scene with a camera. A useful shorthand is:
Scene → renderable object → geometry + material, with Camera → Renderer → Canvas defining how that object becomes an image.
That shorthand is not an implementation recipe so much as a boundary map. The scene owns membership. The mesh owns the relationship between a transform and reusable visual resources. The camera owns projection. The renderer owns the translation from those descriptions into GPU work and browser output. When a cube is missing, each boundary suggests a different question: was it added to the scene, is it inside the camera’s view, does its material make it visible, or did the renderer ever receive the scene and camera together?
A visible application also needs a time model. If a cube rotates, the application changes its transform and renders another frame. Three.js recommends setAnimationLoop() as the renderer-facing way to define that loop. The important idea is not the method name; it is that Three.js does not guess which application state should change. Your code updates the object, then asks the renderer to produce the next image. That same separation remains useful when the “change” is a camera drag, a loaded texture, a selected object, or a new XR pose rather than a spinning cube.
This is already more than a thin wrapper around WebGL. The library is supplying a vocabulary for a world, a view, and a frame. It is also preserving a place for the browser: the output is a canvas with its own display size, resolution, and lifecycle. A 3D scene is therefore both a graphics problem and a web application problem.
The scene graph is the central organizing idea
The most important Three.js object is not necessarily the mesh. It is Object3D, the base for most objects that can participate in 3D space. An Object3D can have a parent and children, and each child describes its position, rotation, and scale in the parent’s local space. Three.js then combines those local transforms into world matrices as it updates the hierarchy.
Consider a solar system. If the Earth is a child of an orbit node, rotating that node moves the Earth around the Sun. If the Moon is a child of an orbit node attached to the Earth, the Moon can revolve around the Earth while the Earth itself revolves around the Sun. The application does not need to derive a spirograph-like world trajectory for the Moon. It states the relationships, and the hierarchy composes them.
The same pattern appears in less playful systems. A car can have a body, wheel pivots, a turret, and a camera arranged under meaningful parent nodes. A camera can be attached to a vehicle or a tool. A light target can be placed in the hierarchy so that it moves with the object it illuminates. Empty Object3D nodes are useful precisely because a local space can be valuable even when nothing visible is attached to it.
This makes the scene graph a model of responsibility as well as a tree of objects. A parent owns a frame of reference. A child owns its relationship to that frame. Traversal lets the renderer, a loader, an inspector, or an application visit the descendants. World-space methods let code ask where a child ended up after all its ancestors were applied. Layers and visibility can restrict what a camera or raycaster considers. The same hierarchy therefore supports rendering, animation, cameras, lights, and interaction.
The abstraction has boundaries. A parent’s scale affects descendants, which is useful when the relationship is intentional and troublesome when an imported asset has an accidental scale buried near its root. Operations such as lookAt() and attach() do not support every possible non-uniformly scaled hierarchy. A loaded model may contain correction nodes, unexpected origins, or names that do not match the behavior you want. In those cases, the scene graph has not failed; it is showing you the authored coordinate system that the application must understand or repair.
That is why a Three.js project benefits from inspecting an asset’s hierarchy instead of treating a model file as a single opaque object. A model that renders correctly may still be difficult to animate if its pivot is wrong. A camera that appears misplaced may be correctly positioned in a parent’s local space. A light that seems ineffective may be aimed at a target elsewhere in the hierarchy. The scene graph turns these problems into relationships that can be examined, rather than unexplained numbers scattered through an animation loop.
Three.js separates reusable resources from objects
The second organizing idea is the split between an object’s transform and the data used to draw it. A Mesh combines a geometry, a material, and a transform, but those parts do not have to be unique. Three blue cubes at different positions can share one box geometry and one material. The meshes carry the differences in position and orientation; the geometry stores vertex data; the material stores appearance settings.
This separation is practical. Geometry can contain positions, indices, normals, colors, UV coordinates, morph targets, and other attributes in buffers suited to the graphics processor. An index can let several triangles reuse the same vertices. A material can choose an unlit surface, a light-reactive surface, or a physically based one, and can refer to textures. A texture can be loaded from an image, generated from a canvas, or produced by another render. Each resource answers a different question, so the application can change one answer without rebuilding everything else.
It is also the beginning of a performance model. Reusing geometry avoids duplicating vertex data, but it does not make three meshes one draw. Reusing a material does not remove the need to place and cull each object. A material array and geometry groups can make one geometry use several materials, but the groups are rendered separately. These choices are not obscure implementation details; they are consequences of keeping transform, data, and appearance independent.
They create lifecycle responsibilities too. A mesh can leave the scene while its geometry is still shared by another mesh. A texture can be referenced by several materials. Removing an object from the scene therefore does not tell Three.js that the underlying GPU resources are dead. The application eventually has to dispose of resources when it knows they are no longer needed. The same design that makes reuse possible prevents the library from safely guessing ownership.
Appearance adds another layer of judgment. Three.js offers materials that trade speed for lighting and surface detail. A simple unlit material is often the honest choice for a diagram, interface, or scene where lighting adds no value. Physically based materials can make surfaces respond more consistently to lights and environments, but their richer calculations cost more per pixel and depend on appropriate environment data. Textures also have meaning beyond “an image”: a color map, a normal map, and a roughness map are not interchangeable data.
This is why correct-looking output cannot be reduced to choosing a brighter light. Three.js uses a linear working color space for lighting and other calculations, while many image inputs and displays use sRGB. If a color texture is treated as linear data, or if output conversion happens twice or not at all, the scene can look too dark, too bright, or wrong under changing illumination. The visual pipeline has contracts just as the scene graph does.
An abstraction with visible boundaries
Three.js is powerful because it gives application code useful structure before asking it to manage GPU details. It lets a reader describe a world as objects, compose motion through parents, reuse geometry and materials, load runtime assets, and render through a standard loop. But it does not make projection, asset authoring, color spaces, browser sizing, performance, or resource lifetime disappear. Those remain visible points of control.
That combination is the core theme of this series. We will treat Three.js as a set of layers that can be composed from the top down and inspected from the bottom up. The scene graph explains relationships. The camera explains selection and projection. Geometry and materials explain the data-to-appearance boundary. Loaders and addons connect authored content and interaction to the runtime. Render targets, batching, disposal, and renderer choice explain what happens when a working scene becomes a real application.
The practical judgment is modest but important: start at the highest layer that expresses the problem clearly, then descend only when the evidence says the current layer is the source of the limitation. If the object is in the wrong place, inspect the hierarchy before writing more math. If the image is distorted, inspect canvas and camera dimensions before rebuilding geometry. If a scene is slow, measure whether the cost comes from objects, pixels, shaders, or memory before merging everything. If a renderer feature is incompatible, treat that as a backend boundary rather than forcing unrelated code to pretend the boundary is not there.
The next step is to follow one frame through those boundaries: from an application change, through camera projection and renderer work, to the pixels the browser displays. That frame is where Three.js’s abstractions become observable behavior.
References
- Three.js repository
- Three.js manual
- Three.js documentation
- Three.js fundamentals
- Creating a scene
- WebGLRenderer API
- Scene graph
- Object3D API
- Scene API
- Loading a glTF file
- Mesh API
- BufferGeometry API
- Material API
- Texture API
- Cameras
- Responsive design
- Optimize lots of objects
- InstancedMesh API
- BatchedMesh API
- Rendering on demand
- WebGPURenderer guide
- WebGPURenderer API
- Three.js TSL guide
- WebGPU post-processing