◆ Signed Distance

Level 4 · ●●

Light, Shadow, and a Camera

Prerequisites: Level 3.

Level 3 left you with a white shape on black — a renderer, technically. This level is the crescendo: we will ask the field five more questions, each answerable with nothing but more field samples, and the silhouette will become the image on this site's front door. No new machinery arrives. That is the secret worth whispering twice: lighting is not a second engine bolted onto the first — it is the same march, aimed at new places.

Which way does the surface face?

Every lighting question starts with orientation. A surface facing the sun is bright; one edge-on is dim. The classical answer needs calculus — but a distance field will sell you the same information for four samples. Stand near the surface and ask the field its value a hair to your left and right: if the right-hand value is larger, the surface lies to your left. Do it on both axes (all three, in 3D) and the differences assemble into an arrow pointing most uphill — away from the surface. That arrow is the gradient, and normalized, it is the surface normal:

n=normalize ⁣(f(p+hx^)f(phx^),    f(p+hy^)f(phy^),    f(p+hz^)f(phz^))n = \operatorname{normalize}\!\big(\,f(p + h\hat{x}) - f(p - h\hat{x}),\;\; f(p + h\hat{y}) - f(p - h\hat{y}),\;\; f(p + h\hat{z}) - f(p - h\hat{z})\,\big)
The gradient, as needles
Every needle points most-uphill — straight away from the nearest surface — computed from four field samples, no calculus required. Needles stay ice-blue while the gradient's length is ≈ 1 (a healthy field) and blush orange where the estimate degrades; push h high and watch corners go warm.

In the library this is calcNormalCentral — six samples in 3D, and the normalize absorbs the 1/2h1/2h factor a mathematician would insist on. It is an approximation of the true gradient, and an honest one: on an exact field the error shrinks with hh, and Level 1's Lipschitz property is why the un-normalized arrow already has length ≈ 1. (Production marchers shave two samples with a tetrahedron trick — it ships in the library as calcNormal, and the code panel shows both.)

The renderer, assembled live

Here is the whole second act of this site in one figure. The scene is the landing page's hero, held still. Every stage below is a toggle; flip them in order, slowly, and watch each idea land. The captions here say what each toggle means — the code panel shows the handful of lines it is:

  • Normals as color — the debug view every shader artist lives in: paint 0.5+0.5n0.5 + 0.5n as RGB. Red faces +x, green faces up. If this looks smooth, the field is healthy.
  • Diffuse light — brightness = how squarely the surface faces the light: clamp(dot(n, lig), 0.0, 1.0). One dot product and the scene has volume. A cool sky term filled from above keeps shadows from going black.
  • Specular glint — a mirror-like highlight where the surface aligns with the half-vector between light and eye, sharpened by a power: pow(clamp(dot(n, hal), 0.0, 1.0), 32.0).
  • Shadows — the move that earns raycasting its keep: from the surface point, march toward the light. Anything hit on the way means darkness. Try hard first; then switch to soft and read the Go deeper on the near-miss trick — shadows with penumbras for free.
  • Ambient occlusion— crevices are dark because they see less sky. Step outward along the normal and compare the field's answers to the distance walked; the shortfall is occlusion.
  • Depth fog— the march already knows how far every pixel's hit is; paint tt itself as atmosphere and the scene gains scale.
  • Gamma — the unglamorous toggle that fixes everything: map linear light through x1/2.2x^{1/2.2} because your eye is not linear. Flip it last and feel the midtones surface.
The renderer, assembled live
Drag to orbit. Start with everything off — the honest white silhouette from Level 3 — then flip the stages one at a time and watch an image become real. Each toggle is a handful of lines in the code panel above.

Take a moment with the “everything” button. A white cutout became a photograph, and every single stage was the same field, asked again: asked sideways (normals), asked toward the light (shadows), asked along the normal (AO), asked about tt (fog). One number per point — still.

A camera you steer

You have been dragging the camera already; here is what the drag does. A camera is three arrows and a number: a position ro\mathit{ro}, a forward direction toward the target, a right and an up vector completing the frame — and a focal length fl=1/tan(fov/2)\mathit{fl} = 1/\tan(\mathit{fov}/2). Each pixel's ray direction is built from its screen coordinates (u,v)(u, v):

rd=normalize(uright+vup+flforward)\mathit{rd} = \operatorname{normalize}\big(u \cdot \mathit{right} + v \cdot \mathit{up} + \mathit{fl} \cdot \mathit{forward}\big)

Orbiting just moves ro\mathit{ro}on a sphere around the target; zooming changes the sphere's radius; the FOV slider trades perspective drama (wide, low fl\mathit{fl}) against telephoto calm. The lens Go deeper unpacks why this is a pinhole camera.

Materials as functions of position

One question remains unanswered by geometry: what color is the surface? In triangle-land the answer is usually a picture stretched over the mesh. Here it is purer — a material is a function of position, evaluated where the ray landed. The checkerboard is a parity of floors; stripes are a sin thresholded; and a cosine palette turns coordinates directly into color. The generative thread from Level 2 never stopped:

Materials as functions of position
Nothing about the geometry changes — only the function albedoAt(p). A material here is not a picture wrapped around a surface; it is a formula evaluated at the point the ray landed on.
Go deeper: soft shadows from one sample per step★★★

A physically correct soft shadow integrates an area light: many shadow rays per pixel, ruinously expensive. The raymarcher's trick — folklore credits the demoscene, the canonical write-up is Quilez's — is to reuse the samples the shadow march already takes. Marching from the surface toward the light, every step evaluates h=f(p+trd)h = f(p + t \cdot \mathit{rd}); that value is how narrowly the ray is missing geometry at distance tt. Track the worst ratio:

res=mint(kf(p+trd)t)\mathit{res} = \min_t \Big( k \cdot \frac{f(p + t\,\mathit{rd})}{t} \Big)

A clean miss keeps the ratio large (full light); a near-miss at small tt darkens it sharply — exactly the geometry of a penumbra, where a thin sliver of light source peeks past an edge. kksharpens or softens the falloff. It is an approximation (it assumes a point of view of the nearest occluder, ignores the light's true shape, and can over-darken when several near-misses stack) — and it looks right because the quantity it tracks, clearance over distance, is proportional to the angular size of the gap the light must squeeze through. One march, zero extra samples, penumbras included.

Go deeper: ambient occlusion as distance-vs-expectation★★

Walk outward from the surface along the normal. Above an open plain, after walking hh the field should read exactly hh — the surface you left is the nearest one. In a crevice, the field reads less than hh: some other wall is closer. The library's calcAO takes five such steps and accumulates the shortfall (hf(p+hn))(h - f(p + h n)), geometrically downweighted:

occ=i(hif(p+hin))0.95i\mathit{occ} = \sum_{i} \big(h_i - f(p + h_i n)\big) \cdot 0.95^{\,i}

The result approximates how much of the hemisphere above the point is blocked at short range — which is what makes corners read as corners even with no shadow ray pointed anywhere near them. It is not energy-correct (real AO is an integral over directions, not a walk along one), but it errs in a direction the eye forgives: it darkens exactly where contact darkening belongs.

Go deeper: the lens — why this is a pinhole camera★★

Put a sensor plane one focal length fl\mathit{fl} behind a pinhole and flip it in front instead (same picture, no inversion): a pixel at screen position (u,v)(u, v) sits at uright+vup+flforwardu \cdot \mathit{right} + v \cdot \mathit{up} + \mathit{fl} \cdot \mathit{forward} in camera space, and the ray from the pinhole through it is exactly the camRay formula. The tan\tan in fl=1/tan(fov/2)\mathit{fl} = 1/\tan(\mathit{fov}/2) is the right triangle between the screen edge (v=1v = 1) and the forward axis. Wide FOV pushes the screen close to the pinhole — rays splay outward and perspective stretches; long FOV pulls it away and the world flattens. The library builds the camera frame with two cross products in cameraBasis: forward toward the target, right = forward × world-up, up completing the triad.

Go deeper: banding and other artifacts — and their fixes★★

Three artifacts account for most ugly raymarched renders:

  • Step banding: visible contour-like rings on large flat surfaces, from rays all stopping at the same quantized ε distance. Fixes: shrink ε with distance (SURF_EPS * t), or dither the starting offset per pixel.
  • Shadow acne: speckle where a surface shadows itself, because the shadow march starts at the surface where f0f \approx 0. The fix is in this site's own code: start at p + n * 0.02 — nudged off the surface along the normal — and begin the march at mint > 0.
  • Normal noise at creases: central differences straddle the crease and average two walls. Shrink hh (cost: floating-point noise) or accept it — smin welds exist precisely to keep creases rare.

A debugging habit worth stealing: when a render misbehaves, switch to normals-as-color first, step-cost second (Level 3). Nine artifacts in ten reveal themselves in one of those two views.

What you now know

  • Normals come from sampling: central differences of the field point most-uphill; normalize and light away. No calculus, six samples.
  • Every lighting effect is the same field, asked again — toward the light (shadows), along the normal (AO), about tt (fog), against the half-vector (specular).
  • Soft shadows fall out of near-miss bookkeeping — min(kh/t)\min(k \cdot h / t) during one shadow march.
  • A camera is three arrows and a focal length; orbit, zoom, and FOV are all just choices of ro\mathit{ro} and fl\mathit{fl}.
  • Materials are functions of position — checkers from floor, stripes from sin, palettes from cosines.

You now hold a complete renderer — the one drawing the front door of this site. Level 5 asks the only question left: how far can formulas go? (Spoiler: past infinity, and into a 4-kilobyte executable.)

Level 5Infinite Worlds From Formulas