LTC table fitting and accuracy¶
The renderer owns a table of linearly transformed cosines (LTCs) fitted to its specular BSDF. The many-area-light resampling estimator and the polygonal-light LTC templates share it. Each uses the table to guide specular sampling of an emissive polygon and to weight lights. The BSDF itself is always evaluated exactly, so an inaccurate fit costs variance, not correctness.
The table, its fitter and their provenance belong to the RIS/many-area-light workstream, as decided on October 27, 2026. The polygonal-light templates reuse them and do not fit their own.
| File | Role |
|---|---|
tools/ltc-fit/ (ludus_ltc_fit) |
Offline fitter |
modules/graphics/renderer/data/ltc_table.bin |
Fitted table, 3.4 MB of little-endian float32 values |
modules/graphics/renderer/data/ltc_table_provenance.json |
Fitter version, parameters and the residual error of every transform |
modules/graphics/renderer/shaders/ltc_table_layout.h |
Generated layout constants shared by C++ and Slang, installed with the renderer shaders |
cmake/shaders/generate_ltc_table.py |
Build step that checks the binary against the layout and embeds it in the renderer as C++ |
modules/graphics/renderer/shaders/ltc_table.slang |
Shader lookup from a bound buffer, installed with the renderer shaders |
modules/graphics/renderer/src/internal/ltc_table.h |
CPU lookup, for the RT1 CPU oracle and the tests |
modules/graphics/renderer/src/internal/ltc_table_data.h |
The embedded table, which the renderer also uploads for shaders |
FoundationMath's LTC polygon transform integrates and samples the looked-up LTC inside a polygon.
Sources and licensing¶
The fit follows Eric Heitz, Jonathan Dupuy, Stephen Hill and David Neubelt, "Real-Time Polygonal-Light Shading with Linearly Transformed Cosines", ACM Transactions on Graphics 35(4), Article 41, 2016, SIGGRAPH 2016 (DOI), section 4. From there it takes:
- the cosine-weighted BRDF as the target (Equation 5);
- the four-parameter matrix of Equation 6;
- the L3 error;
- storage of
M⁻¹and the BRDF norm over(θ, √α).
Christoph Peters, "BRDF Importance Sampling for Polygonal Lights",
ACM Transactions on Graphics 40(4), Article 140, 2021
(DOI), sections 4.1–4.2, explains how
the table drives sampling, and why the specular albedo is stored beside the
transform. Its renderer uses a 64 × 64 × 51 table whose third axis is Schlick's
F0 (section 4.1, page 9); Ludus follows that resolution.
Both papers' published LTC tables and utilities are licensed under the GPL, and Ludus neither copies nor imports them. Ludus reimplements the method from the papers' text and fits its own table.
What is fitted¶
The target is the specular lobe of EvaluateReferenceBsdf
(modules/graphics/renderer/src/reference_bsdf.cpp) and of the GPU preview's
Evaluate (renderer_gpu_preview.slang):
- GGX with
α = roughness²; - the height-correlated Smith visibility
0.5 / (n·l Λv + n·v Λl); - Schlick's Fresnel with
F0 = 0.04 (1 − metallic) + baseColor · metallic.
The fitter repeats that formula in float64 rather than linking the renderer.
As in Peters' renderer, each transform fits the lobe with Schlick's Fresnel for
one scalar F0, so the shape follows the Fresnel weighting. Beside the
transforms, the table stores two F0-independent moments of the exact lobe:
- the norm
n = ∫ ρ cos; - the Fresnel norm
f = ∫ (1 − v·h)⁵ ρ cos.
Schlick's Fresnel is linear in F0, so the specular albedo is exactly
F0 · (n − f) + f for each channel. ComputeSpecularLtcAlbedo returns it.
Peters tabulates the albedo per F0 slice instead; the moments keep it exact for
colored F0.
Layout. ltc_table_layout.h defines the counts. The table is a sequence of
four-float entries:
- Transforms: 51
F0slices, linear over[0, 1](the paper gives the count but not the spacing; Peters' supplemental renderer also spaces them evenly with both endpoints). Each slice has 64 rows, linear in perceptual roughness from 0.045, the renderer's admitted minimum, to 1, and 64 columns, linear in the view inclinationθover[0, π/2], as in Heitz et al. Each entry is(M02, M11, M20, M22)ofM⁻¹ ∝ [[1, 0, M02], [0, M11, 0], [M20, 0, M22]], in the frame whose x-z plane contains the view. - Moments: one 64 × 64 grid of
(n, f, 0, 0)atLUDUS_LTC_TABLE_MOMENT_OFFSET.
Lookup. As in Peters' renderer, the lookup reduces an RGB F0 to its
Rec.709 luminance, clamps it to [0, 1] and takes the nearest slice
(GetLtcTableFresnelSlice). Within the slice it interpolates bilinearly over
roughness and inclination, then rotates M⁻¹ by the view's azimuth into the
caller's tangent frame, so the result can go straight to
TryMakeLinearlyTransformedCosine.
Fitting procedure¶
These parts of the procedure are Ludus choices; the papers do not specify them.
- Objective. Heitz et al.'s L3 error between the LTC and the lobe
ρ F cosnormalized by its own normF0 · (n − f) + f, over the sphere. It is estimated from three fixed, stratified sample sets of 32 × 32 points, combined with the balance heuristic: - visible-normal samples of the BRDF (Heitz, JCGT 7(4), 2018);
- uniform samples of the upper hemisphere;
- cosine samples mapped through
M.
With a small F0, Schlick's (1 − v·h)⁵ moves much of the lobe far from the
mirror direction, where visible-normal samples are rare; the uniform samples
cover it. Fixed samples make the objective deterministic and smooth across
cells.
- Optimizer. Nelder–Mead over (log A, B, log C, D), with three restarts of
at most 1500 iterations. The simplex steps scale with the lobe's width. The
search is confined to A, C ∈ [10⁻⁶, 10⁶] and |B|, |D| ≤ 10³: at a small
F0 and smooth roughness an unconfined fit stretches the LTC towards the
horizon until its stored entries overflow. The F0 = 1 slice stays within
A ∈ [0.004, 273], |B| ≤ 145 and |D| ≤ 0.4.
- Initialization. Each F0 slice is an independent job. Within a slice,
rows run from rough to smooth, and each row from normal to grazing incidence.
A cell starts from the best of these candidates:
- its two already-fitted neighbors;
- the identity;
- four lobes aligned with the target's average direction, at widths of 0.5α
to 4α.
The aligned guesses are what make the sharpest rows converge.
- Moments. 256 × 256 stratified points, each giving one visible-normal and
one uniform hemisphere sample, combined with the balance heuristic. Visible-normal
samples alone underestimate the Fresnel norm f of smooth lobes, whose
(1 − v·h)⁵-weighted mass lies far from the mirror direction: at roughness
0.06 and a 6° view they gave 2.2 × 10⁻¹⁰ where float64 quadrature over the
sphere gives 1.44 × 10⁻⁸. The combined estimate matches the quadrature.
- Residual. The relative L1 error between the LTC and the normalized lobe,
in [0, 2], on independent 64 × 64 sample sets. It is recorded for every
transform in ltc_table_provenance.json.
The fit is deterministic and does not depend on the worker count. A slice took about 4.3 minutes of one core in a release build on a loaded Apple M5, so the whole table took about 3.7 CPU-hours (2 h 20 min with nine workers).
Regenerate the table¶
cmake --preset macos-clang-release -DLUDUS_BUILD_LTC_FITTER=ON
cmake --build out/build/macos-clang-release --target ludus_ltc_fit
./out/build/macos-clang-release/tools/ltc-fit/ludus_ltc_fit modules/graphics/renderer/shaders modules/graphics/renderer/data --workers 8
The first directory receives ltc_table_layout.h, the second ltc_table.bin and
ltc_table_provenance.json; both must already exist. --workers sets the job
system's worker threads (default 8, at most 64). Any Linux or macOS preset works.
What to change.
- A change that alters the fitted values bumps
LTC_FITTER_VERSIONintools/ltc-fit/ltc_fit.hpp. - A change to the stored layout bumps the generated
LUDUS_LTC_TABLE_VERSION.
After regenerating.
- Run the renderer's
[ltc]tests, and update the accuracy figures below and intests/ltc_table_tests.cpp. - Test builds compile the fitter and run
ludus_ltc_fit_quick, a low-resolution--quickfit. That run checks only that the tool works.
Use the table¶
| Language | Include | Notes |
|---|---|---|
| Slang | ltc_table.slang |
Declares the table buffer at LUDUS_LTC_TABLE_BINDING, which the includer defines. |
| C++ (renderer) | src/internal/ltc_table.h |
Reads the embedded table directly. |
At 3.4 MB the table is too large for shader constants, so shaders read the
renderer's GPU copy from a read-only storage buffer of
LUDUS_LTC_TABLE_ENTRY_COUNT float4 entries. Its contents are the bytes of
internal::LTC_TABLE. Define LUDUS_LTC_TABLE_BINDING and include
ltc_table.slang after the declarations of every lower binding: Slang numbers
Metal buffers in declaration order rather than by the declared slot.
tests/ltc_table_parity.slang binds the table this way.
Render-graph shaders request the table with "polygonal_light_sampling": true
in the ray technique's scene_interface
(authoring).
The graph wrapper then defines the binding as slot 13 and includes
ltc_table.slang before the authored callbacks, which call LookupSpecularLtc
directly. The renderer
uploads internal::LTC_TABLE once per ray context and binds it to every pass of
that context. tests/sdk_consumer/rg2/ltc_table reads it from a compute pass on
the GPU and compares the result with the CPU lookup.
Include ltc_table_layout.h directly only for its layout constants.
LUDUS_SHADER_BINDING(0) StructuredBuffer<float4> Inputs;
#define LUDUS_LTC_TABLE_BINDING 1
#include "ltc_table.slang"
#include "linearly_transformed_cosine.slang"
SpecularLtc ltc = LookupSpecularLtc(material.Roughness, viewTangent, f0);
float3 specularAlbedo = ComputeSpecularLtcAlbedo(ltc, f0);
LinearlyTransformedCosine lobe;
LinearlyTransformedCosinePolygon polygon;
if (TryMakeLinearlyTransformedCosine(ltc.InverseTransform, lobe) == true &&
TryPrepareLinearlyTransformedCosinePolygon(lightTangent, lobe, polygon) == true)
{
float unshadowedSpecular = GetLinearlyTransformedCosinePolygonIntegral(polygon); // times specularAlbedo
}
The render-graph ray helpers (HELPERS in render_graph_ray.py and
cmake/LudusRenderGraph.cmake) stage ltc_table.slang and ltc_table_layout.h.
Stage FoundationMath's linearly_transformed_cosine.slang with the template that
first uses it.
Accuracy¶
Residual of the fit (relative L1 error; ltc_table_provenance.json has the
full grid). The F0 = 0 slice is listed apart; see below.
| Cells | Median | 95th percentile | Largest |
|---|---|---|---|
F0 > 0 (50 slices) |
4.5% | 41% | 94% (the θ = 90° column) |
F0 > 0, θ < 90° |
37% | 76% | |
F0 > 0, roughness 0.045 |
0.08% | 2.1% | 92% (the θ = 90° column) |
F0 = 0 |
35% | 61% | 189% |
- Minimum roughness. At the renderer's sharpest lobe, roughness 0.045,
every view up to 81° fits within 0.9% in every
F0 > 0slice. WithF0 = 1the residual grows smoothly from 1e-5 at normal incidence to 2% at 85.7° and 6% at 88.6°. - Grazing views. The residual rises towards grazing views, most at medium roughness. That matches Heitz et al.'s observation that LTC fits degrade at grazing incidence and high roughness. Cells with θ ≤ 60° stay below 25%, with a 99th percentile of 16%.
- The θ = 90° column. It is fitted at
cos θ = 10⁻⁴and is degenerate: a lobe pressed against the horizon that a single LTC cannot represent. The lookup reaches it only through interpolation within 1.4° of grazing. - No optimizer failures for
F0 > 0. No cell's residual jumps relative to its smoother-roughness neighbor. - The
F0 = 0slice. Its target is the pure(1 − v·h)⁵-weighted lobe. At roughness up to 0.21 and views between 9° and 23°, that lobe is a ring near the horizon around a near-empty center, and the L3 fit settles on a poor local optimum: 48 cells exceed 100%. Lookups choose this slice only when the luminance ofF0is below 0.01. The lobe's albedofin those cells is between 6·10⁻⁹ and 2·10⁻⁵, so the poor shape costs LTC-sampling variance on a negligible lobe, not correctness.
Integral checks against Monte Carlo of the exact BSDF.
tests/ltc_table_tests.cpp compares two values, under unit radiance:
- the table's unshadowed specular shading of a polygon,
F0 · (n − f) + ftimes the LTC integral; - a 65,536-sample Monte Carlo estimate that samples and evaluates
SampleReferenceBsdfwith a metallic material.
The test covers:
- roughness 0.045, 0.1, 0.25, 0.5 and 1;
- views at 0°, 30°, 60° and 80°;
- squares centered on the mirror direction (half sizes 0.05 to 2 at unit distance), one square 20° off it, and a square covering nearly the whole hemisphere;
F0= 0.04, 0.5 and 1 per channel, each read from its own slice.
That gives 335 comparisons with estimates of at least 10⁻³:
| Comparisons | Median | 90th percentile | Largest |
|---|---|---|---|
| All | 0.37% | 5.0% | 22% |
| Roughness 0.045 | 0.007% | 0.05% | 16% |
| Whole-hemisphere square | 0.07% | 3.3% |
- Largest overall. The 22% error is a 0.007 estimate at roughness 1, 80°,
F0 = 0.04, on a small square around the mirror direction. - Roughness 0.045 outlier. The 16% error is the far tail, 20° off the mirror direction, of a lobe whose estimate there is only 0.0015.
- Whole hemisphere. The stored norms match a quadrature of the BRDF to four
digits (for example 0.9408 at roughness 0.24 and 80°). The 3.3% error at
roughness 0.25, 80° and
F0 = 0.04therefore comes from the fitted shape at a grazing view, not from the normalization. - Norms are estimates. The fitter's moments are Monte Carlo integrals, so a lobe whose albedo is within noise of one can store a norm slightly above it; the largest is 1.00001.
- Test bounds. The test requires a median of at most 1% and a 90th percentile of at most 15% overall. At roughness 0.045 it requires a median of at most 0.5% and a 90th percentile of at most 1%. The whole-hemisphere square must stay within 4%, and every norm at most 1 + 10⁻⁴.
Develop's earlier single-slice table measured a median of 0.32%, a 90th
percentile of 7.7% and a largest error of 85%. Its Fresnel moment f was also
too small for smooth lobes (66 times at roughness 0.06 and a 6° view), because
the fitter sampled only near the specular lobe
(fitting procedure).
GPU lookup. The renderer's [ltc] GPU test compares ltc_table.slang with
the CPU lookup on 4096 cases, including clamped roughness, views below the
horizon or along the normal, and F0 outside [0, 1]. On Metal (Apple M5),
entries relative to the matrix's largest entry agree to a 99th percentile of
1.4e-6 and at most 1.2e-5. The GPU's arctangent differs by a few ulps. Vulkan
and WGSL first run in batch CI.
The default clustered deferred graph also uses this table for unshadowed direct mesh area lighting. It clips real emitter polygons (merged coplanar triangles) against the receiver horizon before integrating the fitted specular lobe. The table is uploaded once per graph program; scene and camera replacements retain independent immutable resource bindings. This raster approximation does not change the CPU/GPU reference path estimators or establish shadow/indirect-light support.