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 2 – The maths toolkit: vectors, matrices and precision

Floating point, precision and robust geometry

Float32 spacing, sign errors in orientation tests, shadow acne, fixed-point snapping in rasterisers, the accuracy rules of WGSL and ECMAScript, and half floats.

  • 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

  • Predict where float32 rounding breaks geometric tests and choose tolerances
  • Use fixed-point and exact integer arithmetic where rasterisers do
  • Apply the accuracy rules of WGSL built-ins and ECMAScript Math functions
  • Store values as half floats and measure the precision lost

Before you start

What you’ll learn · Before you start

What you will learn

  • Predict where float32 rounding breaks geometric tests and choose tolerances
  • Use fixed-point and exact integer arithmetic where rasterisers do
  • Apply the accuracy rules of WGSL built-ins and ECMAScript Math functions
  • Store values as half floats and measure the precision lost

Before you start

On this page

Two vertices 3 cm apart, a thousand kilometres from the world’s origin, can become the same vertex in float32: there, the nearest float32 values are 6.25 cm apart. The GPU computes in float32, so every position, edge test and ray in a renderer is rounded, and geometry code that assumes exact arithmetic works on most inputs and fails on the rest.

The sections below measure how far apart float32 values are, catch rounding turning an orientation test and a shadow ray around, use the fixed-point grid with which rasterisers make edge tests exact, set tolerances from what WGSL and ECMAScript promise, and store values in half floats with the precision they keep.

Float32 values get sparser as they grow

A floating point: Numbers stored as a sign, a significand and an exponent, so the gap between neighbouring values grows with their size. number is a sign, a significand and an exponent, so the gap between neighbouring values grows with their size. WGSL’s f32 is the IEEE 754 binary32 format, float32: The 32-bit IEEE 754 binary32 format: 24 significant bits, about 7 decimal digits. WGSL's f32 and JavaScript's Float32Array use it., with a 24-bit significand; JavaScript’s own numbers are binary64, 53 bits. The gap to the next value up is one ULP: Unit in the last place: the gap between a floating-point value and its neighbour, the smallest step a value of that size can take., a unit in the last place. The program reads the float32 gap straight from the bits at the sizes a 3D world uses:

Float32 gaps from 1 to 16,777,216, and two vertices far away JavaScript · ulp/ulp_table.mjs
// The gap between a float32 value and the next one up, read from the bits with a DataView, at the sizes a 3D world
// uses. Math.fround rounds a JavaScript number (a float64) to the nearest float32.
const view = new DataView(new ArrayBuffer(4));
function nextFloat32(x) {
  view.setFloat32(0, x);
  view.setUint32(0, view.getUint32(0) + 1); // for a positive float32, the next bit pattern is the next value
  return view.getFloat32(0);
}
const f32Gap = (x) => nextFloat32(Math.fround(x)) - Math.fround(x);
const f64Gap = (x) => x * Number.EPSILON; // for x a power of two; close to it otherwise
const fmt = (n) => (n >= 0.01 ? String(n) : n.toExponential(3));

console.log('value         float32 gap      float64 gap   in metres, the float32 gap is about');
for (const x of [1, 100, 10000, 1000000, 16777216]) {
  const gap = f32Gap(x);
  const words = gap < 1e-4 ? `${(gap * 1e6).toFixed(2)} micrometres` : gap < 1 ? `${(gap * 1000).toFixed(2)} mm` : `${gap} m`;
  console.log(`${String(x).padEnd(12)}  ${fmt(gap).padEnd(15)}  ${fmt(f64Gap(2 ** Math.floor(Math.log2(x)))).padEnd(12)}  ${words}`);
}
console.log(`\nfloat32 of 16777217 is ${Math.fround(16777217)}: above 2^24 not every integer exists`);
console.log(`float32 of 1000000.03 is ${Math.fround(1000000.03)}: a 3 cm move at a million metres is lost`);
const world = 1000000; // two vertices 3 cm apart, 1,000 km from the world's origin
const apart = Math.fround(world + 0.03) - Math.fround(world);
console.log(`\ntwo vertices at 1000000 and 1000000.03 m, stored as float32: ${apart} m apart`);
const camera = world - 1; // render relative to a camera 1 m away: subtract in float64 first, then store as float32
const rel = Math.fround(world + 0.03 - camera) - Math.fround(world - camera);
console.log(`the same vertices stored relative to a camera at 999999 m: ${rel.toFixed(7)} m apart`);

Output

value         float32 gap      float64 gap   in metres, the float32 gap is about
1             1.192e-7         2.220e-16     0.12 micrometres
100           7.629e-6         1.421e-14     7.63 micrometres
10000         9.766e-4         1.819e-12     0.98 mm
1000000       0.0625           1.164e-10     62.50 mm
16777216      2                3.725e-9      2 m

float32 of 16777217 is 16777216: above 2^24 not every integer exists
float32 of 1000000.03 is 1000000: a 3 cm move at a million metres is lost

two vertices at 1000000 and 1000000.03 m, stored as float32: 0 m apart
the same vertices stored relative to a camera at 999999 m: 0.0300000 m apart

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

At 1 the gap is 1.19 × 10⁻⁷, at 10,000 it is 0.98 mm in metres, and at 1,000,000 it is 62.5 mm. Above 2²⁴, 16,777,216, not even every integer exists: 16,777,217 rounds to 16,777,216. So the program’s two vertices, 3 cm apart at a million metres, are stored as the same value, and anything that moves there in small steps jumps in steps of 6.25 cm.

The cure is to keep the large numbers out of float32. Keep world positions in float64 on the CPU, subtract the camera’s position there, and only then convert to float32 for the GPU: measured from a camera at 999,999 m, the same two vertices are 0.0300000 m apart, as they should be.

Render relative to the camera

Precision is best near zero, and the camera is where the eye notices errors. Subtract the camera’s position in float64 before positions become float32, and the nearby world keeps millimetre detail at any distance from the origin.

Checkpoint

Check: Float32 spacing

  1. Question 1 of 2 How far apart are neighbouring float32 values near 1,000,000?

    Type a number.

    A hint

    1,000,000 lies between 2^19 and 2^20, and float32 keeps 24 significant bits.

    Show the answer: Question 1 of 2

    Answer: 0.0625 (anything from 0.0624 to 0.0626 counts)

    The gap is 2^(19 − 23) = 2^-4 = 0.0625, so positions a million metres from the origin move in steps of 6.25 cm, and two vertices 3 cm apart there can become one.

  2. Question 2 of 2 Above which value does float32 stop being able to store every integer?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 2^24 = 16,777,216, where the gap between neighbours becomes 2

    Float32 has a 24-bit significand, so it holds every integer up to 2^24 and then only even ones, then multiples of 4, and so on.

Rounding can turn a geometric test around

The orientation test of the vectors lesson, (b − a) × (c − a), subtracts two products that are nearly equal when the points are nearly collinear. Each product is rounded, and subtracting two rounded values that almost cancel leaves mostly their rounding errors, which is catastrophic cancellation: The loss of most significant digits when two nearly equal rounded numbers are subtracted, so that their rounding errors make up much of the result.. The program puts 20,000 points c on lines through a and b, rounds them to float32 as a vertex shader would, and computes the sign three ways:

The orientation test in float32, float64 and exactly JavaScript · orient/orient_robust.mjs
// The orientation test (b - a) x (c - a) on 20,000 nearly collinear triples: c is put on the line through a and b and
// then rounded to float32, as a vertex shader's output would be. The sign is computed three ways and compared.
const f = Math.fround;
// 1. In float32, rounding after every operation, as an f32 shader computes it.
const orient32 = (a, b, c) => f(f(f(b[0] - a[0]) * f(c[1] - a[1])) - f(f(b[1] - a[1]) * f(c[0] - a[0])));
// 2. In float64, JavaScript's own numbers.
const orient64 = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
// 3. Exactly: every float32 from 1024 to 4096 is a whole multiple of 2^-13, so scale to integers and use BigInt.
const exactSign = (a, b, c) => {
  const I = (v) => BigInt(v * 8192);
  const r = (I(b[0]) - I(a[0])) * (I(c[1]) - I(a[1])) - (I(b[1]) - I(a[1])) * (I(c[0]) - I(a[0]));
  return r > 0n ? 1 : r < 0n ? -1 : 0;
};
const sign = (x) => (x > 0 ? 1 : x < 0 ? -1 : 0);

let seed = 2026;
const rand = () => {
  seed ^= seed << 13;
  seed ^= seed >>> 17;
  seed ^= seed << 5;
  return (seed >>> 0) / 2 ** 32;
};
const coord = () => f(1024 + rand() * 3072);
let tried = 0;
let flipped32 = 0;
let zero32 = 0;
let wrong64 = 0;
let example = null;
while (tried < 20000) {
  const a = [coord(), coord()];
  const b = [coord(), coord()];
  const t = rand();
  const c = [f(a[0] + t * (b[0] - a[0])), f(a[1] + t * (b[1] - a[1]))];
  if (c[0] < 1024 || c[1] < 1024) continue;
  tried++;
  const exact = exactSign(a, b, c);
  const s32 = sign(orient32(a, b, c));
  if (s32 !== exact && s32 === 0) zero32++;
  if (s32 !== exact && s32 !== 0) {
    flipped32++;
    if (!example) example = { a, b, c, v32: orient32(a, b, c), exact };
  }
  if (sign(orient64(a, b, c)) !== exact) wrong64++;
}
console.log(`${tried} triples: float32 got the sign backwards ${flipped32} times and returned 0 for ${zero32} triples that are not collinear`);
console.log(`float64 got ${wrong64} wrong: these float32 inputs need at most about 51 bits, and a double holds 53`);
const p = ([x, y]) => `(${x}, ${y})`;
console.log(`\nfor example a = ${p(example.a)}, b = ${p(example.b)}, c = ${p(example.c)}:`);
console.log(`  float32 says ${example.v32} (left), the exact sign is ${example.exact} (right)`);

// The rasteriser's way: snap every coordinate to 1/256 of a pixel and compare whole numbers, exactly.
const snap = (v) => Math.round(v * 256);
const orientSnapped = (a, b, c) => orient64(a.map(snap), b.map(snap), c.map(snap));
const s = orientSnapped(example.a, example.b, example.c);
console.log(`  snapped to 1/256 of a pixel: ${s}, in units of 1/65536 of a square pixel, ${(s / 65536).toFixed(3)} (left)`);
console.log(`  that is exact for the snapped triangle, the one a rasteriser draws: no product exceeds 2^53`);

Output

20000 triples: float32 got the sign backwards 89 times and returned 0 for 2419 triples that are not collinear
float64 got 0 wrong: these float32 inputs need at most about 51 bits, and a double holds 53

for example a = (3054.60986328125, 3474.336669921875), b = (1311.977294921875, 1123.3292236328125), c = (1364.0750732421875, 1193.614990234375):
  float32 says 0.25 (left), the exact sign is -1 (right)
  snapped to 1/256 of a pixel: 50944, in units of 1/65536 of a square pixel, 0.777 (left)
  that is exact for the snapped triangle, the one a rasteriser draws: no product exceeds 2^53

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

Float32 got the sign backwards 89 times and returned 0 for 2,419 triples that are not collinear. In float64 the same inputs came out right every time, because their exact products need at most about 51 bits and a double has 53, but a shader has no float64. The example triple shows how small the margins are: float32 computes +0.25 where the exact answer is negative. Snapped to 1/256 of a pixel and multiplied as whole numbers, the triple gives an exact answer for the snapped triangle, the one a rasteriser would actually draw.

Rounding breaks ray tracers the same way. A hit point computed in float32 lies a little inside or outside the surface, and a shadow ray started from it can hit the very surface it starts on:

Shadow rays that hit their own sphere, and the offset that stops them JavaScript · acne/shadow_acne.mjs
// Shadow rays in float32: 10,000 camera rays hit a sphere of radius 1000 at the origin, and from each hit point that
// faces the light a shadow ray tests whether the sphere itself blocks the light. Every operation rounds to float32
// (Math.fround), as an f32 shader would. A point facing the light can never be shadowed by its own convex sphere.
const f = Math.fround;
const dot = (a, b) => f(f(f(a[0] * b[0]) + f(a[1] * b[1])) + f(a[2] * b[2]));
const sub = (a, b) => [f(a[0] - b[0]), f(a[1] - b[1]), f(a[2] - b[2])];
const add = (a, b) => [f(a[0] + b[0]), f(a[1] + b[1]), f(a[2] + b[2])];
const scale = (a, s) => [f(a[0] * s), f(a[1] * s), f(a[2] * s)];
const normalize = (a) => scale(a, f(1 / f(Math.sqrt(dot(a, a)))));
const RADIUS = f(1000);

// The nearest positive t at which the ray o + t d (d of length 1) meets the sphere, or null.
function hitSphere(o, d) {
  const b = dot(o, d);
  const c = f(dot(o, o) - f(RADIUS * RADIUS));
  const disc = f(f(b * b) - c);
  if (disc < 0) return null;
  const s = f(Math.sqrt(disc));
  if (f(-b - s) > 0) return f(-b - s);
  if (f(-b + s) > 0) return f(-b + s);
  return null;
}

let seed = 1;
const rand = () => {
  seed ^= seed << 13;
  seed ^= seed >>> 17;
  seed ^= seed << 5;
  return (seed >>> 0) / 2 ** 32;
};
const eye = [0, 0, 5000];
const toLight = normalize([1, 1, 1]);
let facing = 0;
let selfShadowed = 0;
let selfShadowedWithOffset = 0;
for (let i = 0; i < 10000; i++) {
  const aim = [f((rand() - 0.5) * 1400), f((rand() - 0.5) * 1400), 0];
  const d = normalize(sub(aim, eye));
  const t = hitSphere(eye, d);
  if (t === null) continue;
  const p = add(eye, scale(d, t)); // the hit point, rounded: a little inside or outside the true surface
  const n = normalize(p);
  if (dot(n, toLight) <= 0) continue; // turned away from the light: dark anyway
  facing++;
  if (hitSphere(p, toLight) !== null) selfShadowed++;
  if (hitSphere(add(p, scale(n, 0.01)), toLight) !== null) selfShadowedWithOffset++; // start the ray 0.01 off the surface
}
console.log(`${facing} hit points face the light, so none of them should be in shadow`);
console.log(`shadow ray from the hit point itself: ${selfShadowed} report the sphere in the way (${((100 * selfShadowed) / facing).toFixed(1)} %)`);
console.log(`shadow ray started 0.01 along the normal: ${selfShadowedWithOffset} do`);

Output

9519 hit points face the light, so none of them should be in shadow
shadow ray from the hit point itself: 4674 report the sphere in the way (49.1 %)
shadow ray started 0.01 along the normal: 0 do

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

Of 9,519 points facing the light, 4,674, 49.1 %, found their own sphere in the way: the speckled self-shadowing called shadow acne. Starting each shadow ray 0.01 along the normal removed all of it. The offset has to grow with the size of the coordinates, because the error does: at a radius of 1,000 the float32 gap is about 6 × 10⁻⁵.

An epsilon is not a fix for orientation

Treating every |orientation| below some epsilon as collinear only moves the failures: triples just above the epsilon still get the wrong sign, and tests on the same edge from two triangles can disagree. Compute the sign exactly instead, on snapped coordinates, as the next section does.

Checkpoint

Check: Rounding in geometric tests

  1. Question 1 of 2 For the same float32 inputs, the orientation test got the sign wrong 89 times in float32 and never in float64. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Its exact products need about 51 bits; float32 rounds them to 24 bits, and the nearly equal products cancel

    Nearly collinear points make the two products almost equal, so their difference is small; with too few bits the rounding of the products dominates it, which is catastrophic cancellation.

  2. Question 2 of 2 Shadow rays started at float32 hit points found their own sphere in the way 49.1 % of the time. What removed the false shadows, and why does its size matter?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Starting each shadow ray 0.01 along the normal; the offset must grow with the coordinates, because the rounding error does

    A rounded hit point is slightly inside or outside the surface. Starting secondary rays a little way off the surface, along the normal, keeps them from hitting the surface they start on.

Rasterisers snap vertices to a fixed-point grid

A rasteriser cannot afford orientation tests that disagree, because two triangles that share an edge must split the pixels along it exactly: a pixel claimed by both is drawn twice, and a pixel claimed by neither is a hole. So hardware converts vertex positions to 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., whole numbers of a fraction of a pixel. Vulkan calls the fraction subPixelPrecisionBits; it requires at least 4 bits, a 16 × 16 grid in every pixel, and its most recent roadmap profile asks for 8:

One pixel divided into a 16 by 16 grid; a vertex at (0.37, 0.71) snaps to the nearest grid point, (0.375, 0.6875).One pixel, 4 bits of sub-pixel precisionA 16 × 16 grid; y grows downwardscentre (0.5, 0.5)vertex (0.37, 0.71)where the vertex shader put it: (0.37, 0.71)snapped: (6/16, 11/16) = (0.375, 0.6875)With 8 bits the grid is 256 × 256 per pixel.

Snapping turns every vertex into whole numbers of sub-pixels

Text description of the diagram

A single pixel is drawn as a large square divided by thin lines into a 16 by 16 grid, which is 4 bits of sub-pixel precision in each direction. y grows downwards, as in framebuffer coordinates. A small black square marks the pixel's centre, (0.5, 0.5).

A black cross marks a vertex where a vertex shader put it, at (0.37, 0.71) inside the pixel. A blue ring marks the grid point it snaps to, the nearest one, at (6/16, 11/16), which is (0.375, 0.6875). The two marks almost overlap: snapping moves a vertex by at most half a sub-pixel step in each direction.

The legend repeats the two marks, and a last line notes that with 8 bits of sub-pixel precision the grid is 256 by 256 per pixel.

Step through the example triple of the last section in both arithmetics, float32 on the left and the fixed-point grid on the right:

Interactive · step by step One orientation test in float32 and on the fixed-point grid Step through the same three points in both arithmetics, float32 on the left and whole numbers of 1/256 pixel on the right. Watch where each column rounds, and the sign it reaches.
One orientation test in float32 and on the fixed-point grid, step 9 of 9. Showing Float32 positions (exact answer: −0.0012, right), Snapped positions (a = (781980, 889430)), Rounded differences (one of the four rounded), Integer differences (exact: nothing rounded), Rounded products (3,974,460 and 3,974,459.75), Exact products (both below 2⁵³: exact), Float32 result (+0.25: left, the wrong side), Integer result (50,944: left, exactly). In focus: Integer result, Float32 result.× 256 and roundFloat32 positionsexact answer: −0.0012, rightSnapped positionsa = (781980, 889430)Rounded differencesone of the four roundedInteger differencesexact: nothing roundedRounded products3,974,460 and 3,974,459.75Exact productsboth below 2⁵³: exactFloat32 result+0.25: left, the wrong sideInteger result50,944: left, exactlythe other triangle gets −50

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

Every step as text
  1. Three float32 points. The points are the program's example, a ≈ (3054.61, 3474.34), b ≈ (1311.98, 1123.33) and c ≈ (1364.08, 1193.61), each coordinate a float32. Computed exactly, the orientation is −0.0012, so c lies a hair to the right of the line from a to b.
  2. Rounded differences. In float32 every operation rounds to 24 significant bits. Three of the four differences come out exact, and the fourth, the y of b − a, rounds from −2351.00745 to −2351.00732.
  3. Rounded products. The exact products are both about 3,974,459.88, a size at which neighbouring float32 values are 0.25 apart. Computed in float32 from the differences above, they come out as 3,974,460 and 3,974,459.75, and the 0.0012 that told them apart is gone.
  4. The wrong sign. Their difference is +0.25, so float32 says c is on the left. The true margin was smaller than one float32 step at this size, and the rounding of the products decided the sign.
  5. Snap to the grid. A rasteriser first snaps every coordinate to a whole number of 1/256-pixel steps, moving it by at most half a step. a becomes (781980, 889430), b becomes (335866, 287572) and c becomes (349203, 305565).
  6. Differences of whole numbers. The differences are b − a = (−446114, −601858) and c − a = (−432777, −583865). Subtracting whole numbers this small never rounds.
  7. Products that fit. The products are 260,470,350,610 and 260,470,299,666. No difference here can exceed 4096 × 256 = 1,048,576 steps, so no product can exceed about 1.1 × 10¹², far below 2⁵³, and a double holds every one of them exactly.
  8. An exact sign. The difference is 50,944, in units of 1/65,536 of a square pixel, so c is on the left of the snapped edge. That answer is exact for the snapped triangle, the one the rasteriser draws.
  9. Both triangles agree. Snapping moved the points, so this answer differs from the unsnapped one; the rasteriser draws the snapped triangle, not the float32 one. What matters is agreement. The triangle on the other side of the edge computes exactly −50,944, so the two never both claim a pixel, or both miss it.

On snapped coordinates the edge function is integer arithmetic, and for the two triangles of a shared edge E(b, a, p) is then exactly −E(a, b, p). The program checks that against float32, at the pixel centres next to 40 shared edges of a 4096 × 2160 target:

One shared edge, tested from both triangles JavaScript · snap/shared_edge.mjs
// Two triangles share the edge from a to b, one on each side. A rasteriser tests a pixel centre p against that edge
// with the edge function E(a, b, p) for one triangle and E(b, a, p) for the other; exactly, E(b, a, p) = -E(a, b, p),
// so every pixel belongs to exactly one side. 40 random edges on a 4096 x 2160 target, the pixel centres along each.
const f = Math.fround;
const edge32 = (a, b, p) => f(f(f(b[0] - a[0]) * f(p[1] - a[1])) - f(f(b[1] - a[1]) * f(p[0] - a[0])));
const SUB = 256; // 8 bits of sub-pixel precision: positions snapped to 1/256 of a pixel
const snap = (v) => Math.round(v * SUB);
const edgeFixed = (a, b, p) => {
  const [A, B, P] = [a.map(snap), b.map(snap), p.map(snap)];
  return (B[0] - A[0]) * (P[1] - A[1]) - (B[1] - A[1]) * (P[0] - A[0]); // whole numbers below 2^53: exact
};

let seed = 7;
const rand = () => {
  seed ^= seed << 13;
  seed ^= seed >>> 17;
  seed ^= seed << 5;
  return (seed >>> 0) / 2 ** 32;
};
let samples = 0;
let disagree32 = 0;
let disagreeFixed = 0;
for (let k = 0; k < 40; k++) {
  const a = [f(rand() * 4096), f(rand() * 2160)];
  const b = [f(rand() * 4096), f(rand() * 2160)];
  const steps = Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]));
  for (let i = 0; i < steps; i++) {
    const t = i / steps;
    const p = [Math.floor(a[0] + t * (b[0] - a[0])) + 0.5, Math.floor(a[1] + t * (b[1] - a[1])) + 0.5];
    samples++;
    if (edge32(a, b, p) + edge32(b, a, p) !== 0) disagree32++;
    if (edgeFixed(a, b, p) + edgeFixed(b, a, p) !== 0) disagreeFixed++;
  }
}
console.log(`${samples} pixel centres next to 40 shared edges`);
console.log(`float32: E(a, b, p) and E(b, a, p) are not exact opposites at ${disagree32} of them (${((100 * disagree32) / samples).toFixed(1)} %)`);
console.log(`fixed point, 1/${SUB} pixel: they are exact opposites at every one (${disagreeFixed} differ)`);
const group = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
console.log(`largest product on this target: (4096 x ${SUB}) x (2160 x ${SUB}) = ${group(4096 * SUB * 2160 * SUB)}, below 2^53 = ${group(2 ** 53)}`);

Output

62590 pixel centres next to 40 shared edges
float32: E(a, b, p) and E(b, a, p) are not exact opposites at 47539 of them (76.0 %)
fixed point, 1/256 pixel: they are exact opposites at every one (0 differ)
largest product on this target: (4096 x 256) x (2160 x 256) = 579,820,584,960, below 2^53 = 9,007,199,254,740,992

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

In float32 the two values were not exact opposites at 47,539 of 62,590 pixel centres, 76.0 %; with 8 bits of fixed point they were exact opposites at every one, and no product can exceed 579,820,584,960 on this target, far below 2⁵³. Most float32 disagreements change nothing, because both values still have opposite signs; they matter at the pixels whose true value is within the rounding error of zero. A larger run, too long for the browser runner, counts those:

Pixels claimed by both triangles, or by neither JavaScript · seams/seam_pixels.mjs
// The same test at a larger scale (120 shared edges, every pixel centre in a 2 x 2 block at each step along the edge):
// how often float32 lets both triangles claim a pixel, or neither, and how often 1/256-pixel fixed point does.
// About 740,000 tests: run with Node.js, the browser runner would take too long.
const f = Math.fround;
const edge32 = (a, b, p) => f(f(f(b[0] - a[0]) * f(p[1] - a[1])) - f(f(b[1] - a[1]) * f(p[0] - a[0])));
const snap = (v) => Math.round(v * 256);
const edgeFixed = (a, b, p) => {
  const [A, B, P] = [a.map(snap), b.map(snap), p.map(snap)];
  return (B[0] - A[0]) * (P[1] - A[1]) - (B[1] - A[1]) * (P[0] - A[0]);
};
let seed = 7;
const rand = () => {
  seed ^= seed << 13;
  seed ^= seed >>> 17;
  seed ^= seed << 5;
  return (seed >>> 0) / 2 ** 32;
};
const count = { samples: 0, both32: 0, neither32: 0, bothFixed: 0, neitherFixed: 0 };
for (let k = 0; k < 120; k++) {
  const a = [f(rand() * 4096), f(rand() * 2160)];
  const b = [f(rand() * 4096), f(rand() * 2160)];
  const steps = Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]));
  for (let i = 0; i < steps; i++) {
    const t = i / steps;
    const x = a[0] + t * (b[0] - a[0]);
    const y = a[1] + t * (b[1] - a[1]);
    for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
      const p = [Math.floor(x) + dx + 0.5, Math.floor(y) + dy + 0.5];
      count.samples++;
      const [e1, e2] = [edge32(a, b, p), edge32(b, a, p)];
      if (e1 > 0 && e2 > 0) count.both32++;
      if (e1 < 0 && e2 < 0) count.neither32++;
      const [g1, g2] = [edgeFixed(a, b, p), edgeFixed(b, a, p)];
      if (g1 > 0 && g2 > 0) count.bothFixed++;
      if (g1 < 0 && g2 < 0) count.neitherFixed++;
    }
  }
}
console.log(`${count.samples} pixel centres tested against both sides of a shared edge`);
console.log(`float32:     claimed by both triangles ${count.both32}, by neither ${count.neither32}`);
console.log(`fixed point: claimed by both triangles ${count.bothFixed}, by neither ${count.neitherFixed}`);

Output

739864 pixel centres tested against both sides of a shared edge
float32:     claimed by both triangles 2, by neither 0
fixed point: claimed by both triangles 0, by neither 0

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

Two of 739,864 pixel centres were claimed by both triangles in float32, and none in fixed point. Two pixels sound like nothing until a scene has thousands of shared edges every frame and the seams sparkle. A pixel centre that lies exactly on an edge, where both integer values are 0, is assigned to one triangle by a tie rule, which the software rasteriser module builds.

Key idea

Snap positions to a fixed-point grid, then compute edge tests with whole numbers that stay below 2⁵³: the tests become exact, so neighbouring triangles always agree about the pixels between them.

Checkpoint

Check: Fixed-point snapping

  1. Question 1 of 2 With 4 bits of sub-pixel precision, Vulkan's minimum, how many positions does one pixel have along each axis?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 16

    4 bits give 2^4 = 16 steps of 1/16 of a pixel in each direction, the 16 × 16 grid of the figure; 8 bits give 256.

  2. Question 2 of 2 Why does fixed point make E(b, a, p) exactly equal to −E(a, b, p) for the two triangles of a shared edge?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Snapped coordinates are whole numbers, and their differences and products stay below 2^53, so every operation is exact

    Exact arithmetic makes the two triangles' tests consistent, so a pixel is never claimed by both or by neither; a tie rule decides the pixels exactly on the edge.

WGSL and ECMAScript promise different accuracies

Two languages run this track’s code, and they promise different things. JavaScript numbers are IEEE 754 doubles, rounded to nearest, ties to even, and the ECMAScript standard defines Math.sqrt exactly, as the square root rounded to the nearest double, while it leaves Math.sin, Math.cos, Math.exp and Math.pow implementation-approximated: each engine may return a slightly different value. WGSL sets no rounding mode, so even a correctly rounded f32 result may be the neighbour above or the one below, and it gives every operation its own accuracy:

Operation WGSL f32 ECMAScript number
x + y, x - y, x * y Correctly rounded, up or down Rounded to nearest
x / y Within 2.5 ULP (for usual sizes of y) Rounded to nearest
sqrt(x) As 1.0 / inverseSqrt(x); inverseSqrt within 2 ULP Rounded to nearest
sin(x), cos(x) on [−π, π] Absolute error at most 2⁻¹¹ Implementation-approximated
determinant(m) No bound at all No built-in
subnormal number: A floating-point value smaller than the smallest normal one, stored with fewer significant bits. WGSL allows GPUs to flush such values to zero. May be flushed to zero Kept

A test therefore compares with a tolerance: How far two numeric results may differ and still count as equal: an absolute amount, a share of their size, or a number of ULPs., and its kind matters as much as its size. The program tries six comparisons that === gets wrong with an absolute tolerance of 10⁻⁹, a relative one of 10⁻¹² and one of 4 ULPs:

Six comparisons, three kinds of tolerance JavaScript · accuracy/tolerances.mjs
// Six comparisons that an exact === gets wrong, tried with three kinds of tolerance: absolute, relative and a count
// of ULPs (how many representable doubles lie between the two values).
const view = new DataView(new ArrayBuffer(8));
// A double's bits as a signed integer that grows with the value, so subtracting two of them counts the doubles between.
function ordered(x) {
  view.setFloat64(0, x);
  const bits = view.getBigInt64(0);
  return bits < 0n ? -(bits & 0x7fffffffffffffffn) : bits;
}
const ulpDistance = (a, b) => {
  const d = ordered(a) - ordered(b);
  return d < 0n ? -d : d;
};
const absolutely = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
const relatively = (a, b, rel = 1e-12) => Math.abs(a - b) <= rel * Math.max(Math.abs(a), Math.abs(b));
const withinUlps = (a, b, n = 4n) => ulpDistance(a, b) <= n;

const f = Math.fround;
const cases = [
  ['0.1 + 0.2 against 0.3', 0.1 + 0.2, 0.3],
  ['sqrt(2) squared against 2', Math.sqrt(2) ** 2, 2],
  ['1e9 + 1e-6 against 1e9', 1e9 + 1e-6, 1e9],
  ['float32 of 0.1 against 0.1', f(0.1), 0.1],
  ['1 - (1 - 1e-12) against 1e-12', 1 - (1 - 1e-12), 1e-12],
  ['1e-20 against 0', 1e-20, 0],
];
console.log('case                                     ===    abs 1e-9  rel 1e-12  4 ULPs   ULPs apart');
for (const [name, a, b] of cases) {
  console.log(`${name.padEnd(40)} ${String(a === b).padEnd(6)} ${String(absolutely(a, b)).padEnd(9)} ${String(relatively(a, b)).padEnd(10)} ${String(withinUlps(a, b)).padEnd(8)} ${ulpDistance(a, b)}`);
}
console.log(`\n0.1 + 0.2 = ${0.1 + 0.2}; sqrt(2) squared = ${Math.sqrt(2) ** 2}`);
console.log(`float32 of 0.1 = ${f(0.1)}; 1 - (1 - 1e-12) = ${1 - (1 - 1e-12)}`);
console.log(`float32 against float64 needs a float32-sized tolerance: relative error ${(Math.abs(f(0.1) - 0.1) / 0.1).toExponential(2)}, 2^-24 = ${(2 ** -24).toExponential(2)}`);

Output

case                                     ===    abs 1e-9  rel 1e-12  4 ULPs   ULPs apart
0.1 + 0.2 against 0.3                    false  true      true       true     1
sqrt(2) squared against 2                false  true      true       true     1
1e9 + 1e-6 against 1e9                   false  false     true       false    8
float32 of 0.1 against 0.1               false  false     false      false    107374182
1 - (1 - 1e-12) against 1e-12            false  true      false      false    109541452305
1e-20 against 0                          false  true      false      false    4307583784117748259

0.1 + 0.2 = 0.30000000000000004; sqrt(2) squared = 2.0000000000000004
float32 of 0.1 = 0.10000000149011612; 1 - (1 - 1e-12) = 9.999778782798785e-13
float32 against float64 needs a float32-sized tolerance: relative error 1.49e-8, 2^-24 = 5.96e-8

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

0.1 + 0.2 and 0.3 are 1 ULP apart and pass every tolerance. 1,000,000,000 + 10⁻⁶ fails the absolute test, because at that size 10⁻⁹ is far below one ULP, and passes the relative one. Near zero the relative and ULP tests fail: after the cancellation in 1 − (1 − 10⁻¹²) the result is 1.1 × 10¹¹ ULPs from 10⁻¹², and 10⁻²⁰ is billions of billions of ULPs from 0, while an absolute tolerance accepts both. And a float32 result checked against a float64 reference, as in the fourth row, needs a float32-sized tolerance: float32 of 0.1 is off by a relative 1.49 × 10⁻⁸, which 2⁻²⁴ = 5.96 × 10⁻⁸ covers.

Checkpoint

Check: Accuracy rules and tolerances

  1. Question 1 of 2 What accuracy does WGSL promise for an f32 division x / y, for usual sizes of y?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Within 2.5 ULP of the exact result

    WGSL states an accuracy per operation, so two implementations may return values a couple of ULPs apart for the same division, and tests of GPU output use tolerances.

  2. Question 2 of 2 A test checks that a computed value that should be 0 is close to 0, for example 1e-20. Which tolerance works?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An absolute one, such as |a − b| ≤ 1e-9

    Choose the kind by the values: absolute near zero, relative or ULPs elsewhere, so the tolerance scales with the numbers being compared.

Half floats halve memory and keep about three digits

A half float: The 16-bit IEEE 754 binary16 format: 11 significant bits, about 3 decimal digits, with 65,504 as its largest finite value. WGSL calls it f16., IEEE binary16, has 11 significant bits and a largest finite value of 65,504. ECMAScript defines Float16Array and Math.f16round, both present in Node.js 24; WGSL calls the type f16 and allows it only after enable f16; and on a device created with the "shader-f16" feature; and the HTML standard lets ImageData hold "rgba-float16" pixels, 8 bytes each. The program stores colours and positions in half floats and reads them back:

Colours and positions through Float16Array JavaScript · f16/f16.mjs
// Half floats (IEEE binary16) through ECMAScript's Float16Array and Math.f16round: what colours and positions lose.
const h = new Float16Array(1);
const viaF16 = (x) => ((h[0] = x), h[0]);

// 1. Colours: the 256 levels of an 8-bit channel, stored as k / 255 in a half float, then read back as codes.
let worstColour = 0;
let codesKept = 0;
for (let k = 0; k < 256; k++) {
  const back = viaF16(k / 255);
  worstColour = Math.max(worstColour, Math.abs(back - k / 255));
  if (Math.round(back * 255) === k) codesKept++;
}
console.log(`colours: ${codesKept} of 256 codes survive; largest error ${worstColour.toExponential(2)}, against a step of 1/255 = ${(1 / 255).toExponential(2)}`);

// 2. Positions: the gap to the next half float grows with the value, and the range ends at 65504.
const gap = (x) => {
  const v = viaF16(x);
  h[0] = v;
  const bits = new Uint16Array(h.buffer);
  bits[0] += 1; // the next half float up
  return h[0] - v;
};
for (const x of [1, 10, 100, 1000, 2048, 30000]) console.log(`at ${String(x).padStart(5)}: gap to the next half float ${gap(x)}`);
console.log(`2049 is stored as ${viaF16(2049)}; 65504 as ${viaF16(65504)}; 65520 as ${viaF16(65520)}`);
console.log(`the smallest positive half float, a subnormal: 2^-24 = ${viaF16(2 ** -24).toExponential(4)}; half of it rounds to ${viaF16(2 ** -25)}`);

// 3. Memory, and the double-rounding trap the ECMAScript specification warns about.
console.log(`\n1,000,000 positions of x, y, z: float32 ${(12e6 / 2 ** 20).toFixed(2)} MiB, half floats ${(6e6 / 2 ** 20).toFixed(2)} MiB`);
const k = 1.00048828125000022204;
console.log(`Math.f16round(k) = ${Math.f16round(k)}, but Math.f16round(Math.fround(k)) = ${Math.f16round(Math.fround(k))}`);

Output

colours: 256 of 256 codes survive; largest error 2.43e-4, against a step of 1/255 = 3.92e-3
at     1: gap to the next half float 0.0009765625
at    10: gap to the next half float 0.0078125
at   100: gap to the next half float 0.0625
at  1000: gap to the next half float 0.5
at  2048: gap to the next half float 2
at 30000: gap to the next half float 16
2049 is stored as 2048; 65504 as 65504; 65520 as Infinity
the smallest positive half float, a subnormal: 2^-24 = 5.9605e-8; half of it rounds to 0

1,000,000 positions of x, y, z: float32 11.44 MiB, half floats 5.72 MiB
Math.f16round(k) = 1.0009765625, but Math.f16round(Math.fround(k)) = 1

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

Colours survive: all 256 levels of an 8-bit channel come back as the same codes, with errors of at most 2.43 × 10⁻⁴ against a step of 1/255. Positions do not: the gap is 0.5 at 1,000 and 2 at 2,048, so 2,049 is stored as 2,048, and 65,520 is beyond the range and becomes Infinity. Below 2⁻¹⁴ the format runs out of exponent and keeps going with subnormals down to 2⁻²⁴, 5.96 × 10⁻⁸. A million positions take 5.72 MiB instead of 11.44 MiB, which suits colours, normals and texture coordinates, not world positions.

Round to half once

Rounding a number to float32 first and then to half can give a different half than rounding it directly, because the first rounding can land exactly on a tie. The ECMAScript standard’s own example is the last line of the program: Math.f16round(k) is 1.0009765625, but Math.f16round(Math.fround(k)) is 1.

Checkpoint

Check: Half floats

  1. Question 1 of 2 How far apart are neighbouring half floats near 1,000?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 0.5

    1,000 lies between 2^9 and 2^10, and a half float has 11 significant bits, so the gap is 2^(9 − 10) = 0.5: half floats suit colours and normals, not world positions.

  2. Question 2 of 2 What does a WebGPU program need before a WGSL shader can use the f16 type?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The "shader-f16" feature on the device, and the directive enable f16; in the shader

    Request the "shader-f16" feature when creating the device, then write enable f16; at the top of the shader.

Practise: an exact orientation test and honest tolerances

Two steps turn this lesson into tools the rest of the track uses. The first is an orientation test that snaps to 1/256 of a pixel and decides with whole numbers; the second compares results with absolute, relative and ULP tolerances, ULPs counted from the bits:

Code lab · 2 steps

Make geometric tests exact and comparisons honest

Decide orientation exactly on snapped coordinates, then compare floating-point results with the right tolerance.

  1. An exact orientation test on snapped coordinates To do
  2. Absolute, relative and ULP tolerances To do

Exercise · Medium · JavaScript

An exact orientation test on snapped coordinates

Points are [x, y] in pixels. Write two functions in exact.mjs:

  • snap(v): the coordinate in whole 1/256ths of a pixel, Math.round(v * 256). Coordinates outside −8192 to 8192 pixels are a RangeError: inside that range every product below stays far under 2^53, so it is exact.
  • orientExact(a, b, c): the sign, −1, 0 or 1, of (b − a) × (c − a) computed on the snapped coordinates, as whole numbers, so that the answer is exact for the snapped points.

The starter computes in float32, as an f32 shader would. The sample tests include a triple on which float32 and the snapped exact answer disagree, collinear points, the symmetry orientExact(b, a, c) = −orientExact(a, b, c), and 2,000 seeded nearly collinear triples checked against a BigInt reference.

Starter code · exact.mjs

/** A coordinate in whole 1/256ths of a pixel (a RangeError outside -8192..8192). */
export function snap(v) {
  return v; // replace
}

/** The sign (-1, 0 or 1) of (b - a) x (c - a), exactly, on snapped coordinates. */
export function orientExact(a, b, c) {
  const f = Math.fround; // float32, rounding after every operation: not exact
  const v = f(f(f(b[0] - a[0]) * f(c[1] - a[1])) - f(f(b[1] - a[1]) * f(c[0] - a[0])));
  return v > 0 ? 1 : v < 0 ? -1 : 0;
}
The sample tests · exact.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { orientExact, snap } from './exact.mjs';

const exactBig = (a, b, c) => {
  const S = (v) => BigInt(Math.round(v * 256));
  const r = (S(b[0]) - S(a[0])) * (S(c[1]) - S(a[1])) - (S(b[1]) - S(a[1])) * (S(c[0]) - S(a[0]));
  return r > 0n ? 1 : r < 0n ? -1 : 0;
};

test('snap counts whole 1/256ths of a pixel, inside the allowed range', () => {
  assert.equal(snap(1), 256);
  assert.equal(snap(0.37), 95);
  assert.equal(snap(-2.5), -640);
  assert.equal(snap(8192), 2097152);
  assert.throws(() => snap(8192.5), RangeError, 'just past the range');
  assert.throws(() => snap(-9000), RangeError, 'far below it');
});

test('a triple where float32 and the snapped exact answer disagree, and points on one line', () => {
  const a = [2031.5577392578125, 3312.625732421875];
  const b = [2249.5322265625, 1139.56298828125];
  const c = [2133.541259765625, 2295.916259765625];
  assert.equal(orientExact(a, b, c), 1, 'float32 computes -0.125 here; the snapped triangle turns counter-clockwise');
  assert.equal(orientExact([0, 0], [4, 2], [8, 4]), 0, 'collinear');
  assert.equal(orientExact([2, 1], [6, 3], [3, 4]), 1, 'left of the line');
  assert.equal(orientExact([2, 1], [6, 3], [5, 1]), -1, 'right of the line');
});

test('swapping a and b flips the sign, exactly', () => {
  const a = [100.25, 200.5];
  const b = [3000.125, 1800.75];
  for (const c of [[500, 400], [1000.5, 700.25], [2000, 1800]]) {
    assert.equal(orientExact(b, a, c), -orientExact(a, b, c), `c = ${c}`);
  }
});

test('2,000 seeded nearly collinear triples agree with a BigInt reference', () => {
  let seed = 99;
  const rand = () => {
    seed = (seed * 1103515245 + 12345) % 2147483648;
    return seed / 2147483648;
  };
  for (let i = 0; i < 2000; i++) {
    const a = [1024 + rand() * 3072, 1024 + rand() * 3072];
    const b = [1024 + rand() * 3072, 1024 + rand() * 3072];
    const t = rand();
    const c = [a[0] + t * (b[0] - a[0]) + (rand() - 0.5) / 256, a[1] + t * (b[1] - a[1]) + (rand() - 0.5) / 256];
    assert.equal(orientExact(a, b, c), exactBig(a, b, c), `triple ${i}`);
  }
});
A hint

Snap all six coordinates first, then subtract and multiply the snapped integers with ordinary JavaScript numbers: a snapped coordinate is at most 8192 × 256 = 2^21 in size, a difference at most 2^22, and a product at most 2^44, well inside the 2^53 where every whole number is exact. Compare the two products with > and < instead of subtracting them if you want to avoid one more operation.

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

Absolute, relative and ULP tolerances

Write four functions in tolerance.mjs for comparing JavaScript numbers (IEEE binary64 doubles):

  • nearAbsolute(a, b, eps): true when |a − b| ≤ eps.
  • nearRelative(a, b, rel): true when |a − b| ≤ rel × the larger of |a| and |b|.
  • ulpDistance(a, b): how many representable doubles lie between a and b, as a BigInt: 0n for equal values (including 0 and −0), 1n for neighbours, and correct across zero.
  • nearUlps(a, b, n): true when ulpDistance(a, b) is at most n (a BigInt).

The starter estimates ULPs by dividing the difference by Number.EPSILON, which is right only near 1. The sample tests use the cases of the lesson's table, neighbours of 1 and of a million, and values on both sides of zero.

Starter code · tolerance.mjs

export const nearAbsolute = (a, b, eps) => Math.abs(a - b) <= eps;

export const nearRelative = (a, b, rel) => Math.abs(a - b) <= rel * Math.max(Math.abs(a), Math.abs(b));

/** How many doubles lie between a and b, as a BigInt. */
export function ulpDistance(a, b) {
  return BigInt(Math.round(Math.abs(a - b) / Number.EPSILON)); // right near 1 only: replace
}

export const nearUlps = (a, b, n) => ulpDistance(a, b) <= n;
The sample tests · tolerance.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { nearAbsolute, nearRelative, nearUlps, ulpDistance } from './tolerance.mjs';

test('absolute and relative tolerances on the lesson cases', () => {
  assert.equal(nearAbsolute(0.1 + 0.2, 0.3, 1e-9), true);
  assert.equal(nearAbsolute(1e9 + 1e-6, 1e9, 1e-9), false, 'a large value needs more than 1e-9');
  assert.equal(nearRelative(1e9 + 1e-6, 1e9, 1e-12), true);
  assert.equal(nearRelative(1e-20, 0, 1e-12), false, 'nothing is relatively close to 0');
  assert.equal(nearAbsolute(1e-20, 0, 1e-9), true);
});

test('ULP distance between neighbours, near 1 and near a million', () => {
  assert.equal(ulpDistance(1, 1), 0n);
  assert.equal(ulpDistance(1, 1 + Number.EPSILON), 1n);
  assert.equal(ulpDistance(0.1 + 0.2, 0.3), 1n);
  assert.equal(ulpDistance(1e6, 1e6 + 2 ** -33), 1n, 'doubles near a million are 2^-33 apart');
  assert.equal(ulpDistance(1e6 + 2 ** -33 * 5, 1e6), 5n);
});

test('ULP distance across zero', () => {
  assert.equal(ulpDistance(0, -0), 0n, '0 and -0 are equal');
  assert.equal(ulpDistance(Number.MIN_VALUE, -Number.MIN_VALUE), 2n, 'the smallest subnormals on each side');
  assert.equal(ulpDistance(-1, 1), 2n * ulpDistance(0, 1), 'symmetric about zero');
});

test('nearUlps uses the distance', () => {
  assert.equal(nearUlps(0.1 + 0.2, 0.3, 4n), true);
  assert.equal(nearUlps(1e9 + 1e-6, 1e9, 4n), false, 'they are 8 ULPs apart');
  assert.equal(nearUlps(1e9 + 1e-6, 1e9, 8n), true);
});
A hint

Write a double into a DataView with setFloat64 and read the same 8 bytes back with getBigInt64. For positive values the bit patterns are in the same order as the numbers; for negative ones they run backwards, so map a negative pattern b to -(b & 0x7fffffffffffffffn). The distance is then the absolute difference of the two mapped integers.

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.

IEEE 754 Floating-Point Converter Type 1000000.03 and see every bit of its float32 and float64 forms, and how far each is from the number you typed.

Then try the lesson’s self-check, which begins where your answers so far have been weakest and asks a missed question again before it finishes:

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.

  1. Question 1 of 13 What is the gap between 1 and the next float32 value?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: 2^-23, about 1.19 × 10^-7

    The float32 gap at 1 is 1.19 × 10^-7, and it doubles every time the value doubles: 0.98 mm at 10,000 metres.

  2. Question 2 of 13 In millimetres, how far apart are neighbouring float32 values near 10,000 metres?

    Type a number.

    Show the answer: Question 2 of 13

    Answer: 0.98 mm (anything from 0.97 to 0.99 counts)

    10,000 lies between 2^13 and 2^14, so the gap is 2^(13 − 23) = 2^-10 m = 0.000977 m, about 0.98 mm.

  3. Question 3 of 13 A cockpit model 1,000 km from the world's origin shakes as the plane moves, although its vertices are correct in float64 on the CPU. What fixes it best?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: Subtract the camera's position in float64 before converting positions to float32 for the GPU

    Keep large coordinates out of float32: work in float64 on the CPU, make positions relative to the camera, and only then hand them to the GPU.

  4. Question 4 of 13 What is catastrophic cancellation?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: Subtracting two nearly equal rounded numbers, so the result is mostly their rounding errors

    1 − (1 − 10^-12) gave 9.9998 × 10^-13 instead of 10^-12: the subtraction kept only the digits the rounding had already damaged.

  5. Question 5 of 13 Why does treating every orientation value below an epsilon as collinear not make the test robust?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: Values just above the epsilon can still have the wrong sign, and two triangles testing the same edge can still disagree

    Robust geometry decides signs exactly, here with whole numbers on a fixed-point grid, so every test of the same configuration agrees.

  6. Question 6 of 13 With 8 bits of sub-pixel precision, how large is one sub-pixel step, in pixels?

    Type a number.

    Show the answer: Question 6 of 13

    Answer: 0.00390625 pixels

    2^-8 = 1/256 = 0.00390625 of a pixel, the grid the lesson's fixed-point edge functions use.

  7. Question 7 of 13 Why does a GPU snap vertex positions to a fixed-point grid instead of testing edges in floating point?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: Integer edge tests on snapped positions are exact, so triangles that share an edge split its pixels without holes or double coverage

    Exact tests make neighbouring triangles agree. The cost is moving each vertex by up to half a sub-pixel step, which nobody can see.

  8. Question 8 of 13 Which of these ECMAScript functions is defined to return the correctly rounded result?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Math.sqrt

    That is why a test may compare Math.sqrt results exactly across engines but needs a tolerance for Math.sin.

  9. Question 9 of 13 Which tolerance accepts 1,000,000,000 + 10^-6 as equal to 1,000,000,000?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: A relative one of 10^-12, because 10^-6 is 10^-15 of the values

    An absolute tolerance must fit the size of the numbers; a relative or ULP tolerance does that automatically.

  10. Question 10 of 13 An f32 computation in a WGSL shader produces 10^-40, a subnormal value. What may the GPU return?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: 0, because WGSL lets implementations flush subnormal values to zero

    Code that divides by a value which may be subnormal must therefore guard against it becoming 0 on some GPUs.

  11. Question 11 of 13 What is the largest finite half float?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: 65,504

    Beyond 65,504 a half float overflows to Infinity, so world positions and depths do not fit it.

  12. Question 12 of 13 Do all 256 levels of an 8-bit colour channel survive a round trip through half floats?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: Yes; the largest error, 2.43 × 10^-4, is far below the step of 1/255

    Half floats keep about three decimal digits, which is plenty for colours and normals.

  13. Question 13 of 13 Why can Math.f16round(Math.fround(k)) differ from Math.f16round(k)?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Rounding to float32 first can land exactly on a tie between two half floats, which then rounds to the even one

    Round once, from the full value straight to the target format, to avoid double rounding.

Interview questions

Warm-up (fresher to 2-5 years): In JavaScript, 0.1 + 0.2 === 0.3 is false. Why, and how should a test compare floating-point results?

JavaScript numbers are IEEE 754 doubles, which store binary fractions, and none of 0.1, 0.2 or 0.3 can be written exactly in binary. Each is rounded to the nearest double, the sum is rounded again, and it lands on the double just above the one that 0.3 rounds to: 0.30000000000000004, one ULP away. So tests never compare computed floating-point values with ===. They compare within a tolerance chosen for the values: an absolute one near zero, where relative and ULP measures blow up; a relative one or a count of ULPs elsewhere, so the tolerance scales with the size of the numbers. A float32 result, as from a GPU, needs a tolerance of float32 size, a relative error of a few times 2⁻²⁴, when it is checked against a float64 reference.

Two precision bugs close the lesson as interview questions, set out with their first steps on the module’s question page:

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

  1. Mid to senior

    In a flight simulator, the cockpit and the wings visibly shake when the aircraft is far from the world's origin, and sit still near it. Explain the cause with numbers and fix it without changing the world's units.

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

  2. Mid to senior

    A software rasteriser shows sparkling pixels along the shared edges of neighbouring triangles: a few pixels are drawn twice and a few are missing. Explain the cause and fix it so that it cannot happen.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Float32 keeps 24 significant bits, so its gaps grow with size: 0.98 mm at 10,000 and 62.5 mm at 1,000,000; render relative to the camera so that nearby geometry stays precise.
  • Nearly collinear points make the orientation test cancel; in float32 it got the sign backwards 89 times in 20,000 triples, and float32 shadow rays hit their own sphere 49.1 % of the time until they were offset.
  • Rasterisers snap to a fixed-point grid, at least 4 sub-pixel bits in Vulkan, so edge tests become exact integers and two triangles always agree about the pixels on their shared edge.
  • WGSL allows 2.5 ULP for division and 2⁻¹¹ for sin and cos, while ECMAScript rounds sqrt exactly and approximates sin; choose an absolute tolerance near zero and a relative or ULP one elsewhere.
  • Half floats keep every 8-bit colour level but only 0.5 steps at 1,000 and nothing above 65,504; round to half once, not through float32.

Terms in this lesson

catastrophic cancellation
The loss of most significant digits when two nearly equal rounded numbers are subtracted, so that their rounding errors make up much of the result.
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.
float32
The 32-bit IEEE 754 binary32 format: 24 significant bits, about 7 decimal digits. WGSL's f32 and JavaScript's Float32Array use it.
floating point
Numbers stored as a sign, a significand and an exponent, so the gap between neighbouring values grows with their size.
half float
The 16-bit IEEE 754 binary16 format: 11 significant bits, about 3 decimal digits, with 65,504 as its largest finite value. WGSL calls it f16.
subnormal number
A floating-point value smaller than the smallest normal one, stored with fewer significant bits. WGSL allows GPUs to flush such values to zero.
tolerance
How far two numeric results may differ and still count as equal: an absolute amount, a share of their size, or a number of ULPs.
ULP
Unit in the last place: the gap between a floating-point value and its neighbour, the smallest step a value of that size can take.

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.