After the projection step, every point of the scene sits in normalized device coordinates, the cube $[-1, 1]^3$. The renderer still has to place those points on the actual screen, a grid of $n_x \times n_y$ pixels. The viewport transform performs that mapping:

$$ \mathbf{v}_{screen} = \mathbf{M}_{vp} \mathbf{v}_{ndc} $$

The mapping rules are simple. A point with $x = -1$ lands on the left edge of the screen, and $x = 1$ lands on the right edge. Similarly, $y = -1$ and $y = 1$ land on the bottom and top edges. The $z$-coordinate points into the screen, so it has no position in the 2D image and is ignored by the mapping.

Pixel Centers and the Half-Pixel Offset

The endpoints of the $x$ mapping are not $0$ and $n_x$, but $-0.5$ and $n_x - 0.5$. The offset comes from how pixels are defined: the coordinate $(0, 0)$ maps to the center of the bottom-left pixel, so the left edge of the screen sits half a pixel before that center (see Rendering ). The viewport transform preserves the same convention.

Linear Interpolation

The mapping is linear, so a single linear interpolation parameterizes it:

$$ f(x) = out_{lo} + (out_{hi} - out_{lo}) \frac{x - in_{lo}}{ in_{hi} - in_{lo} } $$

For the $x$-coordinate the values are:

  • $out_{lo} = -0.5$
  • $out_{hi} = n_x - 0.5$
  • $in_{lo} = -1$
  • $in_{hi} = 1$

Substituting them into the interpolation formula gives the screen $x$-coordinate:

$$ \begin{align*} x_{screen} &= -0.5 + n_x \frac{x_{ndc} + 1}{2} \\ &= -\frac{1}{2} + \frac{n_x}{2}x_{ndc} + \frac{n_x}{2} \\ &= \frac{n_x}{2}x_{ndc} + \frac{n_x - 1}{2} \end{align*} $$

The screen $y$-coordinate follows the same derivation with $n_y$:

$$ y_{screen} = \frac{n_y}{2}y_{ndc} + \frac{n_y - 1}{2} $$

Both mappings are linear in their coordinate, so they fit into a single matrix. The $z$-coordinate passes through unchanged, since it carries depth for hidden-surface removal rather than a screen position:

$$ \mathbf{M}_{vp} = \begin{bmatrix} \frac{n_x}{2} & 0 & 0 & \frac{n_x - 1}{2} \\ 0 & \frac{n_y}{2} & 0 & \frac{n_y - 1}{2} \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} $$

Key Takeaways

Concept Formula Takeaway
Viewport transform $\mathbf{v}_{screen} = \mathbf{M}_{vp} \mathbf{v}_{ndc}$ Maps the NDC cube onto the pixel grid, the final step before rasterization.
Half-pixel offset $out_{lo} = -0.5$, $out_{hi} = n_x - 0.5$ Pixel $(0, 0)$ is the center of the bottom-left pixel, so the screen edge sits half a pixel outside it.
Screen $x$ $x_{screen} = \frac{n_x}{2}x_{ndc} + \frac{n_x - 1}{2}$ Linear interpolation of $[-1, 1]$ onto $[-0.5, n_x - 0.5]$.
Depth passthrough $z_{screen} = z_{ndc}$ The $z$-coordinate keeps its value for depth testing and is not mapped to a pixel position.