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 6 – Triangles, depth and visibility: a software rasteriser

Rasterising triangles with edge functions

Rasterise a triangle with three edge functions: test pixel centres, give a shared edge to one triangle with the top-left rule, and step the tests exactly.

  • Intermediate
  • 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

  • Test pixel centres against a triangle with three edge functions
  • Build a rasteriser loop from snapping, winding and a bounding box
  • Apply the top-left rule so that the pixels of a shared edge are drawn once
  • Evaluate edge functions incrementally in fixed point with 8 sub-pixel bits

Before you start

What you’ll learn · Before you start

What you will learn

  • Test pixel centres against a triangle with three edge functions
  • Build a rasteriser loop from snapping, winding and a bounding box
  • Apply the top-left rule so that the pixels of a shared edge are drawn once
  • Evaluate edge functions incrementally in fixed point with 8 sub-pixel bits

Before you start

On this page

A triangle that covers 200 pixels on a phone still has to be decided one pixel at a time: is this pixel’s centre inside it or not? A 1920 × 1080 frame asks that question more than two million times for each layer of triangles, so the test must be cheap, exact, and agreed by every triangle that touches the pixel.

Rasterisers answer it with three edge functions per triangle, the test of Juan Pineda’s 1988 paper on parallel rasterisation. You will test pixel centres with them, build a working rasteriser around them, settle the centres that fall exactly on an edge with the top-left rule, and step the tests across a row in fixed point, so that rounding never moves an edge. Every pixel count below comes from a program that runs on this page.

An edge function says which side of an edge a pixel centre is on

Take triangle A with corners a = (2.2, 1.4), b = (4.6, 10.8) and c = (14.3, 6.1), in framebuffer coordinates, where y grows down the screen. A renderer decides each pixel from a single point, its centre (x + 0.5, y + 0.5): that is point sampling: Deciding coverage at one point per pixel, its centre: a primitive covers the pixel when it encloses that point, however much or little of the pixel's square it overlaps., the rule WebGPU’s specification uses when multisampling is off.

The edge function: A function of a point that is 0 on the line through an edge of a triangle, positive on one side and negative on the other: a 2D cross product. Three of them, one per edge, decide whether a pixel centre is inside. of the edge from a to b, at a point p, is the 2D cross product that the vectors lesson built, with its operands in the order Pineda wrote it:

Eab(p)=(px−ax)(by−ay)−(py−ay)(bx−ax)E_{ab}(p) = (p_x - a_x)(b_y - a_y) - (p_y - a_y)(b_x - a_x)

In words: E is 0 on the line through a and b, positive on one side and negative on the other, and it grows in proportion to the distance from the line. At the centre (6.5, 5.5), E_ab = (4.3)(9.4) − (4.1)(2.4) = 30.58. When the corners run counter-clockwise on screen, as A’s do, the inside of the triangle is the positive side of all three edges, so a centre is inside when E_bc, E_ca and E_ab are all positive. Look at the figure: each edge has a + side facing the triangle and a − side facing away, and the filled dots are the centres on the + side of all three.

Triangle A on a 16 by 12 pixel grid, its corners a, b, c running counter-clockwise; 49 of the 120 centres in its bounding box are inside.Triangle A: 49 of 120 centres insidea, b, c run counter-clockwise on screen024681012140246810+−+−+−abccentre inside: all three edge values positivecentre in the box, outside an edgebounding box: 12 × 10 = 120 centres+inside side of an edge,−outside

Each edge has a positive side, and the inside is positive for all three

Text description of the diagram

A grid 16 pixels wide and 12 rows tall, with y growing downwards, holds triangle A: corner a near the top left at (2.2, 1.4), corner b near the bottom at (4.6, 10.8) and corner c on the right at (14.3, 6.1). Arrows in the middle of the edges show the order a, b, c: down the left side, up to the right, and back to a, which is counter-clockwise on screen.

Beside each edge, a plus sign on the side that faces the triangle and a minus sign on the side that faces away mark where that edge's function is positive and negative.

A dashed rectangle is the bounding box: the pixel centres from (2.5, 1.5) to (13.5, 10.5), 12 columns by 10 rows, 120 centres. Filled dots and lightly shaded pixels mark the 49 centres that lie on the positive side of all three edges, which the triangle covers. Open circles mark the other 71 centres of the box, each outside at least one edge.

The three values have a property worth checking by hand: at any point they add up to the same number, 102.46 for triangle A, which is twice its area. At (6.5, 5.5) the values are 42.48, 29.40 and 30.58; at a point far outside they change, but their sum does not.

The cross product written the other way round

(b − a) × (p − a), the orientation test of the vectors lesson, is the same expression with its sign flipped. When the winding check uses one order and the edge tests the other, every counter-clockwise triangle is tested as if it were inside out, no centre is on the positive side of all three edges, and the triangle disappears. Write the function once, check it on one known centre, and use it for both.

Dividing an edge value by the edge’s length gives the distance from the edge: the centre (2.5, 2.5) has E_ab = 0.18, and with edge ab 9.70 long it is inside by less than 0.02 pixel.

Key idea

An edge function is a cross product: 0 on the edge’s line, positive on the inside of a counter-clockwise triangle, and a centre is inside when all three of its edge values are positive.

Checkpoint

Check: The edge function and its sign

  1. Question 1 of 2 For triangle A, whose corners run counter-clockwise on screen, E_ab at the centre (3.5, 9.5) is −7.22. What does that tell you?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The centre is outside the triangle, beyond edge ab

    A centre is inside when E_bc, E_ca and E_ab are all positive. E_ab = −7.22 puts (3.5, 9.5) on the negative side of edge ab, outside, 7.22 ÷ 9.70 ≈ 0.74 pixel from the edge's line. The rasteriser can stop testing this centre at once.

  2. Question 2 of 2 At the centre (8.5, 8.5) of triangle A, E_bc = 3.98 and E_ca = 56.30. What is E_ab?

    Type a number.

    A hint

    The three edge values add up to the same number at every point.

    Show the answer: Question 2 of 2

    Answer: 42.18 square pixels (anything from 42.17 to 42.19 counts)

    The three values always add up to twice the area, 102.46 for triangle A, so E_ab = 102.46 − 3.98 − 56.30 = 42.18. All three are positive, and the centre is inside, close to edge bc.

Three tests over a bounding box make a rasteriser

A complete rasteriser wraps the three tests in a short loop. It snaps the corners to a grid of 1/256 pixel, so that every later number is a whole number. It checks the winding: E of corner c against edge ab is twice the signed area, positive when the corners run counter-clockwise, and when it is negative the loop swaps two corners. Then it tests only the centres inside the triangle’s bounding box: The smallest rectangle, with sides along the axes, that holds a shape. A rasteriser tests only the pixel centres inside its triangle's box, clamped to the target., clamped to the target. Step through the loop on triangle A, from the snapped corners to the last test:

Interactive · stage by stage The rasteriser's loop on triangle A Step through the loop for triangle A. Watch the corners snap, the winding check, the box that limits the search, and three centres the edge tests decide.
The rasteriser's loop on triangle A, step 9 of 9. Showing Snap the corners (a (563, 358), b, c), Check the winding (area +51.24: keep the order), Take the bounding box (x 2-13, y 1-10: 120), Test three edges (ab +0.18: inside, just), Settle ties on an edge (A: no centre on an edge), Write the pixel ((6, 5) covered).next centreSnap the cornersa (563, 358), b, cCheck the windingarea +51.24: keep the orderTake the bounding boxx 2-13, y 1-10: 120Test three edgesab +0.18: inside, justSettle ties on an edgeA: no centre on an edgeWrite the pixel(6, 5) covered

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

Every step as text
  1. Snap. Triangle A's corners (2.2, 1.4), (4.6, 10.8) and (14.3, 6.1) are multiplied by 256 and rounded: (563, 358), (1178, 2765) and (3661, 1562), whole numbers of 1/256 pixel.
  2. Winding. Corner c against edge ab gives 6,716,426 in these units, twice the area, so the snapped triangle covers 51.24 square pixels. The value is positive, so the corners already run counter-clockwise on screen and keep their order.
  3. Bounding box. The corners span x from 2.2 to 14.3 and y from 1.4 to 10.8, so only the centres from (2.5, 1.5) to (13.5, 10.5) can be inside: 12 columns by 10 rows, 120 of the target's 192.
  4. A centre inside. At pixel (6, 5), centre (6.5, 5.5), the edge values are 42.48 for bc, 29.40 for ca and 30.58 for ab, in square pixels. All three are positive, so the centre is inside.
  5. Written. The pixel is written: a colour now, and later a depth and the values interpolated from the corners. 49 of the 120 centres end up here.
  6. A centre outside. Back at the test, pixel (13, 6), centre (13.5, 6.5), gives −0.12 for edge bc. The centre lies a hair outside, next to corner c, so the pixel is skipped although the triangle crosses it.
  7. A close call. Pixel (2, 2) gives 0.18 for edge ab. An edge value divided by the edge's length, 9.70 pixels, is the distance from the edge: this centre is inside by less than 0.02 pixel.
  8. Ties. A centre exactly on an edge gives 0, and only the tie rule can decide it. No centre of triangle A lies on an edge; triangle C, whose corners sit on pixel centres, has 11 such centres.
  9. Done. After 120 tests, 49 pixels are covered. Listed clockwise, the same corners give a negative area; the loop swaps two of them, and the 49 pixels come out the same.

The program is that loop, with a tie rule for centres exactly on an edge that the next section explains. It rasterises four triangles on a 16 × 12 target and prints each mask:

Four triangles through the edge-function loop JavaScript · raster/rasterize_tri.mjs
// A triangle rasteriser built on edge functions. Each vertex is snapped to 1/256 of a pixel (8 sub-pixel bits),
// the triangle is turned to run counter-clockwise on screen, and every pixel centre (x + 1/2, y + 1/2) of its
// bounding box is tested against the three edges. A centre exactly on an edge counts only for a top or a left edge.
const SUB = 256;
const W = 16;
const H = 12;

const snap = ([x, y]) => [Math.round(x * SUB), Math.round(y * SUB)];

// E(a, b, p) = (p.x - a.x)(b.y - a.y) - (p.y - a.y)(b.x - a.x). With y pointing down the screen, it is positive
// for points inside a triangle whose corners run counter-clockwise on screen, 0 on the edge's line.
const edge = (a, b, p) => (p[0] - a[0]) * (b[1] - a[1]) - (p[1] - a[1]) * (b[0] - a[0]);

// For a counter-clockwise triangle a left edge runs down the screen, and a top edge is horizontal, running left.
const topLeft = (a, b) => b[1] > a[1] || (b[1] === a[1] && b[0] < a[0]);

function rasterize(corners) {
  let [a, b, c] = corners.map(snap);
  const area2 = edge(a, b, c); // twice the signed area, in (1/256 pixel) squared
  if (area2 < 0) [b, c] = [c, b]; // clockwise on screen: swap two corners
  const xs = [a[0], b[0], c[0]];
  const ys = [a[1], b[1], c[1]];
  const x0 = Math.max(0, Math.ceil((Math.min(...xs) - SUB / 2) / SUB));
  const x1 = Math.min(W - 1, Math.floor((Math.max(...xs) - SUB / 2) / SUB));
  const y0 = Math.max(0, Math.ceil((Math.min(...ys) - SUB / 2) / SUB));
  const y1 = Math.min(H - 1, Math.floor((Math.max(...ys) - SUB / 2) / SUB));
  const edges = [
    [b, c],
    [c, a],
    [a, b],
  ];
  const rows = Array.from({ length: H }, () => Array(W).fill('.'));
  let tested = 0;
  let covered = 0;
  let ties = 0;
  let tiesKept = 0;
  if (area2 !== 0) {
    for (let y = y0; y <= y1; y++) {
      for (let x = x0; x <= x1; x++) {
        const p = [x * SUB + SUB / 2, y * SUB + SUB / 2];
        tested++;
        let inside = true;
        let onEdge = false;
        for (const [e0, e1] of edges) {
          const w = edge(e0, e1, p);
          if (w === 0) onEdge = true;
          if (w < 0 || (w === 0 && !topLeft(e0, e1))) inside = false;
        }
        if (onEdge) ties++;
        if (inside) {
          covered++;
          if (onEdge) tiesKept++;
        }
        rows[y][x] = inside ? (onEdge ? 'o' : '#') : onEdge ? '-' : rows[y][x];
      }
    }
  }
  return { area: Math.abs(area2) / (2 * SUB * SUB), clockwise: area2 < 0, box: [x0, x1, y0, y1], tested, covered, ties, tiesKept, rows };
}

const TRIANGLES = [
  ['A', [[2.2, 1.4], [4.6, 10.8], [14.3, 6.1]]],
  ['B, A listed clockwise', [[2.2, 1.4], [14.3, 6.1], [4.6, 10.8]]],
  ['C, corners on pixel centres', [[12.5, 2.5], [3.5, 2.5], [7.5, 9.5]]],
  ['D, a sliver', [[0.6, 10.2], [15.4, 8.3], [15.4, 8.9]]],
];

for (const [name, corners] of TRIANGLES) {
  const r = rasterize(corners);
  const [x0, x1, y0, y1] = r.box;
  console.log(`Triangle ${name}: ${corners.map((p) => `(${p.join(', ')})`).join(' ')}`);
  console.log(`  ${r.clockwise ? 'clockwise on screen, two corners swapped' : 'counter-clockwise on screen'}; area ${r.area.toFixed(2)} pixels`);
  console.log(`  bounding box x ${x0}-${x1}, y ${y0}-${y1}: ${r.tested} centres tested, ${r.covered} covered`);
  if (r.ties) console.log(`  ${r.ties} centres exactly on an edge, ${r.tiesKept} of them kept by the top-left rule`);
  for (const row of r.rows) console.log(`  ${row.join('')}`);
}
console.log("# covered, o on a top or left edge (kept), - on a right or bottom edge (dropped), . outside");

Output

Triangle A: (2.2, 1.4) (4.6, 10.8) (14.3, 6.1)
  counter-clockwise on screen; area 51.24 pixels
  bounding box x 2-13, y 1-10: 120 centres tested, 49 covered
  ................
  ................
  ..###...........
  ...#####........
  ...#######......
  ...##########...
  ....#########...
  ....#######.....
  ....#####.......
  ....###.........
  ................
  ................
Triangle B, A listed clockwise: (2.2, 1.4) (14.3, 6.1) (4.6, 10.8)
  clockwise on screen, two corners swapped; area 51.24 pixels
  bounding box x 2-13, y 1-10: 120 centres tested, 49 covered
  ................
  ................
  ..###...........
  ...#####........
  ...#######......
  ...##########...
  ....#########...
  ....#######.....
  ....#####.......
  ....###.........
  ................
  ................
Triangle C, corners on pixel centres: (12.5, 2.5) (3.5, 2.5) (7.5, 9.5)
  counter-clockwise on screen; area 31.50 pixels
  bounding box x 3-12, y 2-9: 80 centres tested, 36 covered
  11 centres exactly on an edge, 9 of them kept by the top-left rule
  ................
  ................
  ...ooooooooo-...
  ....########....
  .....######.....
  .....#####......
  ......####......
  ......###.......
  .......#........
  .......-........
  ................
  ................
Triangle D, a sliver: (0.6, 10.2) (15.4, 8.3) (15.4, 8.9)
  clockwise on screen, two corners swapped; area 4.42 pixels
  bounding box x 1-14, y 8-9: 28 centres tested, 4 covered
  ................
  ................
  ................
  ................
  ................
  ................
  ................
  ................
  ..............#.
  ......###.......
  ................
  ................
# covered, o on a top or left edge (kept), - on a right or bottom edge (dropped), . outside

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

Triangle A covers 49 pixels and needed 120 tests, not the target’s 192. Triangle B is A with its corners listed clockwise: the area comes out negative, two corners are swapped, and the 49 pixels are identical, so the winding order: The order of a triangle's vertices as seen on screen, clockwise or counter-clockwise. A pipeline uses it to decide which triangles face the viewer. More in “Vectors, points, dot and cross products” changes the signs, never the coverage. Triangle D is a sliver triangle: A long, very thin triangle. It may enclose no pixel centre in some columns or rows, so its pixels come out in separate pieces, as point sampling allows. of 4.42 square pixels that covers 4 pixels in two separate pieces: in most columns its two long edges pass between centres, so it encloses none. That is point sampling working as specified, not a bug.

Turn a triangle round

Press Edit and list triangle C’s corners as (3.5, 2.5), (12.5, 2.5), (7.5, 9.5). The program reports it as clockwise, swaps two corners, and prints the same 36 pixels.

Checkpoint

Check: Snapping, winding and the bounding box

  1. Question 1 of 2 A triangle has corners (3.2, 4.7), (9.9, 4.1) and (6.0, 12.8) on a 16 × 16 target. How many pixel centres does its bounding box hold?

    Type a number.

    A hint

    List the centres x + 0.5 that lie between the smallest and largest x of the corners, then do the same for y.

    Show the answer: Question 1 of 2

    Answer: 63 centres

    x runs from 3.2 to 9.9, so the centres 3.5 to 9.5 lie inside: pixels 3 to 9, 7 columns. y runs from 4.1 to 12.8, so the centres 4.5 to 12.5: rows 4 to 12, 9 rows. The box holds 7 × 9 = 63 centres; the triangle covers about half.

  2. Question 2 of 2 For a triangle, corner c against edge ab gives −6,716,426 in fixed point. What does the rasteriser of the lesson do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It swaps two corners, so the order runs counter-clockwise, and tests the centres as usual

    The loop needs one orientation so that "positive means inside" and the top-left tests hold. Swapping two corners of a clockwise triangle gives it that orientation without changing the triangle, so B, A listed clockwise, covered the same 49 pixels.

The top-left rule gives each shared centre to one triangle

Triangle C’s corners lie on pixel centres, so 11 of its centres give an edge value of exactly 0: ten on its horizontal top edge and one at its bottom corner. A centre on an edge is a tie, and in a mesh most edges are shared by two triangles. If both count such a centre as inside, the pixel is drawn twice; if neither does, it is a hole.

The top-left rule: A tie rule for a pixel centre that lies exactly on an edge: it belongs to a shape only when the edge is a top or a left edge of that shape, so shapes sharing the edge never both claim the pixel. More in “Polygon fill: scanline, boundary and flood fill” of Direct3D’s rasterization rules gives such a centre to a triangle when it lies on a top edge, horizontal with the triangle below it, or on a left edge, an edge that is not horizontal and is on the triangle’s left. A rasteriser applies it edge by edge, so a centre that lies on a right edge or a bottom edge as well is left out. For a counter-clockwise triangle in y-down coordinates, an edge from a to b is a left edge when b.y > a.y, and the top edge when b.y = a.y and b.x < a.x, so the rule is one comparison per edge. In triangle C it kept 9 of the 11: the top row from pixel 3 to 11, but not pixel 12, which also lies on the right edge, nor the bottom corner, which lies on a left edge and a right edge.

The real test is a shared edge. Cut a 10 × 10 square whose corners lie on pixel centres along its diagonal, and 11 centres lie on the cut:

A 10 by 10 square cut along its diagonal: the upper triangle gets 55 pixels, the lower one 45, and no centre on the diagonal is drawn twice.One diagonal, two triangles, 100 pixelsCorners on pixel centres; top-left rule02468101214024681012upper triangle: 55 pixels, filled dotslower triangle: 45 pixels, open dots11 centres on the diagonal, none drawn twicethe shared edge

The top-left rule gives every centre on the shared diagonal to one triangle

Text description of the diagram

A grid 15 pixels wide and 13 rows tall, with y growing downwards, holds a square with corners on the pixel centres (2.5, 1.5), (12.5, 1.5), (12.5, 11.5) and (2.5, 11.5). A dashed line along its diagonal, from the top left corner to the bottom right one, cuts it into an upper triangle and a lower triangle.

The upper triangle's 55 pixels are shaded blue with filled dots; the lower triangle's 45 are shaded grey with open dots. Together they make the 100 pixels of columns 2 to 11 and rows 1 to 10: the square's top row and left column are inside, its bottom row and right column outside.

Diamonds mark the 11 pixel centres that lie exactly on the diagonal. The first ten belong to the upper triangle, for which the diagonal is a left edge; the last one, the square's bottom right corner, lies on the square's bottom and right edges as well and is drawn by neither triangle. No pixel is drawn twice.

The diagonal runs down the screen, so it is a left edge of the upper triangle, which lies to its right, and a right edge of the lower one: its centres go to the upper triangle, except the square’s last corner, which lies on the square’s bottom and right edges and goes to neither. The program counts the same square under four tie rules:

A square cut on its diagonal, under four tie rules JavaScript · shared/shared_edge.mjs
// A 10 x 10 square whose corners lie on pixel centres, cut along its diagonal into two triangles. Eleven pixel
// centres lie exactly on the shared diagonal, so the rule for a centre on an edge decides who draws them.
const SUB = 256;
const snap = ([x, y]) => [Math.round(x * SUB), Math.round(y * SUB)];
const edge = (a, b, p) => (p[0] - a[0]) * (b[1] - a[1]) - (p[1] - a[1]) * (b[0] - a[0]);
const topLeft = (a, b) => b[1] > a[1] || (b[1] === a[1] && b[0] < a[0]);

// What each rule does with a centre at w = 0, exactly on the edge from a to b.
const RULES = {
  closed: () => true, // on the edge counts as inside, for both triangles
  open: () => false, // on the edge counts as outside, for both triangles
  'top-left': (a, b) => topLeft(a, b),
  'bottom-right': (a, b) => !topLeft(a, b),
};

function covers(corners, x, y, onEdge) {
  let [a, b, c] = corners.map(snap);
  if (edge(a, b, c) < 0) [b, c] = [c, b];
  const p = [x * SUB + SUB / 2, y * SUB + SUB / 2];
  for (const [e0, e1] of [[b, c], [c, a], [a, b]]) {
    const w = edge(e0, e1, p);
    if (w < 0 || (w === 0 && !onEdge(e0, e1))) return false;
  }
  return true;
}

const corner = [[2.5, 1.5], [12.5, 1.5], [12.5, 11.5], [2.5, 11.5]];
const upper = [corner[0], corner[1], corner[2]];
const lower = [corner[0], corner[2], corner[3]];
// The nine centres on the diagonal between its two ends (the ends are corners of the square too).
const onDiagonal = (x, y) => x - 2 === y - 1 && x >= 3 && x <= 11;

const masks = {};
console.log('rule          drawn once  drawn twice  holes on the diagonal  pixels unlike top-left');
for (const [name, onEdge] of Object.entries(RULES)) {
  let once = 0;
  let twice = 0;
  let holes = 0;
  const mask = new Set();
  for (let y = 0; y < 14; y++) {
    for (let x = 0; x < 16; x++) {
      const n = Number(covers(upper, x, y, onEdge)) + Number(covers(lower, x, y, onEdge));
      if (n === 1) once++;
      if (n === 2) twice++;
      if (n === 0 && onDiagonal(x, y)) holes++;
      if (n > 0) mask.add(`${x},${y}`);
    }
  }
  masks[name] = mask;
  const ref = masks['top-left'];
  const unlike = ref ? [...mask].filter((k) => !ref.has(k)).length + [...ref].filter((k) => !mask.has(k)).length : 0;
  console.log(`${name.padEnd(14)}${String(once).padEnd(12)}${String(twice).padEnd(13)}${String(holes).padEnd(23)}${name === 'closed' || name === 'open' ? '-' : unlike}`);
}
const tl = masks['top-left'];
const cols = [...tl].map((k) => Number(k.split(',')[0]));
const rows = [...tl].map((k) => Number(k.split(',')[1]));
console.log(`top-left: columns ${Math.min(...cols)}-${Math.max(...cols)}, rows ${Math.min(...rows)}-${Math.max(...rows)}, ${tl.size} pixels for a square of area 100`);
const br = masks['bottom-right'];
const bcols = [...br].map((k) => Number(k.split(',')[0]));
const brows = [...br].map((k) => Number(k.split(',')[1]));
console.log(`bottom-right: columns ${Math.min(...bcols)}-${Math.max(...bcols)}, rows ${Math.min(...brows)}-${Math.max(...brows)}, ${br.size} pixels`);

Output

rule          drawn once  drawn twice  holes on the diagonal  pixels unlike top-left
closed        110         11           0                      -
open          72          0            9                      -
top-left      100         0            0                      0
bottom-right  100         0            0                      38
top-left: columns 2-11, rows 1-10, 100 pixels for a square of area 100
bottom-right: columns 3-12, rows 2-11, 100 pixels

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

Counting a centre on an edge as inside, the closed rule, drew the 11 diagonal pixels twice; counting it as outside left 9 holes along the diagonal and shrank the square to 72 pixels. The top-left rule covered exactly 100 pixels, as a square of area 100 should, with none twice. The mirror rule, bottom-right, also covered 100 pixels once, yet its mask and the top-left one differ in 38 pixels: any consistent rule tiles a mesh exactly, and matching another renderer pixel for pixel needs the same rule.

What the GPU APIs promise

The Vulkan specification requires that when two polygons share an edge with identical endpoints, exactly one of them covers a sample that lies on it. WebGPU’s specification goes no further than “whether or not it’s included is not defined” for a pixel centre on an edge, so a test that compares a GPU image with a CPU one should place its triangles so that no pixel centre lies exactly on an edge.

Checkpoint

Check: The top-left rule on shared edges

  1. Question 1 of 2 Two triangles share a vertical edge from (5.5, 1.5) to (5.5, 9.5); one lies to its left, the other to its right. Which one draws the pixel centres on the edge, under the top-left rule?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The one on the right, for which the shared edge is a left edge

    A shared edge is a left edge of one triangle and a right edge of the other, so the top-left rule gives its centres to exactly one of them: here the triangle on the right, which takes the centres of column 5 along the edge.

  2. Question 2 of 2 A rasteriser counts every centre with an edge value of 0 as inside. A 12 × 12 square whose corners lie on pixel centres is cut along its diagonal into two triangles. How many pixels are drawn twice?

    Type a number.

    A hint

    Count the pixel centres on the diagonal, both ends included.

    Show the answer: Question 2 of 2

    Answer: 13 pixels

    A diagonal across 12 pixels passes through 13 pixel centres, both corners included, and under the closed rule both triangles cover all of them. The lesson's 10 × 10 square drew its 11 diagonal pixels twice the same way.

Fixed point makes stepping across a row exact

Testing a centre costs three edge functions, two multiplications each. But E is linear in x and y, so moving one pixel to the right changes it by a constant, the edge’s height, and moving one row down by another, minus its width. With corners snapped to 1/256 pixel, one step in each direction is

E(x+1,y)=E(x,y)+256(by−ay),E(x,y+1)=E(x,y)−256(bx−ax)E(x + 1, y) = E(x, y) + 256\,(b_y - a_y), \qquad E(x, y + 1) = E(x, y) - 256\,(b_x - a_x)

In words: after the first centre of a row, each further centre costs one addition per edge. Triangle A’s snapped corners a and b differ by 2,407 in y, so one pixel to the right adds 256 × 2,407 = 616,192 to E_ab, which is 9.40 square pixels. The snapping is sub-pixel precision: The number of fraction bits with which a rasteriser stores vertex positions. With 8 bits it snaps them to 1/256 of a pixel, so every edge test becomes exact integer arithmetic. of 8 bits: the Vulkan specification requires at least 4, and its Roadmap 2026 milestone asks for 8. With fixed point: Numbers stored as integers that count a fixed fraction, such as 1/256 of a pixel, so that sums, products and comparisons of them are exact. More in “Floating point, precision and robust geometry”, every edge value is a whole number, so a step adds exactly, and E = 0 means the centre is exactly on the edge. The next program steps the three edges of a triangle about 1,000 pixels across over its whole bounding box, in fixed point and in float32:

Stepping edge functions in fixed point and in float32 JavaScript · step/incremental.mjs
// An edge function is linear in x and y, so stepping one pixel right adds a constant and stepping one row down adds
// another. This program steps the three edge functions of one large triangle across its whole bounding box, in
// fixed point (whole numbers of 1/256 pixel) and in float32, and compares every value with a direct evaluation.
const SUB = 256;
const f = Math.fround;
const corners = [[37.3, 21.8], [180.6, 1010.2], [997.9, 412.7]];

const snap = ([x, y]) => [Math.round(x * SUB), Math.round(y * SUB)];
const edge = (a, b, p) => (p[0] - a[0]) * (b[1] - a[1]) - (p[1] - a[1]) * (b[0] - a[0]);
const [a, b, c] = corners.map(snap);
const edges = [[b, c], [c, a], [a, b]];
// The pixels whose centres lie inside the triangle's bounding box.
const first = (v) => Math.ceil((v - SUB / 2) / SUB);
const last = (v) => Math.floor((v - SUB / 2) / SUB);
const x0 = first(Math.min(a[0], b[0], c[0]));
const x1 = last(Math.max(a[0], b[0], c[0]));
const y0 = first(Math.min(a[1], b[1], c[1]));
const y1 = last(Math.max(a[1], b[1], c[1]));

// Exact: every value is a whole number far below 2^53, so a double holds it exactly.
let fixedMismatch = 0;
let floatDecisions = 0;
let worstDrift = 0;
let largest = 0;
let pixels = 0;
const startP = [x0 * SUB + SUB / 2, y0 * SUB + SUB / 2];
for (const [e0, e1] of edges) {
  const stepX = SUB * (e1[1] - e0[1]); // E(p + one pixel right) - E(p)
  const stepY = -SUB * (e1[0] - e0[0]); // E(p + one row down) - E(p)
  let rowFixed = edge(e0, e1, startP);
  // float32: the same values in pixel units, stepped with float32 additions as a float rasteriser would.
  const scale = SUB * SUB;
  let rowFloat = f(rowFixed / scale);
  const stepXf = f(stepX / scale);
  const stepYf = f(stepY / scale);
  for (let y = y0; y <= y1; y++) {
    let eFixed = rowFixed;
    let eFloat = rowFloat;
    for (let x = x0; x <= x1; x++) {
      const direct = edge(e0, e1, [x * SUB + SUB / 2, y * SUB + SUB / 2]);
      if (eFixed !== direct) fixedMismatch++;
      largest = Math.max(largest, Math.abs(direct));
      // How far sideways the edge seems to move: the error divided by the change of E per pixel.
      const drift = Math.abs(eFloat * scale - direct) / Math.abs(stepX || stepY);
      worstDrift = Math.max(worstDrift, drift);
      if (eFloat >= 0 !== direct >= 0) floatDecisions++;
      pixels++;
      eFixed += stepX;
      eFloat = f(eFloat + stepXf);
    }
    rowFixed += stepY;
    rowFloat = f(rowFloat + stepYf);
  }
}
const group = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
console.log(`bounding box x ${x0}-${x1}, y ${y0}-${y1}: ${group(pixels / 3)} pixel centres, 3 edge functions each`);
console.log(`fixed point, stepped: ${fixedMismatch} values differ from a direct evaluation`);
console.log(`float32, stepped: ${floatDecisions} centres land on the wrong side of an edge`);
console.log(`float32: the stepped edges stray up to ${worstDrift.toFixed(3)} pixel from where they are`);
console.log(`largest |E|: ${group(largest)}, about 2^${Math.log2(largest).toFixed(1)}: more than 32-bit integers hold, exact in a double`);

Output

bounding box x 37-997, y 22-1009: 949,468 pixel centres, 3 edge functions each
fixed point, stepped: 0 values differ from a direct evaluation
float32, stepped: 13 centres land on the wrong side of an edge
float32: the stepped edges stray up to 0.084 pixel from where they are
largest |E|: 62,190,990,715, about 2^35.9: more than 32-bit integers hold, exact in a double

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

Over 949,468 centres, the fixed-point steps never differed from a direct evaluation. Float32 steps, the precision of a shader’s f32, put 13 centres on the wrong side of an edge: each addition rounds, and after hundreds of steps the edges had drifted by up to 0.084 pixel. The cost of exactness is width. The largest value was 62,190,990,715, about 2^35.9, more than a 32-bit integer holds, but far below 2^53, so a JavaScript number, a double, keeps every value exact.

Size the integers before you write the loop

An edge value is a difference of two products of coordinate differences. For a 4096 × 4096 target with 8 sub-pixel bits, each difference needs 21 bits with its sign and an edge value up to 42: 64-bit integers or doubles, never 32-bit.

Checkpoint

Check: Stepping edge values in fixed point

  1. Question 1 of 2 An edge runs from a = (512, 768) to b = (1792, 1280), in units of 1/256 pixel. By how much does E_ab change, in fixed point, when the centre moves one pixel to the right?

    Type a number.

    A hint

    One pixel is 256 units, and E changes by the step in x times b.y − a.y.

    Show the answer: Question 1 of 2

    Answer: 131072 fixed-point units

    E(x + 1, y) − E(x, y) = 256 × (b.y − a.y) = 256 × 512 = 131,072, which is 2.0 square pixels: the edge is 2 pixels tall. It is the same at every centre, so each step to the right is one addition.

  2. Question 2 of 2 Why did the float32 steps put 13 of 949,468 centres on the wrong side of an edge, while the fixed-point steps put none?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each float32 addition rounds, and over hundreds of steps the errors add up; whole numbers add exactly

    A float32 sum keeps about 7 significant digits, so every addition rounds, and a row of a thousand additions carries a thousand roundings. Values of whole units of 1/65,536 square pixel, below 2^53, add without any rounding, so the stepped value always equals the direct one.

Practise: rasterise a triangle with the top-left rule

Two exercises build the rasteriser of this lesson, and their sample tests run here. The first writes the edge function, puts the corners in counter-clockwise order and tests a point strictly inside; the second is the whole loop, with snapping, a clamped bounding box and the top-left rule, tested on triangles A to D and on the square cut along its diagonal:

Code lab · 2 steps

Build a triangle rasteriser

Write the edge function and the winding check, then a rasteriser that snaps corners to 1/256 pixel and gives every pixel of a shared edge to one triangle.

  1. Edge functions, winding and the inside test To do
  2. A triangle rasteriser with the top-left rule To do

Exercise · Easy · JavaScript

Edge functions, winding and the inside test

Write three functions in edges.mjs. Points are [x, y] pairs in framebuffer coordinates, with y growing down the screen.

  • edge(a, b, p): the edge function of the edge from a to b at p, (p.x − a.x)(b.y − a.y) − (p.y − a.y)(b.x − a.x). It is positive on the inside of a triangle whose corners run counter-clockwise on screen.
  • counterClockwise(v0, v1, v2): the three corners in counter-clockwise screen order, as a new array [a, b, c]. Keep them as they are when they already run that way; otherwise swap the last two. Return null when the three corners lie on one line.
  • inside(v0, v1, v2, p): true when p lies strictly inside the triangle, whichever way its corners are listed; a point exactly on an edge is not inside.

The starter writes the edge function with its two vectors the other way round, which flips every sign, and it never checks the winding. The sample tests use the lesson's triangle A, its clockwise twin B, a flat triangle and points on and near the edges.

Starter code · edges.mjs

/**
 * edge(a, b, p): the edge function of the edge from a to b at point p. counterClockwise(v0, v1, v2): the corners in
 * counter-clockwise screen order, or null when they lie on one line. inside(v0, v1, v2, p): strictly inside.
 * This first version takes the two vectors the other way round, so every sign is flipped, and it trusts the winding.
 */
export function edge(a, b, p) {
  return (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
}

export function counterClockwise(v0, v1, v2) {
  return [v0, v1, v2];
}

export function inside(v0, v1, v2, p) {
  const [a, b, c] = counterClockwise(v0, v1, v2);
  return edge(b, c, p) > 0 && edge(c, a, p) > 0 && edge(a, b, p) > 0;
}
The sample tests · edges.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { counterClockwise, edge, inside } from './edges.mjs';

// The lesson's triangle A, counter-clockwise on screen, and B, the same corners listed clockwise.
const A = [[2.2, 1.4], [4.6, 10.8], [14.3, 6.1]];
const B = [[2.2, 1.4], [14.3, 6.1], [4.6, 10.8]];
const near = (got, want, label) => assert.ok(Math.abs(got - want) < 1e-9, `${label}: got ${got}, expected ${want}`);

test("edge values of triangle A at the lesson's centres", () => {
  const [a, b, c] = A;
  near(edge(b, c, [6.5, 5.5]), 42.48, 'E_bc at (6.5, 5.5)');
  near(edge(c, a, [6.5, 5.5]), 29.4, 'E_ca at (6.5, 5.5)');
  near(edge(a, b, [6.5, 5.5]), 30.58, 'E_ab at (6.5, 5.5)');
  near(edge(b, c, [13.5, 6.5]), -0.12, 'E_bc at (13.5, 6.5): just outside, near corner c');
  near(edge(a, b, [2.5, 2.5]), 0.18, 'E_ab at (2.5, 2.5): just inside');
  near(edge(a, b, a), 0, 'a corner lies on its own edges');
  near(edge(a, b, [3.4, 6.1]), 0, 'the midpoint of edge ab');
});

test('the three edge values always add up to twice the area', () => {
  const [a, b, c] = A;
  for (const p of [[0, 0], [6.5, 5.5], [20, -3], [13.5, 6.5]]) {
    near(edge(b, c, p) + edge(c, a, p) + edge(a, b, p), 102.46, `the sum at (${p})`);
  }
  near(edge(a, b, c), 102.46, 'corner c against edge ab');
});

test('counterClockwise keeps A, swaps B and refuses a flat triangle', () => {
  assert.deepEqual(counterClockwise(...A), A, 'A already runs counter-clockwise');
  const fixed = counterClockwise(...B);
  assert.ok(Array.isArray(fixed) && fixed.length === 3, 'B gives three corners');
  assert.ok(edge(fixed[0], fixed[1], fixed[2]) > 0, 'after the swap the area is positive');
  assert.deepEqual(fixed, [B[0], B[2], B[1]], 'B with its last two corners swapped');
  assert.equal(counterClockwise([0, 0], [2, 1], [6, 3]), null, 'three corners on one line');
});

test('inside, for either winding, strictly', () => {
  for (const t of [A, B]) {
    assert.equal(inside(...t, [6.5, 5.5]), true, '(6.5, 5.5) is inside');
    assert.equal(inside(...t, [2.5, 2.5]), true, '(2.5, 2.5) is inside, by 0.18');
    assert.equal(inside(...t, [13.5, 6.5]), false, '(13.5, 6.5) is outside edge bc');
    assert.equal(inside(...t, [3.5, 9.5]), false, '(3.5, 9.5) is outside edge ab');
  }
  // Whole-number corners, so that a point on an edge gives exactly 0.
  const T = [[1, 1], [3, 9], [11, 5]];
  assert.equal(inside(...T, [5, 5]), true, '(5, 5) is inside the whole-number triangle');
  assert.equal(inside(...T, [2, 5]), false, 'the midpoint of an edge is not strictly inside');
  assert.equal(inside(T[0], T[2], T[1], [2, 5]), false, 'nor with the corners listed clockwise');
  assert.equal(inside(...T, [1, 1]), false, 'nor a corner');
  assert.equal(inside([0, 0], [2, 1], [6, 3], [2, 1]), false, 'a flat triangle has no inside');
});
A hint

edge(a, b, c) with the third corner as the point is twice the triangle's signed area: positive for counter-clockwise corners, negative for clockwise ones and 0 for corners on one line. Use its sign in counterClockwise, and let inside test the three edges of the ordered corners, each value above 0.

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

A triangle rasteriser with the top-left rule

Write rasterizeTriangle(v0, v1, v2, width, height) in raster.mjs. The corners are [x, y] pairs in framebuffer coordinates (y grows down the screen) and may lie outside the target. Return a Uint8Array of width × height values, row by row from the top, with 1 for every pixel whose centre (x + 0.5, y + 0.5) the triangle covers and 0 elsewhere.

  • Snap each corner to 1/256 of a pixel, Math.round(v * 256), and test the centres in the same units, so that every edge value is a whole number.
  • Accept either winding: swap two corners when twice the signed area is negative, and draw nothing when it is 0.
  • Test only the centres of the triangle's bounding box, clamped to the target.
  • A centre with an edge value of exactly 0 is covered only when that edge is a top edge or a left edge: for a counter-clockwise triangle, an edge from a to b is a left edge when b.y > a.y, and a top edge when b.y equals a.y and b.x < a.x.

The starter counts every centre on an edge as inside and assumes counter-clockwise corners. The sample tests use the lesson's triangles A to D, the 10 × 10 square cut along its diagonal, a flat triangle and one that pokes out of the target.

Starter code · raster.mjs

/**
 * rasterizeTriangle(v0, v1, v2, width, height): a Uint8Array of width × height, 1 where the triangle covers the pixel
 * centre (x + 0.5, y + 0.5). This first version counts every centre on an edge as inside, from both sides, and
 * assumes that the corners run counter-clockwise on screen.
 */
export function rasterizeTriangle(v0, v1, v2, width, height) {
  const mask = new Uint8Array(width * height);
  const edge = (a, b, p) => (p[0] - a[0]) * (b[1] - a[1]) - (p[1] - a[1]) * (b[0] - a[0]);
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const p = [x + 0.5, y + 0.5];
      if (edge(v1, v2, p) >= 0 && edge(v2, v0, p) >= 0 && edge(v0, v1, p) >= 0) mask[y * width + x] = 1;
    }
  }
  return mask;
}
The sample tests · raster.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { rasterizeTriangle } from './raster.mjs';

const W = 16;
const H = 12;
const rows = (mask, w = W, h = H) => Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => (mask[y * w + x] ? '#' : '.')).join(''));
const count = (mask) => mask.reduce((n, v) => n + (v ? 1 : 0), 0);
// The lesson's triangles (examples/raster/rasterize_tri.mjs).
const A = [[2.2, 1.4], [4.6, 10.8], [14.3, 6.1]];
const C = [[12.5, 2.5], [3.5, 2.5], [7.5, 9.5]];
const D = [[0.6, 10.2], [15.4, 8.3], [15.4, 8.9]];

test('triangle A covers the 49 pixels of the lesson', () => {
  const mask = rasterizeTriangle(...A, W, H);
  assert.ok(mask instanceof Uint8Array && mask.length === W * H, 'a Uint8Array of width × height values');
  assert.deepEqual(rows(mask), [
    '................',
    '................',
    '..###...........',
    '...#####........',
    '...#######......',
    '...##########...',
    '....#########...',
    '....#######.....',
    '....#####.......',
    '....###.........',
    '................',
    '................',
  ]);
});

test('the same corners listed clockwise give the same pixels', () => {
  const B = [A[0], A[2], A[1]];
  assert.deepEqual(rows(rasterizeTriangle(...B, W, H)), rows(rasterizeTriangle(...A, W, H)));
  assert.deepEqual(rows(rasterizeTriangle(C[1], C[0], C[2], W, H)), rows(rasterizeTriangle(...C, W, H)), 'C clockwise');
});

test('triangle C: centres on its top and left edges count, those on its right edge do not', () => {
  const mask = rasterizeTriangle(...C, W, H);
  assert.equal(count(mask), 36);
  assert.equal(rows(mask)[2], '...#########....', 'row 2 lies on the top edge: pixels 3 to 11, not 12, which is on the right edge too');
  assert.equal(mask[9 * W + 7], 0, 'the bottom corner (7.5, 9.5) lies on a left and a right edge: not covered');
  assert.equal(mask[3 * W + 4], 1, 'pixel (4, 3) is inside');
});

test('corners are snapped to 1/256 pixel', () => {
  const nudged = C.map(([x, y]) => [x + 0.001, y + 0.001]);
  assert.equal(count(rasterizeTriangle(...nudged, W, H)), 36, '0.001 pixel rounds back onto the corners of C');
  const moved = C.map(([x, y]) => [x + 0.004, y + 0.004]);
  assert.equal(count(rasterizeTriangle(...moved, W, H)), 27, '0.004 pixel snaps 1/256 lower, below the centres of row 2');
});

test('a sliver, a flat triangle and one that pokes out of the target', () => {
  const sliver = rows(rasterizeTriangle(...D, W, H));
  assert.equal(sliver[8], '..............#.');
  assert.equal(sliver[9], '......###.......');
  assert.equal(count(rasterizeTriangle([1, 1], [5, 5], [9, 9], W, H)), 0, 'three corners on one line cover nothing');
  const big = rasterizeTriangle([-5, -5], [20, 2], [3, 20], W, H);
  assert.equal(big.length, W * H, 'nothing is written outside the target');
  assert.equal(count(big), 176);
  assert.equal(rows(big)[11], '###########.....');
});

test('the 10 × 10 square cut on its diagonal: every pixel once', () => {
  const sq = [[2.5, 1.5], [12.5, 1.5], [12.5, 11.5], [2.5, 11.5]];
  const upper = rasterizeTriangle(sq[0], sq[1], sq[2], 16, 14);
  const lower = rasterizeTriangle(sq[0], sq[2], sq[3], 16, 14);
  let twice = 0;
  let once = 0;
  for (let i = 0; i < upper.length; i++) {
    if (upper[i] && lower[i]) twice++;
    else if (upper[i] || lower[i]) once++;
  }
  assert.equal(twice, 0, 'no pixel on the diagonal is drawn by both triangles');
  assert.equal(once, 100, 'the square covers exactly 100 pixels');
  assert.equal(count(upper), 55, 'the upper triangle takes the centres on the diagonal');
});
A hint

Write the three edges of the ordered corners as pairs, [b, c], [c, a] and [a, b], and compute for each one whether it is top or left before the loops. Inside the loops a centre is covered when every edge value is above 0, or exactly 0 on a top or left edge.

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.

Triangle Calculator Enter triangle A's three corners: its area, 51.23, is half the edge value of corner c against edge ab; snapped to 1/256 pixel, the program's corners give 51.24. Programmer Calculator (Bitwise) Check a snapped coordinate in binary: 14.3 × 256 rounds to 3661, the last 8 bits of which are the fraction of a pixel.

A last round of questions covers the four ideas, starting with the one your answers so far found hardest:

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 12 What does E_ab(p) = 0 tell you about the point p?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: p lies on the line through a and b

    E_ab measures which side of the line through a and b the point is on, and how far, so it is 0 on the whole line. Whether such a point is on the triangle's edge depends on the other two edge values.

  2. Question 2 of 12 An edge runs from (0, 0) to (6, 8), and a pixel centre gives E = 30 for it. How far is the centre from the line through the edge?

    Type a number.

    A hint

    The edge function grows by the edge's length for every pixel of distance.

    Show the answer: Question 2 of 12

    Answer: 3 pixels (anything from 2.999 to 3.001 counts)

    The edge is √(6² + 8²) = 10 pixels long, and the edge value divided by that length is the distance from the line: 30 ÷ 10 = 3 pixels. That is how the lesson found that (2.5, 2.5) is inside triangle A by under 0.02 pixel.

  3. Question 3 of 12 For a counter-clockwise triangle, a point gives E_bc = 5, E_ca = 7 and E_ab = −1. Where is it?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Outside the triangle, just beyond edge ab

    Each value places the point on one side of one edge. Positive E_bc and E_ca and a slightly negative E_ab put it in the strip just outside edge ab, between the lines of the other two edges. The sum, 11, is twice the area wherever the point is.

  4. Question 4 of 12 Why does the lesson's rasteriser snap the corners to 1/256 pixel before testing any centre?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: So that every edge value is a whole number, and its sign, and a value of exactly 0, can be trusted

    Rasterisers snap positions to fixed point so that the edge tests are integer arithmetic. The lesson used 8 sub-pixel bits, 1/256 pixel, the value that Vulkan's Roadmap 2026 milestone asks for.

  5. Question 5 of 12 Triangle D, a sliver of 4.42 square pixels, covered 4 pixels in two separate pieces. What should the rasteriser do about it?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Nothing, because a sliver covers only the centres it encloses, and in most columns it encloses none

    With point sampling, a pixel belongs to a triangle when its centre does. A triangle thinner than the spacing of the centres can fall between them; the next triangle of the mesh covers those centres, so a closed mesh still has no gaps.

  6. Question 6 of 12 A thin triangle runs diagonally across a 1,000 × 1,000 target. Its bounding box holds 1,000,000 centres and it covers 2,000 pixels. What percentage of the centres the loop tests are covered?

    Type a number.

    A hint

    Divide the covered pixels by the centres tested.

    Show the answer: Question 6 of 12

    Answer: 0.2 % (anything from 0.19 to 0.21 counts)

    2,000 ÷ 1,000,000 = 0.2 %. A bounding box fits a compact triangle well, as triangle A's 49 of 120, but a long diagonal one wastes almost every test, which is why a fast rasteriser needs more than a box.

  7. Question 7 of 12 Under the top-left rule, which pixel centres that lie exactly on an edge does a triangle keep?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: Those on a top or a left edge that do not also lie on a right or a bottom edge

    A top edge is horizontal with the triangle below it, and a left edge is a non-horizontal edge on the triangle's left. Keeping centres on those edges only gives every centre of a shared edge to one of its two triangles.

  8. Question 8 of 12 A renderer keeps centres on bottom and right edges instead of top and left ones. What happens to the lesson's square cut along its diagonal?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: It still covers 100 pixels, none twice, but its mask differs from the top-left one in 38 pixels

    The program counted it: bottom-right covered 100 pixels once, as top-left did, and the two masks differed in 38 pixels. Matching another renderer, such as a GPU, needs its rule as well as exact arithmetic.

  9. Question 9 of 12 Two triangles of a mesh share an edge, but one is built from corners snapped to 1/256 pixel and the other from corners snapped to 1/16 pixel. What can happen along the edge?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Some pixels are drawn twice or not at all, because the two triangles no longer test the same edge

    The exactly-once guarantee needs the shared edge to be the same edge in both triangles, with identical endpoints and identical arithmetic. Snapping every vertex of a mesh the same way, once, keeps it so.

  10. Question 10 of 12 When the rasteriser moves from one row to the next, how does E_ab change?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: It changes by −256 × (b.x − a.x), the same amount for every row

    E(x, y + 1) = E(x, y) − 256 (b.x − a.x): a constant per edge. A row starts from the value of the row above plus that constant, and then steps right by the other constant.

  11. Question 11 of 12 A rasteriser for an 8,192 × 8,192 target keeps 8 sub-pixel bits. Can 32-bit integers hold its edge values?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: No: a coordinate reaches 2^21 units, and an edge value, a difference of two products, needs about 44 bits

    Coordinates go up to 8,192 × 256 = 2^21 units, so a difference needs 22 bits with its sign, a product up to 43, and an edge value about 44. 64-bit integers hold that, and so does a double, exactly, since 2^44 is below 2^53.

  12. Question 12 of 12 Why does the fixed-point rasteriser cover exactly the same pixels when a triangle's corners are listed starting from another corner?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Its edge values are exact whole numbers, and the edges and their top or left tests are the same whichever corner comes first

    In fixed point E(b, a, p) is exactly −E(a, b, p), a value of 0 is a real tie, and the tie rule depends only on the directed edges of the counter-clockwise triangle. So any listing of the same corners covers the same pixels.

Interview questions

The warm-up is answered here in full. The two questions after it come from rasterisers at work, and each row leads to the software-rasteriser question bank, where its first step and likely follow-ups are listed:

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

  1. Fresher to mid · Warm-up

    What is an edge function, and how do three of them decide whether a pixel centre is inside a triangle?

    For the edge from a to b, E(p) = (p.x − a.x)(b.y − a.y) − (p.y − a.y)(b.x − a.x), a 2D cross product. It is 0 on the line through a and b, positive on one side and negative on the other, and divided by the edge's length it is the distance from that line. When a triangle's corners run counter-clockwise on screen, with y pointing down, its inside is the positive side of all three edges, so a pixel centre (x + 0.5, y + 0.5) is inside when E_bc, E_ca and E_ab are all positive. The three values always add up to twice the signed area, so a negative area means the corners run clockwise and two of them are swapped first. A value of exactly 0 is a tie, which the top-left rule settles.

  2. Mid to senior

    You are writing a CPU rasteriser for a 1920 × 1080 target that keeps 8 sub-pixel bits. How wide must the integers for its edge values be, how do you evaluate them across a row, and how would you skip whole 8 × 8 blocks of pixels that a triangle cannot touch?

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

  3. Mid to senior

    Your CPU rasteriser's images match the GPU's except along some horizontal and vertical edges, where they differ by one row or one column of pixels, yet neither image has a gap or a pixel drawn twice. Explain what is going on, say whether either renderer is wrong, and make the image test reliable.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • An edge function is a cross product that is 0 on the edge’s line and positive on the inside of a counter-clockwise triangle; a pixel centre is inside when all three edge values are positive, and the three always add up to twice the area.
  • A rasteriser snaps the corners, fixes the winding from the sign of the area, and tests only the centres of the bounding box: 120 tests for triangle A’s 49 pixels, the same pixels in either winding.
  • The top-left rule gives a centre that lies exactly on an edge to the triangle for which that edge is a top or a left edge, so the square cut on its diagonal covered exactly 100 pixels, none twice.
  • Edge values change by constants from pixel to pixel; in fixed point with 8 sub-pixel bits every step is exact, while float32 steps put 13 of 949,468 centres on the wrong side.

Terms in this lesson

bounding box
The smallest rectangle, with sides along the axes, that holds a shape. A rasteriser tests only the pixel centres inside its triangle's box, clamped to the target.
edge function
A function of a point that is 0 on the line through an edge of a triangle, positive on one side and negative on the other: a 2D cross product. Three of them, one per edge, decide whether a pixel centre is inside.
point sampling
Deciding coverage at one point per pixel, its centre: a primitive covers the pixel when it encloses that point, however much or little of the pixel's square it overlaps.
sliver triangle
A long, very thin triangle. It may enclose no pixel centre in some columns or rows, so its pixels come out in separate pieces, as point sampling allows.
sub-pixel precision
The number of fraction bits with which a rasteriser stores vertex positions. With 8 bits it snaps them to 1/256 of a pixel, so every edge test becomes exact integer arithmetic.

References

Related tools

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.