Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Computer Graphics (rasterisation, ray tracing, WebGPU)  Module 5 – 3D transformations, rotations, cameras and projection

Clipping in clip space and the viewport transform

Clip triangles against the view volume in homogeneous coordinates before the divide, see why the near plane matters, and map clip space to pixels.

  • Advanced
  • 20 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Implement clipping of triangles against the view volume in homogeneous coordinates, before the divide
  • Explain why clipping comes before the divide by w, and what the near plane guarantees
  • Map clip-space vertices to framebuffer coordinates through NDC and the viewport
  • Decide when to turn depth clipping off, and what the depth test then does

Before you start

What you’ll learn · Before you start

What you will learn

  • Implement clipping of triangles against the view volume in homogeneous coordinates, before the divide
  • Explain why clipping comes before the divide by w, and what the near plane guarantees
  • Map clip-space vertices to framebuffer coordinates through NDC and the viewport
  • Decide when to turn depth clipping off, and what the depth test then does

Before you start

On this page

An edge runs from a point 2 in front of the camera to one 1 behind it. Divide its endpoints by w and join them, and the line is drawn straight across the middle of the screen; the real edge leaves by the right-hand side, on its way to x = 5.774 in normalized device coordinates. That is why WebGPU and OpenGL clip in clip space, before the divide, and clipping one triangle there can leave anything from 0 corners to 9.

You will write the six planes of WebGPU’s clip volume as distances, see exactly what dividing first does to a point behind the eye, clip triangles plane by plane with their shader outputs, map what is left to pixels and decide which way it faces, and choose when depth clipping should be off. Every number comes from a program that runs on this page.

The clip volume is six half-spaces, tested without dividing

The vertex shader’s output position is a point in clip space: The four coordinates (x, y, z, w) a vertex shader outputs as its position, before the divide by w. Primitives are clipped here, against limits that grow with w., four numbers (x, y, z, w). WebGPU’s specification clips every primitive to the clip volume: The region of clip space that primitives are clipped to. In WebGPU it is −w ≤ x ≤ w, −w ≤ y ≤ w and 0 ≤ z ≤ w: six half-spaces tested without a division. −w ≤ x ≤ w, −w ≤ y ≤ w and 0 ≤ z ≤ w. OpenGL 4.6 uses the same x and y limits and, by default, −w ≤ z ≤ w. Written as six signed distances that are 0 or more inside, the planes are

w+x,w−x,w+y,w−y,z,w−z≥0w + x,\quad w - x,\quad w + y,\quad w - y,\quad z,\quad w - z \;\ge\; 0

In words: left, right, bottom, top, near and far. None of them divides, so the test works for every w, even for points behind the camera, whose w is negative. The figure slices the volume twice, through y and w and through z and w, with two corners of a triangle the clipping program uses:

Two slices of WebGPU's clip volume, y against w and z against w: wedges that hold corner A, at w = 2, but not corner C, at w = −1.The clip volume in two slicesw grows to the right; the shaded wedge is insidex and y: −w ≤ y ≤ wwyy = wy = −wAC, behinddepth: 0 ≤ z ≤ wwzz = wz = 0AC, behindC has w = −1: outside both, with no division needed.

The clip volume sliced through y and w, and through z and w

Text description of the diagram

Two panels, each with w across, growing to the right, and a shaded wedge for the inside of the clip volume.

The upper panel plots y up. Its wedge lies between the lines y = w and y = −w, which meet at the origin and open to the right. Corner A of the triangle that crosses the near plane, at w = 2 and y = −0.052, is a filled dot inside the wedge. Corner C, behind the camera at w = −1 and y = 0.035, is an open dot left of the origin, outside it.

The lower panel plots z up. Its wedge lies between the lines z = 0 and z = w. A, at w = 2 and z = 1.902, is inside, just below the line z = w. C, at w = −1 and z = −1.101, is outside, below and to the left of the origin.

A note under the panels reads: C has w = −1, so it is outside both, with no division needed.

Corner A, w = 2, sits inside both wedges. Corner C, behind the camera with w = −1, is outside both: no point with a negative w can satisfy −w ≤ y ≤ w. Because each distance is a linear function of the four coordinates, it also changes linearly along an edge, so an edge from S to P crosses a plane at

t=d(S)d(S)−d(P)t = \frac{d(S)}{d(S) - d(P)}

where every coordinate, w included, is interpolated with the same t. That one fact makes clipping in clip space as easy as clipping in 2D. Blinn and Newell worked out this approach in their SIGGRAPH ’78 paper on clipping with homogeneous coordinates, and the WebGPU and OpenGL specifications both define clipping on these four coordinates.

Checkpoint

Check: The six planes of the clip volume

  1. Question 1 of 2 Which signed distance, 0 or more inside, is WebGPU's near plane?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: z

    The six distances are w + x, w − x, w + y, w − y, z and w − z; a point is inside when all six are 0 or more.

  2. Question 2 of 2 Corner C of the figure has w = −1. Why can no point with a negative w lie inside the clip volume?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: For w = −1, −w ≤ x ≤ w would need an x of at least 1 and at most −1

    That is how the clip test removes points behind the camera without a division: their w is negative, so the x and y limits cannot hold.

Dividing by w first breaks for points behind the eye

The divide by w is perspective: a point twice as far away is drawn half as large. For a point behind the camera it is nonsense. Its w is negative, so dividing flips both of its screen coordinates through the centre, and along an edge that passes beside the eye, w falls through 0, where x / w runs off to infinity. The program follows one such edge, from A, 2 in front of the camera, to B, 1 behind it, with the camera of the cameras lesson:

x / w along an edge that passes the eye JavaScript · divide/through_infinity.mjs
// Why the divide by w must wait until after clipping. An edge runs from A, 2 in front of the camera, to B, 1
// behind it, with the cameras lesson's projection (fovy 60°, aspect 1.5, near 0.1, far 100). Along the edge, x and
// w change linearly, but x / w does not: it runs off to +infinity where w = 0 and comes back from −infinity.

const f = 1 / Math.tan(Math.PI / 6);
const [aspect, near, far] = [1.5, 0.1, 100];
const toClip = ([x, y, z]) => [(f / aspect) * x, f * y, (-far / (far - near)) * z - (far * near) / (far - near), -z];
const lerp = (a, b, t) => a.map((v, i) => v + t * (b[i] - v));
const num = (v) => {
  const r = Number(v.toFixed(3));
  return Object.is(r, -0) ? '0' : String(r);
};

const A = toClip([0.5, 0, -2]);
const B = toClip([0.5, 0, 1]); // behind the camera: w = −1
console.log(`A in clip space: x = ${num(A[0])}, w = ${num(A[3])}; B: x = ${num(B[0])}, w = ${num(B[3])}`);
console.log('');
console.log('    t        w     x / w on screen');
for (const t of [0, 0.3, 0.6, 0.65, 0.66, 0.67, 0.7, 0.8, 1]) {
  const p = lerp(A, B, t);
  console.log(`${t.toFixed(2).padStart(5)}  ${num(p[3]).padStart(7)}  ${num(p[0] / p[3]).padStart(12)}`);
}

// Dividing the endpoints first and joining them draws the wrong segment.
console.log('');
console.log(`divided first: A at x = ${num(A[0] / A[3])}, B at x = ${num(B[0] / B[3])}, joined straight across the screen's centre`);

// Clipping first: the near plane is z = 0 in clip space, crossed where z, linear along the edge, reaches 0.
const t = A[2] / (A[2] - B[2]);
const C = lerp(A, B, t);
console.log(`clipped first: the edge leaves the near plane at t = ${num(t)}, where w = ${num(C[3])}`);
console.log(`  the part it keeps runs from x = ${num(A[0] / A[3])} to x = ${num(C[0] / C[3])}, past the right edge of the screen at x = 1`);

Output

A in clip space: x = 0.577, w = 2; B: x = 0.577, w = -1

    t        w     x / w on screen
 0.00        2         0.289
 0.30      1.1         0.525
 0.60      0.2         2.887
 0.65     0.05        11.547
 0.66     0.02        28.868
 0.67    -0.01       -57.735
 0.70     -0.1        -5.774
 0.80     -0.4        -1.443
 1.00       -1        -0.577

divided first: A at x = 0.289, B at x = -0.577, joined straight across the screen's centre
clipped first: the edge leaves the near plane at t = 0.633, where w = 0.1
  the part it keeps runs from x = 0.289 to x = 5.774, past the right edge of the screen at x = 1

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node through_infinity.mjs

Along the edge x stays 0.577 and w falls linearly from 2 to −1, but x / w climbs from 0.289 to 28.868 at t = 0.66, jumps to −57.735 at t = 0.67 and comes back to −0.577 at B. Divided first, A lands at 0.289 and B at −0.577, and the straight segment between them crosses the centre of the screen, where no part of the edge is. Clipped first, the edge leaves the near plane at t = 0.633, where w = 0.1, and the part the near plane keeps runs from 0.289 to 5.774, out past the right edge of the screen, where the right plane trims it at 1. The chart draws the same numbers:

x / w along an edge from 2 in front of the camera to 1 behind it: the curve climbs to 5.774 at the near plane, then jumps through infinity.x / w along an edge that passes the eyeFrom A, 2 in front, to B, 1 behind the camera−6−3036on screen: −1 to 100.51t along the edgew = 0near plane: 5.774A: 0.289B: −0.577the part the near plane keepsdivided first: the wrong segment, A to B

x / w along the lesson's edge, clipped first and divided first

Text description of the diagram

A chart with t along the edge across, from 0 at A to 1 at B, and x / w up, from −6 to 6. A shaded band from −1 to 1 marks what is on the screen.

A solid blue curve, the part of the edge the near plane keeps, starts at A at 0.289 and climbs ever faster to 5.774 at t = 0.633, where it meets the near plane, marked with a dot. A dotted vertical line at t = 2/3 marks w = 0. Dotted black curves show the rest of x / w: it runs off the top of the chart just before w = 0, comes back from the bottom just after it, and rises to −0.577 at B.

A dashed black line joins the two divided endpoints, A at 0.289 and B at −0.577, straight across the band: the segment a renderer draws when it divides first, where no part of the edge is.

Clipping after the divide

Dividing first and clipping the 2D result loses the only information that says a point is behind the camera, the sign of w. Clip before the divide, in clip space, where a negative w fails the tests: no x lies between −w and w, and with a perspective matrix the near plane fails too.

The near plane does more for a perspective camera. With WebGPU’s matrix, z = f (w − n) / (f − n), so z ≥ 0 holds exactly when w ≥ n: every point the near plane keeps has a w of at least 0.1, the near distance, so the divide that follows never meets a w near 0, and depth after the divide starts at 0. In the same way, z ≤ w holds exactly when w ≤ f.

Key idea

Clip in clip space and divide afterwards: the clip test needs no division, works for every w, and keeps a point behind the camera from being drawn on the wrong side of the screen.

Checkpoint

Check: Clipping before the divide

  1. Question 1 of 2 The lesson's edge runs from A (x 0.577, w 2) to B (x 0.577, w −1). A renderer divides both ends by w and draws the segment between them. What does it draw?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A segment from 0.289 to −0.577, across the centre of the screen, where no part of the edge is

    x / w climbed to 28.868 at t = 0.66 and jumped to −57.735 at t = 0.67: the edge leaves by the right and comes back from the left, which two divided endpoints cannot show.

  2. Question 2 of 2 With WebGPU's projection matrix, near 0.1 and far 100, what is the smallest w a point can have and still pass the near-plane test z ≥ 0?

    Type a number.

    A hint

    The matrix gives z = f (w − n) / (f − n).

    Show the answer: Question 2 of 2

    Answer: 0.1 (anything from 0.0999 to 0.1001 counts)

    z ≥ 0 holds exactly when w ≥ n = 0.1, so every point the near plane keeps has a w of at least the near distance, and the divide after clipping is safe. The new corners on the near plane had w = 0.1.

Clip against the six planes one at a time

homogeneous clipping: Clipping polygons against the planes of the clip volume in four-coordinate clip space, before the divide by w, one plane at a time, with every vertex output interpolated like the position. is Sutherland and Hodgman’s polygon clipper with four coordinates: the polygon goes through one plane after another, and at each plane every edge from S to P falls into one of four cases: both inside, output P; leaving, output the crossing; entering, output the crossing and then P; both outside, output nothing. WebGPU’s specification describes the result in its own words: the edges that cross the boundary of the clip volume “are reconnected by new edges that lie along the boundary”, which may add vertices. Step one triangle, two corners in front and one behind the eye, through the six planes:

Interactive · stage by stage One triangle through the six planes Step down the flow. Each stage keeps the part of the polygon on the inside of one plane; watch the corner count rise and fall.
One triangle through the six planes, step 6 of 6. Showing Left: w + x ≥ 0 (4 corners, 2 before near), Right: w − x ≥ 0 (5 corners, 3 before near), Bottom: w + y ≥ 0 (5 corners), Top: w − y ≥ 0 (6 corners, 4 before near), Near: z ≥ 0 (4 corners, w ≥ 0.1), Far: z ≤ w (4 corners, 2 triangles). In focus: Far: z ≤ w.Left: w + x ≥ 04 corners, 2 before nearRight: w − x ≥ 05 corners, 3 before nearBottom: w + y ≥ 05 cornersTop: w − y ≥ 06 corners, 4 before nearNear: z ≥ 04 corners, w ≥ 0.1Far: z ≤ w4 corners, 2 triangles

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The triangle. Two corners are 2 in front of the camera, w = 2, and the third is 1 behind it, w = −1. With x = 0 and w = −1, the corner behind the eye is outside the left and right planes as well as the near plane: w + x and w − x are both negative.
  2. Left. The corner behind the eye is outside the left plane, so it is replaced by two new corners where its edges cross w + x = 0. The polygon has 4 corners, 2 of them still in front of the near plane, one with a negative w.
  3. Right. The corner with negative w is outside the right plane too and is cut again. 5 corners, 3 of them in front of the near plane, one of them exactly at w = 0, the plane of the eye. Clip space has no trouble with it, because nothing has been divided.
  4. Bottom and top. The bottom plane cuts nothing and leaves 5 corners. The top plane cuts the tip close to the eye and leaves 6 corners, 4 of them in front of the near plane.
  5. Near. The near plane cuts off everything closer than 0.1, the four corners at once, and adds two on the plane itself, with w = 0.1. 4 corners are left, and none of them has a small or negative w.
  6. Far, and the order. The far plane cuts nothing: 4 corners, drawn as 2 triangles. Clipping the near plane first gives the same 4 corners, through 4 at every stage, because no corner with a tiny or negative w is ever made.

The program clips six triangles and passes a shade through with every vertex, interpolated with the same t as the position, as WebGPU interpolates outputs that use perspective interpolation:

Six triangles clipped against WebGPU's clip volume JavaScript · frustum/frustum_clip.mjs
// Clipping triangles against WebGPU's clip volume, one plane at a time (Sutherland-Hodgman in four coordinates),
// before any divide by w. Each vertex is [x, y, z, w, shade]: the shade is a value the vertex shader passes on,
// interpolated with the same t as the position. Camera: the cameras lesson's, fovy 60°, aspect 1.5, near 0.1, far 100.

const f = 1 / Math.tan(Math.PI / 6);
const [aspect, near, far] = [1.5, 0.1, 100];
const toClip = ([x, y, z], shade) => [(f / aspect) * x, f * y, (-far / (far - near)) * z - (far * near) / (far - near), -z, shade];

// The six planes as signed distances: a vertex is inside when every one of them is 0 or more.
const PLANES = [
  ['left', (p) => p[3] + p[0]],
  ['right', (p) => p[3] - p[0]],
  ['bottom', (p) => p[3] + p[1]],
  ['top', (p) => p[3] - p[1]],
  ['near', (p) => p[2]],
  ['far', (p) => p[3] - p[2]],
];
function clipAgainst(poly, d) {
  const out = [];
  for (let i = 0; i < poly.length; i++) {
    const S = poly[(i + poly.length - 1) % poly.length];
    const P = poly[i];
    const [ds, dp] = [d(S), d(P)];
    const cut = () => {
      const t = ds / (ds - dp); // every coordinate, w and the shade change linearly along the edge
      return S.map((s, k) => s + t * (P[k] - s));
    };
    // Cut only between a strictly outside end and a strictly inside one, so a corner on the plane comes out once.
    if (dp >= 0) {
      if (ds < 0 && dp > 0) out.push(cut());
      out.push(P);
    } else if (ds > 0) out.push(cut());
  }
  return out;
}
function clipPolygon(poly, planes = PLANES) {
  for (const [, d] of planes) {
    poly = clipAgainst(poly, d);
    if (!poly.length) break;
  }
  return poly;
}

const num = (v) => {
  const r = Number(v.toFixed(3));
  return Object.is(r, -0) ? '0' : String(r);
};
const show = (v) => `(${v.map(num).join(', ')})`;
const triangles = [
  ['inside the view', [[-0.5, -0.3, -3], [0.5, -0.3, -3], [0, 0.5, -4]]],
  ['crossing the near plane', [[-0.05, -0.03, -2], [0.05, -0.03, -2], [0, 0.02, 1]]],
  ['behind the eye', [[-1, 0, 1], [1, 0, 1], [0, 1, 2]]],
  ['crossing the left plane', [[-4, 0, -3], [1, 0, -3], [0, 1, -3]]],
  ['across the top left corner', [[-4, 0.5, -3], [0, 3, -3], [0, 0, -3]]],
  ['crossing the far plane', [[0, 0, -50], [5, 0, -60], [-5, 1, -150]]],
];
console.log('triangle                      corners before   after   drawn as');
for (const [name, tri] of triangles) {
  const out = clipPolygon(tri.map((p, i) => toClip(p, i)));
  console.log(`${name.padEnd(30)}${'3'.padStart(15)}${String(out.length).padStart(8)}   ${out.length ? `${out.length - 2} triangle${out.length === 3 ? '' : 's'}` : 'nothing'}`);
}

// The triangle across the near plane, with its shades 0, 1 and 2 at the corners.
console.log('');
console.log('crossing the near plane, after clipping (x, y, z, w, shade):');
const nearOne = clipPolygon(triangles[1][1].map((p, i) => toClip(p, i)));
for (const v of nearOne) console.log(`  ${show(v)}${Math.abs(v[2]) < 1e-12 ? '  new, on the near plane, w = 0.1' : ''}`);

// The corner count after each plane, in two orders. Near first, no later polygon holds a corner in front of the
// near plane (z < 0), where w is tiny or negative.
console.log('');
const counts = (planes) => {
  let poly = triangles[1][1].map((p, i) => toClip(p, i));
  return planes.map(([name, d]) => {
    poly = clipAgainst(poly, d);
    const low = poly.filter((v) => v[2] < -1e-9).length;
    return `${name} ${poly.length}${low ? ` (${low} before the near plane)` : ''}`;
  });
};
console.log(`left first: ${counts(PLANES).join(', ')}`);
console.log(`near first: ${counts([PLANES[4], ...PLANES.slice(0, 4), PLANES[5]]).join(', ')}`);

// Each plane can add at most one corner, so a triangle ends with at most 3 + 6 = 9. An orthographic camera
// (w = 1 everywhere) reaches it with a triangle whose plane cuts the clip box into a hexagon.
const e1 = [1, -1, 0].map((v) => v / Math.SQRT2);
const e2 = [1, 1, -2].map((v) => v / Math.sqrt(6));
const inPlane = ([a, b]) => [a * e1[0] + b * e2[0], a * e1[1] + b * e2[1], (a * e1[2] + b * e2[2] + 1) / 2, 1, 0];
const big = [0, 1, 2].map((i) => inPlane([1.7 * Math.cos((2 * Math.PI * i) / 3), 1.7 * Math.sin((2 * Math.PI * i) / 3)]));
const nine = clipPolygon(big);
console.log('');
console.log(`a triangle through the middle of an orthographic clip box becomes ${nine.length} corners, a fan of ${nine.length - 2} triangles`);

Output

triangle                      corners before   after   drawn as
inside the view                             3       3   1 triangle
crossing the near plane                     3       4   2 triangles
behind the eye                              3       0   nothing
crossing the left plane                     3       4   2 triangles
across the top left corner                  3       5   3 triangles
crossing the far plane                      3       4   2 triangles

crossing the near plane, after clipping (x, y, z, w, shade):
  (-0.021, 0.003, 0, 0.1, 1.267)  new, on the near plane, w = 0.1
  (-0.058, -0.052, 1.902, 2, 0)
  (0.058, -0.052, 1.902, 2, 1)
  (0.021, 0.003, 0, 0.1, 1.633)  new, on the near plane, w = 0.1

left first: left 4 (2 before the near plane), right 5 (3 before the near plane), bottom 5 (3 before the near plane), top 6 (4 before the near plane), near 4, far 4
near first: near 4, left 4, right 4, bottom 4, top 4, far 4

a triangle through the middle of an orthographic clip box becomes 9 corners, a fan of 7 triangles

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node frustum_clip.mjs

The triangle inside the view keeps its 3 corners and the one behind the eye loses them all. Crossing the near plane, the left plane or the far plane, a triangle becomes 4 corners, drawn as 2 triangles; across the top left corner, 5. The triangle across the near plane gets two new corners exactly on it, with w = 0.1, the near distance, and shades of 1.267 and 1.633 between its corners’ 0, 1 and 2. Clipped in the order left to far, its corner count runs 4, 5, 5, 6, 4, 4, with up to 4 corners in front of the near plane on the way; clipped near plane first, it stays at 4 throughout. Each plane can add at most one corner, so a triangle ends with at most 3 + 6 = 9: the last line finds such a triangle in an orthographic camera’s clip space, where w = 1 everywhere.

Clip the near plane first

The final polygon is the same in any order, but clipping the near plane first leaves no corner with a tiny or negative w for the other planes to handle, which keeps every later intersection well away from w = 0.

Checkpoint

Check: Clipping plane by plane

  1. Question 1 of 2 A triangle is clipped against the six planes of the clip volume. What is the largest number of corners it can end with?

    Type a number.

    A hint

    Each plane cuts off at most one run of corners and adds at most one corner.

    Show the answer: Question 1 of 2

    Answer: 9

    3 corners plus at most one per plane makes 3 + 6 = 9. The program found a triangle that reaches 9 in an orthographic clip box, drawn as a fan of 7 triangles.

  2. Question 2 of 2 An edge runs from S, with d(S) = 0.3 and shade 0, to P, with d(P) = −0.1 and shade 2. What shade does the new corner on the plane get?

    Type a number.

    A hint

    t = d(S) / (d(S) − d(P)), and the shade moves by the same t as the position.

    Show the answer: Question 2 of 2

    Answer: 1.5 (anything from 1.499 to 1.501 counts)

    t = 0.3 / 0.4 = 0.75, so the shade is 0 + 0.75 × (2 − 0) = 1.5. The near-plane corners of the lesson's triangle got 1.267 and 1.633 in the same way.

The divide and the viewport turn clip space into pixels

After clipping, every vertex has a positive w, and the rest of the pipeline is two maps. The divide by w gives normalized device coordinates (NDC): The coordinates a GPU reaches after dividing positions by w. In WebGPU, x and y run from −1 to 1 with y up and depth from 0 to 1; the viewport maps them to pixels. More in “Window-to-viewport mapping and coordinate systems”, and the viewport transform: The map from normalized device coordinates to framebuffer pixels and depth, set by the viewport's position, size, minDepth and maxDepth. In WebGPU framebuffer y runs downwards. that WebGPU’s specification writes out takes them to framebuffer pixels, x to the right and y downwards from the top left corner, and depth into the viewport’s range:

xfb=vx+12(nx+1)vw,yfb=vy+12(1−ny)vh,depth=vmin⁡+nz(vmax⁡−vmin⁡)x_{fb} = v_x + \tfrac{1}{2}(n_x + 1)\,v_w, \qquad y_{fb} = v_y + \tfrac{1}{2}(1 - n_y)\,v_h, \qquad \text{depth} = v_{\min} + n_z\,(v_{\max} - v_{\min})

Then the rasteriser decides which way the polygon faces from its signed area in framebuffer coordinates, by a formula in the specification that is positive for corners that run counter-clockwise on the screen, and with frontFace "ccw" a positive area is front-facing. The program takes the triangle inside the view to a 64 × 64 target:

NDC to pixels, facing and coverage on a 64 × 64 target JavaScript · viewport/viewport.mjs
// After clipping: divide by w, then map NDC to framebuffer pixels with WebGPU's viewport transform, decide which way
// the triangle faces from its signed area in framebuffer coordinates, and count the pixel centres it covers on a
// 64 x 64 target. The triangle is the "inside the view" one of the clipping program, with the same camera.

const f = 1 / Math.tan(Math.PI / 6);
const [aspect, near, far] = [1.5, 0.1, 100];
const toClip = ([x, y, z]) => [(f / aspect) * x, f * y, (-far / (far - near)) * z - (far * near) / (far - near), -z];
const toNdc = ([x, y, z, w]) => [x / w, y / w, z / w];
// WebGPU's viewport: framebuffer x grows to the right and y downwards; depth is mapped into minDepth..maxDepth.
const toFramebuffer = ([nx, ny, nz], vp) => [vp.x + 0.5 * (nx + 1) * vp.width, vp.y + 0.5 * (1 - ny) * vp.height, vp.minDepth + nz * (vp.maxDepth - vp.minDepth)];
// WebGPU's area formula for a polygon in framebuffer coordinates; with frontFace "ccw", area > 0 faces the viewer.
function area(v) {
  let sum = v[0][0] * v[v.length - 1][1] - v[v.length - 1][0] * v[0][1];
  for (let i = 0; i + 1 < v.length; i++) sum += v[i + 1][0] * v[i][1] - v[i][0] * v[i + 1][1];
  return 0.5 * sum;
}
// A pixel is covered when its centre (x + 0.5, y + 0.5) is inside all three edges.
function covered(v, size) {
  const edge = (a, b, p) => (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
  let n = 0;
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      const p = [x + 0.5, y + 0.5];
      const e = [edge(v[0], v[1], p), edge(v[1], v[2], p), edge(v[2], v[0], p)];
      if (e.every((s) => s >= 0) || e.every((s) => s <= 0)) n++;
    }
  }
  return n;
}
const num = (v) => {
  const r = Number(v.toFixed(3));
  return Object.is(r, -0) ? '0' : String(r);
};
const show = (v) => `(${v.map(num).join(', ')})`;

const triangle = [[-0.5, -0.3, -3], [0.5, -0.3, -3], [0, 0.5, -4]];
const full = { x: 0, y: 0, width: 64, height: 64, minDepth: 0, maxDepth: 1 };
const ndc = triangle.map((p) => toNdc(toClip(p)));
const fb = ndc.map((n) => toFramebuffer(n, full));
console.log('setViewport(0, 0, 64, 64, 0, 1):');
ndc.forEach((n, i) => console.log(`  corner ${i + 1}: NDC ${show(n)} -> framebuffer ${show(fb[i])}`));
const a = area(fb);
console.log(`  signed area ${num(a)} square pixels: ${a > 0 ? 'front-facing' : 'back-facing'} with frontFace "ccw"`);
console.log(`  the same corners in reverse order: ${area([...fb].reverse()) > 0 ? 'front-facing' : 'back-facing'}`);
console.log(`  pixel centres covered: ${covered(fb, 64)} of ${64 * 64}`);

const corner = { x: 32, y: 0, width: 32, height: 32, minDepth: 0.5, maxDepth: 1 };
console.log('');
console.log('setViewport(32, 0, 32, 32, 0.5, 1), the top right quarter with depth from 0.5 to 1:');
ndc.forEach((n, i) => console.log(`  corner ${i + 1}: framebuffer ${show(toFramebuffer(n, corner))}`));

Output

setViewport(0, 0, 64, 64, 0, 1):
  corner 1: NDC (-0.192, -0.173, 0.968) -> framebuffer (25.842, 37.543, 0.968)
  corner 2: NDC (0.192, -0.173, 0.968) -> framebuffer (38.158, 37.543, 0.968)
  corner 3: NDC (0, 0.217, 0.976) -> framebuffer (32, 25.072, 0.976)
  signed area 76.8 square pixels: front-facing with frontFace "ccw"
  the same corners in reverse order: back-facing
  pixel centres covered: 84 of 4096

setViewport(32, 0, 32, 32, 0.5, 1), the top right quarter with depth from 0.5 to 1:
  corner 1: framebuffer (44.921, 18.771, 0.984)
  corner 2: framebuffer (51.079, 18.771, 0.984)
  corner 3: framebuffer (48, 12.536, 0.988)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node viewport.mjs

The three corners land at (25.842, 37.543), (38.158, 37.543) and (32, 25.072), the last one higher on the screen and so with the smallest y. The signed area is 76.8 square pixels, so the triangle is front-facing, and with its corners in reverse order it is back-facing. Sampled at their centres, 84 of the 4,096 pixels lie inside it, the dots of the figure. With setViewport(32, 0, 32, 32, 0.5, 1) the same corners go to the top right quarter, and a depth of 0.968 becomes 0.984. The specification also bounds the viewport: minDepth and maxDepth must lie between 0 and 1, with minDepth no larger than maxDepth.

A 16 by 16 patch of pixels with the lesson's triangle drawn over it; the 84 pixel centres inside the triangle are filled dots.The triangle on the 64 × 64 targetPixels 24 to 39 shown; filled dots: covered centres84 pixel centres covered, signed area 76.8 px²Each pixel is sampled at its centre (x + 0.5, y + 0.5).

The pixel centres the lesson's triangle covers on a 64 × 64 target

Text description of the diagram

A grid of pixels 24 to 39 across and down, part of a 64 × 64 framebuffer, with y running downwards. Every pixel has a dot at its centre, at (x + 0.5, y + 0.5).

The triangle from the viewport program is drawn in black: its base runs from (25.842, 37.543) to (38.158, 37.543) and its apex is at (32, 25.072), near the top. The 84 centres inside it are large blue dots; all the other centres are small grey dots.

Notes under the grid read: 84 pixel centres covered, signed area 76.8 px², and each pixel is sampled at its centre (x + 0.5, y + 0.5).

The 3D viewing pipeline in the syllabus

UGC NET’s Computer Science and Applications syllabus lists the “Viewing Pipeline and Coordinates” and clipping among its 3D topics in Unit 3. This lesson’s steps are that pipeline on a GPU: model and view matrices, projection, clipping in clip space, the divide, and the viewport.

Checkpoint

Check: The divide, the viewport and facing

  1. Question 1 of 2 A viewport covers a 64 × 64 target from (0, 0). A vertex has NDC y = 0.5. What is its framebuffer y?

    Type a number.

    A hint

    Framebuffer y runs downwards, so the viewport uses 1 − y.

    Show the answer: Question 1 of 2

    Answer: 16 pixels (anything from 15.999 to 16.001 counts)

    0 + ½ × (1 − 0.5) × 64 = 16: a vertex in the upper half of NDC lands in the upper half of the target, at a small y.

  2. Question 2 of 2 The lesson's triangle is front-facing with frontFace set to ccw. What happens when its corners come in reverse order?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It becomes back-facing, and a cullMode of back removes it

    The program printed front-facing for the corners in order and back-facing for the reverse order.

Depth clipping can be switched off

Some objects should never be cut by the near or far plane. A sky drawn as a large box would lose its corners beyond the far plane, and a shadow map’s casters just in front of its near plane would lose their shadows. WebGPU’s "depth-clip-control" feature adds primitive.unclippedDepth: when it is true, depth clipping is not applied and the clip volume has no bound in z. Depth is not unbounded for long, though: the specification clamps the depth input of the depth test to the viewport’s minDepth and maxDepth. OpenGL’s DEPTH_CLAMP does the same job. The program follows three points through both settings:

Depth clipping on and off JavaScript · depthclip/unclipped.mjs
// Depth clipping, on and off. With WebGPU's default, a vertex is clipped when z < 0 or z > w. With the
// "depth-clip-control" feature and primitive.unclippedDepth = true, the clip volume has no z bounds, and the depth
// test then clamps each fragment's depth into the viewport's minDepth..maxDepth. Camera: near 0.1, far 100.

const f = 1 / Math.tan(Math.PI / 6);
const [aspect, near, far] = [1.5, 0.1, 100];
const toClip = ([x, y, z]) => [(f / aspect) * x, f * y, (-far / (far - near)) * z - (far * near) / (far - near), -z];
const vp = { minDepth: 0, maxDepth: 1 };
const num = (v) => {
  const r = Number(v.toFixed(4));
  return Object.is(r, -0) ? '0' : String(r);
};

function fate([x, y, z, w], unclippedDepth) {
  const sides = Math.abs(x) <= w && Math.abs(y) <= w;
  const depthOk = unclippedDepth || (z >= 0 && z <= w);
  if (!sides || !depthOk) return 'clipped';
  const depth = vp.minDepth + (z / w) * (vp.maxDepth - vp.minDepth);
  const stored = Math.min(vp.maxDepth, Math.max(vp.minDepth, depth));
  return `kept, depth ${num(depth)}${stored !== depth ? `, clamped to ${num(stored)}` : ''}`;
}

const points = [
  ['a wall 50 away', [0, 0, -50]],
  ['a sky vertex 150 away, beyond far', [0, 0, -150]],
  ['a shadow caster 0.05 away, before near', [0, 0, -0.05]],
];
for (const unclipped of [false, true]) {
  console.log(unclipped ? 'unclippedDepth = true:' : 'default, depth clipping on:');
  for (const [name, p] of points) {
    const c = toClip(p);
    console.log(`  ${name.padEnd(40)} z/w = ${num(c[2] / c[3]).padStart(8)}: ${fate(c, unclipped)}`);
  }
}

Output

default, depth clipping on:
  a wall 50 away                           z/w =    0.999: kept, depth 0.999
  a sky vertex 150 away, beyond far        z/w =   1.0003: clipped
  a shadow caster 0.05 away, before near   z/w =   -1.001: clipped
unclippedDepth = true:
  a wall 50 away                           z/w =    0.999: kept, depth 0.999
  a sky vertex 150 away, beyond far        z/w =   1.0003: kept, depth 1.0003, clamped to 1
  a shadow caster 0.05 away, before near   z/w =   -1.001: kept, depth -1.001, clamped to 0

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node unclipped.mjs

With depth clipping on, a wall 50 away is kept at depth 0.999, a sky vertex 150 away, beyond the far plane at 100, is clipped at depth 1.0003, and a shadow caster 0.05 away, in front of the near plane, is clipped at −1.001. With unclippedDepth the wall is unchanged, the sky vertex is kept and its depth clamped to 1, the far plane’s depth, and the caster is kept with its depth clamped to 0. The sky then stays behind everything else, as long as its depth test lets a depth equal to the cleared 1 pass, and the caster still writes the depth that casts its shadow. The x and y planes still clip as before.

Checkpoint

Check: Turning depth clipping off

  1. Question 1 of 2 With unclippedDepth true, minDepth 0 and maxDepth 1, a sky vertex 150 away has z / w = 1.0003. What happens to it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It is kept, and the depth test uses 1, its depth clamped to maxDepth

    The program printed "kept, depth 1.0003, clamped to 1". The sky then sits at the far plane's depth, behind everything else, once its depth test lets a depth equal to the cleared 1 pass.

  2. Question 2 of 2 A shadow map's camera has its near plane at 0.1, and a caster sits 0.05 in front of it. Why turn depth clipping off for that pass?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Clipped, the caster casts no shadow; unclipped, it is kept with its depth clamped to 0, nearer than anything it shades

    With unclippedDepth the caster was kept with depth −1.001 clamped to 0, so it still writes the depth that casts its shadow.

Practise: clip, then map to pixels

Two exercises build the end of the pipeline, each with sample tests you can run here. The first clips polygons against WebGPU’s six planes with their shader outputs, and is held to the lesson’s six triangles and 500 seeded ones; the second maps clip space to pixels and decides which way a polygon faces:

Code lab · 2 steps

Build the end of the pipeline

Clip polygons against WebGPU's clip volume before the divide, then divide, map them to framebuffer pixels and decide which way they face.

  1. Clip polygons against WebGPU's clip volume To do
  2. From clip space to pixels, and which way a triangle faces To do

Exercise · Hard · JavaScript

Clip polygons against WebGPU's clip volume

Write clipPolygon(polygon) in clip.mjs. A polygon is a list of vertices in clip space, each an array [x, y, z, w, ...rest], where the rest are values the vertex shader passes on, such as a shade or texture coordinates. Return the part of the polygon inside WebGPU's clip volume, −w ≤ x ≤ w, −w ≤ y ≤ w and 0 ≤ z ≤ w, as a list of vertices in the same format, or [] if nothing is inside. Never divide by w.

  • Clip against the six planes one after another, Sutherland-Hodgman style, each written as a distance that is 0 or more inside: w + x, w − x, w + y, w − y, z and w − z.
  • A new vertex on an edge from S to P sits at t = d(S) / (d(S) − d(P)), and every component, the rest included, is interpolated with that t.
  • A vertex exactly on a plane is inside, and must not come out twice.

The starter clips depth the OpenGL way, −w ≤ z ≤ w, gives new vertices the rest of P instead of interpolating it, and repeats a corner that lies exactly on a plane after an outside one. The sample tests use the lesson's six triangles, the triangle across the near plane with its shades, a triangle with a corner on a plane, and 500 seeded triangles whose clipped corners must all lie inside the volume.

Starter code · clip.mjs

/**
 * clipPolygon(polygon) clips a polygon of clip-space vertices [x, y, z, w, ...rest] to WebGPU's clip volume,
 * −w ≤ x, y ≤ w and 0 ≤ z ≤ w, one plane at a time, and returns the vertices left (or []). This first version
 * clips depth to −w ≤ z ≤ w, as OpenGL does, gives new vertices the rest of P instead of interpolating it, and repeats
 * a corner that lies exactly on a plane.
 */
const PLANES = [(p) => p[3] + p[0], (p) => p[3] - p[0], (p) => p[3] + p[1], (p) => p[3] - p[1], (p) => p[3] + p[2], (p) => p[3] - p[2]];

export function clipPolygon(polygon) {
  let poly = polygon;
  for (const d of PLANES) {
    const out = [];
    for (let i = 0; i < poly.length; i++) {
      const S = poly[(i + poly.length - 1) % poly.length];
      const P = poly[i];
      const ds = d(S);
      const dp = d(P);
      const cut = () => {
        const t = ds / (ds - dp);
        return [...[0, 1, 2, 3].map((k) => S[k] + t * (P[k] - S[k])), ...P.slice(4)];
      };
      if (dp >= 0) {
        if (ds < 0) out.push(cut());
        out.push(P);
      } else if (ds > 0) out.push(cut());
    }
    poly = out;
    if (!poly.length) return [];
  }
  return poly;
}
The sample tests · clip.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { clipPolygon } from './clip.mjs';

// The lesson's camera: fovy 60°, aspect 1.5, near 0.1, far 100, WebGPU's depth from 0 to 1.
const f = 1 / Math.tan(Math.PI / 6);
const toClip = ([x, y, z], shade = 0) => [(f / 1.5) * x, f * y, (-100 / 99.9) * z - (100 * 0.1) / 99.9, -z, shade];
const inside = (p, eps = 1e-9) => p[3] + p[0] >= -eps && p[3] - p[0] >= -eps && p[3] + p[1] >= -eps && p[3] - p[1] >= -eps && p[2] >= -eps && p[3] - p[2] >= -eps;
function near(got, want, label, eps = 1e-6) {
  const ok = got.length === want.length && got.every((v, i) => Math.abs(v - want[i]) < eps);
  assert.ok(ok, `${label}: got (${got.map((v) => Number(v.toFixed(4)) + 0).join(', ')}), expected (${want.map((v) => Number(v.toFixed(4)) + 0).join(', ')})`);
}

test("the lesson's six triangles keep the right number of corners", () => {
  const cases = [
    ['inside the view', [[-0.5, -0.3, -3], [0.5, -0.3, -3], [0, 0.5, -4]], 3],
    ['crossing the near plane', [[-0.05, -0.03, -2], [0.05, -0.03, -2], [0, 0.02, 1]], 4],
    ['behind the eye', [[-1, 0, 1], [1, 0, 1], [0, 1, 2]], 0],
    ['crossing the left plane', [[-4, 0, -3], [1, 0, -3], [0, 1, -3]], 4],
    ['across the top left corner', [[-4, 0.5, -3], [0, 3, -3], [0, 0, -3]], 5],
    ['crossing the far plane', [[0, 0, -50], [5, 0, -60], [-5, 1, -150]], 4],
  ];
  for (const [name, tri, corners] of cases) {
    const out = clipPolygon(tri.map((p) => toClip(p)));
    assert.equal(out.length, corners, `${name}: ${out.length} corners instead of ${corners}`);
    for (const v of out) assert.ok(inside(v), `${name}: a corner lies outside the clip volume`);
  }
});

test('the triangle across the near plane: new corners on z = 0, shades interpolated', () => {
  const tri = [[-0.05, -0.03, -2], [0.05, -0.03, -2], [0, 0.02, 1]].map((p, i) => toClip(p, i));
  const out = clipPolygon(tri);
  const onNear = out.filter((v) => Math.abs(v[2]) < 1e-9);
  assert.equal(onNear.length, 2, 'two corners on the near plane');
  for (const v of onNear) near([v[3]], [0.1], 'w on the near plane is the near distance');
  const shades = onNear.map((v) => v[4]).sort((a, b) => a - b);
  near(shades, [1.266667, 1.633333], 'the shades of the new corners, between 0, 1 and 2');
});

test('a triangle inside comes back unchanged; a corner on a plane comes back once', () => {
  const tri = [[-0.5, -0.3, -3], [0.5, -0.3, -3], [0, 0.5, -4]].map((p, i) => toClip(p, i));
  assert.deepEqual(clipPolygon(tri), tri, 'nothing to clip');
  const w = 2;
  // A is beyond the left plane, B exactly on it (w + x = 0), C inside.
  const onPlane = [[-3, 0, 1, w, 7], [-2, 0.5, 1, w, 8], [0.5, -0.5, 1, w, 9]];
  const out = clipPolygon(onPlane);
  assert.equal(out.length, 3, `B is its own crossing and must not be repeated: got ${out.length} corners`);
  assert.equal(out.filter((v) => v[0] === -2 && v[1] === 0.5).length, 1, 'B comes out exactly once');
  const crossing = [[-3, 0, 1, w, 0], [0.5, 0.5, 1, w, 1], [0.5, -0.5, 1, w, 2]];
  const cut = clipPolygon(crossing);
  assert.equal(cut.length, 4, 'a corner beyond the left plane is replaced by two');
  for (const v of cut) assert.ok(inside(v), 'every corner inside');
});

test('500 seeded triangles: every corner inside, nothing divided by w', () => {
  let seed = 16; // mulberry32
  const random = () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
  let kept = 0;
  for (let n = 0; n < 500; n++) {
    const tri = [0, 1, 2].map(() => toClip([random() * 12 - 6, random() * 8 - 4, random() * 20 - 15], random()));
    const out = clipPolygon(tri);
    assert.ok(out.length === 0 || (out.length >= 3 && out.length <= 9), `triangle ${n + 1}: ${out.length} corners`);
    for (const v of out) {
      assert.ok(inside(v), `triangle ${n + 1}: a corner lies outside the clip volume`);
      assert.equal(v.length, 5, `triangle ${n + 1}: a corner lost its shade`);
      assert.ok(v[3] > 0, `triangle ${n + 1}: a corner with w ≤ 0 survived`);
    }
    if (out.length) kept++;
  }
  assert.ok(kept > 100 && kept < 500, `${kept} of 500 triangles kept something: the seeded scene should keep some and drop some`);
});
A hint

Keep the planes as six small functions of a vertex, (p) => p[3] + p[0] and so on, with (p) => p[2] for the near plane: WebGPU's depth starts at 0, not at −w. In the cut, interpolate the whole vertex at once: S.map((s, k) => s + t * (P[k] - s)). Cut an edge only when one end is strictly inside and the other strictly outside, so a corner on the plane is output once.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · JavaScript

From clip space to pixels, and which way a triangle faces

Write, in viewport.mjs, the two steps after clipping, as WebGPU defines them:

  • toFramebuffer(clip, viewport): divide the clip-space position [x, y, z, w] by w to get NDC, then map it with the viewport { x, y, width, height, minDepth, maxDepth } to [xFb, yFb, depth]: x grows to the right, y grows downwards from the top of the target, and depth runs from minDepth to maxDepth.
  • isFrontFacing(points, frontFace): the polygon's signed area in framebuffer coordinates, by WebGPU's formula 0.5 × ((x1 yn − xn y1) + Σ (x(i+1) yi − xi y(i+1))), decides it: with frontFace 'ccw' a positive area faces the viewer, with 'cw' a negative one does.

The starter forgets to turn y downwards, and it uses the usual shoelace sum, which has the opposite sign of WebGPU's. The sample tests use the lesson's triangle on a 64 × 64 target, the top right quarter of the target with depth from 0.5 to 1, and both winding orders under both settings.

Starter code · viewport.mjs

/**
 * toFramebuffer(clip, viewport) divides a clip-space position by w and maps it to [xFb, yFb, depth] with WebGPU's
 * viewport transform. isFrontFacing(points, frontFace) applies WebGPU's signed-area rule in framebuffer coordinates.
 * This first version keeps y pointing up, and its area has the opposite sign of WebGPU's formula.
 */
export function toFramebuffer([x, y, z, w], vp) {
  const [nx, ny, nz] = [x / w, y / w, z / w];
  return [vp.x + 0.5 * (nx + 1) * vp.width, vp.y + 0.5 * (ny + 1) * vp.height, vp.minDepth + nz * (vp.maxDepth - vp.minDepth)];
}

export function isFrontFacing(points, frontFace) {
  let sum = 0;
  for (let i = 0; i < points.length; i++) {
    const [x0, y0] = points[i];
    const [x1, y1] = points[(i + 1) % points.length];
    sum += x0 * y1 - x1 * y0;
  }
  const area = 0.5 * sum;
  return frontFace === 'ccw' ? area > 0 : area < 0;
}
The sample tests · viewport.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { isFrontFacing, toFramebuffer } from './viewport.mjs';

// The lesson's camera: fovy 60°, aspect 1.5, near 0.1, far 100, WebGPU's depth from 0 to 1.
const f = 1 / Math.tan(Math.PI / 6);
const toClip = ([x, y, z]) => [(f / 1.5) * x, f * y, (-100 / 99.9) * z - (100 * 0.1) / 99.9, -z];
const triangle = [[-0.5, -0.3, -3], [0.5, -0.3, -3], [0, 0.5, -4]].map(toClip);
const full = { x: 0, y: 0, width: 64, height: 64, minDepth: 0, maxDepth: 1 };
function near(got, want, label, eps = 1e-3) {
  const ok = got.length === want.length && got.every((v, i) => Math.abs(v - want[i]) < eps);
  assert.ok(ok, `${label}: got (${got.map((v) => Number(v.toFixed(3)) + 0).join(', ')}), expected (${want.join(', ')})`);
}

test("the lesson's triangle on a 64 × 64 target", () => {
  const fb = triangle.map((c) => toFramebuffer(c, full));
  near(fb[0], [25.842, 37.543, 0.968], 'corner 1');
  near(fb[1], [38.158, 37.543, 0.968], 'corner 2');
  near(fb[2], [32, 25.072, 0.976], 'corner 3, the top one: its y is the smallest');
});

test('NDC corners land on the corners of the target', () => {
  near(toFramebuffer([-2, 2, 0, 2], full), [0, 0, 0], 'NDC (−1, 1) is the top left corner');
  near(toFramebuffer([3, -3, 3, 3], full), [64, 64, 1], 'NDC (1, −1) is the bottom right corner, at depth 1');
});

test('a viewport in the top right quarter with depth from 0.5 to 1', () => {
  const corner = { x: 32, y: 0, width: 32, height: 32, minDepth: 0.5, maxDepth: 1 };
  const fb = triangle.map((c) => toFramebuffer(c, corner));
  near(fb[0], [44.921, 18.771, 0.984], 'corner 1');
  near(fb[2], [48, 12.536, 0.988], 'corner 3');
  near(toFramebuffer([0, 0, 0, 1], corner), [48, 16, 0.5], 'the centre of NDC at depth 0');
});

test('winding: counter-clockwise on screen faces the viewer under "ccw"', () => {
  const fb = triangle.map((c) => toFramebuffer(c, full));
  assert.equal(isFrontFacing(fb, 'ccw'), true, "the lesson's triangle is front-facing with frontFace 'ccw'");
  assert.equal(isFrontFacing([...fb].reverse(), 'ccw'), false, 'its reverse is back-facing');
  assert.equal(isFrontFacing(fb, 'cw'), false, "with frontFace 'cw' it is back-facing");
  assert.equal(isFrontFacing([...fb].reverse(), 'cw'), true, "and its reverse front-facing");
  const square = [[10, 10], [10, 20], [20, 20], [20, 10]]; // left side down, bottom side right: counter-clockwise on screen
  assert.equal(isFrontFacing(square, 'ccw'), true, 'a quad counter-clockwise on screen');
});
A hint

The viewport transform is xFb = x + 0.5 (nx + 1) width and yFb = y + 0.5 (1 − ny) height: NDC y = 1 is the top row. Depth is minDepth + nz (maxDepth − minDepth). For the area, check with the lesson's triangle: corners (25.842, 37.543), (38.158, 37.543) and (32, 25.072) must give +76.8 square pixels.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

The self-check covers the five ideas, from the six planes to unclipped depth. It starts with the idea your answers put lowest, moves up a level after each right answer, and brings back anything you miss:

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 Which inequalities describe WebGPU's clip volume for a clip-space point (x, y, z, w)?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: −w ≤ x ≤ w, −w ≤ y ≤ w and 0 ≤ z ≤ w

    As distances that are 0 or more inside, the planes are w + x, w − x, w + y, w − y, z and w − z.

  2. Question 2 of 15 Is the clip-space point (1.5, −0.2, 0.9, 1.2) inside WebGPU's clip volume?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: No, since w − x = −0.3 puts it beyond the right plane

    Its distances are w + x = 2.7, w − x = −0.3, w + y = 1, w − y = 1.4, z = 0.9 and w − z = 0.3: only the right plane fails.

  3. Question 3 of 15 A clipper written for OpenGL's default tests the near plane as w + z ≥ 0, and it runs on clip space from the lesson's WebGPU matrix, near 0.1 and far 100. What goes wrong?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: It keeps corners from about 0.05 to 0.1 in front of the eye, whose z is below 0, so their depth comes out below 0

    With z = f (w − n) / (f − n), w + z ≥ 0 gives w ≥ fn / (2f − n) = 10 / 199.9 = 0.05. A clipper must use the near plane of the API whose matrix made the clip space; the first exercise's starter makes this mistake.

  4. Question 4 of 15 A point behind the camera has x = 0.577 and w = −1. Where does dividing by w put it?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: At x = −0.577, on the other side of the screen's centre

    That is B in the lesson: a point behind the eye lands on the screen, mirrored through the centre, which is why it is clipped before the divide.

  5. Question 5 of 15 Along the lesson's edge, w = 2 − 3t. At which t does x / w run off to infinity?

    Type a number.

    A hint

    x / w has no finite value where w = 0.

    Show the answer: Question 5 of 15

    Answer: 0.6667 (anything from 0.6657 to 0.6677 counts)

    2 − 3t = 0 at t = 2/3 = 0.667. The table jumps from 28.868 at t = 0.66 to −57.735 at t = 0.67, either side of it, and the near plane cuts the edge before that, at t = 0.633.

  6. Question 6 of 15 A software renderer divides every vertex by w and then clips each 2D triangle to the screen square. Which triangles does it get wrong?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: Those with a corner behind the eye, since the divide flips that corner and its edges are drawn in the wrong place

    Clipping in clip space first, at least against the near plane, removes every corner with w below the near distance before anything is divided.

  7. Question 7 of 15 Clipping against one plane, an edge runs from S, inside, to P, outside. What does the clipper output for this edge?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: Only the point where the edge crosses the plane

    S was already output by the edge before, the one that ends at S, so each edge adds only its crossing or its end.

  8. Question 8 of 15 An edge runs from S with d(S) = 0.2 to P with d(P) = −0.6. At what t along the edge does it cross the plane?

    Type a number.

    A hint

    t = d(S) / (d(S) − d(P)).

    Show the answer: Question 8 of 15

    Answer: 0.25 (anything from 0.249 to 0.251 counts)

    0.2 / (0.2 + 0.6) = 0.25: the distance changes linearly along the edge, from 0.2 to −0.6, and passes 0 a quarter of the way along.

  9. Question 9 of 15 A clipped triangle comes out with 9 corners. How many triangles does a fan from its first corner draw?

    Type a number.

    Show the answer: Question 9 of 15

    Answer: 7

    A convex polygon with n corners is a fan of n − 2 triangles, so 9 − 2 = 7, as the program printed.

  10. Question 10 of 15 The lesson's triangle across the near plane was clipped once with the left plane first and once with the near plane first. What differed?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: Only the counts on the way, up to 6 corners left first and 4 throughout near first; both ended with the same 4 corners

    Near plane first also keeps every later crossing away from corners with a tiny or negative w.

  11. Question 11 of 15 A viewport covers a 64 × 64 target from (0, 0). What framebuffer x does NDC x = 0 get?

    Type a number.

    Show the answer: Question 11 of 15

    Answer: 32 pixels (anything from 31.999 to 32.001 counts)

    0 + ½ × (0 + 1) × 64 = 32, the middle of the target; the lesson's top corner, with NDC x = 0, landed at x = 32.

  12. Question 12 of 15 With setViewport(32, 0, 32, 32, 0.5, 1), what depth does an NDC depth of 0.968 get?

    Type a number.

    A hint

    depth = minDepth + n_z × (maxDepth − minDepth).

    Show the answer: Question 12 of 15

    Answer: 0.984 (anything from 0.9835 to 0.9845 counts)

    0.5 + 0.968 × 0.5 = 0.984, as the program printed for the quarter viewport.

  13. Question 13 of 15 A model is drawn as its mirror image by scaling it by −1 along x. Its triangles used to face the camera with frontFace set to ccw. What happens, and what fixes it?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: The triangles that face the camera now run clockwise on screen and count as back-facing; use frontFace cw for it, or reverse its index order

    With a cullMode of back, the mirrored model would show its inside faces, since the triangles turned away from the camera now count as front-facing.

  14. Question 14 of 15 Which WebGPU setting turns depth clipping off?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: unclippedDepth set to true in the pipeline's primitive state, with the depth-clip-control feature enabled

    With it, the clip volume has no bound in z, and the depth test clamps depth into minDepth to maxDepth.

  15. Question 15 of 15 With unclippedDepth true, a vertex lies far to the right of the view, with x greater than w. Is it clipped?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: Yes, since unclippedDepth removes only the z bounds and the left, right, bottom and top planes still clip

    In the lesson the x and y planes still clipped as before; only the sky and the caster changed.

Interview questions

Warm-up (fresher to 2-5 years): What are the six planes of WebGPU’s clip volume, and how does the test differ from OpenGL’s?

A clip-space position (x, y, z, w) is inside WebGPU’s clip volume when −w ≤ x ≤ w, −w ≤ y ≤ w and 0 ≤ z ≤ w. As distances that are 0 or more inside, the six planes are w + x and w − x on the left and right, w + y and w − y at the bottom and top, z for the near plane and w − z for the far plane. OpenGL 4.6 has the same x and y limits but, by default, −w ≤ z ≤ w, so its near plane is w + z ≥ 0; glClipControl can switch it to WebGPU’s range. The test needs no division, so it works for any w: a point behind the camera has a negative w and fails the x and y tests. After clipping, dividing by w gives normalized device coordinates, −1 to 1 in x and y and 0 to 1 in depth for WebGPU.

Two questions of this module take clipping into production, a sky that loses its corners and the memory a software clipper needs. Follow a row’s link for how to open the answer and the follow-ups that come after it:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A WebGPU game draws its sky as a cube of half-size 80 around the camera, with the near plane at 0.1 and the far plane at 100. When the camera turns, parts of the sky near the cube's edges and corners vanish and the clear colour shows through. Explain why, and give two fixes with their trade-offs.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    You are writing the clipping stage of a software rasteriser that must not allocate memory per triangle. How large must the buffers for one clipped triangle be, and how do you turn the result into triangles? Prove the bound and say how you would test it.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • WebGPU’s clip volume is −w ≤ x ≤ w, −w ≤ y ≤ w and 0 ≤ z ≤ w, six linear distances that are tested without dividing, so they work for every w, behind the camera included.
  • Dividing before clipping flips points behind the eye and sends edges past the eye through infinity: the edge drawn from 0.289 to −0.577 should have run from 0.289 off the right of the screen, towards 5.774.
  • Clipping goes plane by plane with every vertex output interpolated by the same t; a triangle can end with 0 to 9 corners, and clipping the near plane first keeps every later corner’s w away from 0.
  • After clipping, the divide and the viewport map vertices to pixels with y downwards, and the signed area in framebuffer coordinates decides whether the polygon faces the viewer.
  • With unclippedDepth nothing is clipped in z, and the depth test clamps each fragment’s depth into minDepth to maxDepth, which keeps skies and shadow casters whole.

Terms in this lesson

clip space
The four coordinates (x, y, z, w) a vertex shader outputs as its position, before the divide by w. Primitives are clipped here, against limits that grow with w.
clip volume
The region of clip space that primitives are clipped to. In WebGPU it is −w ≤ x ≤ w, −w ≤ y ≤ w and 0 ≤ z ≤ w: six half-spaces tested without a division.
homogeneous clipping
Clipping polygons against the planes of the clip volume in four-coordinate clip space, before the divide by w, one plane at a time, with every vertex output interpolated like the position.
viewport transform
The map from normalized device coordinates to framebuffer pixels and depth, set by the viewport's position, size, minDepth and maxDepth. In WebGPU framebuffer y runs downwards.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.