CMPUT-411 · Lesson 5 of 12 · 32 min

Projection and the viewing pipeline

After the view matrix has placed the camera at the origin, the renderer must turn three-dimensional camera-space positions into two-dimensional screen positions. This is projection. It decides how much of the world the camera sees, how objects shrink with distance, and how depth is stored for visibility tests. This lesson derives the orthographic and perspective projection matrices, explains clip space, the perspective divide and normalized device coordinates, follows the viewport transform to pixels, and examines why depth precision is uneven and how reversed-Z fixes much of the problem. AI-350 (lesson 1) describes the pinhole camera model from the vision side, where the goal is the reverse: recovering 3D information from images.

The pinhole camera and perspective

A pinhole camera admits light through a single point, the center of projection, onto an image plane. A scene point at camera-space position \((x, y, z)\), with the camera looking down the negative z axis, projects onto an image plane at distance d in front of the eye at \(x' = d\,x/(-z)\) and \(y' = d\,y/(-z)\). Division by depth is the essence of perspective: objects twice as far away appear half as large, parallel lines receding from the viewer converge to vanishing points, and the projection is not affine, because it does not preserve ratios of distances. Renaissance painters such as Brunelleschi and Alberti worked out the geometry of linear perspective in the fifteenth century; graphics computes it with matrices.

The camera's field of view determines how much of the scene is visible. A vertical field of view of \(\theta\) corresponds to an image plane at distance \(d = 1/\tan(\theta/2)\) if the image extends from −1 to 1 vertically. Typical values are 60 to 90 degrees for games, wider for driving and racing games that convey speed, and narrower for telephoto effects. The aspect ratio, width divided by height, scales the horizontal extent. A field of view that is too wide distorts shapes near the image edges, and one that does not match the viewer's actual angle to the screen can cause discomfort, which is why games let players adjust it.

The view frustum

A real camera cannot see infinitely near or far, and a renderer must limit the depth range to store depth with finite precision. The visible region is the view frustum, a truncated pyramid bounded by the near and far clipping planes and four side planes through the eye and the edges of the image. Geometry outside the frustum is clipped or culled. The near plane cannot be at zero, because projection divides by depth, and, as we will see, depth precision depends strongly on how close it is.

On the left, a truncated pyramid with its apex at the eye, a small near rectangle and a large far rectangle, with a few objects inside it. An arrow labeled projection matrix and divide by w leads to the right, where the frustum has become a cube labeled NDC with corners at minus one and one, and the objects inside are distorted, with nearer objects larger.
The perspective projection and divide map the view frustum to the normalized device coordinate cube

Orthographic projection

Orthographic (parallel) projection discards depth for the image position: points project straight onto the image plane along parallel lines, so objects keep their size regardless of distance. It is used for engineering and architectural drawings, CAD views, two-dimensional games, user interfaces, shadow maps for directional lights (lesson 8) and isometric games. The orthographic projection matrix maps a box with left, right, bottom, top, near and far bounds \(l, r, b, t, n, f\) to the normalized cube. It is simply a scale and translation in each axis, an affine map, so w stays 1.

\[P_{\text{ortho}} = \begin{bmatrix} \frac{2}{r-l} & 0 & 0 & -\frac{r+l}{r-l} \\ 0 & \frac{2}{t-b} & 0 & -\frac{t+b}{t-b} \\ 0 & 0 & -\frac{2}{f-n} & -\frac{f+n}{f-n} \\ 0 & 0 & 0 & 1 \end{bmatrix}\]

The perspective projection matrix

Perspective requires division by depth, which no affine matrix can perform. Homogeneous coordinates solve this: the projection matrix copies \(-z\) into the w component, and the hardware divides x, y and z by w afterwards. With vertical field of view \(\theta\), \(f = 1/\tan(\theta/2)\), aspect ratio a, and near and far distances n and f (written \(z_n\) and \(z_f\) below to avoid confusion), the classic OpenGL perspective matrix is

\[P_{\text{persp}} = \begin{bmatrix} \frac{f}{a} & 0 & 0 & 0 \\ 0 & f & 0 & 0 \\ 0 & 0 & \frac{z_f + z_n}{z_n - z_f} & \frac{2 z_f z_n}{z_n - z_f} \\ 0 & 0 & -1 & 0 \end{bmatrix}\]

The first two rows scale x and y by the field of view and aspect ratio; the last row sets \(w = -z\), the distance in front of the camera. The third row is chosen so that, after division by w, depth maps the near plane to −1 and the far plane to +1. The result after the matrix is clip space, a four-dimensional homogeneous space in which the frustum test becomes simple: a point is inside the frustum exactly when \(-w \le x, y, z \le w\). Clipping is performed in clip space, before division, because there the planes are linear and points behind the eye (with negative w) can be handled correctly; dividing first would flip them through the eye and produce wild artifacts. Lesson 6 implements polygon clipping.

Dividing by w gives normalized device coordinates (NDC), a cube from −1 to 1 in x and y. Graphics APIs differ in the depth range: OpenGL uses −1 to 1, while Direct3D, Vulkan, Metal and WebGPU use 0 to 1, which changes the third row of the matrix; and Vulkan's NDC y axis points down. Libraries provide projection functions for each convention, and porting renderers between APIs often begins with these differences. Finally, the viewport transform maps NDC to window coordinates in pixels: \(x_w = (x_{ndc} + 1)\,W/2\), \(y_w\) similarly (flipped if pixel rows count downward), and depth to the range stored in the depth buffer.

Projecting camera-space points through a perspective matrix, the divide and the viewport to pixels
# Perspective and orthographic projection (OpenGL conventions: camera looks
# down -z, NDC cube from -1 to 1), perspective divide and viewport mapping.
import numpy as np
def perspective(fovy_deg, aspect, near, far):
    f = 1 / np.tan(np.radians(fovy_deg) / 2)
    return np.array([[f / aspect, 0, 0, 0],
                     [0, f, 0, 0],
                     [0, 0, (far + near) / (near - far), 2 * far * near / (near - far)],
                     [0, 0, -1, 0]])
def ortho(l, r, b, t, n, f):
    return np.array([[2 / (r - l), 0, 0, -(r + l) / (r - l)],
                     [0, 2 / (t - b), 0, -(t + b) / (t - b)],
                     [0, 0, -2 / (f - n), -(f + n) / (f - n)],
                     [0, 0, 0, 1]])
W, H = 1280, 720
P = perspective(60, W / H, 0.1, 100.0)
def to_pixels(M, p):
    clip = M @ np.append(p, 1.0)
    ndc = clip[:3] / clip[3]                         # perspective divide
    x = (ndc[0] + 1) / 2 * W                        # viewport transform
    y = (1 - ndc[1]) / 2 * H                        # pixel rows grow downward
    return clip[3], ndc, (x, y)
print(" view-space point        w     NDC (x, y, z)            pixel")
for p in [(0, 0, -5), (1, 1, -5), (1, 1, -10), (1, 1, -50), (0, 0, -0.1), (0, 0, -100)]:
    w, ndc, (x, y) = to_pixels(P, p)
    print(f"{str(p):18} {w:7.2f}   ({ndc[0]:6.3f}, {ndc[1]:6.3f}, {ndc[2]:6.3f})   ({x:7.1f}, {y:6.1f})")
O = ortho(-8, 8, -4.5, 4.5, 0.1, 100)
print("orthographic: (1, 1, -5) and (1, 1, -50) ->",
      tuple(round(float(v), 1) for v in to_pixels(O, (1, 1, -5))[2]), tuple(round(float(v), 1) for v in to_pixels(O, (1, 1, -50))[2]))
Output
 view-space point        w     NDC (x, y, z)            pixel
(0, 0, -5)            5.00   ( 0.000,  0.000,  0.962)   (  640.0,  360.0)
(1, 1, -5)            5.00   ( 0.195,  0.346,  0.962)   (  764.7,  235.3)
(1, 1, -10)          10.00   ( 0.097,  0.173,  0.982)   (  702.4,  297.6)
(1, 1, -50)          50.00   ( 0.019,  0.035,  0.998)   (  652.5,  347.5)
(0, 0, -0.1)          0.10   ( 0.000,  0.000, -1.000)   (  640.0,  360.0)
(0, 0, -100)        100.00   ( 0.000,  0.000,  1.000)   (  640.0,  360.0)
orthographic: (1, 1, -5) and (1, 1, -50) -> (720.0, 280.0) (720.0, 280.0)

The table shows perspective at work. The point (1, 1) at depth 5 lands about 125 pixels right of and above the image center; at depth 10 it is half as far from the center, and at depth 50 one tenth. The w column is the distance in front of the camera. The near-plane point has NDC depth −1 and the far-plane point +1, but look at the intermediate depths: a point at 5 units, only 5 percent of the way to the far plane, already has NDC depth 0.96. Most of the depth range is spent near the camera. The orthographic projection, by contrast, puts the two points at depths 5 and 50 at the same pixel.

Depth precision and z-fighting

After the perspective divide, stored depth is proportional to \(1/z\) plus a constant, not to z itself. This is not an accident: \(1/z\) varies linearly across a projected triangle in screen space, which makes depth interpolation exact and cheap in the rasterizer. But it means depth precision is concentrated near the near plane. With a near plane at 0.1 and a far plane at 1,000, half of the depth range is used within 0.2 units of the camera. Far away, many different depths round to the same stored value, and two nearly coplanar surfaces, such as a decal on a wall or overlapping road markings, flicker as pixels randomly show one or the other. This artifact is called z-fighting.

A graph with distance from the camera from 0 to 100 on the horizontal axis and stored depth from 0 to 1 on the vertical axis. The curve rises almost vertically near zero distance and reaches 0.99 within about nine units, then flattens close to 1 for the rest of the range.
Standard depth mapping: stored depth rises steeply near the camera, so distant objects share very few depth values

The most effective fix for z-fighting is to push the near plane out as far as the scene allows, because precision depends on the ratio of far to near and especially on the near distance. Moving the near plane from 0.1 to 1 improves distant precision about tenfold, while moving the far plane barely matters. Other remedies include a polygon offset or depth bias for decals and shadow maps, logarithmic depth buffers written by the shader, and, best of all on modern hardware, reversed-Z.

Floating-point numbers have their own uneven distribution: they are densest near zero. In a standard 0-to-1 depth buffer stored as 32-bit floats, distant objects map near 1, where floats are sparse, so the two nonuniformities compound. Reversing the mapping, so that the near plane maps to 1 and the far plane to 0, makes them cancel: distant depths land near zero, where floats are dense. Reversed-Z with a 32-bit floating-point depth buffer gives nearly uniform relative precision across the range and even allows an infinitely distant far plane. It requires a depth range of 0 to 1 (native in Direct3D, Vulkan, Metal and WebGPU, and available in OpenGL 4.5 through clip control), a projection matrix with the near and far roles swapped, and a reversed depth comparison.

Comparing depth resolution for 24-bit fixed, 32-bit float and reversed 32-bit float depth buffers
# Depth precision: the smallest depth difference a depth buffer can resolve at
# distance d, for a 24-bit fixed-point buffer, a 32-bit float buffer, and a
# 32-bit float buffer with reversed Z (near maps to 1, far maps to 0).
import numpy as np
near, far = 0.1, 1000.0
def z_std(d):  return far / (far - near) * (1 - near / d)          # 0 at near, 1 at far
def z_rev(d):  return near / (far - near) * (far / d - 1)          # 1 at near, 0 at far
def resolve(zfun, d, step):
    h = 1e-4 * d
    slope = abs(zfun(d + h) - zfun(d - h)) / (2 * h)                # dz / dd
    return step(zfun(d)) / slope
fixed24 = lambda z: 2.0 ** -24
float32 = lambda z: float(np.spacing(np.float32(z)))
print("distance   24-bit fixed   float32 standard   float32 reversed")
for d in (1, 10, 100, 500, 900):
    a = resolve(z_std, d, fixed24); b = resolve(z_std, d, float32); c = resolve(z_rev, d, float32)
    print(f"{d:7} m   {a:10.2e} m   {b:12.2e} m     {c:12.2e} m")
print(f"fraction of the [0, 1] standard depth range used by the first 1 m: {z_std(1.0):.4f}")
Output
distance   24-bit fixed   float32 standard   float32 reversed
      1 m     5.96e-07 m       5.96e-07 m         7.45e-08 m
     10 m     5.96e-05 m       5.96e-05 m         9.31e-07 m
    100 m     5.96e-03 m       5.96e-03 m         5.82e-06 m
    500 m     1.49e-01 m       1.49e-01 m         1.82e-05 m
    900 m     4.83e-01 m       4.83e-01 m         7.37e-06 m
fraction of the [0, 1] standard depth range used by the first 1 m: 0.9001

The numbers make the case. With the standard mapping, resolution degrades with the square of distance: a fraction of a micrometer at 1 meter, but about 15 centimeters at 500 meters and about half a meter at 900 meters, enough for a decal or road marking to flicker against the surface beneath it. The standard float buffer is no better than 24-bit fixed point, because its extra precision sits near zero, where no distant depths land. With reversed-Z the same 32-bit float buffer resolves a few hundredths of a millimeter at 500 meters, an improvement of about four orders of magnitude at long range. The last line shows the root cause: in the standard mapping the first meter in front of the camera uses about 90 percent of the depth range.

Culling and level of detail

Projection also enables culling, skipping work for geometry the camera cannot see. View-frustum culling tests each object's bounding volume against the six frustum planes and skips objects entirely outside; with a scene graph or bounding volume hierarchy, whole subtrees are rejected at once. Back-face culling skips triangles facing away from the camera, which for closed objects are always hidden: after projection, a triangle's winding order in screen space reveals its facing, so the rasterizer can discard back faces using the sign of the same signed area used in lesson 2. Occlusion culling skips objects hidden behind others, using depth from the previous frame, hardware occlusion queries or software rasterization of large occluders. Level of detail selects simpler versions of distant objects, based on their projected size, so that triangles stay larger than a few pixels.

Culling techniques enabled by projection
TechniqueSkipsTypical test
Frustum cullingObjects outside the view volumeBounding sphere or box against six planes
Back-face cullingTriangles facing away from the cameraSign of screen-space signed area (winding)
Occlusion cullingObjects hidden behind othersDepth buffer from the previous frame, hierarchical depth, occlusion queries
Small-feature and detail cullingObjects smaller than a few pixelsProjected bounding-sphere size
Level of detailUnneeded triangles on distant objectsProjected size or screen-space error

Worked examples

Worked problem

Projecting a point

With a 90-degree vertical field of view and an aspect ratio of 1, where does the camera-space point (2, 1, −4) land in NDC x and y?

Show solution
  1. \(f = 1/\tan 45° = 1\), so the first two rows give clip x = 2 and clip y = 1.
  2. The last row gives \(w = -z = 4\).
  3. Divide: \(x_{ndc} = 2/4 = 0.5\), \(y_{ndc} = 1/4 = 0.25\).

Answer: NDC (0.5, 0.25): halfway to the right edge and a quarter of the way to the top.

Worked problem

Viewport transform

Map NDC (0.5, 0.25) to pixels in a 1920 by 1080 window whose rows count downward from the top.

Show solution
  1. \(x_w = (0.5 + 1)/2 \times 1920 = 1440\).
  2. \(y_w = (1 - 0.25)/2 \times 1080 = 405\).

Answer: Pixel (1440, 405).

Worked problem

Depth at mid-range

With near 1 and far 100 and an OpenGL-style matrix, what NDC depth does a point 10 units away have?

Show solution
  1. Clip z: \(\frac{101}{-99}(-10) + \frac{200}{-99} = 10.202 - 2.020 = 8.182\); w = 10.
  2. NDC z: \(8.182/10 = 0.818\).

Answer: About 0.82: a point one tenth of the way into the depth range uses about 91 percent of the −1 to 1 interval.

Common mistakes

  • MistakeSet the near plane as small as possible "to be safe".

    InsteadPush the near plane out as far as the scene allows; depth precision depends mostly on it.

  • MistakeClip triangles after dividing by w.

    InsteadClip in homogeneous clip space so points behind the eye are handled correctly.

  • MistakeAssume all graphics APIs share the same NDC conventions.

    InsteadCheck the depth range (−1 to 1 or 0 to 1) and y direction for each API and use matching projection functions.

  • MistakeStore depth linearly in z for easy interpolation.

    InsteadHardware interpolates 1/z linearly in screen space; use reversed-Z or logarithmic depth for better precision instead.

  • MistakeUse a wide field of view to show more without considering distortion.

    InsteadWide angles stretch shapes near the edges; let users adjust the field of view.

Summary and key terms

  • Perspective divides by depth, so distant objects shrink and parallel lines converge.
  • The view frustum, bounded by near, far and side planes, is the visible volume.
  • Orthographic projection is affine; perspective projection puts depth into w and relies on the divide.
  • Clip space tests visibility as \(-w \le x, y, z \le w\), and clipping happens there before division.
  • NDC conventions differ by API: depth −1 to 1 in OpenGL, 0 to 1 in Direct3D, Vulkan, Metal and WebGPU.
  • Stored depth is proportional to 1/z, concentrating precision near the camera and causing z-fighting far away.
  • Reversed-Z with floating-point depth balances precision across the range; pushing out the near plane helps most.
Projection
Mapping three-dimensional points to a two-dimensional image.
View frustum
The truncated pyramid of space visible to a perspective camera.
Field of view
The angle subtended by the visible scene, usually given vertically.
Clip space
Homogeneous coordinates after the projection matrix and before division by w.
Perspective divide
Division of x, y and z by w that produces normalized device coordinates.
Normalized device coordinates
A cube from −1 to 1 in x and y that is independent of the output resolution.
Viewport transform
The mapping from normalized device coordinates to window pixel coordinates.
Z-fighting
Flickering between nearly coplanar surfaces because their depths round to the same value.
Reversed-Z
Mapping the near plane to depth 1 and the far plane to 0 to pair with floating-point precision.
Back-face culling
Discarding triangles that face away from the camera.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. What is f for a 60-degree vertical field of view?

    Show answer

    \(1/\tan 30° = 1.732\).

  2. Why can the near plane not be at zero distance?

    Show answer

    Projection divides by depth, and depth precision collapses as near approaches zero.

  3. What does the last row of a perspective matrix do?

    Show answer

    It copies the negated camera-space z into w.

  4. What is the clip-space condition for a point to be inside the frustum?

    Show answer

    \(-w \le x, y, z \le w\).

  5. Which APIs use a 0 to 1 NDC depth range?

    Show answer

    Direct3D, Vulkan, Metal and WebGPU.

  6. Why is depth stored as a function of 1/z?

    Show answer

    Because 1/z interpolates linearly in screen space, so the rasterizer can interpolate it exactly.

  7. Name two remedies for z-fighting.

    Show answer

    Push the near plane out; use reversed-Z; apply a depth bias for decals.

  8. Why does reversed-Z help floating-point depth buffers?

    Show answer

    Distant depths map near 0, where floating-point values are densest.

  9. What reveals whether a projected triangle is back-facing?

    Show answer

    Its winding order in screen space, the sign of its signed area.

  10. Under orthographic projection, does an object's size change with distance?

    Show answer

    No.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz