This page runs from a web server, not from a file: in the repository run python3 -m http.server 8000 and open http://localhost:8000/tutorial/.

Part III · What surfaces do · Chapter 9

Rough and shiny

Between the matte surface and the mirror of chapter 8 lies almost everything real: brushed metal, a varnished table, a plastic toy, whose reflections are blurred. ShadyTracer models them all one way, as a surface of tiny mirrors tilted at random. This chapter is that model (GGX), how the renderer picks a direction from it, and two places it once went wrong: a table of the light one reflection loses, and a sampler that was right only for light coming straight down.

1A surface of tiny mirrors

Look closely at a shiny surface that is not a perfect mirror, and it is made of microfacets: flat pieces, each a mirror, far too small to see, tilted this way and that. Light reflects off each facet as off a mirror, and the tilts spread the reflections into a lobe round the mirror direction. The roughness says how tilted the facets are. Here a white metal of roughness 0.15 under light 40° from the normal (drag the light round): a narrow lobe, almost a mirror. The lobe's distance from the point in each direction is how much light goes that way. The thin lines are 60 directions picked at random the way the renderer picks a rough reflection (chapter 8: pick at random, divide by the chance). They are directions in 3D, most of them a little out of the page, shown by their shadow on it, all drawn the same length: only where they point matters. The lobe is only the slice of the light in the page itself, so a pick that leaves the page can land, by its shadow, outside it.

Two things about those picks, used later. The renderer starts its paths at the eye (chapter 8), so strictly it picks reflections of the view; a reflection is as strong with light and view swapped, so these figures put the light where the view would be and call it the light. And each pick is made from two random numbers between 0 and 1: the first turns into a direction round the view, the second into how far out: near 0, a facet facing the eye; near 1, the rim of what the eye sees, the facets seen edge-on. The rim's facets are the most tilted, and theirs are the reflections that go into the surface.

At roughness 0.6 the lobe is wide. And some of the picked directions now point into the surface (dashed, red, inside the metal): reflected off one facet, they run into a neighbouring one. A single reflection loses that light; a real surface would bounce it again among its facets. One reflection keeps 0.8012 of the light here, E. What it loses, 19.88%, is the picks that go into the surface, and a share of the others too: a facet's reflection can be hidden by a neighbour on its way out (the fold-out's G2 / G1).

Light at a grazing 78° (nearly along the surface): the lobe leans and stretches along the surface. The share lost is a little smaller here, 17.76% against 19.88% at 40°: how much a single reflection loses depends on the angle in a way the next section charts.

ShadyTracer's facets follow the GGX distribution, the one most renderers use: a few formulas give how many facets point each way (D), and how many of those are hidden from the light or the eye by their neighbours (Smith's G: masking and shadowing). Its tilt, α, is the roughness squared: a roughness slider that way feels even to the eye ("perceptual"), and most renderers offer it.

The mathsThe lobe in four pieces

Light from ωi (in) reflects towards ωo (out) only off facets whose normal is halfway between, h = (ωi + ωo) normalised. Every angle θ is measured from the surface's normal; θh is the facet normal's. The reflection's BSDF:

f = D(h) G2(ωo, ωi) F(ωo·h)4 cos θo cos θi

D
how many facets point along h, per unit of solid angle (per bit of direction): GGX's 1 / (π α² (tan²θh/α² + 1)² cos⁴θh)
Λ
how much of the surface a direction's neighbours hide, growing as the direction gets lower: Λ(ω) = (√(1 + α² tan²θ) − 1)/2, 0 straight down. A direction (x, y, z) in the surface's frame, z along the normal, has tan²θ = (x² + y²)/z², which is how the code writes it.
G1
the share of facets seen from one direction: 1 / (1 + Λ(ω))
G2
the share seen from both directions at once, not hidden by neighbours: 1 / (1 + Λ(ωo) + Λ(ωi))
F
the facet's own Fresnel reflectance: how much of the light a facet reflects at the angle it is met. F0 is that straight on, which for a metal is its colour (chapter 8); glass and plastic use the exact formula (chapter 10)
4 cos θo cos θi
turns a density of facet normals into one of reflected directions, and the facets' area into the surface's

The renderer draws the facet normal h from the facets the eye can see, in proportion to how much of each it sees (visible normals), and reflects about it. The chance of the reflected direction then works out to G1(ωo) D(h) / (4 cos θo), and the weight f cos θi over that chance is F G2 / G1: never more than F (before section 2's compensation), and exactly the share of light kept when averaged (section 2). The algebra, with every piece from the list above: f cos θi ÷ (G1D / 4 cos θo) = [DG2F / (4 cos θo cos θi)] cos θi × 4 cos θo / (G1 D) = F G2 / G1; and since Λ is never below 0, G2 is never more than G1. That the chance works out to G1D / (4 cos θo) is to take on trust here (Heitz, below).

A rough white metal in the plane of the light: the light coming in (drag its handle), the mirror direction (dashed), the lobe of reflected light (bold, red: its distance from the point is how much light goes that way, scaled so its peak always reaches the same length), and 60 directions picked as the renderer picks them, shown by their shadow on the page, those going into the surface dashed inside it. Set the roughness, or pick new directions.

2The light a reflection loses, and giving it back

The share one reflection keeps, E, depends on the roughness and the view. It is an average: over all the directions the renderer might pick, of what each one keeps (0 for one that goes into the surface). The top chart gives it for a white metal at roughness 0.3, at every angle from the normal: 0.991 straight on, falling slowly, lowest near 85° (0.886) and rising again at the very edge. A real metal keeps the rest too, after a second or third bounce among its facets, so a white metal in a white furnace (chapter 8) should vanish. The single-reflection model would show it darker.

So ShadyTracer gives the light back. Before the first picture, the graphics card works out E for every roughness and angle into a table, and a metal's reflection is multiplied by 1 + F0(1/E − 1) (Turquin's compensation; for a white metal, 1/E). A white metal then reads exactly 1, if the table is right; with a table that is off, it reads Etrue / Etable, the bottom chart.

The table was off. Each entry is that average taken over a plain 32 × 32 grid of the two random numbers a pick is made from, in place of random ones (the red dots). At roughness 0.3, seen 60° from the normal, it says 0.97850, against 0.97505 worked out finely (the comment in section 4). Why: the second number, near 1, picks the facets the eye sees most edge-on, the most tilted ones, at the rim of what it sees, and theirs are the reflections that go below the surface. That rim is a thin strip of the grid, and a plain grid puts few points in it, so it saw too few of the losses. A white metal of roughness 0.3 read 0.997 in the furnace (the same comment). A sphere shows every angle at once, so it reads something like an average of the bottom chart's red line, which runs from 0.991 at 0° to about 0.996 further out (inferred: the comment gives the sphere's reading, not how it was averaged).

The grid is now squeezed towards that rim (the blue rings): the second number becomes 1 − (1 − v)². The last hundredth of the range, from 0.99 to 1, now gets the grid's points from 0.9 to 1: a tenth of them, ten times their old share. Near the rim the squeeze is ten-fold: v from 0.94 to 0.96 moves the number only from 0.9964 to 0.9984, a step ten times smaller. So each of those points stands for a tenth as much of the range, and counts 2(1 − v) = 0.1 times. Points crowded into a strip, each counting for its strip's share: the average is unchanged and only its error falls, 0.97556, and the metal within 0.1% of 1.

At roughness 1 one reflection keeps less than half the light at 60° (0.451), and only 0.307 straight on; the compensation more than doubles it. Here the loss comes from everywhere, not from a thin rim, so squeezing the grid buys nothing: both grids are off by up to about 0.15%, either way.

Top: E, the share of light one reflection off a white rough metal keeps, by the view's angle from the normal: worked out finely (line), and on the renderer's 32 × 32 grid, plain as it was (red dots) and warped as it is (blue rings). Bottom: what a white metal compensated by each table then reads, Etrue / Etable, the plain table's line red, the warped one's blue. Set the roughness; point at either chart to read it.

3A density that was right only straight on

Chapter 8's rule: a direction picked with chance p counts 1/p times (its probability density). If the code that picks and the code that states p disagree, every direction counts the wrong number of times, and the picture is biased: wrong in one direction, however many paths are averaged. ShadyTracer's old sampler used a formula for its density that is right when the light comes straight down. The chart is the old density over the true one for every reflected direction. Straight down it is 1 everywhere: correct.

At 60° it runs from 0.154 to 1.623. Where it was too large a direction counted too little, and where too small too much: up to 6.5 times, a rare pixel far too bright, a firefly. A clamp, a cap on any one count at 10, hid the fireflies and kept the bias. A test that looks straight down at a floor can never see this, which is why the renderer's material test looks at 70° off vertical.

At 80°, as low as 0.023: a weight 43 times too large.

The old sampler's density over the true one for each reflected direction in the plane of the light (the light comes in from the left, its mirror direction on the right); 1 (dashed) is right. Set the light's angle and the roughness; point at the curve to read it.

4How ShadyTracer does it

The model, in the file that holds the one GGX the renderer has:


    

    

Drawing a visible facet normal, and the density of the reflection that follows:


    

The weight of a rough metal's reflection, with the compensation of section 2 (lb.cm, 1 + F0(1/E − 1), from the table: ):


    

And the table's grid, with why it is warped:


    

A claim in the comments, checked. The header and the bounce's comment say a reflection "weighs at most F G2/G1 ≤ 1", which is why no clamp is needed. That holds for the reflection alone. The metal's weight is then multiplied by section 2's compensation, which is 1 or more, so a metal's single weight can exceed 1 (by up to 1/E, 3.3 times at roughness 1 straight on): by design, since the furnace reads 1 because of it. The comment leaves the compensation out.

5Tricks and trade-offs

Each is stamped with how it is known; point at a stamp to see what it means.

One GGX, on the graphics card only. The tables are built on the graphics card by the very functions shading calls, so the share the compensation divides by is the share the lobe really has. "A JS copy of GGX would be a second model the first time either was edited" (microfacet.js's header): this chapter's figures use a copy of their own, which the renderer never sees.

Predicted, then measured. A white metal on a sphere in a furnace shows exactly Etrue / Etable, so the furnace test can be predicted without a graphics card. Predicted 0.9998, 1.0006, 0.9971, 0.9999 for four metals; measured 0.9996, 1.0009, 0.9966, 1.0001 (on ShadyTracer's developer's own graphics card, an Ampere, at 946 × 1061 pixels, 2026-09-29: chapter 0 says where such numbers come from). After the warp, the metals at roughness 0.5, 0.3 and 0.15 read 1.0003, 1.0002 and 1.0002, each where its grid predicted (roadmap/SURFACES.md).

Picking and evaluating agree. A surface lit by the sun can be rendered two ways: by its picked directions alone, which find the sun by chance, or by also aiming a shadow ray at the sun and using the BSDF's formula (chapter 14). If the picking and the formula disagree, so do the two pictures. Seen 70° off vertical they agree to under 1%, in cd/m²: a plastic at roughness 0.3 18,557 against 18,635, a metal at 0.3 3,439.7 against 3,447.0. A matte floor, whose cosine pick cannot be wrong, differs by as much, 0.79%: that is the noise of finding a 5° sun by chance (on the developer's older Pascal card, docs/TESTING.md, "BSDF parity").

6What went wrong before

  • A density right only straight on, and a clamp at 10 that hid the fireflies it made: section 3 (microfacet.js's header).
  • Three answers to "is it a mirror?", and an alpha clamped where the BSDF was evaluated but not where its density was: chapter 8's one threshold replaced them.
  • Every glass reflected 4%. Fresnel came from a "specular" slider, never from the index: "water, glass and diamond all reflected 4%, and total internal reflection jumped from 4% to 100%" (chapter 10).
  • A table read the wrong way. An anisotropic metal looked up the isotropic table, and read 0.988 in the furnace; the tables gained two dimensions, the view's direction against the grain (microfacet.js, "THE ANISOTROPIC TABLES").

7Try it

  • In the first figure, find the roughness where the drawn directions first go into the surface at 40°, then at 78°.
  • In the second, slide the roughness: where does the plain grid go most wrong, and why near the rim?
  • In the third, at what angle is the old density first off by a factor of 2?
  • In ShadyTracer, put a white metal sphere in an empty scene and set its roughness from 0 to 1. Does it stay as bright?

Words from this chapter