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.
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
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:
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
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
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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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 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
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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:
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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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.
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
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. 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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.
- Clip polygons against WebGPU's clip volume To do
- 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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: withfrontFace'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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
-
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.
-
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.
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
unclippedDepthnothing 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
- WebGPU: 23.2.4 Primitive Clipping (World Wide Web Consortium (W3C))
- WebGPU: 23.2.5 Rasterization and 23.2.5.4 Polygon Rasterization (World Wide Web Consortium (W3C))
- WebGPU: 25.2 "depth-clip-control" and GPURenderPassEncoder.setViewport() (World Wide Web Consortium (W3C))
- The OpenGL Graphics System: A Specification (Version 4.6, Core Profile), 13.7 Primitive Clipping and 13.8 Coordinate Transformations (Khronos Group)
- Reentrant polygon clipping (Communications of the ACM, vol. 17, no. 1) (Association for Computing Machinery)
- Clipping using homogeneous coordinates (SIGGRAPH ’78 proceedings) (Association for Computing Machinery)
- UGC NET syllabus: Computer Science and Applications (code 87) (University Grants Commission (UGC NET Bureau))
Report a problem with this lesson
Kept only in this browser. Your Learn progress