Every Shade Path Imports lux::pbr

I’ve written a few posts about Lux’s PBR pipeline. The Cook-Torrance post covered metallic/roughness workflow and microfacet theory. The image-based-lighting post covered the split-sum approximation and the bake passes. There’s a modules/pbr.wgsl file in the repo that contains the canonical implementation: Kulla-Conty multi-scatter compensation, Smith-correlated visibility, OpenPBR f82 edge tint. It’s been there for months. It’s well-tested. It has a docstring describing exactly which paper each term comes from.

The live mesh.wgsl was not importing it.

The live mesh.wgsl was inlining its own Cook-Torrance, with its own Smith G, its own Schlick Fresnel, its own diffuse term, none of which had multi-scatter compensation. A chrome sphere at roughness 0.5 was losing 12 to 18 percent of its incident radiance to the rough-mirror energy hole that Kulla-Conty exists to plug. I hadn’t noticed, because the test scenes I’d been eyeballing had enough fill light to cover for it.

I’ve been writing PBR posts at about one a month, and the actual shader has been wrong the whole time.

This post fixes that.

The unification

Every active shade path now imports modules/pbr.wgsl. The mesh.wgsl entry points (the canonical static-mesh path) call pbr::evaluate_shade_standard_params for direct lighting and pbr::evaluate_shade_ibl_params for the split-sum IBL contribution. These are the only shade entry points. If a future shader wants a different shading model, it has to either add to the params struct or extend the module, not re-implement the math next door.

mesh.wgsl is now 392 lines, mostly bind-group preamble plus light-loop scaffolding plus TBN unpack plus the PCF shadow helper. The actual BRDF evaluation is one function call. Adding a new BRDF refinement is a one-file change in pbr.wgsl, not a hunt across every active shader.

The white-furnace test gates this. A chrome sphere lit by a uniform white environment should reflect uniform white back at the camera regardless of view angle, if the shading model conserves energy. Pre-unification, the test was failing by 12 to 18 percent at roughness 0.5. Post-unification, it passes within 1 percent across all reference metals (gold, copper, silver, aluminium, iron, chrome, titanium) at every roughness in {0.5, 0.8, 1.0}. The aggregate gate is in white_furnace_metal_radiance_conservation_at_every_roughness.

The 4-channel BRDF LUT

The pre-unification BRDF LUT was 2 channels: scale and bias for Schlick Fresnel. That’s what every game engine published before about 2018. The Kulla-Conty multi-scatter compensation needs a third value (the integral of the GGX BRDF over the hemisphere, called Ess) that equals scale + bias by mathematical identity. The OpenPBR f82 edge-tint path needs a fourth value (the f82 coefficient).

The new LUT is Rgba16Float. R = scale, G = bias, B = Ess (which equals R + G), A = f82_coeff. The B = R + G identity is locked at f16 quantisation precision by a test that re-derives B from R + G across 30 sample cells and asserts the drift is below 5e-3. If anyone bakes a future LUT that breaks the identity (which would happen if Ess gets baked from a different sample distribution than scale/bias), the test fails before the patch can merge.

The GPU bake matches the canonical CPU integrator within 0.04 absolute, which is the Hammersley-noise floor at 1024 samples. That’s another test. The mirror-corner identity (NoV ≈ 1, rough = 0 should give scale > 0.95, bias < 0.05, Ess > 0.95) is a third. Three gates on the LUT, all green.

The A channel (f82_coeff = 0.2005, a constant) is currently unused. The OpenPBR f82 edge-tint path that consumes it lands when I get around to wiring it. For now the channel costs 4 bytes per cell, and the LUT carries it forward so a future OpenPBR shader doesn’t have to re-bake.

One tonemap module

Before this post, there were four tonemap operators implemented in three different files, all of them claiming to be “ACES.” None of them were the same. One was ACES Hill. One was ACES-fitted. One was the legacy AgX with a Punchy variant. One was an undated attempt at the ACES 2.0 DRT.

There is now one tonemap module: modules/tonemap.wgsl. It carries the operators behind a mode: u32 uniform: AgX, ACES 2.0 DRT, ACES-Hill (legacy, kept for back-compat with existing goldens), Tony McMapface, Khronos-Neutral, Reinhard-Jodie. The plugin-side tone_map.wgsl is gone. The texture.ToneMap node is gone. If anyone tries to add a “yet another ACES variant” sibling, the review blocks it.

The mesh path tonemap pass uses ACES 2.0 DRT by default. Switching to AgX is a one-line uniform change. Switching to a custom 3D LUT is the topic of the next subsection.

ColorGrade3DLUT actually grades now

This was the embarrassing one. The ColorGrade3DLUT node had been in the inspector for months. It accepted a .cube file path. It had pins for input range, output range, intensity. The implementation, in its entirety, was:

return src;

Pass-through. The node compiled, the pins worked, the file path got read, the parser validated the .cube structure. None of that information made it to the GPU. Whatever you fed in came out unchanged.

Replaced with a real Selan 2005 tetrahedral sample on the slab-strip layout the renderer’s tonemap module was already using for AgX and Tony McMapface. The .cube parser was already producing correct LUT data. The slab-strip layout was already established. The shader was the missing piece. It’s there now, .cube grades work, and identity LUTs round-trip with mean ΔE76 < 0.5 and max < 1.0 across 256 Halton samples.

I have built one creative coding tool where ColorGrade did nothing, and one creative coding tool where it does something. The something version is preferable.

The Rec.709 flip

Thirteen plugin shaders were using the Rec.601 luminance weights (0.299, 0.587, 0.114). Sobel, FXAA, Histogram, Threshold, LumaBlur, the chroma key pass, the colour-grade nodes, a couple of post-FX I’d ported from random shadertoy snippets. Rec.709’s weights are (0.2126, 0.7152, 0.0722). They’re not interchangeable. Rec.601 is the SDTV broadcast weighting; Rec.709 is the HDTV / sRGB weighting. Modern displays produce Rec.709 colour. Filtering with Rec.601 weights produces a subtle hue drift you’d notice if you were doing colour-critical work, and you wouldn’t otherwise.

Flipped all thirteen. There’s a grep test that asserts zero Rec.601 weight occurrences in any shader under app/plugins/. If anyone copies a snippet from somewhere using Rec.601, the gate catches it before merge.

glTF 2.0 pin set on PbrMaterial

PbrMaterialNode previously had three pins: base_color, metallic, roughness. Real glTF 2.0 materials carry albedo (with alpha), metallic-roughness (combined), emissive, occlusion, plus tier-1 extensions for clearcoat (intensity, roughness, normal map) and anisotropy (strength, rotation). Anyone trying to load a real glTF asset got a tinted-grey blob because all the texture pins were missing.

The pin set is now full glTF 2.0 plus the tier-1 extensions. Adding tier-2 (sheen, transmission, volume, IOR, specular) is a pin addition on the same node, not a sibling node. There will not be a PbrMaterial2 next year. Sibling material nodes for non-glTF workflows (a Notch-style “ColourMaterial,” a Substance-style “ChannelMaterial”) are fine. The canonical glTF surface lives on PbrMaterialNode.

The honest deferral

The cross-renderer match against the Khronos glTF Sample Viewer is not gated yet, because the glTF mesh loader isn’t fully wired. The DamagedHelmet test patch uses a sphere as a stand-in. The shading is gated against a self-baseline (rebake of the reference) at SSIM ≥ 0.9999 and ΔE76 < 5, which proves Lux is internally consistent. It does not prove Lux matches the Khronos reference renderer. That gate lands when the .glb loader does.

I’m not happy about this. I want the cross-renderer match. The .glb loader is a separate piece of work that I’d rather land properly than rush through to make this post’s gate green. Self-baseline now, Khronos cross-match later, both honestly recorded.

What this unblocks

Every active shade path imports the same module. The white-furnace test passes within 1 percent across every reference metal. The 4-channel LUT is locked at f16 quantisation. ColorGrade3DLUT actually grades. Rec.709 across every plugin shader. Full glTF 2.0 surface on PbrMaterial.

The next post is about the shadow path, which until now was a hardcoded ±10 orthographic projection, an 0.005 magic-number bias, an 8-light cap that silently dropped lights past slot 7, and a per-fragment loop that ran against an 8-slot uniform instead of the 1024-slot LightStore that’s been in the codebase for months. There were two reasons that uniform was capped at 8. Both of them are gone next post.


I have no idea what I’m doing or if any of this is right, but it’s fun. Follow along.

← Back to devlog