Part I · Seeing with rays · Chapter 4
Which way is up
Once a ray has found a surface, almost everything that happens next depends on which way that surface faces: how much light falls on it, where a bounce goes, how glass bends the ray. A distance field knows the answer without being told, because the direction straight out of a surface is the direction its distance grows fastest. Finding it costs either a few lines of maths per shape, or a few more looks at the whole scene per hit.
1Uphill, by looking around
The normal is the arrow straight out of a surface, one unit long. In a distance field, it is the way the distance grows fastest, straight across the lines of equal distance: the field's gradient. For a circle that is the direction from its middle to the point, one line of maths, and many shapes give theirs like that, with their distance (the arrow marked exact). For any other, the renderer finds it by differences: it asks the distance a small step h away, once to the right and once up (the dots), and sees how much it grew each way. Think of the distance as the height of a hill: if a step east climbs 3 cm and a step north 1 cm, the arrow (3, 1) points the way the hill climbs fastest. So those two changes, as an arrow, point uphill: straight out of the surface (forward differences). Asking a step either side in each direction, four looks instead of two, gives central differences.
In the last picture the step was large (0.3, in the figure's units, where the circle's radius is 0.95), and on the curved surface both arrows were a little off: forward by 3.9°, central by 0.6°. The surface bends within the step. Here the step is a tenth of that: forward is 0.5° off, central 0.0°. Halve the step and forward's error halves, but central's falls to a quarter: forward measures the slope half a step to one side of the point, central measures it centred on the point. (Here forward fell to an eighth, not a tenth: the halving rule holds once the step is small, and 0.3 is a third of the circle's radius.) So central is far better for the same step, and costs twice the looks. ShadyTracer's renderer still uses forward differences: three looks in 3D instead of six, at a step so small (section 3) that forward's error does not show.
Near a crease, where the circle meets the box, a step can land on the other shape. Here the probe is on the circle, a short way from the crease: the central differences' looks to the left and below land in the box's field, and their arrow leans 20° towards the box's own normal (straight up from its top). The forward looks go right and up, away from the box, and stay within 7°.
With a step a tenth as big, no look reaches the box, and both are nearly right again (forward 0.8° off, central 0.0°). A differences normal blurs every crease over about one step's width; with ShadyTracer's step, a hair's breadth (section 3), nobody sees it.
The mathsForward and central differences
nforward ∝ ( f(p + h x̂) − f(p), f(p + h ŷ) − f(p), … )
ncentral ∝ ( f(p + h x̂) − f(p − h x̂), … )
- f(p)
- the scene's distance at the point; the renderer already has it from the march
- x̂, ŷ
- one unit along each axis (and ẑ in 3D, the "…")
- ∝
- "points the same way as": the arrow is then made one unit long
Forward needs one new look per axis, 3 in 3D; central needs two, 6 in 3D. Their errors on a smooth surface shrink like h and like h²: the bend of the surface spoils forward's difference at first order and cancels between central's two sides (a Taylor series, if you meet one, shows it in a line). Forward differences also lean: they look only one way along each axis, which is why, near the crease, they missed the box that central ones found. Quilez's article below shows a third way, four looks at the corners of a tetrahedron: even-handed like central differences, for four looks instead of six.
2Too small a step
So the smaller the step, the better? Not on a GPU. Its numbers are 32-bit (f32): about seven significant digits. At a metre from the scene's origin, two neighbouring f32 numbers are 0.12 µm apart, one spacing, and a distance can only change in such jumps. This figure does the sums the way the GPU does, every one rounded to f32, for a circle of radius a tenth of a metre, a metre from the origin. On the left it is lit through its normals by differences, and each normal's error is drawn as a tick outside it, longer for a larger error; on the right, the worst error around the circle against the step. At 128 spacings it is 2.2 milliradians (an eighth of a degree), and the ticks are too short to see.
At 2 spacings, the change the differences measure is a few jumps, and rounding decides it: the normal is off by up to 110 mrad (6°), and the ticks are a ragged fringe, different from one point to the next, a pattern made by f32 rather than by the shape. On a shiny surface that is grain in the highlight. It is the red curve, labelled f32 noise, falling as the step grows.
At 16,384 spacings, 2 mm, the rounding is gone, but the step is large enough for the circle to bend within it: the error of section 1 (the curve labelled the step's own error, rising). Now the ticks are smooth, the same at neighbouring points. The best step for this circle sits between the two, around 500 spacings, at about 0.7 mrad. The renderer's floor is lower than that because it is only a floor: the step it really takes is the surface tolerance, which is usually larger (section 3).
A shape much smaller than its distance from the origin is different: a circle of radius one millimetre, a metre away. Here f32's part stays small at every step (the red curve; the renderer's own floor test finds the same, and puts it down to the shape's numbers being its own size). That backwards-sounding result has a reason: the circle's distance is worked out from the point less its centre, two numbers near a metre whose difference is about a millimetre and comes out exactly, and from then on every number is millimetre-sized, with f32's spacing a thousand times finer than at a metre. But at 128 spacings, 15 µm, the step is a fair part of the circle's size, and its normal is off by 7.7 mrad, smoothly.
3The step ShadyTracer takes
The renderer's step for a normal is its surface tolerance at the point (chapter 2): the distance at which a ray counts as having hit. One number does both jobs, because a normal by differences only has to see the surface at the scale the march sees it. Unless a scene sets its own, the tolerance is a ten-thousandth of the scene's size, never coarser than a millimetre: 0.1 mm for a scene a metre across.
It is never smaller than a floor of 128 × 2⁻²³ times the point's largest coordinate. 2⁻²³ (about 0.00000012) is f32's spacing just above 1: a number between 1 and 2 is kept in steps of 2⁻²³, between 2 and 4 in steps twice that, and so on. So 2⁻²³ times a coordinate is one to two spacings there, and the floor is 128 to 256 spacings, depending on where the coordinate sits between two powers of two; the figure above counts it as 128. At a metre from the middle of the scene, that is 15 µm.
Why 128. Measured in an f32 emulation in 3D (core/test/floor.test.mjs, every sum
through Math.fround, 2026-10-07; not on a GPU): the worst noise in a normal by differences, on spheres
and turned boxes a tenth to a third of their distance from the origin, at a step of K spacings, was about 700
mrad at K = 1, 150 at 4, 44 at 16, 11 at 64, 5 at 128 and 2 at 256 (lib/pathtracer/PRECISION.md).
The figure above is the same kind of sum in 2D, with fewer numbers in it to round, so smaller errors at small
steps: 2.2 mrad at 128 against 5. The document states no threshold; its rule is that "below a hundred or so
spacings, a surface's shading is f32's pattern", and 128 is the first power of two past that, where the noise is
about 5 mrad, a third of a degree (inferred: the document does not say why 128 rather than 100). So the floor is
for the normal's noise. It is not for the march: in the same emulation every march ended at a tolerance
of a single spacing. The code's comment above also says the march "can stall" below about a hundred spacings;
the emulation did not find that, and this page goes by what was found.
4How ShadyTracer does it
A shape that knows its normal hands it over with its distance, four numbers at once. The sphere, from the shape library:
The renderer uses the shape's own normal when there is one, and differences when not:
The loop is there for the shader's size, not its speed. The whole scene's distance is one long function, and a
loop the compiler is not allowed to unroll keeps one copy of it in the kernel instead of three
(noUnroll). The shade kernel's own copy of this does the same, one look per pass of a loop it shares
with its other questions ().
The pick, which finds what is under the mouse, takes central differences, six looks, for one ray at a time
().
5Tricks and trade-offs
Each is stamped with how it is known; point at a stamp to see what it means.
A shape's own normal is worth a lot. With the shapes' own normals switched off
(fastnormals 0, a developer setting until 0.74.0), every hit took its normal by differences, and a
sample took 4.3 times as long: 63.6 ms against 14.8, on the user's card, an NVIDIA of the Pascal family
(docs/TESTING.md, "Where the render time goes"). The setting's label said so: off = 4.3x
slower. A switch with one sane value is no setting, so it went: the shape's own normal is now always taken
where there is one (the comment where it was, in uniforms.schema.js).
Why that much is not known. Three more looks per hit, against a march of dozens of looks to find
it, would suggest a few percent, not four times. That count is a guess about what costs what; the 4.3 is the
measurement. The renderer can time each of its kernels on
the GPU (core/kerneltime.js, used by the Tests bench); timed with the normal by differences forced
on and off, it would show which kernel grows, and that would say why. With the setting gone, that now takes a
build changed by hand for the purpose.
A normal left out on purpose. The cone's exact distance comes from Quilez's list, but its
normal is left to the renderer: "the exact gradient has four regions, and a normal that is wrong in one of them is
worse than a slower right one"
(). Each newer shape's normal is
held to central differences by core/test/shapes.test.mjs.
Bent space throws normals away. A twist or bend (chapter 3) zeroes every shape's normal inside it, since the shapes' own arrows were worked out in unbent space; the renderer then takes them by differences of the bent field, which are right.
6What went wrong before
- A normal that was not one unit long.
sign(x)gives −1, 0 or 1, the side of zero x is on. A normal built from the signs of the point's coordinates (an octahedron's, where this was measured) loses a part of its arrow exactly on an axis or a fold, where a coordinate is 0 and its sign 0: an arrow shorter than one unit. The newer shapes ask "is it at or above zero?" instead,select(-1, 1, x >= 0), which is always −1 or 1 (the comment at the top ofshapes.js). - A normal that was not a number deleted its surface: making a zero arrow one unit long divides by zero, and the surface then fails the facing test every surface goes through (chapter 3). Every normalize in the newer shapes is guarded.
- The warped fillet: a blend made its normal from one shape's arrow and another's zero (chapter 3). The fix was to give no normal at all, and let differences find it.
- Six looks nobody asked for. A central-difference normal sat in the shader beside the forward
one, uncalled, until version 0.22.3 (the comment above
calcNormals_fast). - A line round a cut. A rough surface (a relief, chapter 3) makes the distance change
faster than one unit per unit of travel, so a march trusting it could step through a bump. The scene therefore
divides the distance by how steep the relief can get, and in a group of shapes blended together it does that
once, by the steepest relief near the point. Cut a sphere of steep stucco out of a box of gentle plaster, and on
the plaster that number was 4.67 near the hole, where the stucco's relief still counted, and 1.11 farther out.
Two looks a step apart could fall either side of that boundary: a distance of 0.05 came back as 0.05 / 1.11 =
0.045 from one and 0.05 / 4.67 = 0.011 from the other. Their difference measured the jump in the divisor, not
the slope of the surface, and the normal turned by up to 86°: a thin line round the hole. Dividing every
distance by the same number shrinks every difference by that number and leaves the arrow pointing the same way
(it is made one unit long anyway), so a normal does not need the division. Since version 0.71.1 the scene leaves
it out of a normal's looks (
NORMAL_LOOK). The distance at the hit, which the march found divided, is then no base for them, so in such a scene the normal takes a fourth look, at the hit itself (NORMAL_AT_HIT).
7Try it
- In the first figure, put the probe inside the box, near a corner. Which way does the exact arrow point, and why does it jump as you cross the box's diagonal?
- Find the step at which forward differences are 1° off on the circle. How many times smaller must it be for central ones?
- In the second figure, find the step with the smallest error for a tenth-sized circle, and for a third-sized one. Section 2 says why the renderer's floor is lower than both.
- In ShadyTracer, turn on Developer in the Render settings, find Fast Normals, and turn it off. Watch the samples per second in Render stats.