The isotropic GGX specular chunk added in #8299 evaluates the Smith height-correlated visibility term with the wrong power of roughness. Materials using enableGGXSpecular (without anisotropy) are too bright at grazing angles, increasingly so with roughness.
Where
- GLSL:
|
float roughness = max((1.0 - gloss) * (1.0 - gloss), 0.001); |
|
float alpha = roughness * roughness; |
|
|
|
float NoH = max(dot(worldNormal, h), 0.0); |
|
float NoV = max(dot(worldNormal, viewDir), 0.0); |
|
float NoL = max(dot(worldNormal, -lightDirNorm), 0.0); |
|
|
|
// GGX Distribution |
|
float NoH2 = NoH * NoH; |
|
float denom = NoH2 * (alpha - 1.0) + 1.0; |
|
float D = alpha / (PI * denom * denom); |
|
|
|
// Smith G (height-correlated) |
|
float alpha2 = alpha * alpha; |
|
float lambdaV = NoL * sqrt(NoV * NoV * (1.0 - alpha2) + alpha2); |
|
float lambdaL = NoV * sqrt(NoL * NoL * (1.0 - alpha2) + alpha2); |
|
float G = 0.5 / max(lambdaV + lambdaL, 0.00001); |
- WGSL:
|
let roughness: f32 = max((1.0 - gloss) * (1.0 - gloss), 0.001); |
|
let alpha: f32 = roughness * roughness; |
|
|
|
let NoH: f32 = max(dot(worldNormal, h), 0.0); |
|
let NoV: f32 = max(dot(worldNormal, viewDir), 0.0); |
|
let NoL: f32 = max(dot(worldNormal, -lightDirNorm), 0.0); |
|
|
|
// GGX Distribution |
|
let NoH2: f32 = NoH * NoH; |
|
let denom: f32 = NoH2 * (alpha - 1.0) + 1.0; |
|
let D: f32 = alpha / (PI * denom * denom); |
|
|
|
// Smith G (height-correlated) |
|
let alpha2: f32 = alpha * alpha; |
|
let lambdaV: f32 = NoL * sqrt(NoV * NoV * (1.0 - alpha2) + alpha2); |
|
let lambdaL: f32 = NoV * sqrt(NoL * NoL * (1.0 - alpha2) + alpha2); |
|
let G: f32 = 0.5 / max(lambdaV + lambdaL, 0.00001); |
What's wrong
With gloss = 1 - perceptualRoughness, roughness = (1 - gloss)^2 is already the glTF alphaRoughness, so alpha = roughness * roughness is alphaRoughness^2. The D term uses alpha correctly. The visibility term then squares it a second time:
float roughness = max((1.0 - gloss) * (1.0 - gloss), 0.001); // alphaRoughness
float alpha = roughness * roughness; // alphaRoughness^2 - correct for D
...
float alpha2 = alpha * alpha; // alphaRoughness^4 - wrong for V
float lambdaV = NoL * sqrt(NoV * NoV * (1.0 - alpha2) + alpha2);
float lambdaL = NoV * sqrt(NoL * NoL * (1.0 - alpha2) + alpha2);
The glTF reference V_GGX uses alphaRoughness^2 in these terms.
Impact
Transliterating the chunk and comparing it against the glTF Sample Renderer's D_GGX / V_GGX: D matches exactly. V matches at normal incidence but is too large at grazing angles (engine / reference):
| Perceptual roughness |
NoV = NoL = 1 |
NoV = NoL = 0.5 |
NoV = NoL = 0.2 |
| 0.3 |
1.00x |
1.01x |
1.09x |
| 0.5 |
1.00x |
1.08x |
1.51x |
| 0.7 |
1.00x |
1.21x |
1.68x |
| 0.9 |
1.00x |
1.14x |
1.22x |
Very glossy materials are barely affected, because both powers of alpha are close to zero. Mid to high roughness GGX materials are over-bright towards silhouettes and at grazing views. Blinn-Phong (the default) and the anisotropic GGX chunk do not use this code.
Fix
Use alpha itself in lambdaV / lambdaL, in both the GLSL and WGSL chunks:
float lambdaV = NoL * sqrt(NoV * NoV * (1.0 - alpha) + alpha);
float lambdaL = NoV * sqrt(NoL * NoL * (1.0 - alpha) + alpha);
With that change the transliterated V matches the reference exactly at all of the angles and roughness values above.
Found while investigating #4260 (specular anti-aliasing), where any gloss adjustment has to map consistently onto each BRDF's roughness.
The isotropic GGX specular chunk added in #8299 evaluates the Smith height-correlated visibility term with the wrong power of roughness. Materials using
enableGGXSpecular(without anisotropy) are too bright at grazing angles, increasingly so with roughness.Where
engine/src/scene/shader-lib/glsl/chunks/lit/frag/lightSpecularGGX.js
Lines 5 to 21 in de3a8d4
engine/src/scene/shader-lib/wgsl/chunks/lit/frag/lightSpecularGGX.js
Lines 5 to 21 in de3a8d4
What's wrong
With
gloss = 1 - perceptualRoughness,roughness = (1 - gloss)^2is already the glTFalphaRoughness, soalpha = roughness * roughnessisalphaRoughness^2. The D term usesalphacorrectly. The visibility term then squares it a second time:The glTF reference
V_GGXusesalphaRoughness^2in these terms.Impact
Transliterating the chunk and comparing it against the glTF Sample Renderer's
D_GGX/V_GGX: D matches exactly. V matches at normal incidence but is too large at grazing angles (engine / reference):Very glossy materials are barely affected, because both powers of alpha are close to zero. Mid to high roughness GGX materials are over-bright towards silhouettes and at grazing views. Blinn-Phong (the default) and the anisotropic GGX chunk do not use this code.
Fix
Use
alphaitself inlambdaV/lambdaL, in both the GLSL and WGSL chunks:With that change the transliterated V matches the reference exactly at all of the angles and roughness values above.
Found while investigating #4260 (specular anti-aliasing), where any gloss adjustment has to map consistently onto each BRDF's roughness.