Computer Graphics (rasterisation, ray tracing, WebGPU) Module 1 – Foundations: pixels, displays, colour and the pipeline
How computer graphics makes images
Raster and vector images, pixels as samples, rasterisation and ray tracing, the seven stages of the GPU pipeline, and the time and memory every frame costs.
What you will learn
- Distinguish raster and vector images and explain what a pixel sample represents
- Compare rasterisation and ray tracing as two answers to the visibility problem
- Trace the real-time pipeline from vertices to framebuffer and name WebGPU's term for each stage
- Compute frame-time budgets and framebuffer memory for a resolution, pixel format and refresh rate
What you’ll learn
What you will learn
- Distinguish raster and vector images and explain what a pixel sample represents
- Compare rasterisation and ray tracing as two answers to the visibility problem
- Trace the real-time pipeline from vertices to framebuffer and name WebGPU's term for each stage
- Compute frame-time budgets and framebuffer memory for a resolution, pixel format and refresh rate
On this page
A 1920 × 1080 screen that refreshes 60 times a second needs 2,073,600 new colours every 16.7 milliseconds, about 124 million a second, and each one has to be worked out from a description of a scene: shapes, lights, a camera. Computer graphics is that work: deciding the colour of every pixel, correctly and in time.
Five ideas carry the rest of this track: what a pixel really holds, the two ways a renderer finds the surface each pixel shows, the seven stages a GPU runs for every draw call, the time and memory one frame costs, and the display and input vocabulary that exams still ask about. The first four come with a program you can run and change.
A picture is a grid of samples, or a list of shapes
There are two ways to keep a picture of a circle. A vector image: An image stored as shapes, such as lines, curves and fills, given by their coordinates. It is turned into pixels when it is drawn, at any size. keeps the shape itself, “circle, centre (0.5, 0.5), radius 0.4”, and can be drawn again at any size; the SVG standard describes graphics this way, as shapes such as paths of straight lines and curves, plus images and text. A raster image: An image stored as a grid of pixels, each with its own colour. Its detail is fixed by its width and height in pixels. keeps a grid of colours instead, one for each position. Screens, cameras and the memory a GPU draws into are all raster, so every vector drawing becomes a grid before anyone sees it.
What does one cell of that grid hold? It is tempting to call a pixel: One sample of a raster image: the colour stored for one position of its grid. A renderer decides it from the scene at a single point, the pixel's centre, not from a little square. a little square of colour, but Alvy Ray Smith’s memo on the subject puts it plainly: a pixel is a point sample, a value taken at one point. Renderers work exactly that way. Without multisampling, WebGPU makes a triangle cover a pixel when the pixel’s centre, the point (x + 0.5, y + 0.5) in framebuffer coordinates, lies inside the triangle. Look at the dashed pixel in the figure: the circle’s edge crosses its square, yet it stays empty, because its centre is outside.
The shape is exact; the raster keeps one point per pixel
Text description of the diagram
An 8 by 8 grid of square pixels fills most of the figure, and a circle is drawn over it: its centre is the middle of the grid and its radius is 0.4 of the grid's width, which is the circle that examples/samples/sample_circle.mjs samples.
Every pixel has a dot at its centre. Where that centre point lies inside the circle the dot is filled and the pixel is shaded; this happens for 32 of the 64 pixels, which form a blocky disc two pixels narrower at the top and bottom rows than in the middle. Where the centre lies outside, the dot is hollow and the pixel stays empty.
Pixel (1, 1), second from the left in the second row, has a dashed outline. The circle's edge crosses the corner of its square, so part of the square is inside the circle, but its centre point is outside, so the pixel is left empty: the raster records only what happens at the sample point.
The legend under the grid repeats the four marks: a filled dot for a centre inside (the pixel is set), a hollow dot for a centre outside, the dashed outline of pixel (1, 1), and the solid curve of the shape itself, described exactly by its centre (0.5, 0.5) and radius 0.4.
The figure is the 8 × 8 run of the program below. It describes the circle once and samples it at the centre of every pixel on three grids. The share of samples that land inside is an estimate of the circle’s area, which is exactly π × 0.4² = 0.5027 of the square:
// One circle, described once as a shape and then sampled at the centre of every pixel of three grids.
// The shape lives in a unit square: centre (0.5, 0.5), radius 0.4. Change RADIUS or SIZES and run it again.
const CENTRE = { x: 0.5, y: 0.5 };
const RADIUS = 0.4;
const SIZES = [8, 16, 64];
console.log(`vector description: circle centre (${CENTRE.x}, ${CENTRE.y}) radius ${RADIUS}`);
console.log(`exact area: ${(Math.PI * RADIUS * RADIUS).toFixed(4)} of the square\n`);
for (const n of SIZES) {
let covered = 0;
const rows = [];
for (let y = 0; y < n; y++) {
let row = '';
for (let x = 0; x < n; x++) {
// The sample point of pixel (x, y) is its centre, (x + 0.5, y + 0.5) in pixel units.
const px = (x + 0.5) / n;
const py = (y + 0.5) / n;
const inside = (px - CENTRE.x) ** 2 + (py - CENTRE.y) ** 2 <= RADIUS ** 2;
if (inside) covered++;
row += inside ? '#' : '.';
}
rows.push(row);
}
const share = covered / (n * n);
console.log(`${n} x ${n} grid: ${covered} of ${n * n} samples inside, area estimate ${share.toFixed(4)}`);
if (n <= 16) console.log(rows.join('\n') + '\n');
} Output
vector description: circle centre (0.5, 0.5) radius 0.4 exact area: 0.5027 of the square 8 x 8 grid: 32 of 64 samples inside, area estimate 0.5000 ........ ..####.. .######. .######. .######. .######. ..####.. ........ 16 x 16 grid: 124 of 256 samples inside, area estimate 0.4844 ................ ................ .....######..... ...##########... ...##########... ..############.. ..############.. ..############.. ..############.. ..############.. ..############.. ...##########... ...##########... .....######..... ................ ................ 64 x 64 grid: 2056 of 4096 samples inside, area estimate 0.5020
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node sample_circle.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 estimate does not simply improve as the grid grows: 0.5000 on 8 × 8, then 0.4844 on 16 × 16, then 0.5020 on 64 × 64. Each grid puts its sample points in different places relative to the edge, so each one errs differently. That wobble is aliasing, the price of storing a continuous shape as samples, and the textures module returns to it with the theory of sampling.
Move the edge across the samples
Press Edit, set RADIUS to 0.38 and run it: the exact area falls by about a tenth, from 0.5027 to 0.4536, yet the
8 × 8 grid still counts 32 samples, because no pixel centre lies between the old edge and the new one. At 0.36 a
ring of 8 centres falls outside at once, and the count drops to 24.
Checkpoint
Check: Pixels as point samples
Rasterisation and ray tracing answer the same question
Every renderer has to solve the visibility problem: Deciding, for every pixel, which surface of the scene is nearest the camera along that pixel's line of sight, and so shows there.: where several surfaces line up behind one pixel, which one is nearest the camera and therefore shows? There are two classic answers, and comparing them is one of the learning outcomes the CS2023 curriculum lists for graphics courses.
rasterisation: Rendering by looping over the shapes, usually triangles, and finding the pixels each one covers, keeping the nearest surface per pixel. GPUs render this way. loops over the shapes. For each one it finds the pixels it covers and keeps, in a depth buffer: An image the size of the framebuffer that stores, for each pixel, the depth of the nearest surface drawn so far, so that a farther surface drawn later is rejected., the distance of the nearest surface drawn so far at every pixel; a later surface that is farther away is thrown out. ray tracing: Rendering by looping over the pixels and following a ray from the camera through each one to the first surface it hits; more rays from that point give shadows and reflections. loops over the pixels. For each one it follows a ray from the camera through the pixel’s centre, tests it against the shapes, and keeps the hit nearest along the ray. The program draws the same three spheres both ways, with a parallel camera that looks straight along z:
// The same three spheres drawn twice into a 32 x 12 grid of characters, seen through a parallel camera that looks
// along +z: once by a rasteriser (loop over the shapes, then their pixels) and once by a ray caster (loop over the
// pixels, then the shapes). Both keep, for every pixel, the nearest surface, so their pictures must be identical.
const W = 32;
const H = 12;
const spheres = [
{ name: 'A', x: 11, y: 6, z: 10, r: 5 },
{ name: 'B', x: 18, y: 6, z: 6, r: 4 },
{ name: 'C', x: 22, y: 5, z: 14, r: 6 },
];
// Depth of the front of sphere s at pixel centre (px, py), or Infinity if the pixel misses it.
function frontDepth(s, px, py) {
const d2 = (px - s.x) ** 2 + (py - s.y) ** 2;
return d2 <= s.r * s.r ? s.z - Math.sqrt(s.r * s.r - d2) : Infinity;
}
function rasterise() {
const color = Array(W * H).fill('.');
const depth = Array(W * H).fill(Infinity);
let visited = 0;
for (const s of spheres) {
// Only the pixels of the sphere's bounding square are visited.
for (let y = Math.max(0, Math.floor(s.y - s.r)); y < Math.min(H, Math.ceil(s.y + s.r)); y++) {
for (let x = Math.max(0, Math.floor(s.x - s.r)); x < Math.min(W, Math.ceil(s.x + s.r)); x++) {
visited++;
const z = frontDepth(s, x + 0.5, y + 0.5);
if (z < depth[y * W + x]) {
depth[y * W + x] = z; // the depth test: keep the nearer surface
color[y * W + x] = s.name;
}
}
}
}
return { color, work: `${visited} pixel visits` };
}
function rayCast() {
const color = Array(W * H).fill('.');
let tests = 0;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
let nearest = Infinity;
for (const s of spheres) {
tests++;
const z = frontDepth(s, x + 0.5, y + 0.5); // where the ray through this pixel enters s
if (z < nearest) {
nearest = z;
color[y * W + x] = s.name;
}
}
}
}
return { color, work: `${tests} ray-sphere tests` };
}
const a = rasterise();
const b = rayCast();
for (let y = 0; y < H; y++) console.log(a.color.slice(y * W, (y + 1) * W).join(''));
console.log(`\nrasteriser: ${a.work}; ray caster: ${b.work}`);
console.log(`same picture: ${a.color.every((c, i) => c === b.color[i])}`);
for (const s of spheres) console.log(`${s.name} (front at z = ${s.z - s.r}): ${a.color.filter((c) => c === s.name).length} pixels`); Output
..................CCCCCCCC...... .........AAAA....CCCCCCCCCC..... .......AAAAAAAA.BBBBCCCCCCC..... .......AAAAAAAABBBBBBCCCCCCC.... ......AAAAAAAABBBBBBBBCCCCCC.... ......AAAAAAAABBBBBBBBCCCCCC.... ......AAAAAAAABBBBBBBBCCCCCC.... ......AAAAAAAABBBBBBBBCCCCC..... .......AAAAAAAABBBBBBCCCCCC..... .......AAAAAAAA.BBBBCCCCCC...... .........AAAA.......CCCC........ ................................ rasteriser: 296 pixel visits; ray caster: 1152 ray-sphere tests same picture: true A (front at z = 5): 72 pixels B (front at z = 2): 52 pixels C (front at z = 8): 71 pixels
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node two_renderers.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 two pictures are identical, character for character, because both keep the nearest surface at every pixel centre. B’s front is at z = 2, nearer than A’s at 5 and C’s at 8, so B covers part of each. The work differs: the rasteriser visited 296 pixels, only those inside each sphere’s bounding square, while the ray caster made 1,152 ray-sphere tests, 32 × 12 pixels times 3 spheres, because a ray cannot know in advance which shapes it will meet. Ray tracers fix that with acceleration structures, which the ray-tracing module builds.
| Aspect | Rasterisation | Ray tracing |
|---|---|---|
| Outer loop | Shapes (object order) | Pixels (image order) |
| Nearest surface kept by | A depth buffer per pixel | The nearest hit per ray |
| Cost grows with | Pixels each shape covers | Pixels × shapes tested per ray |
| Comes easily | Many triangles, fast, on GPU hardware | Shadows, mirrors and glass: more rays from each hit |
One question, two loop orders
Swap the two loops of a renderer and you move between the two methods: shapes outside and pixels inside is rasterisation, pixels outside and shapes inside is ray tracing. Real systems mix them, rasterising what the camera sees and tracing rays for the effects that need them.
Checkpoint
Check: Rasterisation and ray tracing
A draw call runs seven stages, and two of them are your code
A GPU rasterises in a fixed order of stages called the render pipeline. The WebGPU standard names seven: index resolution, vertex processing, primitive assembly, primitive clipping, rasterization, fragment processing and output merging. Two of them run programs you write, called shader: A program the GPU runs for each item of a draw call: a vertex shader once per vertex, returning its position, and a fragment shader once per fragment, returning its colour.: the vertex shader in vertex processing, which runs once per vertex and returns its position in The four coordinates (x, y, z, w) a vertex shader returns for a vertex. In WebGPU a point is inside the visible volume when −w ≤ x ≤ w, −w ≤ y ≤ w and 0 ≤ z ≤ w., and the fragment shader in fragment processing, which runs once per covered pixel and returns its colour. The other five are fixed-function: settings configure them, but you cannot replace them.
Follow one triangle through all seven. Each step names what one stage receives and what it hands on, from three vertices to the pixels written:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The draw call. A draw call asks for three vertices. Index resolution reads the index buffer, [0, 1, 2], and lists the vertices to process in that order.
- The vertex shader. The vertex shader runs once for each vertex and returns a clip-space position (x, y, z, w). This one passes the positions through, so the first vertex stays at (−0.75, −0.75, 0.5, 1).
- Triangles from vertices. The topology is a triangle list, so primitive assembly takes the vertices three at a time. Three vertices make one triangle.
- Clipping. A point is inside the clip volume when −w ≤ x ≤ w, −w ≤ y ≤ w and 0 ≤ z ≤ w. All three vertices are inside, so clipping cuts nothing and adds no vertex.
- Pixels the triangle covers. Rasterization divides by w and maps to framebuffer coordinates, where y points down, giving (2, 7), (14, 6) and (6, 1). It then tests the centre of every pixel; 34 of the 128 centres are inside, so it produces 34 fragments.
- The fragment shader. The fragment shader runs once for each fragment and returns its colour. This one picks the colour of the vertex whose weight at that pixel is largest, so the picture splits into three regions.
- Depth test and write. Output merging compares each fragment's depth with the depth buffer, cleared to 1.0. Every depth here is 0.5, which is nearer, so all 34 fragments pass and their colours are written to the framebuffer.
- Programmable or fixed. Only vertex processing and fragment processing run code you write, the shaders. The other five stages are fixed-function; the pipeline's settings, such as the topology, the culling mode and the depth test, configure them. The next draw call starts again at the top.
The primitive: A shape the GPU draws as one unit, a point, a line or a triangle, assembled from vertices in primitive assembly. here is one triangle, and each pixel centre it covers becomes a fragment: What rasterisation produces for one pixel a primitive covers: its position, depth and interpolated values, before the fragment shader gives it a colour., the data the fragment shader colours. The program runs the same seven stages on the CPU, with the formulas WebGPU defines: the clip-volume test, the division by w, the mapping to framebuffer coordinates in which y points down, the test at pixel centres and the depth test:
// One triangle through the stages of WebGPU's render pipeline (spec section 23.2), on a 16 x 8 framebuffer.
// Each vertex carries a clip-space position (x, y, z, w) and a colour letter; try moving a vertex and run it again.
const W = 16;
const H = 8;
const vertexBuffer = [
{ pos: [-0.75, -0.75, 0.5, 1], color: 'r' },
{ pos: [0.75, -0.5, 0.5, 1], color: 'g' },
{ pos: [-0.25, 0.75, 0.5, 1], color: 'b' },
];
const indexBuffer = [0, 1, 2];
// 1 Index resolution and 2 vertex processing (this vertex shader passes positions through unchanged).
const processed = indexBuffer.map((i) => ({ clip: vertexBuffer[i].pos, color: vertexBuffer[i].color }));
// 3 Primitive assembly: every three vertices of a triangle list make one triangle.
const triangle = processed.slice(0, 3);
// 4 Primitive clipping: inside the clip volume means -w <= x, y <= w and 0 <= z <= w.
const inside = triangle.every(({ clip: [x, y, z, w] }) => Math.abs(x) <= w && Math.abs(y) <= w && z >= 0 && z <= w);
// 5 Rasterization: divide by w (NDC), map to framebuffer coordinates (y points down), test pixel centres.
const fb = triangle.map(({ clip: [x, y, z, w] }) => ({ x: 0.5 * (x / w + 1) * W, y: 0.5 * (-y / w + 1) * H, z: z / w }));
const edge = (a, b, p) => (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
const area = edge(fb[0], fb[1], fb[2]);
const fragments = [];
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const p = { x: x + 0.5, y: y + 0.5 };
const w0 = edge(fb[1], fb[2], p) / area;
const w1 = edge(fb[2], fb[0], p) / area;
const w2 = edge(fb[0], fb[1], p) / area;
if (w0 >= 0 && w1 >= 0 && w2 >= 0) fragments.push({ x, y, weights: [w0, w1, w2] });
}
}
// 6 Fragment processing: this fragment shader picks the colour of the vertex with the largest weight.
const shaded = fragments.map((f) => ({ ...f, color: triangle[f.weights.indexOf(Math.max(...f.weights))].color }));
// 7 Output merging: depth test against a buffer cleared to 1.0, then write the colour.
const color = Array(W * H).fill('.');
const depth = Array(W * H).fill(1.0);
let written = 0;
for (const f of shaded) {
const z = f.weights[0] * fb[0].z + f.weights[1] * fb[1].z + f.weights[2] * fb[2].z;
if (z < depth[f.y * W + f.x]) {
depth[f.y * W + f.x] = z;
color[f.y * W + f.x] = f.color;
written++;
}
}
console.log(`vertices processed: ${processed.length}; triangles assembled: 1; inside the clip volume: ${inside}`);
console.log(`framebuffer positions: ${fb.map((v) => `(${v.x}, ${v.y})`).join(' ')}`);
console.log(`fragments: ${fragments.length} of ${W * H} pixel centres; pixels written: ${written}`);
for (let y = 0; y < H; y++) console.log(color.slice(y * W, (y + 1) * W).join('')); Output
vertices processed: 3; triangles assembled: 1; inside the clip volume: true framebuffer positions: (2, 7) (14, 6) (6, 1) fragments: 34 of 128 pixel centres; pixels written: 34 ................ ......b......... .....bbb........ ....bbbbbb...... ....rrrbgggg.... ...rrrrrggggg... ..rrrrrr........ ................
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node one_triangle.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 run reports 3 vertices, 1 triangle, 34 fragments and 34 pixels written, the same counts the stepped figure shows. Its picture is printed with y going down, as the framebuffer stores it: the vertex at y = 0.75, near the top of clip space, lands on row 1.
Clip space is not framebuffer space
A position of (−0.25, 0.75) is not a pixel address. Clip space runs from −1 to 1 with y pointing up; the framebuffer counts pixels from the top-left corner with y pointing down. Rasterization converts one into the other, here into (6, 1) on a 16 × 8 framebuffer. A shader that writes (6, 1) itself into its position draws nothing at that pixel: x = 6 lies far outside the clip volume, so clipping cuts that corner off the triangle.
Checkpoint
Check: The seven pipeline stages
Every frame has a deadline and a memory size
A browser updates a page’s rendering when the display can show a new image. The HTML standard’s own example is a browser aiming at 60 Hz, which gets a rendering opportunity at most every 60th of a second, about 16.7 milliseconds. Everything a frame needs, reading input, moving things, drawing, has to fit inside that interval, the refresh rate: How many times a second a display shows a new image, in hertz. It sets the frame budget: 1000 ÷ 60 ≈ 16.7 ms at 60 Hz.’s budget: 1000 ÷ 60 = 16.7 ms, and only 8.3 ms at 120 Hz.
Memory follows from three numbers. A framebuffer: The memory a renderer draws into: one or more images, such as colour and depth, holding a value for every pixel.’s colour image holds width × height
pixels of a fixed size: 4 bytes in WebGPU’s rgba8unorm format (8 bits for each of red, green, blue and alpha) and 8
bytes in rgba16float (16-bit floating point per channel, used for high dynamic range). The program prints the
budgets, the size of one colour buffer at five resolutions, and the bytes written each second if every frame writes
every pixel once:
// Frame budgets and framebuffer sizes. A colour buffer holds width x height pixels of bytesPerPixel each;
// writing it once per frame at a refresh rate of hz moves that many bytes hz times a second.
const RESOLUTIONS = [[640, 360], [1280, 720], [1920, 1080], [2560, 1440], [3840, 2160]];
const FORMATS = [['rgba8unorm', 4], ['rgba16float', 8]];
const RATES = [30, 60, 120];
/** 1234567 -> "1,234,567" (written out, because the browser runner has no Intl). */
const group = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
console.log('frame budget: ' + RATES.map((hz) => `${hz} Hz = ${(1000 / hz).toFixed(1)} ms`).join(', '));
console.log('');
console.log(`${'size'.padEnd(10)} ${'format'.padEnd(11)} ${'bytes'.padStart(10)} ${'MiB'.padStart(6)} GB/s at 30, 60, 120 Hz`);
for (const [w, h] of RESOLUTIONS) {
for (const [format, bpp] of FORMATS) {
const bytes = w * h * bpp;
const mib = (bytes / 2 ** 20).toFixed(2);
const rates = RATES.map((hz) => ((bytes * hz) / 1e9).toFixed(2)).join(' ');
console.log(`${`${w}x${h}`.padEnd(10)} ${format.padEnd(11)} ${group(bytes).padStart(10)} ${mib.padStart(6)} ${rates}`);
}
}
const fullHd = 1920 * 1080 * 4;
console.log(`\ndouble-buffered 1920x1080 rgba8unorm: 2 x ${group(fullHd)} = ${group(2 * fullHd)} bytes`);
console.log(`10 s of 1920x1080 rgba8unorm frames at 30 Hz, uncompressed: ${group(10 * 30 * fullHd)} bytes`); Output
frame budget: 30 Hz = 33.3 ms, 60 Hz = 16.7 ms, 120 Hz = 8.3 ms size format bytes MiB GB/s at 30, 60, 120 Hz 640x360 rgba8unorm 921,600 0.88 0.03 0.06 0.11 640x360 rgba16float 1,843,200 1.76 0.06 0.11 0.22 1280x720 rgba8unorm 3,686,400 3.52 0.11 0.22 0.44 1280x720 rgba16float 7,372,800 7.03 0.22 0.44 0.88 1920x1080 rgba8unorm 8,294,400 7.91 0.25 0.50 1.00 1920x1080 rgba16float 16,588,800 15.82 0.50 1.00 1.99 2560x1440 rgba8unorm 14,745,600 14.06 0.44 0.88 1.77 2560x1440 rgba16float 29,491,200 28.13 0.88 1.77 3.54 3840x2160 rgba8unorm 33,177,600 31.64 1.00 1.99 3.98 3840x2160 rgba16float 66,355,200 63.28 1.99 3.98 7.96 double-buffered 1920x1080 rgba8unorm: 2 x 8,294,400 = 16,588,800 bytes 10 s of 1920x1080 rgba8unorm frames at 30 Hz, uncompressed: 2,488,320,000 bytes
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node pixel_budget.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
A 1920 × 1080 rgba8unorm buffer is 8,294,400 bytes, 7.91 MiB, and two of them for double buffering are 16,588,800.
At 3840 × 2160 in rgba16float one buffer is 66,355,200 bytes, and writing it once a frame at 120 Hz moves 7.96 GB
a second, before any depth buffer, texture or overdraw is counted. The same arithmetic sizes video: 10 seconds of
uncompressed 1920 × 1080 frames at 30 Hz is 2,488,320,000 bytes, which is why video is always compressed.
Key idea
A frame must be finished within 1000 ÷ refresh rate milliseconds, and each image it writes costs width × height × bytes per pixel; work out both before you choose a resolution or a format.
Checkpoint
Check: Frame budgets and buffer sizes
Raster displays redraw a grid, vector displays redraw lines
Displays have drawn pictures in two ways. A raster-scan display shows a grid: every refresh it reads the whole framebuffer, row by row from the top, so a refresh takes the same time whether the picture is a single dot or a crowded scene. Between two refreshes comes a short pause, the vertical blanking interval, which the next lesson uses to swap images without tearing. The flat panels of today’s phones and monitors are raster displays.
A random-scan display, also called a vector display, keeps a list of points and lines instead and steers its beam straight along each one, in any order, with no grid at all. Ivan Sutherland’s Sketchpad thesis describes one: the TX-2’s display could plot up to about 100,000 spots a second, from a table of spot positions in memory, the display file. Its refresh time therefore grew with the picture: even at that top rate a drawing of 4,000 spots could be redrawn only 25 times a second, and Sketchpad, which gave each spot 20 microseconds to leave time for computing, managed half that. The thesis fought the flicker of large drawings by interlacing the spots or by showing them in random order.
| Aspect | Raster scan | Random scan (vector) |
|---|---|---|
| Draws from | A framebuffer of pixels | A display list of points and lines |
| Time per refresh | Fixed by the resolution | Grows with the number of lines |
| Draws well | Filled areas, images, shading | Sharp lines with no stair steps |
| Struggles with | Stair steps on edges (aliasing) | Filled areas; flicker on big drawings |
Input devices close the loop. Sketchpad’s user pointed at the screen with a light pen; today’s programs read a keyboard, a mouse, a pen tablet, a touch screen or a game controller. A browser reports mouse, pen and touch input as pointer events and lets a program poll game controllers once a frame, which the next lesson turns into code.
UGC NET Computer Science, Unit 3
The UGC NET syllabus for Computer Science and Applications opens its computer graphics topics with video-display devices, raster-scan and random-scan systems, graphics monitors and input devices. The table above is that vocabulary, and two of its rows carry most of it: which system needs a framebuffer (raster scan) and whose refresh time depends on the picture (random scan).
What a renderer draws also reaches real people. The WCAG 2.2 accessibility standard forbids content that flashes more than three times in any one second, unless the flashes stay below its flash thresholds, because flashing can trigger seizures, and it forbids colour as the only way to convey information. CS2023 adds the questions every graphics developer meets: ownership of images and models, deepfakes, facial recognition and privacy.
Checkpoint
Check: Displays and devices
Practise: budgets, buffer sizes and the order of the stages
Put the numbers of this lesson into code. The sample tests cover five display configurations, from a small
640 × 360 window to a 4K display with two rgba16float buffers, and the order of the seven stages:
Exercise · Easy · JavaScript
Frame budgets, framebuffer sizes and the order of the stages
Write three things in budget.mjs:
frameBudgetMs(hz): the milliseconds one frame may take on a display that refresheshztimes a second. A rate of 0 or less has no budget: throw aRangeError.framebufferBytes(width, height, bytesPerPixel, buffers): the bytes ofbufferscolour buffers ofwidth×heightpixels, each pixelbytesPerPixelbytes (4 forrgba8unorm, 8 forrgba16float). Whenbuffersis left out, count one buffer; fewer than one buffer is aRangeError.PIPELINE: the seven stages of WebGPU's render pipeline in the order a draw call runs them. The starter lists the right names in the wrong order; reorder them without changing any name.
The sample tests check five display configurations, from 640 × 360 at 30 Hz to 3840 × 2160 at 120 Hz with two rgba16float buffers, the two errors, and the order of the stages.
Starter code · budget.mjs
/** Milliseconds one frame may take at a refresh rate of hz frames a second (a RangeError when hz <= 0). */
export function frameBudgetMs(hz) {
return 0; // replace with the budget
}
/**
* Bytes of `buffers` colour buffers of width x height pixels, bytesPerPixel bytes each: one buffer if left out, and a
* RangeError for fewer than one.
*/
export function framebufferBytes(width, height, bytesPerPixel, buffers) {
return 0; // replace with the size
}
/** The seven stages of WebGPU's render pipeline, in the order a draw call runs them. Reorder these names. */
export const PIPELINE = [
'rasterization',
'output merging',
'vertex processing',
'primitive clipping',
'index resolution',
'fragment processing',
'primitive assembly',
]; The sample tests · budget.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { frameBudgetMs, framebufferBytes, PIPELINE } from './budget.mjs';
const close = (got, want, what) => assert.ok(Math.abs(got - want) < 1e-9, `${what}: expected ${want}, got ${got}`);
test('frame budgets at 30, 60, 90, 120 and 144 Hz', () => {
close(frameBudgetMs(30), 1000 / 30, '30 Hz');
close(frameBudgetMs(60), 16.666666666666668, '60 Hz');
close(frameBudgetMs(90), 11.11111111111111, '90 Hz');
close(frameBudgetMs(120), 8.333333333333334, '120 Hz');
close(frameBudgetMs(144), 6.944444444444445, '144 Hz');
});
test('five display configurations, one or more buffers', () => {
const cases = [
[640, 360, 4, 1, 921600],
[1280, 720, 4, 2, 7372800],
[1920, 1080, 4, 1, 8294400],
[2560, 1440, 8, 3, 88473600],
[3840, 2160, 8, 2, 132710400],
];
for (const [w, h, bpp, n, want] of cases) {
assert.equal(framebufferBytes(w, h, bpp, n), want, `${n} buffer(s) of ${w} x ${h} at ${bpp} bytes a pixel`);
}
});
test('no budget without a positive refresh rate, and at least one buffer', () => {
assert.throws(() => frameBudgetMs(0), RangeError, 'a rate of 0 Hz');
assert.throws(() => frameBudgetMs(-60), RangeError, 'a negative rate');
assert.throws(() => framebufferBytes(1920, 1080, 4, 0), RangeError, 'zero buffers');
close(frameBudgetMs(1), 1000, '1 Hz');
});
test('one buffer when buffers is left out', () => {
assert.equal(framebufferBytes(1920, 1080, 4), 8294400, 'one 1920 x 1080 rgba8unorm buffer');
assert.equal(framebufferBytes(3, 2, 8), 48, 'a 3 x 2 rgba16float buffer');
});
test('the seven stages in the order a draw call runs them', () => {
assert.deepEqual(PIPELINE, [
'index resolution',
'vertex processing',
'primitive assembly',
'primitive clipping',
'rasterization',
'fragment processing',
'output merging',
]);
}); A hint
A second has 1,000 milliseconds, and a display that refreshes hz times a second shares them equally between its frames. A buffer holds one value of bytesPerPixel bytes for each of its width * height pixels, and every extra buffer is another copy of the same size. For the order, follow one triangle: its vertices have to be chosen and processed before they can form a triangle, and a pixel can only be coloured once the triangle has become fragments.
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.
Then check every idea of the lesson in one session. Questions about the ideas you have answered least well come first, and each wrong choice says what it misses:
Practice that adapts to you
Practise this lesson
13 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): How do rasterisation and ray tracing differ, and which one does a GPU’s render pipeline use?
Both solve the same problem: for every pixel, find the surface nearest the camera. Rasterisation loops over the primitives, usually triangles. For each one it finds the pixel centres it covers and keeps the nearest surface per pixel in a depth buffer. Ray tracing loops over the pixels. For each one it shoots a ray through the pixel centre and keeps the nearest intersection along it. The render pipelines of WebGPU and OpenGL rasterise: their stages assemble triangles, rasterise them into fragments and depth-test them. Rasterisation’s cost grows with the pixels each triangle covers; a ray tracer’s grows with pixels times the shapes each ray must test, which is why it needs an acceleration structure such as a BVH. Ray tracing’s strength is that shadows, reflections and refraction are just more rays from the hit point.
Two harder questions grow out of this lesson, each with its first step and follow-ups on the module’s question page:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A game renders at 3840 × 2160 and 120 Hz into an rgba16float colour target with double buffering. Estimate the memory and the bytes per second of its colour output alone, and say what you would change first if frames miss their budget.
-
Mid to senior
A product viewer must show a car with accurate reflections and soft shadows at 60 Hz on mid-range laptops. Which parts of the frame would you rasterise, which would you ray trace, and how would you decide?
Key takeaways
- A raster image is a grid of point samples, each decided at the pixel’s centre, while a vector image keeps the shapes and can be sampled again at any size.
- Rasterisation and ray tracing both keep the nearest surface for every pixel; one loops over shapes with a depth buffer, the other over pixels with a ray each.
- A draw call runs seven stages, from index resolution to output merging; the vertex and fragment stages run your shaders and the other five are fixed-function.
- A frame has 1000 ÷ refresh rate milliseconds, and one colour buffer costs width × height × bytes per pixel, 8,294,400
bytes for 1920 × 1080 in
rgba8unorm. - Raster-scan displays redraw the whole framebuffer every refresh; random-scan displays draw a list of lines, so their refresh time grows with the picture.
Terms in this lesson
- depth buffer
- An image the size of the framebuffer that stores, for each pixel, the depth of the nearest surface drawn so far, so that a farther surface drawn later is rejected.
- fragment
- What rasterisation produces for one pixel a primitive covers: its position, depth and interpolated values, before the fragment shader gives it a colour.
- framebuffer
- The memory a renderer draws into: one or more images, such as colour and depth, holding a value for every pixel.
- pixel
- One sample of a raster image: the colour stored for one position of its grid. A renderer decides it from the scene at a single point, the pixel's centre, not from a little square.
- primitive
- A shape the GPU draws as one unit, a point, a line or a triangle, assembled from vertices in primitive assembly.
- raster image
- An image stored as a grid of pixels, each with its own colour. Its detail is fixed by its width and height in pixels.
- rasterisation
- Rendering by looping over the shapes, usually triangles, and finding the pixels each one covers, keeping the nearest surface per pixel. GPUs render this way.
- ray tracing
- Rendering by looping over the pixels and following a ray from the camera through each one to the first surface it hits; more rays from that point give shadows and reflections.
- refresh rate
- How many times a second a display shows a new image, in hertz. It sets the frame budget: 1000 ÷ 60 ≈ 16.7 ms at 60 Hz.
- shader
- A program the GPU runs for each item of a draw call: a vertex shader once per vertex, returning its position, and a fragment shader once per fragment, returning its colour.
- vector image
- An image stored as shapes, such as lines, curves and fills, given by their coordinates. It is turned into pixels when it is drawn, at any size.
- visibility problem
- Deciding, for every pixel, which surface of the scene is nearest the camera along that pixel's line of sight, and so shows there.
References
- WebGPU: 23.2 Rendering, and 3.3 Coordinate Systems (World Wide Web Consortium (W3C))
- HTML Living Standard: event loops and rendering opportunities (WHATWG)
- Scalable Vector Graphics (SVG) 2, About SVG (World Wide Web Consortium (W3C))
- A Pixel Is Not A Little Square (Technical Memo 6) (Alvy Ray Smith (Microsoft technical memo))
- Sketchpad: A man-machine graphical communication system (UCAM-CL-TR-574) (University of Cambridge Computer Laboratory (Ivan E. Sutherland's MIT thesis))
- Computer Science Curricula 2023: Graphics and Interactive Techniques (GIT) (ACM, IEEE Computer Society and AAAI (CS2023 task force))
- UGC NET syllabus: Computer Science and Applications (code 87) (University Grants Commission (UGC NET Bureau))
- Vulkan specification: VkPresentModeKHR (vertical blanking and tearing) (Khronos Group)
- Web Content Accessibility Guidelines (WCAG) 2.2 (World Wide Web Consortium (W3C))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress