Physically Based Rendering (PBR) – Part One

pbr-shader-final

Goals

Here, I’m attempting to provide a full development walk-through of building a free-to-use PBR shader which you can copy/paste directly into your own code/engine, and then modify as you see fit, hopefully understanding what to tweak, and how to use it.

I have the following objectives;

  • No oddball curveballs
  • No ‘works only with my system’ traps to fall into
  • Works ‘out of the box’
    • no hidden/secret code or dependencies
    • no unexplained functions/parameters
    • no typo’s or code that cannot possibly work

The aim is to provide a ‘how to’ do Physically Based Rendering which can be used as a coding (and digital content) reference for those seeking to adopt PBR themselves. Although intended to be general purpose, the context is for it to work in Dominium (a space scene based game). As such, liberties may be taken compared to other PBR references on the web. These will be clearly stated.

Disclaimer: the noble goal said, this is all just based on my experience, and my current understanding of PBR, so don’t take it as the holy grail of PBR implementation 😉 I’m just chipping away at the cliff face just like any other… also, code uplifted from other sources is credited in the final shader.

Pre-requisites

Knowledge is presumed in the following areas;

  • OpenGL
  • OpenGLSL
  • Texture/Model loading System (inc. cube map textures)
  • Sphere Model loading & rendering
  • AMD’s CubeMapGen

Reading List

Don’t shy away from a spot of related reading / homework… go read these, even if just skim reading. You really need to understand the basics of what we’re trying to achieve here.

What is Physically Based Rendering?

I strongly recommend you read through the pre-requisite list for full details, but in a nutshell it’s a way to create a predictable, realistic rendering outcome for a given set of ‘materials’ configured by the artist, which (more importantly) look correct under almost any lighting conditions.

Using PBR, we can provide the appearance of metalness, roughness, and complex lighting with a modest amount of texture information and some tunable parameters.

PBR Requirements

For a basic PBR system to work, we need to establish the following core capabilities;

  • Illumination
  • Reflection
  • The Concept of Metalness
  • The Concept of Roughness (or it’s opposite, Glossiness)
  • Albedo
  • Fresnel Reflections
  • Conservation of Energy

Creating the ‘pbr-shader’…

I find the best way to understand and implement something new is to break it down and build the individual parts, finally assembling them into the complete system. So we’ll work through the above PBR requirements, more or less in order. Each section provides a shader that delivers that particular capability, with the fully formed, final shader and texture set available at the end of the article.

Illumination

Nice and easy to start with – we just adopt the standard Lambert lighting model…

pbr-shader.vertex.c

#version 330 compatibility

attribute vec3 aVertexTangent;

varying vec3 vvLocalSurfaceNormal ;
varying vec3 vvLocalSurfaceToLightDirection;

void main()
{
    vec3 vLightModelPosition = (gl_ModelViewMatrixInverse * gl_LightSource[0].position).xyz;
    vvLocalSurfaceToLightDirection = normalize(vLightModelPosition – gl_Vertex.xyz) ;

    vvLocalSurfaceNormal = normalize(gl_Normal) ;

    gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}

pbr-shader.fragment.c

#version 330 compatibility

varying vec3 vvLocalSurfaceNormal ;
varying vec3 vvLocalSurfaceToLightDirection;

void main()
{
    vec3 vNormalisedLocalSurfaceNormal = normalize(vvLocalSurfaceNormal) ;
    vec3 vNormalisedLocalSurfaceToLightDirection = normalize(vvLocalSurfaceToLightDirection) ;

    float fLightIntensity = max(dot(vNormalisedLocalSurfaceToLightDirection, vNormalisedLocalSurfaceNormal), 0.0) ;

    vec3 rgbFragment = vec3(1.0) ;

    rgbFragment *= fLightIntensity ;

    gl_FragColor.rgb = rgbFragment ;
    gl_FragColor.a = 1.0 ; // TODO : Worry about materials which allow transparency!
}

shader-1

Apologies for the crappy formatting – WordPress is only so good. There are &nbp’s all over the place. Don’t bother copy/pasting otherwise you’ll adopt all this formatting guff – you can find everything neatly zipped at the end of the article 😉 Also my pipeline is on Shader Model 3.3 until I get around to updating, and this has only been tested on Desktop/Laptop GPU’s – compatibility with older Shader Models or OpenGLES is left as an exercise.

I have chosen to work in local(model) space, you may prefer to work in world space. The key thing is to keep it consistent, and do the transforms as little as possible. I chose local space for the purposes of this article as it was easier to think in terms relative to the ‘pixel’ we are about to colour in.

You’ll also notice I’ve assumed a sphere geometry (vvLocalSurfaceNormal = normalize(gl_Vertex.xyz)). This is purely for simplicity and consistency. You would provide your model geometries own local normal here instead.

Reflection

This is one of the two underlying foundations of PBR, which in turn is determined by how metallic a material is. The more metallic, the more reflective. However, you have to have something to reflect. You could just ‘reflect’ a global environmental light (like ambient, though not applied as part of the general lighting model)… but that’s boring.

This is where Environment Mapping comes in – more specifically in this implementation, Cube Mapping. Again, I don’t want to repeat details better explained elsewhere, but in brief a Cube Map is a cube (!) built from six square textures. Each texture is part of a larger image of the environment surrounding the reflecting object. Other environment mapping techniques are available.

Our chore here is just to provide cube mapping in the shader, and we’ll revisit this later on in ‘Roughness’

pbr-shader.vertex.c

#version 330 compatibility

attribute vec3 aVertexTangent;

varying vec3 vvLocalSurfaceNormal ;
varying vec3 vvLocalSurfaceToLightDirection;
varying vec3 vvLocalReflectedSurfaceToViewerDirection;

void main()
{
    vec3 vViewModelPosition = vec3(gl_ModelViewMatrixInverse * vec4(0, 0, 0, 1.0));
    vec3 vLightModelPosition = (gl_ModelViewMatrixInverse * gl_LightSource[0].position).xyz;
    vvLocalSurfaceToLightDirection = normalize(vLightModelPosition – gl_Vertex.xyz) ;

    vvLocalSurfaceNormal = normalize(gl_Normal) ;

    vec3 vLocalSurfaceToViewerDirection = normalize(vViewModelPosition – gl_Vertex.xyz) ;
    vvLocalReflectedSurfaceToViewerDirection = normalize(reflect(vLocalSurfaceToViewerDirection, vvLocalSurfaceNormal)) ;

    gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}

pbr-shader.fragment.c

#version 330 compatibility

varying vec3 vvLocalSurfaceNormal ;
varying vec3 vvLocalSurfaceToLightDirection;

varying vec3 vvLocalReflectedSurfaceToViewerDirection;

uniform samplerCube cubeMap ;

void main()
{
    vec3 vNormalisedLocalSurfaceNormal = normalize(vvLocalSurfaceNormal) ;
    vec3 vNormalisedLocalSurfaceToLightDirection = normalize(vvLocalSurfaceToLightDirection) ;
    vec3 vNormalisedLocalReflectedSurfaceToViewerDirection = normalize(vvLocalReflectedSurfaceToViewerDirection) ;

    float fLightIntensity = max(dot(vNormalisedLocalSurfaceToLightDirection, vNormalisedLocalSurfaceNormal), 0.0) ;

    vec3 rgbFragment = vec3(1.0) ;

    vec3 rgbReflection = texture(cubeMap, vNormalisedLocalReflectedSurfaceToViewerDirection).rgb ;

    rgbFragment *= fLightIntensity ;
    rgbFragment += rgbReflection ;

    gl_FragColor.rgb = rgbFragment ;
    gl_FragColor.a = 1.0 ; // TODO : Worry about materials which allow transparency!
}

pbr-shader-2

The environment map itself is a complex, precomputed source of light, and that’s a vitally important distinction here. You can only see the environment if it is lit. A perfectly reflective sphere in deep space would reflect all incoming visible light from all stars around it. The reflection map is effectively ambient lighting.

(The reflection maps used are in the .zip)

The Concept of Metalness

This is provided through a texture which allows the artist to specify how metallic the material is. White (1.0) represents full on metalness (and thus reflectance). Black (0.0) represents full dielectric properties (and thus zero reflectance). For now we just add the basic texture map shader support (and munge the reflection map purely to see the effect). We’ll expand on this properly when we handle albedo.

pbr-shader.vertex.c

#version 330 compatibility

attribute vec3 aVertexTangent;

varying vec3 vvLocalSurfaceNormal ;
varying vec3 vvLocalSurfaceToLightDirection;
varying vec3 vvLocalReflectedSurfaceToViewerDirection;
varying vec2 vuvCoord0 ;

void main()
{
    vec3 vViewModelPosition = vec3(gl_ModelViewMatrixInverse * vec4(0, 0, 0, 1.0));
    vec3 vLightModelPosition = (gl_ModelViewMatrixInverse * gl_LightSource[0].position).xyz;
    vvLocalSurfaceToLightDirection = normalize(vLightModelPosition – gl_Vertex.xyz) ;

    vvLocalSurfaceNormal = normalize(gl_Normal) ;

    vec3 vLocalSurfaceToViewerDirection = normalize(vViewModelPosition – gl_Vertex.xyz) ;
    vvLocalReflectedSurfaceToViewerDirection = normalize(reflect(vLocalSurfaceToViewerDirection, vvLocalSurfaceNormal)) ;

    vuvCoord0 = gl_MultiTexCoord0.st ;

    gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}

pbr-shader.fragment.c

#version 330 compatibility

varying vec3 vvLocalSurfaceNormal ;
varying vec3 vvLocalSurfaceToLightDirection;
varying vec3 vvLocalReflectedSurfaceToViewerDirection;
varying vec2 vuvCoord0 ;

uniform samplerCube cubeMap ;
uniform sampler2D metalnessMap ;

void main()
{
    vec3 vNormalisedLocalSurfaceNormal = normalize(vvLocalSurfaceNormal) ;
    vec3 vNormalisedLocalSurfaceToLightDirection = normalize(vvLocalSurfaceToLightDirection) ;
    vec3 vNormalisedLocalReflectedSurfaceToViewerDirection = normalize(vvLocalReflectedSurfaceToViewerDirection) ;

    float fLightIntensity = max(dot(vNormalisedLocalSurfaceToLightDirection, vNormalisedLocalSurfaceNormal), 0.0) ;

    float fMetalness = texture(metalnessMap, vuvCoord0).r ;

    vec3 rgbFragment = vec3(0.0) ;

    vec3 rgbSourceReflection = texture(cubeMap, vNormalisedLocalReflectedSurfaceToViewerDirection).rgb ;
    vec3 rgbReflection = rgbSourceReflection ;
    rgbReflection *= fMetalness ;
    rgbReflection = min(rgbReflection, rgbSourceReflection) ; // conservation of energy

    rgbFragment += vec3(1.0) ;

    rgbFragment *= fLightIntensity ;
    rgbFragment += rgbReflection;

    gl_FragColor.rgb = rgbFragment ;
    gl_FragColor.a = 1.0 ; // TODO : Worry about materials which allow transparency!
}

pbr-shader-3

The Concept of Roughness

There are two aspects to ‘Roughness’… the specular contribution from each light source, and also the diffuse contribution caused by its effect on the reflection map.

Rougher surfaces diffuse light more than smooth surfaces. The opposite is glossiness, which reflects light more (so appear shinier and more precise). Given that Reflection and Metalness already cater for ‘teh shiny’ aspects, I prefer to my PBR workflow in terms of Roughness.

A texture map provides ‘roughness’ where White (1.0) is ‘very rough’ and Black (0.0) is ‘very smooth’. We use this value to control the two aspects of roughness. (To think in ‘glossiness’ simply flip the value, 1.0 is very glossy, 0.0 is very matt.)

Controlling specular requires some mathematical trickery in the shader. Although our Roughness map will control the amount of roughness on the surface, how do we emulate ‘roughness’? In straightforward terms, we need to ‘spread out’ the incoming light on a rough surface, the rougher the surface, the more spread out the light becomes. The smoother the surface, the more concentrated the light is.

We achieve this using some cunning mathematics based on the concept of ‘micro-facets’, which is well beyond this article, and better explained here. Look close enough at any flat surface (even glass) and at the microscopic level you’ll see lots of micro-facets all pointing in different directions, thereby reflecting light in different directions. The rougher a surface, the more of these micro-facets point in different ways (they can even self-shadow) which diffuses the incoming light and spreads out the reflected light in many directions. The smoother the surface, the more micro-facets are aligned and more or less aim the same way, resulting in a more concentrated light, being reflected in a (more or less) single direction.

For our principal sources of incoming light, it’s a simply matter of modifying the lighting contribution from every light source, based on some calculations which emulate these micro-facets.

pbr-shader.vertex.c

#version 330 compatibility

attribute vec3 aVertexTangent;

varying vec3 vvLocalSurfaceNormal ;
varying vec3 vvLocalSurfaceToLightDirection;
varying vec3 vvLocalReflectedSurfaceToViewerDirection;
varying vec3 vvLocalSurfaceToViewerDirection;
varying vec2 vuvCoord0 ;

void main()
{
    vec3 vViewModelPosition = vec3(gl_ModelViewMatrixInverse * vec4(0, 0, 0, 1.0));
    vvLocalSurfaceToViewerDirection = normalize(vViewModelPosition – gl_Vertex.xyz) ;

    vec3 vLightModelPosition = (gl_ModelViewMatrixInverse * gl_LightSource[0].position).xyz;
    vvLocalSurfaceToLightDirection = normalize(vLightModelPosition – gl_Vertex.xyz) ;

    vvLocalSurfaceNormal = normalize(gl_Normal) ;

    vec3 vLocalSurfaceToViewerDirection = normalize(vViewModelPosition – gl_Vertex.xyz) ;
    vvLocalReflectedSurfaceToViewerDirection = normalize(reflect(vLocalSurfaceToViewerDirection, vvLocalSurfaceNormal)) ;

    vuvCoord0 = gl_MultiTexCoord0.st ;

    gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}

pbr-shader.fragment.c

#version 330 compatibility

varying vec3 vvLocalSurfaceNormal ;
varying vec3 vvLocalSurfaceToLightDirection;
varying vec3 vvLocalReflectedSurfaceToViewerDirection;
varying vec3 vvLocalSurfaceToViewerDirection;
varying vec2 vuvCoord0 ;

uniform samplerCube cubeMap ;
uniform sampler2D metalnessMap ;
uniform sampler2D roughnessMap;


const float cpi = 3.14159265358979323846264338327950288419716939937510f ;

float chiGGX(float f)
{
    return f > 0.0 ? 1.0 : 0.0 ;
}

float computeGGXDistribution(vec3 vSurfaceNormal, vec3 vSurfaceToLightDirection, float fRoughness)
{
    float fNormalDotLight = clamp(dot(vSurfaceNormal, vSurfaceToLightDirection), 0.0, 1.0) ;
    float fNormalDotLightSquared = fNormalDotLight * fNormalDotLight ;
    float fRoughnessSquared = fRoughness * fRoughness ;
    float fDen = fNormalDotLightSquared * fRoughnessSquared + (1 – fNormalDotLightSquared);

    return clamp((chiGGX(fNormalDotLight) * fRoughnessSquared) / (cpi * fDen * fDen), 0.0, 1.0);
}

float computeGGXPartialGeometryTerm(vec3 vSurfaceToViewerDirection, vec3 vSurfaceNormal, vec3 vLightViewHalfVector, float fRoughness)
{
    float fViewerDotLightViewHalf = clamp(dot(vSurfaceToViewerDirection, vLightViewHalfVector), 0.0, 1.0) ;
    float fChi = chiGGX(fViewerDotLightViewHalf / clamp(dot(vSurfaceToViewerDirection, vSurfaceNormal), 0.0, 1.0));
    fViewerDotLightViewHalf *= fViewerDotLightViewHalf;
    float fTan2 = (1.0 – fViewerDotLightViewHalf) / fViewerDotLightViewHalf;

    return clamp((fChi * 2.0) / (1.0 + sqrt(1 + fRoughness * fRoughness * fTan2)), 0.0, 1.0) ;
}

void main()
{
    vec3 vNormalisedLocalSurfaceNormal = normalize(vvLocalSurfaceNormal) ;
    vec3 vNormalisedLocalSurfaceToLightDirection = normalize(vvLocalSurfaceToLightDirection) ;
    vec3 vNormalisedLocalReflectedSurfaceToViewerDirection = normalize(vvLocalReflectedSurfaceToViewerDirection) ;
    vec3 vNormalisedLocalSurfaceToViewerDirection = normalize(vvLocalSurfaceToViewerDirection) ;

    vec3 vLocalLightViewHalfVector = normalize(vNormalisedLocalSurfaceToLightDirection + vNormalisedLocalSurfaceToViewerDirection) ;

    float fLightIntensity = max(dot(vNormalisedLocalSurfaceToLightDirection, vNormalisedLocalSurfaceNormal), 0.0) ;

    float fMetalness = texture(metalnessMap, vuvCoord0).r ;
    float fRoughness = texture(roughnessMap, vuvCoord0).r ;

    float distributionMicroFacet = computeGGXDistribution(vNormalisedLocalSurfaceNormal, vNormalisedLocalSurfaceToLightDirection, fRoughness) ;
    float geometryMicroFacet = computeGGXPartialGeometryTerm(vNormalisedLocalSurfaceToViewerDirection, vNormalisedLocalSurfaceNormal, vLocalLightViewHalfVector, fRoughness) ;
    float microFacetContribution = distributionMicroFacet * geometryMicroFacet ;

    vec3 rgbFragment = vec3(0.0) ;

    vec3 rgbSourceReflection = texture(cubeMap, vNormalisedLocalReflectedSurfaceToViewerDirection).rgb ;
    vec3 rgbReflection = rgbSourceReflection ;
    rgbReflection *= fMetalness ;
    rgbReflection = min(rgbReflection, rgbSourceReflection) ; // conservation of energy

    vec3 rgbSpecular = vec3(0.0) ;

    if (fLightIntensity > 0.0)
    {
        rgbSpecular = vec3(1.0) ;
        rgbSpecular *= microFacetContribution ;
        rgbSpecular = min(vec3(1.0), rgbSpecular) ; // conservation of energy
    }
    rgbFragment += rgbSpecular ;

    rgbFragment *= fLightIntensity ;
    rgbFragment += rgbReflection;

    gl_FragColor.rgb = rgbFragment ;
    gl_FragColor.a = 1.0 ; // TODO : Worry about materials which allow transparency!
}

pbr-shader-4a
That’s a lot of new code there – what just happened?

The important changes are the functions to emulate roughness – being computeGGXDistribution and computeGGXPartialGeometryTerm. Describing these in detail is best done by the references, you can treat them as ‘black boxes’ which just work.
Their effect is then combined and used to alter the illumination intensity, and this is ultimately what roughens up the light contribution.

I also add in the ‘concept’ of specular highlights. In my terms, it’s not specular as you may know it, it’s more a nod towards it, and you may choose to leave it out entirely. Note, I’ve used pure white (vec3(1.0)) for expedience, you should pass this in via a property. (I may get shot down for this, but for me, without this specular contribution, the PBR system looked very flat and non-realistic. YMMV.)

If you skip back to the first ‘Illumination’ image and compare it with the ‘Bare Effect’ shown here, you can see the light fall-off has changed dramatically due to the rough surface implementation. The incoming light is now being spread around a bit more by the micro-facets.

Dealing with the reflection map is another matter. This emulates a highly complex light environment, and applying the above to all this much light is going to cripple the shader, as we’d need to calculate the above for all possible reflected incoming light at a given point. (That said, it can be simulated with finite sampling methods, but that’s another article!)

How to solve this? Well, we turn to the Cube Map itself, and pre-bake the effect by manipulating its mipmaps. This is where AMD’s CubeMapGen comes in. We take our source cube map textures, and then apply certain filtering to each mipmap level which produces a ‘rougher’ version of the cube map itself. In this example, I have used ‘Cosine’ filering. We then load these mipmaps manually instead of relying on generated mipmap techniques. We could rely on the standard mipmap generation instead, but trust me it won’t look as good.

Because I’m nice, here is a DOS batch file which will generate a set of cube map mipmaps from a set of source maps suitable for our purposes. This presumes you’ve installed CubeMapGen and then added the path to the cubemapgen.exe to your system PATH environment variable.

gencubemap.bat

To use it from a DOS prompt;

gencubemap right.png left.png up.png down.png front.png back.png

It then squirts all the mip maps out into a sub-folder of the current directory called ‘mips’. The filenaming convention used by CubeMapGen is a little bizarre initially. The ‘m00’, ‘m01’ handily refers to the mip level, but then instead of using axes or ‘front’, ‘back’ etc, it numbers the cube faces thus;

00 is +x (right)
01 is -x (left)
02 is +y (up)
03 is -y (down)
04 is +z (front)
05 is -z (back)

You’ll note the order and mapping is fairly logical, once you know it.

Nb. I use the ‘common sense’(IMHO) Left Hand Coordinate system, where +X is right, +Y is up, and +Z is forward. Depending on your particular coordinate system, you may need to juggle things around and flip textures, or reassign the above numbers to different axes. Even I have to juggle this, by rotating the entire cube map 180 degrees around the X axis.

After all that hard prep work, you’ll be glad to hear that the shader changes we need to support this are actually very simple…

pbr-shader.vertex.c

No changes required

pbr-shader.fragment.c

#version 330 compatibility

varying vec3 vvLocalSurfaceNormal ;
varying vec3 vvLocalSurfaceToLightDirection;
varying vec3 vvLocalReflectedSurfaceToViewerDirection;
varying vec2 vuvCoord0 ;
varying vec3 vvLocalSurfaceToViewerDirection;

uniform samplerCube cubeMap ;
uniform sampler2D metalnessMap ;
uniform sampler2D roughnessMap;

const float cpi = 3.14159265358979323846264338327950288419716939937510f ;

float chiGGX(float f)
{
    return f > 0.0 ? 1.0 : 0.0 ;
}

float computeGGXDistribution(vec3 vSurfaceNormal, vec3 vSurfaceToLightDirection, float fRoughness)
{
    float fNormalDotLight = clamp(dot(vSurfaceNormal, vSurfaceToLightDirection), 0.0, 1.0) ;
    float fNormalDotLightSquared = fNormalDotLight * fNormalDotLight ;
    float fRoughnessSquared = fRoughness * fRoughness ;
    float fDen = fNormalDotLightSquared * fRoughnessSquared + (1 – fNormalDotLightSquared);

    return clamp((chiGGX(fNormalDotLight) * fRoughnessSquared) / (cpi * fDen * fDen), 0.0, 1.0);
}

float computeGGXPartialGeometryTerm(vec3 vSurfaceToViewerDirection, vec3 vSurfaceNormal, vec3 vLightViewHalfVector, float fRoughness)
{
    float fViewerDotLightViewHalf = clamp(dot(vSurfaceToViewerDirection, vLightViewHalfVector), 0.0, 1.0) ;
    float fChi = chiGGX(fViewerDotLightViewHalf / clamp(dot(vSurfaceToViewerDirection, vSurfaceNormal), 0.0, 1.0));
    fViewerDotLightViewHalf *= fViewerDotLightViewHalf;
    float fTan2 = (1.0 – fViewerDotLightViewHalf) / fViewerDotLightViewHalf;

    return clamp((fChi * 2.0) / (1.0 + sqrt(1 + fRoughness * fRoughness * fTan2)), 0.0, 1.0) ;
}

void main()
{
    vec3 vNormalisedLocalSurfaceNormal = normalize(vvLocalSurfaceNormal) ;
    vec3 vNormalisedLocalSurfaceToLightDirection = normalize(vvLocalSurfaceToLightDirection) ;
    vec3 vNormalisedLocalReflectedSurfaceToViewerDirection = normalize(vvLocalReflectedSurfaceToViewerDirection) ;
    vec3 vNormalisedLocalSurfaceToViewerDirection = normalize(vvLocalSurfaceToViewerDirection) ;

    vec3 vLocalLightViewHalfVector = normalize(vNormalisedLocalSurfaceToLightDirection + vNormalisedLocalSurfaceToViewerDirection) ;

    float fLightIntensity = max(dot(vNormalisedLocalSurfaceToLightDirection, vNormalisedLocalSurfaceNormal), 0.0) ;

    float fMetalness = texture(metalnessMap, vuvCoord0).r ;
    float fRoughness = texture(roughnessMap, vuvCoord0).r ;

    float distributionMicroFacet = computeGGXDistribution(vNormalisedLocalSurfaceNormal, vNormalisedLocalSurfaceToLightDirection, fRoughness) ;
    float geometryMicroFacet = computeGGXPartialGeometryTerm(vNormalisedLocalSurfaceToViewerDirection, vNormalisedLocalSurfaceNormal, vLocalLightViewHalfVector, fRoughness) ;
    float microFacetContribution = distributionMicroFacet * geometryMicroFacet ;

    vec3 rgbFragment = vec3(0.0) ;

    vec3 rgbSourceReflection = textureCubeLod(cubeMap, vNormalisedLocalReflectedSurfaceToViewerDirection, 9.0 * fRoughness).rgb ;
    vec3 rgbReflection = rgbSourceReflection ;
    rgbReflection *= fMetalness ;
    rgbReflection = min(rgbReflection, rgbSourceReflection) ; // conservation of energy

    vec3 rgbSpecular = vec3(0.0) ;

    if (fLightIntensity > 0.0)
    {
        rgbSpecular = vec3(1.0) ;
        rgbSpecular *= microFacetContribution ;
        rgbSpecular = min(vec3(1.0), rgbSpecular) ; // conservation of energy
    }
    rgbFragment += rgbSpecular ;
    rgbFragment *= fLightIntensity ;
    rgbFragment += rgbReflection;

    gl_FragColor.rgb = rgbFragment ;
    gl_FragColor.a = 1.0 ; // TODO : Worry about materials which allow transparency!
}

pbr-shader-4b

Now the roughness also controls the mip level used when looking up the reflection from the cube map. The rougher the surface, the higher the mip level, which leads to using the rougher mip maps. This gives us a blurry reflection and convincingly emulates a rougher, more diffuse effect. Here I’ve hard coded 9 levels (9.0), you may want to pass the number of available mip levels in via a property. I looked to see if GLSL can determine this value in the shader itself, but can’t find any references. If you know of how to do this, please let me know.

Albedo

This is similar to the typical diffuse map – the usual texture artists would provide which gives us the colour, shading and ‘texture’ detail on our models. However, the albedo map has no lighting, no shadows, just plain old colour. Think of it as the paint/transfer you’d apply to a wall, or the side of a van. It, in itself, has no specific detail. That comes from the surface itself via the cleverness that is PBR.

Albedo also provides another important function. It not only directly provides the colour of the non-metallic surface, it is also used to tint the colour of light reflected by the metallic surface.

pbr-shader.vertex.c

No changes

pbr-shader.fragment.c

#version 330 compatibility

varying vec3 vvLocalSurfaceNormal ;
varying vec3 vvLocalSurfaceToLightDirection;
varying vec3 vvLocalReflectedSurfaceToViewerDirection;
varying vec2 vuvCoord0 ;
varying vec3 vvLocalSurfaceToViewerDirection;

uniform samplerCube cubeMap ;
uniform sampler2D metalnessMap ;
uniform sampler2D roughnessMap;
uniform sampler2D albedoMap ;

const float cpi = 3.14159265358979323846264338327950288419716939937510f ;

float chiGGX(float f)
{
    return f > 0.0 ? 1.0 : 0.0 ;
}

float computeGGXDistribution(vec3 vSurfaceNormal, vec3 vSurfaceToLightDirection, float fRoughness)
{
    float fNormalDotLight = clamp(dot(vSurfaceNormal, vSurfaceToLightDirection), 0.0, 1.0) ;
    float fNormalDotLightSquared = fNormalDotLight * fNormalDotLight ;
    float fRoughnessSquared = fRoughness * fRoughness ;
    float fDen = fNormalDotLightSquared * fRoughnessSquared + (1 – fNormalDotLightSquared);

    return clamp((chiGGX(fNormalDotLight) * fRoughnessSquared) / (cpi * fDen * fDen), 0.0, 1.0);
}

float computeGGXPartialGeometryTerm(vec3 vSurfaceToViewerDirection, vec3 vSurfaceNormal, vec3 vLightViewHalfVector, float fRoughness)
{
    float fViewerDotLightViewHalf = clamp(dot(vSurfaceToViewerDirection, vLightViewHalfVector), 0.0, 1.0) ;
    float fChi = chiGGX(fViewerDotLightViewHalf / clamp(dot(vSurfaceToViewerDirection, vSurfaceNormal), 0.0, 1.0));
    fViewerDotLightViewHalf *= fViewerDotLightViewHalf;
    float fTan2 = (1.0 – fViewerDotLightViewHalf) / fViewerDotLightViewHalf;

    return clamp((fChi * 2.0) / (1.0 + sqrt(1 + fRoughness * fRoughness * fTan2)), 0.0, 1.0) ;
}

void main()
{
    vec3 vNormalisedLocalSurfaceNormal = normalize(vvLocalSurfaceNormal) ;
    vec3 vNormalisedLocalSurfaceToLightDirection = normalize(vvLocalSurfaceToLightDirection) ;
    vec3 vNormalisedLocalReflectedSurfaceToViewerDirection = normalize(vvLocalReflectedSurfaceToViewerDirection) ;
    vec3 vNormalisedLocalSurfaceToViewerDirection = normalize(vvLocalSurfaceToViewerDirection) ;

    vec3 vLocalLightViewHalfVector = normalize(vNormalisedLocalSurfaceToLightDirection + vNormalisedLocalSurfaceToViewerDirection) ;

    float fLightIntensity = max(dot(vNormalisedLocalSurfaceToLightDirection, vNormalisedLocalSurfaceNormal), 0.0) ;

    float fMetalness = texture(metalnessMap, vuvCoord0).r ;
    float fRoughness = texture(roughnessMap, vuvCoord0).r ;

    float distributionMicroFacet = computeGGXDistribution(vNormalisedLocalSurfaceNormal, vNormalisedLocalSurfaceToLightDirection, fRoughness) ;
    float geometryMicroFacet = computeGGXPartialGeometryTerm(vNormalisedLocalSurfaceToViewerDirection, vNormalisedLocalSurfaceNormal, vLocalLightViewHalfVector, fRoughness) ;
    float microFacetContribution = distributionMicroFacet * geometryMicroFacet ;


    vec3 rgbAlbedo = texture(albedoMap, vuvCoord0).rgb ;

    vec3 rgbFragment = rgbAlbedo * (1.0 – fMetalness);

    vec3 rgbSourceReflection = textureCubeLod(cubeMap, vNormalisedLocalReflectedSurfaceToViewerDirection, 9.0 * fRoughness).rgb ;
    vec3 rgbReflection = rgbSourceReflection ;
    rgbReflection *= rgbAlbedo * fMetalness ;
    rgbReflection = min(rgbReflection, rgbSourceReflection) ; // conservation of energy

    vec3 rgbSpecular = vec3(0.0) ;

    if (fLightIntensity > 0.0)
    {
        rgbSpecular = vec3(1.0) ;
        rgbSpecular *= microFacetContribution ;
        rgbSpecular = min(vec3(1.0), rgbSpecular) ; // conservation of energy
    }
    rgbFragment += rgbSpecular ;
    rgbFragment *= fLightIntensity ;
    rgbFragment += rgbReflection ;

    gl_FragColor.rgb = rgbFragment ;
    gl_FragColor.a = 1.0 ; // TODO : Worry about materials which allow transparency!
}

pbr-shader-5

Fresnel Reflection

Everything is Shiny! Everything has Fresnel! Filmic Games nail it with some example images – so go read them, be convinced 🙂

Fresnel applies to any light source shining on our object, including the reflection map. This may seem like a lot of additional computational effort/expense for very little effect, but trust me, you see this effect every single time you look at anything in the real world. Beauty lies in the detail… where we can spare the processing time!

pbr-shader.vertex.c

No change

pbr-shader.fragment.c

#version 330 compatibility

uniform samplerCube cubeMap ;
uniform sampler2D metalnessMap ;
uniform sampler2D roughnessMap;
uniform sampler2D albedoMap ;

varying vec3 vvLocalSurfaceNormal ;
varying vec3 vvLocalSurfaceToLightDirection;
varying vec3 vvLocalReflectedSurfaceToViewerDirection;
varying vec2 vuvCoord0 ;
varying vec3 vvLocalSurfaceToViewerDirection;

const float cpi = 3.14159265358979323846264338327950288419716939937510f ;


float computeFresnelTerm(float fZero, vec3 vSurfaceToViewerDirection, vec3 vSurfaceNormal)
{
    float baseValue = 1.0 – dot(vSurfaceToViewerDirection, vSurfaceNormal);
    float exponential = pow(baseValue, 5.0) ;
    float fresnel = exponential + fZero * (1.0 – exponential) ;

    return fresnel ;
}

float chiGGX(float f)
{
    return f > 0.0 ? 1.0 : 0.0 ;
}

float computeGGXDistribution(vec3 vSurfaceNormal, vec3 vSurfaceToLightDirection, float fRoughness)
{
    float fNormalDotLight = clamp(dot(vSurfaceNormal, vSurfaceToLightDirection), 0.0, 1.0) ;
    float fNormalDotLightSquared = fNormalDotLight * fNormalDotLight ;
    float fRoughnessSquared = fRoughness * fRoughness ;
    float fDen = fNormalDotLightSquared * fRoughnessSquared + (1 – fNormalDotLightSquared);

    return clamp((chiGGX(fNormalDotLight) * fRoughnessSquared) / (cpi * fDen * fDen), 0.0, 1.0);
}

float computeGGXPartialGeometryTerm(vec3 vSurfaceToViewerDirection, vec3 vSurfaceNormal, vec3 vLightViewHalfVector, float fRoughness)
{
    float fViewerDotLightViewHalf = clamp(dot(vSurfaceToViewerDirection, vLightViewHalfVector), 0.0, 1.0) ;
    float fChi = chiGGX(fViewerDotLightViewHalf / clamp(dot(vSurfaceToViewerDirection, vSurfaceNormal), 0.0, 1.0));
    fViewerDotLightViewHalf *= fViewerDotLightViewHalf;
    float fTan2 = (1.0 – fViewerDotLightViewHalf) / fViewerDotLightViewHalf;

    return clamp((fChi * 2.0) / (1.0 + sqrt(1 + fRoughness * fRoughness * fTan2)), 0.0, 1.0) ;
}

void main()
{
    vec3 vNormalisedLocalSurfaceNormal = normalize(vvLocalSurfaceNormal) ;
    vec3 vNormalisedLocalSurfaceToLightDirection = normalize(vvLocalSurfaceToLightDirection) ;
    vec3 vNormalisedLocalReflectedSurfaceToViewerDirection = normalize(vvLocalReflectedSurfaceToViewerDirection) ;
    vec3 vNormalisedLocalSurfaceToViewerDirection = normalize(vvLocalSurfaceToViewerDirection) ;

    vec3 vLocalLightViewHalfVector = normalize(vNormalisedLocalSurfaceToLightDirection + vNormalisedLocalSurfaceToViewerDirection) ;

    float fLightIntensity = max(dot(vNormalisedLocalSurfaceToLightDirection, vNormalisedLocalSurfaceNormal), 0.0) ;

    float fMetalness = texture(metalnessMap, vuvCoord0).r ;
    float fRoughness = max(0.001, texture(roughnessMap, vuvCoord0).r ) ;

    float distributionMicroFacet = computeGGXDistribution(vNormalisedLocalSurfaceNormal, vNormalisedLocalSurfaceToLightDirection, fRoughness) ;
    float geometryMicroFacet = computeGGXPartialGeometryTerm(vNormalisedLocalSurfaceToViewerDirection, vNormalisedLocalSurfaceNormal, vLocalLightViewHalfVector, fRoughness) ;
    float microFacetContribution = distributionMicroFacet * geometryMicroFacet ;

    float fLightSourceFresnelTerm = computeFresnelTerm(0.5, vNormalisedLocalSurfaceToViewerDirection, vNormalisedLocalSurfaceNormal) ;

    vec3 rgbAlbedo = texture(albedoMap, vuvCoord0).rgb ;

    vec3 rgbFragment = rgbAlbedo * (1.0 – fMetalness);

    vec3 rgbSourceReflection = textureCubeLod(cubeMap, vNormalisedLocalReflectedSurfaceToViewerDirection, 9.0 * fRoughness).rgb ;
    vec3 rgbReflection = rgbSourceReflection ;
    rgbReflection *= rgbAlbedo * fMetalness ;
    rgbReflection *= fLightSourceFresnelTerm ;
    rgbReflection = min(rgbReflection, rgbSourceReflection) ; // conservation of energy

    vec3 rgbSpecular = vec3(0.0) ;

    if (fLightIntensity > 0.0)
    {
        rgbSpecular = vec3(1.0) ;
        rgbSpecular *= microFacetContribution * fLightSourceFresnelTerm ;
        rgbSpecular = min(vec3(1.0), rgbSpecular) ; // conservation of energy
    }
    rgbFragment += rgbSpecular ;
    rgbFragment *= fLightIntensity ;
    rgbFragment += rgbReflection ;

    gl_FragColor.rgb = rgbFragment ;
    gl_FragColor.a = 1.0 ; // TODO : Worry about materials which allow transparency!
}

pbr-shader-6

Congratulations! You now have a basic PBR shader!

You’ll note there is also a tweak to the reflection map, to ensure it’s brightness contribution never tips the balance. I’m not 100% convinced by the overall Conservation of Energy in this shader, being the law by which the total reflected light cannot exceed the total incoming light. I may have to revisit this as the diffuse/specular element is far too saturated

The Result

Putting it all together, we now have a highly tunable, artist controlled shader ready to be tuned/tweaked/improved to your hearts content!

You can download the shader and source textures here: pbr-shader.zip

Going Forward…

There are lots of things to do to improve the system, optimisation, normal maps, shadows, et al, and no doubt some of the tweaks I’ve chosen may violate the PBR experts sense of correctness, I humbly stand ready to be corrected in that case.

Optimisation is – as ever – an exercise for the reader. The goal here is to provide concise, readable, and understandable code without too much magic. Hopefully I succeeded! Feel free to weave whatever dark arts you possess over the shader as you deem fit, and also use it freely with no restriction wherever you like. Also feel free to share the results in return and let me know – I’ll gladly add links here. Though please link to this source wherever you use or reference this shader, the more feet passing Dominium’s open doorway the better, someone may even look in 😉

For me, the main thing that stands out is ‘Why all these really expensive texture maps just for Metalness or Roughness when they are just grayscale?’ etc. Well, stay tuned for Part Two!

References

Lambert lighting model

AMD’s CubeMapGen – grab this, and it’s source code, while it lasts!

Roughness/micro-facets Mathematics

Refractive Indices – useful values for various materials – this can be used to determine FresnelZero(f0)

https://www.kdab.com/wp-content/uploads/stories/KDAB-whitepaper-PBR-2016-04-v3.pdf

Nice PBR reference images

https://blog.sketchfab.com/tutorial-blender-quixelsubstance-sketchfab-a-proper-pbr-workflow/

https://seblagarde.wordpress.com/2011/08/17/hello-world/

http://www.codinglabs.net/article_physically_based_rendering.aspx

http://www.codinglabs.net/article_physically_based_rendering_cook_torrance.aspx

Subscribe to The Dominium Observer Newsletter!
Subscribe