The Viewport Transform

After the perspective divide a vertex sits in a clean abstract cube: normalised device coordinates, with x_n, y_n \in [-1, 1]. Lovely — but nobody owns a monitor that runs from -1 to 1. Real screens are measured in pixels, from the corner. The viewport transform is the short, dull, essential map that turns the cube into your actual rectangle of pixels.

From the cube to the rectangle, line by line

Step 1 — name the two rectangles. The source is the NDC square [-1, 1] \times [-1, 1]. The target is the framebuffer rectangle [0, W] \times [0, H], where W and H are the width and height in pixels.

Step 2 — shift the range from [-1, 1] to [0, 2]. Add one:

x_n + 1 \;\in\; [0, 2].

Step 3 — squash [0, 2] down to [0, 1]. Halve it:

\frac{x_n + 1}{2} \;\in\; [0, 1].

Step 4 — stretch [0, 1] across the screen width. Multiply by W. That is the horizontal pixel coordinate:

x_{\text{screen}} = \frac{x_n + 1}{2}\, W.

Step 5 — do y the same way, but flip it. Here is the one trap. In NDC, y points up; on a screen, pixel rows are counted downward from the top-left corner. So the y map must reverse direction — use 1 - y_n in place of y_n + 1:

y_{\text{screen}} = \frac{1 - y_n}{2}\, H.

Check it: the top of NDC, y_n = +1, gives y_{\text{screen}} = 0 (the top row), and the bottom, y_n = -1, gives y_{\text{screen}} = H (the bottom row). The picture is the right way up.

Step 6 — carry depth along too. The third NDC coordinate z_n \in [-1, 1] is remapped to [0, 1] and stored for the depth buffer — same affine idea, no flip:

z_{\text{depth}} = \frac{z_n + 1}{2} \;\in\; [0, 1].

Step 7 — recognise the whole thing. Every line above is just a scale plus a translate per axis. No rotation, no perspective — that work is already done. The viewport transform is the gentle final handshake that delivers a vertex to a pixel.

The viewport transform maps normalised device coordinates onto the framebuffer:

It is easy to code the viewport transform as if pixels and NDC agreed on which way is "up" — copy the x formula, swap in y, and move on. Do that and every render comes out upside down: a sky that should sit at the top of the screen lands at the bottom, and a character's head ends up where their feet should be.

The bug is invisible in the maths and obvious on screen, which is exactly why it is so common. NDC y points up (increasing y is "higher"), but pixel rows are numbered downward from the top-left corner — row 0 is the top, row H-1 is the bottom. Using y_n + 1 for y, the way you did for x, silently keeps the wrong direction. The fix is the explicit flip in Step 5 above — 1 - y_n, not y_n + 1 — and it is worth a habit: whenever a mirrored or upside-down image appears on screen, the y-flip in the viewport transform is the first place to look.

A pixel is not a point; it is a little square with area. A framebuffer of width W has columns indexed 0, 1, \dots, W-1, and the centre of column i sits at the half-integer coordinate i + 0.5. Rasterisers sample at those centres, so a point that lands exactly on an integer boundary is ambiguous between two pixels — the classic off-by-one that leaves a one-pixel seam between two abutting quads.

That is why the convention is to treat (0.5, 0.5) as the centre of the top-left pixel, and why a full-screen blit that maps NDC [-1, 1] onto [0, W] hits pixel edges, not centres. Get the half-pixel offset wrong and your beautiful render is mysteriously blurry: every texel is sampled halfway between two source pixels.