Skip to content

Isotropic GGX specular visibility term uses alpha^4 instead of alpha^2 #9510

Description

@willeastcott

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.

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions