Skip to content

Commit 8d7f40b

Browse files
committed
Implement GGX multiscatter energy compensation using Cycles approach
1 parent b3d05dc commit 8d7f40b

File tree

2 files changed

+50
-20
lines changed

2 files changed

+50
-20
lines changed

src/shader/bsdf/bsdf_functions.glsl.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,12 @@ export const bsdf_functions = /* glsl */`
7272
// Single-scatter term (standard Cook-Torrance microfacet BRDF)
7373
vec3 singleScatter = wi.z * F * G * D / ( 4.0 * abs( wi.z * wo.z ) );
7474
75-
// Multi-scatter energy compensation (Kulla-Conty 2017)
76-
// This accounts for energy lost due to multiple bounces within the microfacet structure
77-
// The multiscatter term is already divided by PI and accounts for cosine weighting
75+
// Multiscatter energy compensation
76+
// Adds back energy lost to multiple bounces within the microfacet structure
77+
// Returns a diffuse-like lobe scaled by the missing energy and average Fresnel
7878
vec3 multiScatter = ggxMultiScatterCompensation( wo, wi, roughness, f0Color ) * wi.z;
7979
80+
// Total specular reflection = single-scatter + multiscatter
8081
color = singleScatter + multiScatter;
8182
return ggxPdf / ( 4.0 * dot( wo, wh ) );
8283
@@ -207,10 +208,12 @@ export const bsdf_functions = /* glsl */`
207208
// Single-scatter clearcoat term
208209
float fClearcoatSingle = F * D * G / ( 4.0 * abs( wi.z * wo.z ) );
209210
210-
// Multi-scatter compensation for clearcoat layer
211+
// Multiscatter energy compensation for clearcoat layer
212+
// Clearcoat is a dielectric layer (IOR 1.5), so we use its F0 for compensation
211213
vec3 f0ColorClearcoat = vec3( f0 );
212214
vec3 clearcoatMultiScatter = ggxMultiScatterCompensation( wo, wi, roughness, f0ColorClearcoat );
213215
216+
// Total clearcoat reflection = single-scatter + multiscatter
214217
float fClearcoat = fClearcoatSingle + clearcoatMultiScatter.r;
215218
color = color * ( 1.0 - surf.clearcoat * F ) + fClearcoat * surf.clearcoat * wi.z;
216219

src/shader/bsdf/multiscatter_functions.glsl.js

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,53 @@
11
export const multiscatter_functions = /* glsl */`
22
3-
// Analytical multiscatter energy compensation for GGX BRDF
4-
// Compensates for energy loss due to multiple bounces within the microfacet structure
5-
// Based on observations that rough surfaces at grazing angles lose the most energy
3+
// GGX Multiscatter Energy Compensation
4+
// Implementation based on Blender Cycles' approach
5+
//
6+
// References:
7+
// - "Multiple-Scattering Microfacet BSDFs with the Smith Model" (Heitz et al. 2016)
8+
// - Blender Cycles: intern/cycles/kernel/closure/bsdf_microfacet_multi.h
9+
//
10+
// Single-scatter GGX loses energy due to rays bouncing multiple times within
11+
// the microfacet structure before escaping. This compensation adds back the
12+
// missing energy as a diffuse-like multiscatter lobe.
13+
//
14+
// The approach uses a fitted albedo approximation to estimate how much energy
15+
// single-scatter captures, then adds the remainder back. This is simpler and
16+
// faster than full random-walk multiscatter simulation while providing good
17+
// energy conservation for path tracers.
18+
19+
// Directional albedo approximation for single-scatter GGX
20+
// Returns the fraction of energy captured by single-scatter as a function of roughness
21+
// Fitted curve from Blender Cycles based on precomputed ground truth data
22+
float ggxAlbedo( float roughness ) {
23+
float r2 = roughness * roughness;
24+
return 0.806495 * exp( -1.98712 * r2 ) + 0.199531;
25+
}
26+
27+
// GGX multiscatter energy compensation term
28+
// wo: outgoing direction (view direction)
29+
// wi: incident direction (light direction)
30+
// roughness: surface roughness [0, 1]
31+
// F0: Fresnel reflectance at normal incidence
32+
// Returns: Additional BRDF contribution to compensate for multiscatter energy loss
633
vec3 ggxMultiScatterCompensation( vec3 wo, vec3 wi, float roughness, vec3 F0 ) {
7-
float NdotV = abs( wo.z );
8-
float NdotL = abs( wi.z );
34+
// Estimate the fraction of energy captured by single-scatter GGX
35+
float singleScatterAlbedo = ggxAlbedo( roughness );
936
10-
// Energy compensation increases with roughness
11-
// At roughness=0, no compensation needed (perfect mirror)
12-
// At roughness=1, significant compensation needed (very rough)
13-
float a = roughness * roughness;
14-
float energyFactor = a * sqrt( a ); // Scales as roughness^1.5
37+
// The missing energy that needs compensation
38+
float missingEnergy = 1.0 - singleScatterAlbedo;
1539
16-
// Angular dependence - more energy lost at grazing angles
17-
float angularLoss = ( 1.0 - NdotV * 0.9 ) * ( 1.0 - NdotL * 0.9 );
40+
// Average Fresnel reflectance over all directions (spherical albedo)
41+
// Approximation: F_avg ≈ F0 + (1 - F0) / 21
42+
vec3 Favg = F0 + ( 1.0 - F0 ) / 21.0;
1843
19-
// Combined energy compensation
20-
vec3 compensation = F0 * energyFactor * angularLoss;
44+
// Multiscatter contribution: diffuse-like lobe scaled by average Fresnel
45+
// This represents energy that bounced multiple times before escaping
46+
vec3 Fms = Favg * missingEnergy;
2147
22-
// Conservative global scale to avoid over-brightening
23-
return compensation * 0.25;
48+
// Return as a Lambertian BRDF (energy / π)
49+
// The π accounts for the hemispherical integral in the rendering equation
50+
return Fms / PI;
2451
}
2552
2653

0 commit comments

Comments
 (0)