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.
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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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 (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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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:
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:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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).
- Differences of whole numbers. The differences are b − a = (−446114, −601858) and c − a = (−432777, −583865). Subtracting whole numbers this small never rounds.
- 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.
- 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.
- 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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
// 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
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 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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.
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 aRangeError: 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · JavaScript
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 aBigInt: 0n for equal values (including 0 and −0), 1n for neighbours, and correct across zero.nearUlps(a, b, n): true whenulpDistance(a, b)is at mostn(aBigInt).
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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then 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.
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.
-
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.
-
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.
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
sinandcos, while ECMAScript roundssqrtexactly and approximatessin; 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
- WGSL: 6.2.4 Floating Point Types, and 15.7 Floating Point Evaluation (World Wide Web Consortium (W3C))
- ECMAScript 2026 Language Specification: 6.1.6.1 The Number Type, and 21.3 the Math object (Ecma International (TC39))
- Vulkan specification: Limits (subPixelPrecisionBits and the required limits) (Khronos Group)
- WebGPU: 23.2.5.4 Polygon Rasterization, and 25.11 "shader-f16" (World Wide Web Consortium (W3C))
- HTML Living Standard: ImageData and its rgba-float16 pixel format (WHATWG)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress