1
$\begingroup$

I'm trying to implement a basic form of physically based rendering (PBR) and I am using the PBR tutorial from learnopengl dot com as a reference.

The PBR I am implementing consists of two parts, direct illumination and indirect illumination. The direct illumination is quite straight forward and is working as expected (well, according to my untrained eye...). It's the indirect illumination that I'm having troubles implementing. I want to use image based lighting (IBL) for this part.

The following image showcases the output after rendering spheres with this scuffed titanium PBR metal material using only direct illumination.

close up of only direct illumination


When I also include indirect illumination (from the environment map), this is what I get

direct illumination and indirect illumination

close up of direct illumination and indirect illumination

I would've expected to see something much more like the preview picture from the page where I found the PBR material from, see link above.


Here is another image of another more sophisticated model with direct and indirect illumination

jet engine with both direct and indirect illumination

if I remove the indirect illumination I get this

jet engine with only direct illumination


Instead the environment map seems to be overpowering everything which looks odd to me.

I am using a pre-convolved reflectance map that is mipmapped (7 HDR images in total) as well as an irradiance map (HDR image). These are correct and are not the issue.


If I use a plain-sphere with a red base color and vary the metalness (growing vertically) and the roughness (growing horizontally) I get this with NO indirect illumination.

plain-red spheres with varying metalness and roughness with only direct illumination

note how the left-most spheres (with roughness=0.0) have no specular reflection, that seems odd to me.

... with indirect illumination

plain-red spheres with varying metalness and roughness with direct illumination and indirect illumination


I am not sure where I'm going wrong. Below you'll find the fragment shader used to render the seen images (excluding the last shown image)

#version 460 core

#define EPSILON 0.00001
#define PI 3.14159265359

/////////////////////////
// VERTEX SHADER INPUT //
/////////////////////////
in vec3 position; // world space
in vec3 normal;
in vec2 texcoord;

/////////////////////////
// Material properties //
/////////////////////////
struct Material {
    ...
};
layout (binding = 5) uniform sampler2D u_DiffuseMap;
layout (binding = 6) uniform sampler2D u_RoughnessMap;
layout (binding = 7) uniform sampler2D u_MetalnessMap;
layout (binding = 8) uniform sampler2D u_EmissionMap;
layout (binding = 9) uniform sampler2D u_NormalMap;
layout (binding = 11) uniform sampler2D u_AoMap;
uniform Material u_Material;

/////////////////////
// Environment map //
/////////////////////
layout (binding = 3) uniform sampler2D u_ReflectanceMap; // 8 maps in total, mipmapped
layout (binding = 4) uniform sampler2D u_IrradianceMap;

///////////////////////////
// Pointlight properties //
///////////////////////////
struct PointLight {
    vec3 position; // in world space
    vec3 color; // [0, 1]
};
uniform PointLight u_PointLights[1]; // only supporting one light source right now

////////////
// Camera //
////////////
uniform vec3 u_CameraPosition; // in world space

////////////
// OUTPUT //
////////////
out vec4 FragColor;

///////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////

vec3 getNormalFromMap()
{
    vec3 tangentNormal = texture(u_NormalMap, texcoord).xyz * 2.0 - 1.0;

    vec3 Q1  = dFdx(position);
    vec3 Q2  = dFdy(position);
    vec2 st1 = dFdx(texcoord);
    vec2 st2 = dFdy(texcoord);

    vec3 N  = normalize(normal);
    vec3 T  = normalize(Q1 * st2.t - Q2 * st1.t);
    vec3 B  = -normalize(cross(N, T));
    mat3 TBN = mat3(T, B, N);

    return normalize(TBN * tangentNormal);
}

float DistributionGGX(vec3 N, vec3 H, float roughness)
{
    float a = roughness*roughness;
    float a2 = a*a;
    float NdotH = max(dot(N, H), 0.0);
    float NdotH2 = NdotH*NdotH;

    float nom   = a2;
    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    denom = PI * denom * denom;

    return nom / denom;
}

float GeometrySchlickGGX(float NdotV, float roughness)
{
    float r = (roughness + 1.0);
    float k = (r*r) / 8.0;

    float nom   = NdotV;
    float denom = NdotV * (1.0 - k) + k;

    return nom / denom;
}

float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness)
{
    float NdotV = max(dot(N, V), 0.0);
    float NdotL = max(dot(N, L), 0.0);
    float ggx2 = GeometrySchlickGGX(NdotV, roughness);
    float ggx1 = GeometrySchlickGGX(NdotL, roughness);

    return ggx1 * ggx2;
}

vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness)
{
    return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
} 

///////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////

void main()
{

    // NOTE: Lighting calculations being done in world space.

    vec3 baseColor = u_Material.color;
    if (u_Material.has_color_texture)
        baseColor = texture(u_DiffuseMap, texcoord).rgb;

    float metalness = u_Material.metalness;
    if (u_Material.has_metalness_texture)
        metalness = texture(u_MetalnessMap, texcoord).r;

    float roughness = u_Material.roughness;
    if (u_Material.has_roughness_texture)
        roughness = texture(u_RoughnessMap, texcoord).r;

    vec3 emission = u_Material.emission;
    if (u_Material.has_emission_texture)
        emission = texture(u_EmissionMap, texcoord).rgb;

    vec3 N = normalize(normal);
    if (u_Material.has_normal_texture)
        N = getNormalFromMap();

    float ao = 0.0;
    if (u_Material.has_ao_texture)
        ao = texture(u_AoMap, texcoord).r;

    vec3 V = normalize(u_CameraPosition - position);

    // Disney us
    vec3 F0 = vec3(0.04); 
    F0 = mix(F0, baseColor, metalness);

    vec3 Lo = vec3(0.0);
    for(int i = 0; i < 1; i++) // only 1 pointlight at the moment
    {
        vec3 L = u_PointLights[i].position - position;
        vec3 H = normalize(V + normalize(L));
        float d = length(L);
        float attenuation = 1.0 / (d * d);
        vec3 radiance = 125 * u_PointLights[i].color * attenuation;
        // --------------^ pointlight_intensity, without envmap
        //                 objects come out way too dark without this factor.
        //                 What's a good number depends on scene.

        float NDF = DistributionGGX(N, H, roughness);   
        float G = GeometrySmith(N, V, L, roughness);      
        vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);
        
        // Cook-Torrance model
        vec3 numerator = NDF * G * F; 
        float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + EPSILON;
        vec3 specular = numerator / denominator;
        
        vec3 kS = F;
        vec3 kD = vec3(1.0) - kS;
        kD *= 1.0 - metalness;    

        float NdotL = max(dot(N, L), 0.0);        

        Lo += (kD * baseColor / PI + specular) * radiance * NdotL;
    }   

    vec3 F = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);
    
    vec3 kS = F;
    vec3 kD = 1.0 - kS;
    kD *= 1.0 - metalness;
    
    // Sample irradiance map
    float theta = acos(max(-1.0f, min(1.0f, N.y)));
    float phi = atan(N.z, N.x);
    if(phi < 0.0f)
        phi = phi + 2.0f * PI;
    vec2 lookup = vec2(phi / (2.0 * PI), 1 - theta / PI);
    vec3 irradiance = texture(u_IrradianceMap, lookup).rgb;
    vec3 diffuse = irradiance * baseColor;
    
    // Sample reflectance map
    vec3 wi = normalize(reflect(-V, N));
    float theta2 = acos(max(-1.0f, min(1.0f, wi.y)));
    float phi2 = atan(wi.z, wi.x);
    if(phi2 < 0.0f)
        phi2 = phi2 + 2.0f * PI;
    vec2 lookup2 = vec2(phi2 / (2.0 * PI), 1 - theta2 / PI);
    const float MAX_LOD = 7.0;
    vec3 specular = textureLod(u_ReflectanceMap, lookup2, roughness * MAX_LOD).rgb;    

    vec3 ambient = (kD * diffuse + specular) * ao;

    /////////////////////////////////////

    vec3 color = ambient + emission + Lo;

    // HDR tonemapping
    color = color / (color + vec3(1.0));
    // gamma correct
    color = pow(color, vec3(1.0/2.2)); 

    FragColor = vec4(color, 1.0);
}
$\endgroup$

1 Answer 1

1
$\begingroup$

Your results look broadly correct to me. The problem is not in the lighting calculations, but in the simplicity of the scene.

A shiny metal object is like a mirror except that it isn't flat. Your material doesn't absorb much light, so it reflects it instead, and will appear as bright as the surroundings (on average). Then, when you add a "direct" light on top of your IBL, the object is now brighter than the environment around it, because it is receiving more light than is represented in the environment map.

Here are specific ways in which your render diverges from typical reality, and how you can fix them:

  1. Your environment map already contains a bright light — the sun. If your intention is for it to represent only “indirect” illumination and compute the “direct” illumination separately, then you must exclude the “direct” component from the environment map to avoid double-counting it.

  2. Most real objects are not made completely of highly reflective surfaces, but in your renders you're displaying everything “chrome-plated” (well, titanium-plated). That can be physically realistic, but it's not how most objects are. When you use materials that are (accurately for the subject) darker and more matte, the objects will look more like they should.

  3. You're currently rendering an object floating in open air with nothing around it to shadow it. Therefore, it will be well lit in the same kind of way an aircraft in flight would be. Objects on the ground or indoors have surroundings that partially prevent light from the sky from reaching them. If you're inside a room, you use an environment map which is a picture of that room, not the sky. If you're on the ground, the bottom half of the environment map should be ground, not ocean.

$\endgroup$
1
  • $\begingroup$ The direct illumination consists of 1 weak pointlight, it should not contribute nearly as much as the indirect illumination. The jet engine image source with indirect illumination looks super metallic (icey) when in reality it isn't. It should not look like that. Everything is NOT titanium-plated, only the spheres in the very first image. Your third point is a good one though and definitely plays a role in "overpowering". With that said, something still does not seem right to me primarily due to the weird jet engine $\endgroup$ Commented Mar 8 at 14:26

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.