Add GGX multiscatter compensation with Cycles albedo method
I was working on improving three.js' PBR implementation and while I was using the pathtracer as reference I noticed rough materials were getting darker. I now know that that's because lack of multiscattering.
I asked Claude if it could implement multiscattering in the path tracer, and after a few tries this is what he did:
Description
Implements GGX multiscatter energy compensation to fix energy loss in rough surfaces. Uses Blender Cycles' albedo-based approach to add back missing energy as a diffuse-like multiscatter lobe.
Implementation
-
Albedo approximation: Uses Cycles' fitted curve
0.806495 * exp(-1.98712 * r²) + 0.199531to estimate single-scatter energy capture -
Energy compensation: Adds
(1 - albedo) * Favg / πas a diffuse-like multiscatter term - Applied to all GGX lobes: Main specular and clearcoat both receive compensation
- Production-tested: Based on Blender Cycles' implementation used in professional rendering
Approach
This implementation uses an analytical compensation method rather than explicit random-walk simulation. The approach estimates how much energy single-scatter GGX captures using a fitted albedo curve, then adds the missing energy back as a Lambertian-like lobe scaled by average Fresnel.
This is simpler and faster than full microsurface random-walk methods while providing good energy conservation. The fitted albedo values come from Blender Cycles' ground-truth precomputed data.
Visual Impact
- Furnace test: All material combinations (smooth/rough × dielectric/metal) now show proper energy conservation
- Rough surfaces: Brighter and more physically accurate, no longer darkening incorrectly
- Smooth surfaces: Minimal compensation (albedo ≈ 1.0), maintaining correct mirror-like behavior
- Performance: Negligible overhead - just one exp() and a few multiplications per ray
References
- "Multiple-Scattering Microfacet BSDFs with the Smith Model" (Heitz et al. 2016)
- Blender Cycles:
intern/cycles/kernel/closure/bsdf_microfacet_multi.h
| Before | After | Diff |
|---|---|---|
Here is the new subtracted from old. It clearly shows diffuse materials got a bit lighter.
At the same time old subtracted from new shows the shaded areas in the mid-roughness low-metal got slightly darker
Thanks! I'll take a look at this more in depth when I have a chance. There has been some pretty bad energy loss for metallic materials, unfortunately. Do you mind doing a comparison of the furnace test, as well? See here.
Lastly - I know this is from Claude but do you have a reference implementation for these new functions?
The furnace test did get "worse" indeed.
| Before | After |
|---|---|
Looking better now...
| Before | After |
|---|---|
Asked Claude to study Cycles code 🤓
| Before | After |
|---|---|
Updated PR description.
I just found this other PR: https://github.com/gkjohnson/three-gpu-pathtracer/pull/349
How come you didn't merge that one back then?
An update on this... Three.js PBR shaders now pass the furnace test: https://github.com/mrdoob/three.js/pull/32190
Thanks - I'll have to take a deeper look at this next week. The reason #349 wasn't merged is because it was causing some models to render as too bright in some cases and it felt generally like a downgrade outside of cases like the furnace test.
The extra brightness may have been coming from the overly strong fresnel term (which is still present in this fix). Getting that addressed would give me a bit more confidence about things, I think. At some point it would be good to go through and review or rewrite a lot of the BRDF code, though that may not happen until a WebGPU overhaul. Chasing down down all the intermittent breakage on the project from new browser releases and devices, etc. Hoping WebGPU smooths some of that out, at least 🤞
I think there's also part of me that has a hard time trusting the correctness of AI code especially when 3+ different versions of the same fix are made with varying degrees of success. It just makes me wonder if this new one is actually right or wrong in ways that make it look right (though admittedly there are parts of the project that are already a bit wrong), so I just want to make sure it's understood before being merged.