Praxis · M3 · ticket #351 · PR #361

Highlights narrower than a pixel

At the visual check of 2026-10-27, the two smoothest helmets in helmet's metallic row showed coloured sparkle and streaks under the scene's three point lights. Nothing was wrong with the BRDF. Each pixel evaluated it once, at one normal, and the highlight was much narrower than the range of normals the pixel covered. This guide covers why that sparkles and why 4x MSAA leaves it alone. It then covers how the lit pass widens roughness by the shading normal's screen-space derivatives, and the two limits on that widening. Testing the change needed a white furnace that works at the roughness floor, so the guide explains how to sum a lobe narrower than the grid. That furnace found an older f32 error in D, which the guide traces to its cause and fixes. It ends with the cost. Two live demos let you shade a small sphere three ways while its highlight moves, and round D's inputs to f32 near the peak.

Provenance. Ticket #351 asked for Tokuyoshi and Kaplanyan's geometric specular antialiasing in the lit pass, with an oracle test, before and after images on helmet and pbr-sweep at 1440p, and the lit pass's cost on town. An earlier decision, on #354, folded in a white furnace that sweeps roughness from the floor to 1 and adds dielectrics. PR #361 did all of it in one squash-merged commit, d8162f0, and fixed the f32 error in D that the new furnace found. Code facts were read at d8162f0: src/render/shaders/shading.wgsl, main.wgsl and src/render/shader_function_tests.rs. The cost figures are the PR's, measured on an Apple M4 Pro with three runs of each variant in one session on a quiet GPU. The project's owner accepted both the images and the cost on 2026-10-28. Where this guide does its own arithmetic, it says so; those sums are f64, on the grids it names.

Chapter 1 · helmet · pbr-sweep

Why highlights sparkle

Key idea. A pixel shaded once evaluates the BRDF at one normal. Where the normal turns further across the pixel than the highlight is wide, the pixel either catches the highlight or misses it, and which one changes whenever anything moves.

A smooth surface's highlight is narrow. A curved surface's normal turns across every pixel. Once the turn is wider than the highlight, the pixel's one shading point says nothing about the rest of what the pixel covers.

D, the GGX normal distribution function, depends on the angle between the normal N and the half vector H. At α = roughness², by this guide's arithmetic, D falls to half its peak when H is 0.37° from N at roughness 0.1, and 0.059° from N at the roughness floor, 0.04. Compare that with a sphere 30 pixels across: at its centre, its normal turns by 1/15 radian, about 3.8°, from one pixel to the next, and more towards its rim. The roughness-0.1 highlight is a tenth of that pixel-to-pixel step. Most pixels miss it. The few whose centres land on it show it at full strength, and as the light, the object or the camera moves, the highlight jumps from pixel to pixel instead of gliding. That is specular aliasing.

4x MSAA does not reach it. The lit pass tests coverage and depth at four samples per pixel, but runs its fragment shader once per pixel (ADR 0019), so MSAA smooths the edges of triangles, not the shading inside them. Shading per sample would only move the problem a factor of two finer, at up to four times the shading cost, and the ticket ruled it out on cost. Temporal accumulation, which averages over frames, is ruled out by ADR 0008: nothing the image depends on reads screen-space history. So the fix has to live in the shading itself. The single evaluation has to return what the pixel's average would have been.

The cooked mips already do part of this. The cooker widens each mip's roughness by how much the normal map's normals disagree within a texel, after Toksvig (Anisotropy Explained). That covers detail smaller than a texel of the mip being sampled. It does nothing for the normal's change from pixel to pixel: the curvature of the mesh, and normal-map detail at a fine mip, where each texel spans several pixels.

Before: the two smoothest metallic helmets at 2x zoom. The left one is mostly dark with isolated coloured dots and broken streaks along its edges; the right one shows a few broken streaks. After: the same helmets. The left one shows continuous highlights along its edges and rims, and the coloured dots have become soft continuous glints; the right one shows broader, continuous highlights.
helmet's metallic row at 1440p, 2× zoom, before (top) and after (bottom). The left helmet is roughness 0.1 and the right one 0.3. Before, the left one's highlights are isolated coloured sparkles where a pixel centre happened to catch one of the three point lights. After, they are continuous highlights along its edges and rims. The point lights are coloured, hence the colours. From PR #361.

Once per pixel

Once per pixel, widened

Reference: the pixel's average

Live demo. A white metal sphere under one directional light, seen head-on through a grid of pixels. The left panel shades each pixel once, at its centre. The middle one does the same with roughness widened as the lit pass widens it, from normal differences within each 2×2 quad. The right one averages each pixel at the sphere's own roughness, over 64 points and 64 more within each point's share of the pixel wherever the highlight could fall: what the pixel should show. Move the light and watch the left panel's highlight flare and vanish while the other two glide. At roughness 0.1, the left panel's brightest pixel swings between 0.07 and 0.63 as the light turns from 40° to 61°, while the reference's stays between 0.37 and 0.51. The middle panel's highlight is steady but softer than the reference's, peaking near 0.2: it spreads the same light over more pixels, since the image's total light stays within about 1% of the reference's by this page's sums. That softness is the price of the filter. Simplified: one light, F = 1, Appendix B's D and V, a box pixel for the reference where the filter assumes a Gaussian of σ = 0.5 px, and x/(1 + x) for display, whose values the readouts give from 0 to 1.
Chapter 2 · shading.wgsl · specular_antialiased_roughness

Filtering the distribution

Key idea. A narrow lobe averaged over a spread of normals is, near enough, a wider lobe over one normal: the two widths add in quadrature. So the pixel can be shaded once, with α² raised by the spread of the normals it covers.

The pixel should show the BRDF averaged over its footprint. Inside that footprint the normal varies. Averaging a lobe over a varying normal blurs it, and a blurred GGX lobe is close to a wider GGX lobe. So widen the lobe, and shade once.

The argument runs in slope space, where a microfacet normal m becomes the slope (−mx/mz, −my/mz). There the Beckmann distribution is a Gaussian whose variance along each axis is α²/2. Kaplanyan, Hill, Patney and Lefohn (HPG 2016) write it as a covariance matrix, ΣB = diag(αs²/2, αt²/2), "where we use the relation α² = 2σ² [BS63] between the standard deviation of a Gaussian distribution and the roughness parameter of a Beckmann distribution". Averaging a Gaussian over another Gaussian adds their covariances, so their eq. (15) filters the distribution by adding the pixel's own, Σ′B = ΣB + ΣP. GGX is not a Gaussian, but the same paper uses a Beckmann lobe of the same α as a proxy for it, and Tokuyoshi and Kaplanyan (I3D 2019) note that "this roughness matrix can be used to approximate the filtering of the GGX NDF". Their roughness matrix is twice the covariance.

What is the pixel's covariance? Model the pixel as a Gaussian filter of standard deviation σ in screen space; Tokuyoshi and Kaplanyan take σ = 0.5 pixels. Near the pixel's centre the normal changes linearly, by ∂n/∂x per pixel across the screen and ∂n/∂y down it. Over the filter, it therefore spreads by about σ times those changes. For small turns, a normal's change and its slope's change are about the same size. Their isotropic form, eq. (4), adds both screen directions' squared changes, doubles the variance into roughness and caps it at κ:

ᾱ² = α² + min(2σ² (|∂n/∂x|² + |∂n/∂y|²), κ) σ = 0.5 px, κ = 0.18
widened roughness = min(ᾱ², 1)1/4 since α = roughness²

In the shader, σ² is PIXEL_FILTER_VARIANCE, 0.25, and κ is MAXIMUM_FILTER_ALPHA_SQUARED, 0.18. The function takes roughness and returns roughness, since that is what decode_surface stores; the fourth root undoes α² = roughness⁴.

fn specular_antialiased_roughness(
    roughness: f32,
    normal_change_x: vec3<f32>,
    normal_change_y: vec3<f32>
) -> f32 {
    let normal_variance = PIXEL_FILTER_VARIANCE
        * (dot(normal_change_x, normal_change_x) + dot(normal_change_y, normal_change_y));
    let filter_alpha_squared = min(2.0 * normal_variance, MAXIMUM_FILTER_ALPHA_SQUARED);
    let alpha = roughness * roughness;
    return sqrt(sqrt(min(alpha * alpha + filter_alpha_squared, 1.0)));
}

How much does it widen? It depends on how many pixels the curvature is spread over. By this guide's arithmetic, at the centre of a sphere, where each derivative is 1/radius per pixel:

RoughnessSphere 30 px acrossSphere 260 px across
0.04, the floor0.2580.089
0.10.2600.112
0.30.3350.301
0.50.5090.500
Widened roughness at a sphere's centre, by this guide's arithmetic from eq. (4), with |∂n/∂x| = |∂n/∂y| = 1/radius. A pbr-sweep sphere is about 70 px across at Golden size, 640×360, and about 260 px at 1440p; 30 px is a smaller object, such as a distant one. Towards the rim the derivatives grow and so does the widening, up to κ.

The small sphere's mirror becomes roughness 0.26 at its centre. That is a large change, and it is what the pixel's average looks like: at that size, each pixel covers normals spread over several degrees, and a 0.06°-wide lobe averaged over them is a lobe several degrees wide. Rough surfaces barely change, because their lobes are already wider than the pixel's spread. Variances add, so a small spread added to a large one makes little difference.

Chapter 3 · main.wgsl · fs_main · decode_surface

Derivatives, κ and the floor

Key idea. The derivatives come free from the 2×2 quads the GPU shades in, and under 4x MSAA they are per pixel, the footprint the filter assumes. Two limits stop the widening from running away: κ on what it adds and 1 on the total. The roughness floor applies after both.

The GPU already shades pixels in groups of four so it can compare neighbours. Asking how the normal changes costs a subtraction. The limits matter where the normal changes fastest, at the rims.

A GPU runs fragment shaders in 2×2 quads. WGSL's dpdx(v) is the difference in v between horizontal neighbours in a quad, and dpdy(v) between vertical ones. These are screen-space derivatives. A derivative is only defined while every pixel of the quad is running the same code, so fs_main samples every texture first, builds the shading normal, and takes dpdx(normal) and dpdy(normal) before any per-light branching. The normal it differences is the one after the normal map, so both the mesh's curvature and the normal map's detail count.

Under 4x MSAA the lit pass shades once per pixel, not per sample, so neighbouring invocations are one pixel apart and the derivatives are per pixel. A comment beside the call says so. That is the unit σ is measured in.

A quad belongs to one triangle. Where a quad overhangs its triangle, the extra pixels run as helpers with that triangle's interpolants, so a derivative never differences across a triangle edge. A flat facet with one normal across it therefore gets no widening at all. Its highlight lights the facet whole or not at all, as helmet's faceplate shows when the sweep draws it without its textures. That is not aliasing: it is what a flat mirror does.

κ, the cap on what filtering adds

Towards a silhouette, the normal turns ever faster per pixel and the derivatives grow without bound. Uncapped, every rim would be shaded at roughness 1. κ = 0.18 caps the α² that filtering adds, so a perfect mirror widens to at most 0.181/4 ≈ 0.65 roughness (this guide's arithmetic). Tokuyoshi and Kaplanyan give κ = 0.18 as "the clamping threshold" with their eq. (1), and eq. (4) keeps it. The listing in their paper then saturates the sum, so the total α² stays at or below 1, and specular_antialiased_roughness does the same with min(…, 1.0).

Then the floor

decode_surface widens the Material's roughness first and applies MINIMUM_ROUGHNESS, 0.04, to the result. The floor exists so that D stays finite (Lit Pass Explained). Widening can only raise roughness, so a widened value never needs the floor. A surface whose normal does not change, such as a flat floor or the furnace in chapter 4, still lands on the floor exactly as before.

The oracle

specular_antialiased_roughness_adds_the_normals_variance_to_alpha_squared runs the WGSL function on the GPU and compares it with eq. (4) evaluated in f64. It covers a still normal, a change along each screen axis alone, a change large enough to reach κ, and a roughness high enough to reach the cap at 1. The decode_surface oracle passes zero derivatives, so it still tests only the decoding.

Chapter 4 · shader_function_tests.rs · the white furnace

A furnace from the floor

Key idea. To sum a lobe narrower than the grid, sum over the variable it is narrow in. The specular lobe is narrow in H at every view, so the furnace sums over half vectors and maps each to its light with the Jacobian 4 (V·H).

A furnace adds up how much light a white surface sends back. If the grid it adds over is coarser than the highlight, the sum depends on where the grid's points happen to fall. That tells you nothing about the shader.

The white furnace lights a surface equally from every direction and sums what it sends towards V, its directional albedo. A white metal that conserves energy reflects at most 1. Before #351, the oracle summed over 16,200 light directions in cells of 1° by 2°, at roughness 0.5 and 1 only (Lit Pass Explained). The ticket asked for the sweep to run from the floor to 1. On the old grid, the floor is meaningless:

White metal, seen head-onOld grid: 1°×2° light directionsNew grid: half vectors
Roughness 0.04, the floor0.4450.9992
Roughness 0.11.0870.9991
Roughness 0.50.9160.916
Roughness 10.3070.307
One BRDF, two grids. Appendix B's white metal summed in f64 by this guide's arithmetic, on the oracle's old grid and on its new one. At the floor, the whole lobe falls inside the first ring of light directions, whose samples all sit 0.5° off the mirror direction, far down the lobe's side: the sum reads 0.445. At roughness 0.1, a sample near the peak stands for a 1°×2° cell and overcounts it: the sum reads 1.087, and a correct BRDF would fail. Only the rough rows can be trusted on the old grid.

Summing over half vectors

At every view, the specular lobe sits around H = N, as narrow in H as α. So the test puts its grid on H and derives the light from each half vector by reflecting the view about it: L = 2 (V·H) H − V. Cells of equal size in H are not equal in L. A small solid angle dωH about H maps to dωL = 4 (V·H) dωH about L, the reflection Jacobian that Walter et al. (2007) derive to sample GGX. Each cell's light is weighted by that.

The half vectors lie on rings about the normal, 48 to a ring in azimuth. The first ring ends 10⁻⁵ radian from the normal, and each ring after it is 10% wider than the one inside it, until rings reach 1° wide at 10.5°; from there to the horizon they stay 1° wide. That is 184 rings and 8,832 half vectors per view, less those facing away from V. The rings are finest where the floor's lobe lives, so the sum no longer depends on where it lands.

The sweep and its bounds

The test sweeps seven roughnesses (the floor, 0.1, 0.2, 0.3, 0.5, 0.7 and 1), three views (0°, 60° and 85° off the normal) and three materials (a white metal, a white dielectric and a black dielectric): 63 furnaces. Each GPU sum must land within 10⁻⁵ of an f64 sum of Appendix B's BRDF over the same cells. On the M4 Pro they land within 2 × 10⁻⁷. Then the bounds:

  • A white metal reflects at most 1, plus 10⁻³.
  • A white dielectric reflects at most 1 plus what a black dielectric reflects, plus 10⁻³. Appendix B's coupling of diffuse and specular is not energy conserving, so a white dielectric can pass 1: by this guide's sums it reflects about 1.59 at the floor seen at 85°. A black dielectric of the same roughness reflects only the specular part, about 0.65 there, so the difference, about 0.94, is the diffuse lobe alone, which must stay at or below 1.
  • The 10⁻³ is the grid's own error. These sums land within 7.1 × 10⁻⁴ of sums over cells half or a quarter the size, below them where the lobe is narrow.

The furnace's normal is +Z, untilted, because f32 holds it exactly. A tilted normal, rounded off unit length, moved D near the floor's peak by about 1%, and the reference's sum with it, which would hide anything smaller.

The ticket also asked that "the widened α keeps every case under its bound". Widening adds nothing when the normal is still, so the floor is a case it really produces, and the sweep has to pass there. It did not.

Chapter 5 · shading.wgsl · distribution_ggx

f32 at the peak

Key idea. Near D's peak, 1 − (N·H)² is a tiny number computed from N·H, a number near 1. Any error in N·H becomes a large relative error in it. |N × H|², which equals it for unit vectors, is built from small components and keeps its precision.

With the normal untilted, the furnace at the floor still failed. A white metal seen head-on reflected 1.011 of what it received, 1.1% more, where the f64 sum over the same cells was 0.9992. The BRDF was right. Its f32 arithmetic was not.

Appendix B's D is α² / (π ((N·H)² (α² − 1) + 1)²). PR #245, under #199, had already rearranged its denominator as (N·H)² α² + (1 − N·H)(1 + N·H), so that f32 never forms α² − 1, close to −1, only to add 1 back (Lit Pass Explained). That fixed the arithmetic, but the arithmetic starts from N·H, which is already rounded.

Just below 1, neighbouring f32 values are 2⁻²⁴ ≈ 6.0 × 10⁻⁸ apart. At the floor, by this guide's arithmetic, D falls to half its peak where 1 − N·H ≈ 5.3 × 10⁻⁷: only 8.9 f32 steps from 1. A rounding error of half a step in N·H is a few percent of 1 − N·H there, and more nearer the peak. The conditioning is the problem, not the order of operations: no rearrangement of a formula whose input is N·H can recover digits N·H has already lost.

Why a 1.1% error, not a random one

Rounding to nearest errs evenly in both directions, and much of that error cancels over the lobe. This guide modelled it by rounding H's components to f32, with N = +Z and every operation rounded, as the demo below does. The old form's D errs by up to 4.7% within 0.3° of the peak, but its sum over the lobe moves by only about +0.04%. The GPU's sum was 1.2% above the f64 one, so its errors ran mostly one way. A one-way error is enough. If N·H reads δ high throughout the lobe, 1 − (N·H)² reads about 2δ low, and the lobe's integral, ∫ α² / (α² + s − c)² ds = α² / (α² − c) with c = 2δ, grows by about 2δ/α². For 1.2%, δ ≈ 1.5 × 10⁻⁸: about a quarter of one f32 step. Leaving H a hair longer than 1 after normalize would do it. The GPU's sequence of roundings was not traced one operation at a time, and it does not need to be: the fix removes the sensitivity, whatever the error's source.

Lagrange's identity

For any two vectors, |a × b|² = |a|² |b|² − (a·b)². For unit N and H this gives |N × H|² = 1 − (N·H)², which is exactly the small term in D's denominator. Near the peak, the cross product's components are of the order of sin θ, where θ is the angle between N and H. f32 rounds each of them to a relative error of about ε/sin θ. Going through N·H costs about ε/sin² θ instead. At the floor's half maximum, sin θ ≈ 10⁻³, so that is about a thousand times better. A length error in H also scales |N × H|² by only (1 + 2η), a relative error of 2η, where through N·H it is 2η/sin² θ. Filament's mobile path computes D this way, and its comment gives the reason: "This computes 1.0 - NoH^2 directly (which is close to zero in the highlights and has enough precision)".

fn distribution_ggx(normal_dot_half: f32, normal_cross_half_squared: f32, alpha: f32) -> f32 {
    let alpha_squared = alpha * alpha;
    let denominator = max(
        normal_dot_half * normal_dot_half * alpha_squared + normal_cross_half_squared,
        alpha_squared
    );
    let distribution = alpha_squared / (PI * denominator * denominator);
    return select(0.0, distribution, normal_dot_half > 0.0);
}

reflected_luminance computes cross(normal, half_vector) once per light and passes its squared length down through metallic_roughness_brdf. The max holds the denominator at α². For unit N and H the sum is never below α² anyway, so the max changes nothing there. It catches H = 0, where the light is exactly opposite the view, both terms are 0 and D would otherwise divide by zero; review caught that case.

The tests changed with it. The D and BRDF oracles now pass |N × H|² computed in f64, so they test D's arithmetic rather than how a test vector's N·H was rounded. The D integral test, which had left the floor out because f32 cosines brought it to 0.9991, now includes it. With the fix, the GPU's furnace sums and the f64 sums agree within 2 × 10⁻⁷ at every roughness, and every bound holds.

through N·H (before #361)through |N × H|² (now)D's shape, peak at the top
Lobe's sum, through N·H
Lobe's sum, through |N × H|²
1 − N·H at half max
Live demo. D computed with every operation rounded to f32 (JavaScript's Math.fround), against D in f64, as N·H moves away from 1. H's components are rounded to f32 and N = +Z. The slider adds a one-way error to N·H in quarters of an f32 step, as some GPU operation might; it leaves |N × H|² alone, as a length error would barely touch it. The sums are the lobe's integral out to ten times its half-maximum angle, relative to f64. This page's model of rounding, not the GPU's: at the floor, a quarter step moves the sum by about as much as the GPU's error.
Chapter 6 · praxis bench town · Goldens

Cost and Goldens

Key idea. The widening costs town's lit pass +0.7% and the precise D +1.3%, +2.0% together, which the owner accepted. Goldens moved where the widening reaches: smooth highlights and the rims of small spheres.

Two small changes per pixel and per light, measured against each other on the same machine in the same session.

The ticket asked for the lit pass's cost on Reference scene town. Three builds were benched three times each, interleaved, on a quiet M4 Pro: main, main with the widening only, and the whole PR. Each figure is the median of a run's 1,800 measured frames at 2560×1440.

Town's lit pass, median µsRun 1Run 2Run 3Against main
main8,698.98,712.08,706.0—
Widening only8,768.88,759.28,763.4+58 (+0.7%)
PR #361: widening and |N × H|²8,879.08,875.28,872.9+170 (+2.0%)
The A/B. Each variant's runs agree within about 13 µs, so both steps are well outside the noise. Against the Baseline re-recorded just before, the PR's second run reads 8,875.2 against 8,726.0, +149.3 µs, a ratio of 1.017. The ticket had asked for "within noise". It is not, so the cost went to the owner with a recommendation to keep both, and was accepted.

The widening is two dot products, two mins and two square roots per pixel, plus the two derivatives. The precise D adds a cross product for every light, and town's pixels have many point lights, so it costs more than the widening even though each instance of it is cheaper.

Goldens

At Golden size, 640×360, a pbr-sweep sphere is about 70 px across, small enough that the widening reaches roughness 0.3 and above at the silhouettes. The ticket allowed for that: hold the Golden, or re-record it and say why. On Metal, the PR re-recorded minification, normal-tangent, orrery, pbr-sweep, shadows and town. On CI's software Vulkan it re-recorded minification, normal-tangent, orrery, pbr-sweep and town, taken from CI's own captures, whose debug and shipping builds came out identical. The RTX 2070 SUPER's Vulkan Goldens for those scenes stay stale until that machine runs again.

The difference between pbr-sweep before and after, scaled eight times: rings around the highlights of the two roughness-0.1 spheres, faint outlines at the rims of the rougher spheres, and black elsewhere.
pbr-sweep at 1440p, before minus after, scaled ×8. At roughness 0.1, each highlight widens, so its rim lights up in the difference. At 0.3 and above, only the silhouettes change, where the normal turns fastest. From PR #361.
Review

Check yourself

A sphere 30 px across has roughness 0.1. What roughness does the lit pass shade its centre pixel with, and why is that the right answer rather than a blur?

About 0.26, by eq. (4). At the centre each derivative is 1/15 per pixel, so 2σ² (|∂n/∂x|² + |∂n/∂y|²) = 2 × 0.25 × 2/225 ≈ 0.0044. Added to α² = 0.0001, that gives ᾱ² ≈ 0.0045 and a roughness of 0.00451/4 ≈ 0.26. The pixel covers normals spread over several degrees, and a lobe 0.37° wide averaged over them is several degrees wide. Shading once with the wider lobe gives the pixel what averaging many samples would. The reference panel of the first demo shows the same thing.

The old furnace grid read 0.445 for a white metal at the floor and 1.087 at roughness 0.1, where both should be just under 1. What went wrong, and what does summing over half vectors change?

The specular lobe is narrower than the grid's cells. At the floor, the whole lobe falls inside the first ring of light directions, whose samples all sit 0.5° off the mirror direction, on the lobe's side, and read far below its peak. At 0.1, a sample near the peak stands for a whole 1°×2° cell and overcounts it. The lobe is narrow in H at every view, so the new grid places its cells on H, finest near the normal, and maps each to its light by L = 2 (V·H) H − V with weight 4 (V·H). Then every lobe from the floor to 1 is resolved by many cells.

|N × H|² and 1 − (N·H)² are equal for unit vectors. Why does only one of them hold up in f32 near D's peak?

1 − (N·H)² is computed from N·H, a number near 1 whose f32 rounding is an absolute error of order 6 × 10⁻⁸. Near the peak at the floor, 1 − N·H is only a few such steps, so that rounding becomes a relative error of several percent, and a one-way error of about a quarter step is enough to move the white metal's sum by the 1.2% the GPU showed. The cross product's components are of order sin θ, formed from products whose rounding errors are of order ε, so |N × H|² keeps a relative error near ε/sin θ instead of ε/sin² θ. Reordering the arithmetic, as #245 did, cannot help once the input has lost the digits.

Reference

Reading list

Grouped by source, each with what it is good for here. Links into the code are pinned to d8162f0.

This ticket

In the repo

  • Ticket #351 the sparkle at the visual check, what to build, the acceptance criteria
  • PR #361 the images, the furnace finding, the A/B and the review
  • The owner's decision on #351 visual check and the +2.0% accepted
  • Ticket #354 where the furnace sweep from the floor was decided
  • PR #245 the earlier rearrangement of D's denominator

The code

Papers and code

Books

  • Akenine-Möller, Haines, Hoffman, Pesce, Iwanicki and Hillaire, Real-Time Rendering, 4th edition, CRC Press, 2018: section 9.13, Blending and Filtering Materials, pp. 365–374 filtering normals and their distributions, Toksvig at p. 369; pages from the book's table of contents and index
Reference

Glossary

Reference scene, Golden and Baseline are CONTEXT.md's definitions at d8162f0, Baseline shortened. Normal distribution function, roughness floor, white furnace and directional albedo follow Lit Pass Explained. The rest are this guide's own.

Specular aliasing
Flicker and sparkle where a highlight is narrower than the range of normals one pixel covers, so the pixel's one shading point catches or misses it.
Normal distribution function
D: how densely microfacets face each direction H. Appendix B's is Trowbridge-Reitz, which Walter et al. named GGX.
Roughness floor
MINIMUM_ROUGHNESS, 0.04: decode_surface raises any lower roughness to it, so α is at least 0.0016 and D stays finite.
Slope space
Microfacet normals written as slopes, (−mx/mz, −my/mz). In slope space a Beckmann distribution is a Gaussian, and small tilts of the surface shift slopes by about the angle they turn.
Quad
The 2×2 block of pixels a GPU runs a fragment shader on together, so that each value has a horizontal and a vertical neighbour to differ from. Pixels outside the triangle run as helper invocations.
Screen-space derivative
How fast a value changes from one pixel to the next, taken by differencing it between the pixels of a 2×2 quad: dpdx across the screen, dpdy down it.
White furnace
A check that lights a white surface equally from every direction. A surface that conserves energy reflects at most what it receives.
Directional albedo
The share of uniform light from the whole hemisphere that a surface reflects towards one view, ∫ f(L, V)(N·L) dω.
Conditioning
How much a computation magnifies its input's relative error. 1 − x for x near 1 is badly conditioned: a tiny relative error in x becomes a large one in the result.
Reference scene
A Scene paired with a fixed camera path and injected time, so that every run produces the same frames. Used for Goldens and Baselines.
Golden
A committed image that a Reference scene must reproduce within tolerance.
Baseline
The recorded measurements of a Reference scene on one adapter and backend: the reference machine, where Budgets are judged against it; CI's lavapipe, where only its counters are judged; or the RTX 2070 SUPER, the hardware floor benched for comparison.
Built from ticket #351, PR #361 and its merge d8162f0, the shading code and tests at that commit, CONTEXT.md, ADRs 0008 and 0019, and the sources in the reading list. The cost figures are the PR's own A/B on the M4 Pro. Where the guide does its own arithmetic (lobe widths, the widening table, both grids' sums, the dielectric's sums and the rounding model), it says so; the sums are f64 over the grids named. The sparkle demo is this page's model of one sphere under one light. The precision demo is this page's model of f32 rounding, not a trace of the GPU's.