PBR Fresnel - color banding/wrong formula?

Started by
8 comments, last by Lewa 5 years, 10 months ago

Me again.

I noticed a weird issue with color banding in my current PBR shader. After a bit of experimentation i noticed that the fresnel calculation seems to be the culprit.

Here is how the colorbanding looks like (it's a bit dark, but noticeable):

Spoiler

banding%201.png

I also think that the fresnel calculation is a bit off. 

This is the actual shader (stripped everything away except the fresnel calculation:)

Spoiler


vec3 BRDF_F_FresnelSchlick(float VdotH, vec3 F0)
{
    return (F0 + (1.0f - F0) * (pow(1.0f - max(VdotH,0.0f),5.0f)));
}


void main()
{

	vec2 texCoord = vec2(gl_FragCoord.x * uPixelSize.x,gl_FragCoord.y*uPixelSize.y);
	
	vec3 fragPos = depthToWorld(gDepth,texCoord,uInverseViewProjectionBiased);
	vec3 fragNormal = texture2D(gNormal, texCoord).rgb;

		
	
	//--------------
	vec3 fragToLightNormal = uLightPos-fragPos;
	
	
	vec3 N = normalize(fragNormal);//normal vector
	vec3 L = normalize(fragToLightNormal);//light vector
	vec3 V = normalize(uCameraPosition-fragPos.xyz); //eye vector
	vec3 H = normalize(L+V); //half vector
	
	float NdotH = max(dot(N,H),0.0f);
	float NdotV = max(dot(N,V),0.0f);
	float NdotL = max(dot(N,L),0.0f);
	float VdotH = max(dot(V,H),0.0f);

	

	vec3 F0 = vec3(0.04f,0.04f,0.04f);//assumption

	vec3 color = BRDF_F_FresnelSchlick(VdotH, F0);
	
    outputF = vec4(color,1.0f);
	

	
}

 

It's worth noting that i'm writing those values into a 16 bit floating point buffer. (So the FBO precision shouldn't be the culprit.)

Is this a math based precision error? (especially in the pow() function)

 

Also another thing i noticed,

The fresnel effect is supposed to look like this: (picture shamelessly stolen from google)

Spoiler

iVi5W3V.png

However, no matter what i do i never get this effect in my shader. (I tried all lighting conditions and material values.) Here is what a material with 50% roughness and 50% metallic value looks like:

 

Spoiler

fresnel1.png

Frontfresnel2.png

From behind:fresnel3.png

I noticed that switching the "VdotH" dot product to "LdotV" makes this effect somewhat work, but i read conflicting information on the internet as if this is even correct.

Here is the complete shader:

Spoiler


#version 330
 

in vec2 vTexcoord;
out vec4 outputF;

uniform sampler2D gDepth;
uniform sampler2D gNormal;
uniform sampler2D gAlbedo;
uniform sampler2D gMetallicRoughness;


uniform vec2 uPixelSize;
uniform float uLightRadius;
uniform vec3 uLightColor;
uniform vec3 uLightPos;
uniform vec3 uCameraPosition;


const float PI = 3.141592653589793;


uniform mat4 uInverseViewProjectionBiased;


vec3 depthToWorld(sampler2D depthMap,vec2 texcoord,mat4 biasedInverseProjView){
		float depth = texture2D(depthMap,texcoord).r;
        
        vec4 position = vec4(texcoord,depth,1.0);
        
        position = ((biasedInverseProjView)*position);
        return vec3(position/position.w);
}



float BRDF_D_GGX(float NdotH, float roughness)
{
	float roughness2 = roughness * roughness;
	float roughness4 = roughness2 * roughness2;
	
	float denomA = (NdotH*NdotH * (roughness4 -1.0f) + 1.0f);
	return roughness4 / (PI * denomA * denomA);
}

//NdotV seems to be correct (instead of HdotV)
vec3 BRDF_F_FresnelSchlick(float VdotH, vec3 F0)
{
    return (F0 + (1.0f - F0) * (pow(1.0f - max(VdotH,0.0f),5.0f)));
}


float BRDF_G_SchlickGGX(float NdotV,float roughness){
	float k = (roughness*roughness)/2.0f;	
	return (NdotV)/(NdotV * (1.0f - k) + k);
}

//geometrix shadowing - cook-Torrance
float BRDF_G_Smith(float NdotV,float NdotL, float roughness)
{
	NdotV = max(NdotV,0.0f);
	NdotL = max(NdotL,0.0f);

	return BRDF_G_SchlickGGX(NdotV,roughness) * BRDF_G_SchlickGGX(NdotL,roughness);

}

float calcAttenuation(float distToFragment,float lightRadius){
	float att = clamp(1.0 - distToFragment*distToFragment/(lightRadius*lightRadius), 0.0, 1.0);
	att *= att;
	
	return att;
}




void main()
{

	vec2 texCoord = vec2(gl_FragCoord.x * uPixelSize.x,gl_FragCoord.y*uPixelSize.y);
	
	vec3 fragPos = depthToWorld(gDepth,texCoord,uInverseViewProjectionBiased);
	vec3 fragNormal = texture2D(gNormal, texCoord).rgb;
	vec3 fragAlbedo = texture2D(gAlbedo, texCoord).rgb;
	vec2 fragMetallicRoughness = texture2D(gMetallicRoughness, texCoord).rg;
	float fragMetallic = fragMetallicRoughness.r;
	float fragRoughness = fragMetallicRoughness.g;
	
	fragRoughness = max(fragRoughness,0.05f);//if value is 0 it doesnt reflect anything

		
	
	//--------------
	vec3 fragToLightNormal = uLightPos-fragPos;
	
	
	vec3 N = normalize(fragNormal);//normal vector
	vec3 L = normalize(fragToLightNormal);//light vector
	vec3 V = normalize(uCameraPosition-fragPos.xyz); //eye vector
	vec3 H = normalize(L+V); //half vector
	
	float NdotH = max(dot(N,H),0.0f);
	float NdotV = max(dot(N,V),0.0f);
	float NdotL = max(dot(N,L),0.0f);
	float VdotH = max(dot(V,H),0.0f);

	//------------------

	vec3 F0 = vec3(0.04f,0.04f,0.04f);//assumption
	F0 = mix(F0,fragAlbedo,fragMetallic);

	
	float D = BRDF_D_GGX(NdotH, fragRoughness); //normal distribution
    float G = BRDF_G_Smith(NdotV,NdotL,fragRoughness); //geometric shadowing
	vec3 F = BRDF_F_FresnelSchlick(VdotH, F0); // Fresnel
		
    vec3 specular = (D * F * G) / 4.0f * max(max(NdotL,0.0) * max(NdotV,0.0),0.001);
	
	//------light----------
	float lightNormalLength = length(fragToLightNormal);
	float attenuation = calcAttenuation(lightNormalLength, uLightRadius);
    vec3 radiance = attenuation * uLightColor;
	//-----------
	
	vec3 kS = F;
	vec3 kD = vec3(1.0f) - kS;
	kD *= 1.0f - fragMetallic;
	
	vec3 diffuse = fragAlbedo * kD / PI;
	vec3 color = (diffuse + specular ) * radiance * NdotL;

	
	//tone mapping (does nothing ATM.)
	float maxExposure = 1.0f;
	color*=1.0f/maxExposure;
	
    outputF = vec4(color,1.0f);
	

	
}

 

Anyone has an idea why the banding effect takes place and if the fresnel calculation is even correct?

Advertisement

I don't think that the Fresnel calculation is the source of your banding. I looked at your screen capture in photoshop, and the different bands have an intensity difference of exactly 1/255. This implies that you're at the limit of what can be represented in 8-bit sRGB. You may want to look into applying some dithering to effectively hide the banding. Playdead had some presentations about this that you should check out.

Regarding fresnel, the "correct" version depends on the context. The basic fresnel equations deal with reflection and refraction of a ray of light. In plain english, I would explain it like this: "When the light ray is pointed directly into the surface, less light is reflected off the surface and more light is refracted into the surface. When the light ray is grazing the surface, more light is reflected off and less light is refracted into the surface." Based on that explanation you can see why Schlick's approximation makes use of a dot product, since it's a simple way of determining whether a vector is grazing a surface or pointing into a surface given a normal vector for that surface. So your basic Schlick's approximation for determining the amount of reflected light would look something like this:


return F0 + (1.0f - F0) * pow(1.0f - saturate(dot(N, L)), 5.0f);

If you're dealing with perfectly mirror surfaces (roughness of 0), this equation applies. However, typically we're using a specular BRDF that models surface that are "rough" at a microscopic level. These BRDF's assume that the surfaces are made up of tiny "microfacets" that each behave like a perfect Fresnel behavior, but might be oriented in all kinds of directions (the roughness parameter controls the degree to which those microfacets are all aligned, or un-aligned). With a microfacet BRDF instead of directly computing the reflected light off of a surface, you typically determine a portion of the microfacets that aligned with the half vector. The half vector is exactly between the light direction and view direction, so if a microfacet is aligned with the half vector then it will produce a perfect reflection towards the eye. Because of this, the half vector is sometimes referred to as the "active microfacet direction". With the BRDF being formulated this way, you instead want to compute your fresnel reflectance using that active microfacet direction instead of the surface normal:


return F) + (1.0f - FO) * pow(1.0f - saturate(dot(H, L)), 5.0f);

This is also equivalent to using dot(H, V), since the half-vector is exactly in-between L and V.

So to make a long story short: the Fresnel equation that you're using is correct. The one you're looking at in that Google image appears to be using dot(V, N), which is something else. This is essentially giving you the reflectance amount assuming you started at the eye and shot a ray towards the surface, which due to reciprocity is the same as doing dot(reflect(V, N), N). This is probably what you would use if you were computing Fresnel for a mirror BRDF and sampling a lighting environment, for instance an IBL cubemap. This is *not* what you would want to use for a local light source, since Fresnel always depends on the incoming lighting direction!

 

Is dithering the goto solution for this kind of banding in the industry?

 

Also good to know that the fresnel equation is correct. I just wondered why i was never able to get the fresnel to show up no matter the lighting condition. Is it supposed to have only a small contribution to the surface? (it's barely if at all noticeable)

I made a test where i removed the fresnel equation from the shader and i wasn't really able to tell a difference between the render with- and without the fresnel equation.

Yeah, dithering is the only way I know of to improve quality in dark scenes. Some games apply a film grain effect for aesthetic purposes, and that effectively ends up giving you a dither pattern.

What's your F0 value? Typical non-metal materials have an F0 in range of 0.02-0.04, and the fresnel effect can be very noticeable on them (especially for lower roughness). It will be most noticable at a "grazing" angle where the eye/camera direction is nearly parallel to the surface, and the light is on the opposite side of the surface from the eye. It also depends on the rest of your specular BRDF, since the geometry/visibility terms in proper microfacet BRDF's will also give much stronger reflections at grazing angles.

I'm not on my main PC right now, but I'll get some screenshots for you later when I'm on my desktop.

There's even dithering in offline stuff. It ends up as a fairly standard thing to do.

As promised, here are some images. This first set is taken from BRDF Explorer, which is a very useful tool for these sorts of things:
BRDF_Explorer_HeadOn.thumb.png.7d264c2cdb9ca1df0f143d6232471f42.pngBRDF_Explorer_Grazing_NoFresnel.thumb.png.07cb54f31dc4cf4467ac472bf08daccb.pngBRDF_Explorer_Grazing_Fresnel.thumb.png.a922acfc85dd8404bb7d757dcc043d79.png

The first image shows the "head-on" angle, where the lighting and viewing direction are nearly lined up with the surface normal. I Included the polar plot in there so that you can clearly see what I'm talking about: the blue line is the light direction, the green line is the surface normal, and the pink line is the viewing direction. The red blobby shape is the BRDF lobe, which shoes the intensity of the reflected lighting in a particular direction. The second image shows a "grazing" angle with no fresnel, where the lighting and viewing directions are nearly perpendicular to the surface normal. The third image shows the grazing angle with fresnel enabled. You can see that the difference is very noticeable, both in the BRDF plot as well as in the actual rendered image. For reference, these were all rendered with F0 == 0.03, using a Cook-Torrance specular BRDF with a GGX distribution and roughnes of about 0.12.

For completeness, here's images with fresnel disabled and enabled using a path tracer to render a scene with full indirect specular:

BakingLab_NoFresnel.thumb.png.16bed642acc94e61970c25b2d23c9a24.png

BakingLab_Fresnel.thumb.png.f96c406ef01180a57fee78cf387cd5e1.png

Notice how you lose a lot of the specular in the scene without fresnel!

My F0 value is hardcoded at 0.04.

What is kind of confusing for me is that the fresnel effect doesn't depend on the surface normal at all.

Given that my vectors are defined like this:


	vec3 N = normalize(fragNormal);//normal vector of the surface
	vec3 L = normalize(fragToLightNormal);//light vector
	vec3 V = normalize(uCameraPosition-fragPos.xyz); //vector from surface pixel to the camera
	vec3 H = normalize(L+V); //half vector between light and eye vector
	
	float NdotH = max(dot(N,H),0.0f);
	float NdotV = max(dot(N,V),0.0f);
	float NdotL = max(dot(N,L),0.0f);
	float VdotH = max(dot(V,H),0.0f);//this get's passed into the fresnel equation

The fresnel equation then takes VdotH


vec3 F = BRDF_F_FresnelSchlick(VdotH, F0); // Fresnel

As far as i can tell this means the fresnel doesn't change/is affected by the surface normal of the particulare geometry.

An example:

Spoiler

fresnel_b1.png

fresnel_b2.png

fresnel_b3.png

 

This is the fresnel effect rendered from the shadowed area. (the light is on the other side.) This time i tried to use a directional light to see how this impacts the image. (and and ambient light to give the dark areas a bit of light.)

The black spot is always the same size and doesn't really change the form/geometry.

Here is how the result of the fresnel looks like:

Spoiler

F%20value%20rendered.png

You can see that having a Sphere in front of an empty skybox doesn't impact the fresnel effect.

I'm just wondering because you said:

Quote

It will be most noticable at a "grazing" angle where the eye/camera direction is nearly parallel to the surface, and the light is on the opposite side of the surface from the eye.

which implies that the surface normal should impact the fresnel equation. (which isn't the case in my equation.)

 

Here is the shader again this time with the directional light:

Spoiler


#version 330
 

in vec2 vTexcoord;
out vec4 outputF;

uniform sampler2D gDepth;
uniform sampler2D gNormal;
uniform sampler2D gAlbedo;
uniform sampler2D gMetallicRoughness;

uniform vec3 uCameraPosition;

uniform vec2 uPixelSize;
uniform vec3 uDirectionalLightColor;
uniform vec3 uDirectionalLightNormal;
uniform vec3 uAmbientLightColor;


const float PI = 3.141592653589793;


uniform mat4 uInverseViewProjectionBiased;


vec3 depthToWorld(sampler2D depthMap,vec2 texcoord,mat4 biasedInverseProjView){
		float depth = texture2D(depthMap,texcoord).r;
        
        vec4 position = vec4(texcoord,depth,1.0);
        
        position = ((biasedInverseProjView)*position);
        return vec3(position/position.w);
}



float BRDF_D_GGX(float NdotH, float roughness)
{
	float roughness2 = roughness * roughness;
	float roughness4 = roughness2 * roughness2;
	
	float denomA = (NdotH*NdotH * (roughness4 -1.0f) + 1.0f);
	return roughness4 / (PI * denomA * denomA);
}


vec3 BRDF_F_FresnelSchlick(float VdotH, vec3 F0)
{
    return (F0 + (1.0f - F0) * (pow(1.0f - max(VdotH,0.0f),5.0f)));
}


float BRDF_G_SchlickGGX(float NdotV,float roughness){
	float k = (roughness*roughness)/2.0f;	
	return (NdotV)/(NdotV * (1.0f - k) + k);
}

//geometrix shadowing - cook-Torrance
float BRDF_G_Smith(float NdotV,float NdotL, float roughness)
{
	NdotV = max(NdotV,0.0f);
	NdotL = max(NdotL,0.0f);

	return BRDF_G_SchlickGGX(NdotV,roughness) * BRDF_G_SchlickGGX(NdotL,roughness);

}




void main()
{

	vec2 texCoord = vec2(gl_FragCoord.x * uPixelSize.x,gl_FragCoord.y*uPixelSize.y);
	
	vec3 fragPos = depthToWorld(gDepth,texCoord,uInverseViewProjectionBiased);
	vec3 fragNormal = texture2D(gNormal, texCoord).rgb;
	vec3 fragAlbedo = texture2D(gAlbedo, texCoord).rgb;
	vec2 fragMetallicRoughness = texture2D(gMetallicRoughness, texCoord).rg;
	float fragMetallic = fragMetallicRoughness.r;
	float fragRoughness = fragMetallicRoughness.g;
	
	fragRoughness = max(fragRoughness,0.05f);//if value is 0 it doesnt reflect anything

		
	
	//--------------
	vec3 fragToLightNormal = -uDirectionalLightNormal;
	
	
	vec3 N = normalize(fragNormal);//normal vector
	vec3 L = normalize(fragToLightNormal);//light vector
	vec3 V = normalize(uCameraPosition-fragPos.xyz); //eye vector
	vec3 H = normalize(L+V); //half vector
	
	float NdotH = max(dot(N,H),0.0f);
	float NdotV = max(dot(N,V),0.0f);
	float NdotL = max(dot(N,L),0.0f);
	float VdotH = max(dot(V,H),0.0f);

	//------------------

	vec3 F0 = vec3(0.04f,0.04f,0.04f);//assumption
	F0 = mix(F0,fragAlbedo,fragMetallic);

	
	float D = BRDF_D_GGX(NdotH, fragRoughness); //normal distribution
    float G = BRDF_G_Smith(NdotV,NdotL,fragRoughness); //geometric shadowing
	vec3 F = BRDF_F_FresnelSchlick(VdotH, F0); // Fresnel
		
    vec3 specular = (D * F * G) / 4.0f * max(max(NdotL,0.0) * max(NdotV,0.0),0.001);
	
	//------light----------

    vec3 radiance = uDirectionalLightColor;
	//-----------
	
	vec3 kS = F;
	vec3 kD = vec3(1.0f) - kS;
	kD *= 1.0f - fragMetallic;
	
	vec3 diffuse = fragAlbedo * kD / PI;
	vec3 color = (diffuse + specular );
	
	color = (color * uAmbientLightColor) + (color * radiance * NdotL);

	

	
    outputF = vec4(color,1.0f);
	
	
}

 

 

That's correcty: for a microfacet specular BRDF, the fresnel term isn't computed in terms of the macro surface normal, it's computed in terms of the active microfacet normal (H). However this doesn't mean that it's completely decoupled from the surface normal, because microfacet specular BRDF's are only defined on the upper hemisphere such that N dot L > 0  and N dot V > 0 (this should make sense intuitively: if the view direction is "below" the surface then it can't actually see the surface, and vice versa for the light).

With that constraint in place, it means that if L dot H or V dot H is ~0, then N dot L must also be close to 0. Here's a diagram showing what I mean:

Fresnel_N_Range.thumb.png.d67e70c876d2a724dc744a5b20582817.png

That red cone shows the possible range of values for N where L and V would still be on the upper hemisphere with regards to N, and its angle equivalent to the angle between L/V and the vector perpendicular to H. Therefore as L dot H and V dot H get smaller, N has to be close to H in order for for to get any reflections at all. In other words, if L/V are setup such that the light is grazing the microfacet normal, it will also be grazing the surface normal for valid configurations of the BRDF.  So really there's no point in looking at the fresnel behavior in the "shadowed area" like you were doing, since the BRDF is always there 0 anyway. 

I think i undestand this now?

Essentially the Fresnel Value "F" resulting from this equation isn't the actual fresnel value of the geometry but gets feeded into the BRDF equation which results in the desired effect.


	float D = BRDF_D_GGX(NdotH, fragRoughness); //normal distribution
    float G = BRDF_G_Smith(NdotV,NdotL,fragRoughness); //geometric shadowing
	vec3 F = BRDF_F_FresnelSchlick(VdotH, F0); // Fresnel
		
    vec3 specular = (D * F * G) / 4.0f * max(max(NdotL,0.0) * max(NdotV,0.0),0.001);

I have to read up more on the theory. Implementing PBR without properly understanding the inner workings is kinda a dead end.

 

Thank you for your throughout explenation,

This topic is closed to new replies.

Advertisement