The Frame: How a Scene Becomes an Image
In Three.js, the frame is the unit that connects application state to visible output. This chapter follows that connection: how a camera selects a volume of space, how the browser’s canvas dimensions affect projection, and what the renderer does before the GPU draws anything. The practical result is a way to diagnose stretched, blurry, clipped, or unexpectedly slow output by locating the failure in the frame rather than changing unrelated scene data.
A camera selects a volume of space
A perspective camera does not describe a finished picture. It describes a viewing volume, called a frustum: a pyramid with its tip cut off. The four values that define the common perspective camera each control a different part of that volume.
The field of view, or FOV, determines how wide the view is vertically. The aspect ratio determines how wide it is horizontally relative to its height. The near plane marks where the visible volume begins, and the far plane marks where it ends. Objects outside that range are clipped; they are not merely made smaller or dimmer. When a cube disappears as the camera moves, the first question is therefore whether it has left the frustum, not whether the cube’s geometry has changed.
That camera model also explains perspective. A nearby object occupies more of the frustum’s projected area than an equally sized distant object. An orthographic camera makes a different choice: it projects a box, so distance does not make objects appear smaller. That is useful for 2D layouts, editors, maps, and technical views. The choice is not a matter of visual taste alone. It determines how world units map to the image and what the application must update when the display changes.
The camera’s position and orientation are part of the scene’s spatial state, but the camera does not have to be a child of the scene to render. It can be detached and still define the view, or it can be parented to a vehicle, rig, or controller so that its local transform follows another object. In either case, the renderer needs an up-to-date world transform and projection matrix before it can decide what the camera sees.
A useful frame-level picture is this:
The diagram is a behavioral model, not a promise that every internal function always runs in exactly this order. Three.js’s WebGL renderer updates scene and camera matrices, builds a frustum from the camera projection, walks objects into render lists, and sorts those lists before rendering. The internal implementation can change without changing the important contract: the application supplies a scene and a camera, and the renderer turns the camera’s visible portion into an image.
A camera setting is also state with a refresh cost. Changing a perspective camera’s field of view, near plane, far plane, or aspect changes its projection matrix. The application must update that matrix before the new projection can be used. This is easy to miss in an interactive editor: a control may visibly change a number while the rendered view continues using the old projection until the dependent state is marked current. The same principle applies when a camera moves through a parent hierarchy. Its world transform must represent the latest parent and local changes before the renderer can construct the correct view.
The frustum is not only a clipping shape; it is a compact statement of what work the frame is allowed to consider. A camera helper can make that statement visible by drawing the frustum in another view. Three.js can also use scissor and viewport regions to render different cameras into different portions of one canvas. In a modeling tool, one perspective view and several orthographic views may share a scene while each camera gets a different rectangle and aspect ratio. The scene is not duplicated, but the frame contains multiple camera projections and multiple render-list passes. This is a useful reminder that “render the scene” does not always mean one camera, one viewport, or one pass.
The same camera may also render to a render target instead of directly to the canvas. That target is an offscreen texture that can become an input to a later effect, a shadow or reflection step, or a picking operation. The visible canvas is then the final destination of a longer image pipeline. The scene-camera-renderer contract still holds, but the output boundary has moved temporarily. Understanding that distinction makes later features feel like extensions of the frame rather than unrelated magic.
The canvas has a display size and a drawing-buffer size
Web development adds a second geometry problem. A canvas has the size at which CSS displays it and the size of its internal drawing buffer—the actual number of pixels the renderer writes. Those values can be different, just as a small image can be displayed at a larger CSS size. Three.js needs both values to be handled deliberately.
Suppose CSS stretches a canvas to fill a wide browser window, but the camera continues using the canvas’s original aspect ratio. The scene will look squeezed or stretched even though the 3D objects are correct. The normal remedy is to read the canvas’s displayed width and height, set the camera aspect to their ratio, and update the camera’s projection matrix when that display size changes.
That fixes the shape of the projection, but not necessarily its resolution. If the canvas’s drawing buffer remains small while CSS displays it large, the browser scales a low-resolution image. The result is blocky or blurry edges. A resize function therefore compares the internal canvas dimensions with the desired drawing-buffer dimensions and calls the renderer’s size method only when they differ. Passing false for the style update lets CSS remain responsible for the displayed size while Three.js updates the internal resolution.
This distinction is especially important on high-density displays. Rendering at every device pixel can multiply the amount of fragment work without improving the application enough to justify the cost. Rendering at a controlled resolution saves GPU time but may soften edges. The current manual guidance favors making the calculation explicit when an application needs to know the actual buffer size—for example, for post-processing, screenshots, pixel reads, or GPU picking. A project can also cap the total pixel count so an unusually large or densely scaled display does not silently turn into a performance problem.
The lesson is broader than responsive design: the frame has a logical coordinate system and a physical work budget. The camera uses the logical aspect of the view. The renderer and GPU pay for the physical pixels. Treating those as one number makes it hard to explain either distortion or slow performance.
What the renderer does before drawing
Calling renderer.render(scene, camera) looks simple because Three.js absorbs a long sequence of work behind that call. It first needs current transforms. For the scene, that means updating the hierarchy’s world matrices when automatic updates are enabled. For a detached camera, it also needs the camera’s world matrix. The camera’s projection and inverse world transform then define the matrix that maps world positions into camera space and onward into the screen.
From that combined view, the renderer constructs a frustum. It traverses the scene and ignores objects that are invisible or filtered out by layers. Renderable objects such as meshes, lines, and points can be tested against the frustum. If an object’s bounding volume lies outside the visible region, the renderer can skip its draw work. This is why bounds and transforms matter even when the final image contains only pixels: they determine which pixels are worth attempting.
Objects that survive that selection enter render lists. The renderer keeps separate treatment for opaque, transmissive, and transparent objects, and sorting attempts to produce a sensible order. Transparency is inherently difficult because the correct result can depend on what is behind what, and sorting cannot solve every case. Render order and material depth settings are therefore additional controls, not guarantees that every overlapping transparent surface will look correct.
The renderer also manages more than the main scene pass. Shadows may require additional views. A render target redirects output away from the canvas. Post-processing can render the scene to textures and run more passes before the final image reaches the screen. Shader compilation can create a first-use stall, which is why the WebGL renderer exposes compile methods for applications that want to prepare materials ahead of time. These features all extend the frame, and each extension adds work or state that the direct scene-to-canvas example does not show.
This is a useful way to read a performance symptom. If a scene is slow, the cause may be the number of objects the renderer traverses, the number of draw calls it issues, the number of pixels each material shades, the cost of lights or shadows, the cost of extra render passes, or a shader compiling at an unfortunate moment. Triangle count is relevant, but it is not a complete explanation. The renderer’s information counters can help distinguish some of these categories, while the scene graph and camera determine which objects enter the work at all.
The distinction between traversal and drawing is important. A scene can contain many nodes that are cheap to reject, or a small number of large objects that cover most of the screen. In the first case, hierarchy depth, matrix updates, and bounds checks may dominate the CPU side of the frame. In the second, a physically based material, several texture samples, high resolution, or expensive transparency may dominate the GPU side. A camera that sees less can reduce both the number of objects admitted to the render list and the number of fragments produced, but it cannot make an admitted object’s shader simpler.
Bounds also have a lifecycle. Three.js can compute a geometry’s bounding sphere when it needs one for culling or raycasting, but an application that changes vertex data may need to recompute bounds. An object whose vertices move but whose bounds remain stale can be culled when it should be visible, or kept when it should have been rejected. The frame is therefore not just a sequence of renderer calls; it is a consistency agreement between application state, derived matrices, derived bounds, and GPU resources.
The same reasoning applies to shader preparation. A material may be perfectly valid but still cause a visible pause when its program is compiled for the first time. Precompilation can reduce that surprise when the application knows the scene, camera, lighting, and environment that will be used. It does not remove the cost of every future material or configuration, and it must be planned around the actual target scene. The useful question is not whether Three.js can make a frame free, but which work can be moved earlier, avoided, shared, or measured.
Depth precision is a bounded resource
The near and far planes have another consequence besides clipping. The GPU uses a depth buffer to decide which surface is in front of another. The available precision is distributed across the camera’s depth range, and the distribution is not uniform: the region near the camera normally receives finer precision than the region near the far plane.
If an application chooses an extremely small near value and an extremely large far value simply to “see everything,” two surfaces that are almost coplanar may receive depth values too close for the GPU to distinguish. They flicker or appear to compete for the same pixels. This is z-fighting. It is not usually fixed by changing a material color because the conflict happens in depth comparison.
Three.js exposes alternatives for scenes with unusually large scale differences. A logarithmic depth buffer can distribute precision differently, but the manual notes that it can be slower and is not supported by every device. Current WebGL documentation also describes a reversed depth buffer as a faster and more accurate option when the required extension is available. Neither choice removes the need to think about scale. Each depends on hardware and can affect performance or compatibility.
The most reliable first step is still to choose near and far values that fit the use case. A close-up face and a mountain range many kilometers away may not belong to one ordinary depth range. The application may need multiple views, a different scale convention, a separate scene, or a specialized depth strategy. A renderer option is a tool for a known constraint, not permission to ignore the constraint.
Not every frame needs a permanent loop
Animated scenes need repeated frames. A rotating object, a moving character, or a changing simulation must update state and render again. A static product viewer, editor, map, or diagram often does not. Rendering continuously while nothing changes consumes processing power and battery without changing the image.
Three.js’s render-on-demand pattern is straightforward: render once, then request another frame when a control changes the camera, the browser resizes the canvas, an asset finishes loading, a user changes a setting, or another relevant event changes the scene. Damping and other smooth transitions are an exception in form rather than principle. While the transition is active, the application requests frames; when it stops, rendering can stop too.
This makes the frame loop an ownership decision. The browser can provide animation timing, but the application knows whether its state is changing. A continuous loop is a good fit for a game or animated artwork. An event-driven loop is often better for a mostly static viewer. The goal is not to minimize frames at any cost; it is to align rendering work with changes that can actually affect the canvas.
The frame therefore connects three kinds of correctness. The camera must describe the intended visible volume. The canvas and drawing buffer must represent the intended display and pixel budget. The renderer must traverse and schedule the state that the application has prepared. When a result is wrong or slow, those boundaries provide a disciplined place to look.
The renderer can only project objects after it knows their world transforms. Those transforms do not come from the camera or the GPU; they come from the hierarchy that relates each object to its parent. The next step is to see how local spaces and world matrices turn that hierarchy into manageable motion.