LearnOpenGL

Translation in progress of learnopengl.com.
git clone https://git.mtkn.jp/LearnOpenGL
Log | Files | Refs

Specular-IBL.html (50270B)


      1     <h1 id="content-title">Specular IBL</h1>
      2 <h1 id="content-url" style='display:none;'>PBR/IBL/Specular-IBL</h1>
      3 <p>
      4   In the <a href="https://learnopengl.com/PBR/IBL/Diffuse-irradiance" target="_blank">previous</a> chapter we've set up PBR in combination with image based lighting by pre-computing an irradiance map as the lighting's indirect diffuse portion. In this chapter we'll focus on the specular part of the reflectance equation:
      5 </p>
      6   
      7  \[
      8     L_o(p,\omega_o) = \int\limits_{\Omega} 
      9     	(k_d\frac{c}{\pi} + k_s\frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)})
     10     	L_i(p,\omega_i) n \cdot \omega_i  d\omega_i
     11  \]
     12 
     13 <p>
     14   You'll notice that the Cook-Torrance specular portion (multiplied by \(kS\)) isn't constant over the integral and is dependent on the incoming light direction, but <strong>also</strong> the incoming view direction. Trying to solve the integral for all incoming light directions including all possible view directions is a combinatorial overload and way too expensive to calculate on a real-time basis. Epic Games proposed a solution where they were able to pre-convolute the specular part for real time purposes, given a few compromises, known as the <def>split sum approximation</def>.
     15 </p>
     16 
     17 <p>
     18   The split sum approximation splits the specular part of the reflectance equation into two separate parts that we can individually convolute and later combine in the PBR shader for specular indirect image based lighting. Similar to how we pre-convoluted the irradiance map, the split sum approximation requires an HDR environment map as its convolution input. To understand the split sum approximation we'll again look at the reflectance equation, but this time focus on the specular part:
     19 </p>
     20 
     21 \[
     22 	L_o(p,\omega_o) = 
     23 		\int\limits_{\Omega} (k_s\frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)}
     24 			L_i(p,\omega_i) n \cdot \omega_i  d\omega_i
     25 			=
     26 		\int\limits_{\Omega} f_r(p, \omega_i, \omega_o) L_i(p,\omega_i) n \cdot \omega_i  d\omega_i
     27 \]
     28 
     29 <p>
     30   For the same (performance) reasons as the irradiance convolution, we can't solve the specular part of the integral in real time and expect a reasonable performance. So preferably we'd pre-compute this integral to get something like a specular IBL map, sample this map with the fragment's normal, and be done with it. However, this is where it gets a bit tricky. We were able to pre-compute the irradiance map as the integral only depended on \(\omega_i\) and we could move the constant diffuse albedo terms out of the integral. This time, the integral depends on more than just \(\omega_i\) as evident from the BRDF:
     31 </p>
     32 
     33 \[
     34 	f_r(p, w_i, w_o) = \frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)}
     35 \]
     36 
     37 <p>
     38   The integral also depends on \(w_o\), and we can't really sample a pre-computed cubemap with two direction vectors. The position \(p\) is irrelevant here as described in the previous chapter. Pre-computing this integral for every possible combination of \(\omega_i\) and \(\omega_o\)  isn't practical in a real-time setting. 
     39 </p>
     40 
     41 <p>
     42   Epic Games' split sum approximation solves the issue by splitting the pre-computation into 2 individual parts that we can later combine to get the resulting pre-computed result we're after. The split sum approximation splits the specular integral into two separate integrals:
     43 </p>
     44 
     45 \[
     46      L_o(p,\omega_o) = 
     47 		\int\limits_{\Omega} L_i(p,\omega_i) d\omega_i
     48 		*
     49 		\int\limits_{\Omega} f_r(p, \omega_i, \omega_o) n \cdot \omega_i d\omega_i
     50 \]
     51 
     52 <p>
     53   The first part (when convoluted) is known as the <def>pre-filtered environment map</def> which is (similar to the irradiance map) a pre-computed environment convolution map, but this time taking roughness into account. For increasing roughness levels, the environment map is convoluted with more scattered sample vectors, creating blurrier reflections. For each roughness level we convolute, we store the sequentially blurrier results in the pre-filtered map's mipmap levels. For instance, a pre-filtered environment map storing the pre-convoluted result of 5 different roughness values in its 5 mipmap levels looks as follows: 
     54 </p>
     55 
     56 <img src="/img/pbr/ibl_prefilter_map.png" class="clean" alt="Pre-convoluted environment map over 5 roughness levels for PBR"/>
     57   
     58   
     59   <p>
     60     We generate the sample vectors and their scattering amount using the normal distribution function (NDF) of the Cook-Torrance BRDF that takes as input both a normal and view direction. As we don't know beforehand the view direction when convoluting the environment map, Epic Games makes a further approximation by assuming the view direction (and thus the specular reflection direction) to be equal to the output sample direction \(\omega_o\). This translates itself to the following code: 
     61 </p>
     62 
     63 <pre><code>
     64 vec3 N = normalize(w_o);
     65 vec3 R = N;
     66 vec3 V = R;
     67 </code></pre>
     68 
     69 <p>
     70   This way, the pre-filtered environment convolution doesn't need to be aware of the view direction. This does mean we don't get nice grazing specular reflections when looking at specular surface reflections from an angle as seen in the image below (courtesy of the <em>Moving Frostbite to PBR</em> article); this is however generally considered an acceptable compromise:
     71 </p>
     72 
     73 <img src="/img/pbr/ibl_grazing_angles.png" class="clean" alt="Removing grazing specular reflections with the split sum approximation of V = R = N."/>
     74   
     75 <p>
     76   The second part of the split sum equation equals the BRDF part of the specular integral. If we pretend the incoming radiance is completely white for every direction (thus \(L(p, x) = 1.0\)) we can pre-calculate the BRDF's response given an input roughness and an input angle between the normal \(n\) and light direction \(\omega_i\), or \(n \cdot \omega_i\). Epic Games stores the pre-computed BRDF's response to each normal and light direction combination on varying roughness values in a 2D lookup texture (LUT) known as the <def>BRDF integration</def> map. The 2D lookup texture outputs a scale (red) and a bias value (green) to the surface's Fresnel response giving us the second part of the split specular integral:
     77 </p>
     78   
     79   <img src="/img/pbr/ibl_brdf_lut.png" alt="Visualization of the 2D BRDF LUT according to the split sum approximation for PBR in OpenGL."/>
     80     
     81 <p>
     82   We generate the lookup texture by treating the horizontal texture coordinate (ranged between <code>0.0</code> and <code>1.0</code>) of a plane as the BRDF's input \(n \cdot \omega_i\), and its vertical texture coordinate as the input roughness value. With this BRDF integration map and the pre-filtered environment map we can combine both to get the result of the specular integral:
     83 </p>
     84     
     85 <pre><code>
     86 float lod             = getMipLevelFromRoughness(roughness);
     87 vec3 prefilteredColor = textureCubeLod(PrefilteredEnvMap, refVec, lod);
     88 vec2 envBRDF          = texture2D(BRDFIntegrationMap, vec2(NdotV, roughness)).xy;
     89 vec3 indirectSpecular = prefilteredColor * (F * envBRDF.x + envBRDF.y) 
     90 </code></pre>
     91     
     92 <p>
     93   This should give you a bit of an overview on how Epic Games' split sum approximation roughly approaches the indirect specular part of the reflectance equation. Let's now try and build the pre-convoluted parts ourselves.
     94 </p>
     95 
     96 <h2>Pre-filtering an HDR environment map</h2>
     97 <p>
     98   Pre-filtering an environment map is quite similar to how we convoluted an irradiance map. The difference being that we now account for roughness and store sequentially rougher reflections in the pre-filtered map's mip levels.
     99 </p>
    100     
    101 <p>
    102   First, we need to generate a new cubemap to hold the pre-filtered environment map data. To make sure we allocate enough memory for its mip levels we call <fun><function id='51'>glGenerateMipmap</function></fun> as an easy way to allocate the required amount of memory:
    103 </p>
    104     
    105 <pre><code>
    106 unsigned int prefilterMap;
    107 <function id='50'>glGenTextures</function>(1, &prefilterMap);
    108 <function id='48'>glBindTexture</function>(GL_TEXTURE_CUBE_MAP, prefilterMap);
    109 for (unsigned int i = 0; i &lt; 6; ++i)
    110 {
    111     <function id='52'>glTexImage2D</function>(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 128, 128, 0, GL_RGB, GL_FLOAT, nullptr);
    112 }
    113 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    114 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    115 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
    116 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); 
    117 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    118 
    119 <function id='51'>glGenerateMipmap</function>(GL_TEXTURE_CUBE_MAP);
    120 </code></pre>
    121     
    122 <p>
    123   Note that because we plan to sample <var>prefilterMap</var>'s mipmaps you'll need to make sure its minification filter is set to <var>GL_LINEAR_MIPMAP_LINEAR</var> to enable trilinear filtering. We store the pre-filtered specular reflections in a per-face resolution of 128 by 128 at its base mip level. This is likely to be enough for most reflections, but if you have a large number of smooth materials (think of car reflections) you may want to increase the resolution.
    124 </p>
    125     
    126 <p>
    127   In the previous chapter we convoluted the environment map by generating sample vectors uniformly spread over the hemisphere \(\Omega\) using spherical coordinates. While this works just fine for irradiance, for specular reflections it's less efficient. When it comes to specular reflections, based on the roughness of a surface, the light reflects closely or roughly around a reflection vector \(r\) over a normal \(n\), but (unless the surface is extremely rough) around the reflection vector nonetheless:
    128 </p>
    129     
    130     <img src="/img/pbr/ibl_specular_lobe.png" class="clean" alt="Specular lobe according to the PBR microfacet surface model."/>
    131       
    132 <p>
    133   The general shape of possible outgoing light reflections is known as the <def>specular lobe</def>. As roughness increases, the specular lobe's size increases; and the shape of the specular lobe changes on varying incoming light directions. The shape of the specular lobe is thus highly dependent on the material.
    134 </p>
    135       
    136 <p>
    137   When it comes to the microsurface model, we can imagine the specular lobe as the reflection  orientation about the microfacet halfway vectors given some incoming light direction. Seeing as most light rays end up in a specular lobe reflected around the microfacet halfway vectors, it makes sense to generate the sample vectors in a similar fashion as most would otherwise be wasted. This process is known as <def>importance sampling</def>.
    138 </p>
    139 
    140 <h3>Monte Carlo integration and importance sampling</h3>
    141 <p>
    142   To fully get a grasp of importance sampling it's relevant we first delve into the mathematical construct known as <def>Monte Carlo integration</def>. Monte Carlo integration revolves mostly around a combination of statistics and probability theory. Monte Carlo helps us in discretely solving the problem of figuring out some statistic or value of a population without having to take <strong>all</strong> of the population into consideration.
    143 </p>
    144       
    145 <p>
    146   For instance, let's say you want to count the average height of all citizens of a country. To get your result, you could measure <strong>every</strong> citizen and average their height which will give you the <strong>exact</strong> answer you're looking for. However, since most countries have a considerable population this isn't a realistic approach: it would take too much effort and time.
    147 </p>
    148       
    149 <p>
    150   A different approach is to pick a much smaller <strong>completely random</strong> (unbiased) subset of this population, measure their height, and average the result. This population could be as small as a 100 people. While not as accurate as the exact answer, you'll get an answer that is relatively close to the ground truth. This is known as the <def>law of large numbers</def>. The idea is that if you measure a smaller set of size \(N\) of truly random samples from the total population, the result will be relatively close to the true answer and gets closer as the number of samples \(N\) increases.
    151 </p>
    152       
    153 <p>
    154   Monte Carlo integration builds on this law of large numbers and takes the same approach in solving an integral. Rather than solving an integral for all possible (theoretically infinite) sample values \(x\), simply generate \(N\) sample values randomly picked from the total population and average. As \(N\) increases, we're guaranteed to get a result closer to the exact answer of the integral: 
    155 </p>
    156       
    157 \[
    158   	O =  \int\limits_{a}^{b} f(x) dx 
    159       = 
    160       \frac{1}{N} \sum_{i=0}^{N-1} \frac{f(x)}{pdf(x)}
    161 \]
    162 
    163 <p>
    164   To solve the integral, we take \(N\) random samples over the population \(a\) to \(b\), add them together, and divide by the total number of samples to average them. The \(pdf\) stands for the <def>probability density function</def> that tells us the probability a specific sample occurs over the total sample set. For instance, the pdf of the height of a population would look a bit like this:
    165 </p>
    166 
    167 <img src="/img/pbr/ibl_pdf.png" class="clean" alt="Example PDF (probability distribution function)."/>
    168 
    169 <p>
    170   From this graph we can see that if we take any random sample of the population, there is a higher chance of picking a sample of someone of height 1.70, compared to the lower probability of the sample being of height 1.50.
    171   </p>
    172   
    173 <p>
    174 When it comes to Monte Carlo integration, some samples may have a higher probability of being generated than others. This is why for any general Monte Carlo estimation we divide or multiply the sampled value by the sample probability according to a pdf. So far, in each of our cases of estimating an integral, the samples we've generated were uniform, having the exact same chance of being generated. Our estimations so far were <def>unbiased</def>, meaning that given an ever-increasing amount of samples we will eventually <def>converge</def> to the <strong>exact</strong> solution of the integral. 
    175 </p>
    176 
    177 <p>
    178     However, some Monte Carlo estimators are <def>biased</def>, meaning that the generated samples aren't completely random, but focused towards a specific value or direction. These biased Monte Carlo estimators have a <def>faster rate of convergence</def>, meaning they can converge to the exact solution at a much faster rate, but due to their biased nature it's likely they won't ever converge to the exact solution. This is generally an acceptable tradeoff, especially in computer graphics, as the exact solution isn't too important as long as the results are visually acceptable.
    179     As we'll soon see with importance sampling (which uses a biased estimator), the generated samples  are biased towards specific directions in which case we account for this by multiplying or dividing each sample by its corresponding pdf.
    180   </p>
    181       
    182 <p>
    183   Monte Carlo integration is quite prevalent in computer graphics as it's a fairly intuitive way to approximate continuous integrals in a discrete and efficient fashion: take any area/volume to sample over (like the hemisphere \(\Omega\)), generate \(N\) amount of random samples within the area/volume, and sum and weigh every sample contribution to the final result. 
    184 </p>
    185             
    186 <p>
    187   Monte Carlo integration is an extensive mathematical topic and I won't delve much further into the specifics, but we'll mention that there are multiple ways of generating the <em>random samples</em>. By default, each sample is completely (pseudo)random as we're used to, but by utilizing certain properties of semi-random sequences we can generate sample vectors that are still random, but have interesting properties. For instance, we can do Monte Carlo integration on something called <def>low-discrepancy sequences</def> which still generate random samples, but each sample is more evenly distributed (image courtesy of James Heald):
    188 </p>
    189       
    190       <img src="/img/pbr/ibl_low_discrepancy_sequence.png" class="clean" alt="Low discrepancy sequence."/>
    191         
    192 <p>
    193   When using a low-discrepancy sequence for generating the Monte Carlo sample vectors, the process is known as <def>Quasi-Monte Carlo integration</def>. Quasi-Monte Carlo methods have a faster <def>rate of convergence</def> which makes them interesting for performance heavy applications.
    194 </p>
    195         
    196 <p>
    197   Given our newly obtained knowledge of Monte Carlo and Quasi-Monte Carlo integration, there is an interesting property we can use for an even faster rate of convergence known as <def>importance sampling</def>. We've mentioned it before in this chapter, but when it comes to specular reflections of light, the reflected light vectors are constrained in a specular lobe with its size determined by the roughness of the surface. Seeing as any (quasi-)randomly generated sample outside the specular lobe isn't relevant to the specular integral it makes sense to focus the sample generation to within the specular lobe, at the cost of making the Monte Carlo estimator biased.
    198 </p>
    199 
    200 <p>
    201   This is in essence what importance sampling is about: generate sample vectors in some region constrained by the roughness oriented around the microfacet's halfway vector. By combining Quasi-Monte Carlo sampling with a low-discrepancy sequence and biasing the sample vectors using importance sampling, we get a high rate of convergence. Because we reach the solution at a faster rate, we'll need significantly fewer samples to reach an approximation that is sufficient enough. 
    202 </p>
    203         
    204 <h3>A low-discrepancy sequence</h3>
    205 <p>
    206   In this chapter we'll pre-compute the specular portion of the indirect reflectance equation using importance sampling given a random low-discrepancy sequence based on the Quasi-Monte Carlo method. The sequence we'll be using is known as the <def>Hammersley Sequence</def> as carefully described by <a href="http://holger.dammertz.org/stuff/notes_HammersleyOnHemisphere.html" target="_blank">Holger Dammertz</a>. The Hammersley sequence is based on the <def>Van Der Corput</def> sequence which mirrors a decimal binary representation around its decimal point.
    207 </p>
    208         
    209 <p>
    210   Given some neat bit tricks, we can quite efficiently generate the Van Der Corput sequence in a shader program which we'll use to get a Hammersley sequence sample <var>i</var> over <code>N</code> total samples:
    211 </p>
    212         
    213 <pre><code>
    214 float RadicalInverse_VdC(uint bits) 
    215 {
    216     bits = (bits &lt;&lt; 16u) | (bits &gt;&gt; 16u);
    217     bits = ((bits & 0x55555555u) &lt;&lt; 1u) | ((bits & 0xAAAAAAAAu) &gt;&gt; 1u);
    218     bits = ((bits & 0x33333333u) &lt;&lt; 2u) | ((bits & 0xCCCCCCCCu) &gt;&gt; 2u);
    219     bits = ((bits & 0x0F0F0F0Fu) &lt;&lt; 4u) | ((bits & 0xF0F0F0F0u) &gt;&gt; 4u);
    220     bits = ((bits & 0x00FF00FFu) &lt;&lt; 8u) | ((bits & 0xFF00FF00u) &gt;&gt; 8u);
    221     return float(bits) * 2.3283064365386963e-10; // / 0x100000000
    222 }
    223 // ----------------------------------------------------------------------------
    224 vec2 Hammersley(uint i, uint N)
    225 {
    226     return vec2(float(i)/float(N), RadicalInverse_VdC(i));
    227 }  
    228 </code></pre>
    229         
    230 <p>
    231   The GLSL <fun>Hammersley</fun> function gives us the low-discrepancy sample <var>i</var> of the total sample set of size <var>N</var>. 
    232 </p>
    233 
    234 <note>
    235 <strong>Hammersley sequence without bit operator support</strong><br/>
    236 <p>
    237   Not all OpenGL related drivers support bit operators (WebGL and OpenGL ES 2.0 for instance) in which case you may want to use an alternative version of the Van Der Corput Sequence that doesn't rely on bit operators:
    238 </p>
    239   
    240 <pre><code>
    241 float VanDerCorput(uint n, uint base)
    242 {
    243     float invBase = 1.0 / float(base);
    244     float denom   = 1.0;
    245     float result  = 0.0;
    246 
    247     for(uint i = 0u; i &lt; 32u; ++i)
    248     {
    249         if(n > 0u)
    250         {
    251             denom   = mod(float(n), 2.0);
    252             result += denom * invBase;
    253             invBase = invBase / 2.0;
    254             n       = uint(float(n) / 2.0);
    255         }
    256     }
    257 
    258     return result;
    259 }
    260 // ----------------------------------------------------------------------------
    261 vec2 HammersleyNoBitOps(uint i, uint N)
    262 {
    263     return vec2(float(i)/float(N), VanDerCorput(i, 2u));
    264 }
    265 </code></pre>
    266   
    267 <p>
    268   Note that due to GLSL loop restrictions in older hardware, the sequence loops over all possible <code>32</code> bits. This version is less performant, but does work on all hardware if you ever find yourself without bit operators.
    269 </p>
    270 </note>
    271         
    272 <h3>GGX Importance sampling</h3>
    273 <p>
    274   Instead of uniformly or randomly (Monte Carlo) generating sample vectors over the integral's hemisphere \(\Omega\), we'll generate sample vectors biased towards the general reflection orientation of the microsurface halfway vector based on the surface's roughness. The sampling process will be  similar to what we've seen before: begin a large loop, generate a random (low-discrepancy) sequence value, take the sequence value to generate a sample vector in tangent space, transform to world space, and sample the scene's radiance. What's different is that we now use a low-discrepancy sequence value as input to generate a sample vector:
    275 </p>
    276         
    277 <pre><code>
    278 const uint SAMPLE_COUNT = 4096u;
    279 for(uint i = 0u; i &lt; SAMPLE_COUNT; ++i)
    280 {
    281     vec2 Xi = Hammersley(i, SAMPLE_COUNT);   
    282 </code></pre>
    283         
    284 <p>
    285   Additionally, to build a sample vector, we need some way of orienting and biasing the sample vector towards the specular lobe of some surface roughness. We can take the NDF as described in the <a href="https://learnopengl.com/PBR/Theory" target="_blank">theory</a> chapter and combine the GGX NDF in the spherical sample vector process as described by Epic Games:
    286 </p>
    287         
    288 <pre><code>
    289 vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness)
    290 {
    291     float a = roughness*roughness;
    292 	
    293     float phi = 2.0 * PI * Xi.x;
    294     float cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a*a - 1.0) * Xi.y));
    295     float sinTheta = sqrt(1.0 - cosTheta*cosTheta);
    296 	
    297     // from spherical coordinates to cartesian coordinates
    298     vec3 H;
    299     H.x = cos(phi) * sinTheta;
    300     H.y = sin(phi) * sinTheta;
    301     H.z = cosTheta;
    302 	
    303     // from tangent-space vector to world-space sample vector
    304     vec3 up        = abs(N.z) &lt; 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
    305     vec3 tangent   = normalize(cross(up, N));
    306     vec3 bitangent = cross(N, tangent);
    307 	
    308     vec3 sampleVec = tangent * H.x + bitangent * H.y + N * H.z;
    309     return normalize(sampleVec);
    310 }  
    311 </code></pre>
    312   
    313 <p>
    314   This gives us a sample vector somewhat oriented around the expected microsurface's halfway vector based on some input roughness and the low-discrepancy sequence value <var>Xi</var>. Note that Epic Games uses the squared roughness for better visual results as based on Disney's original PBR research. 
    315 </p>
    316         
    317 <p>
    318   With the low-discrepancy Hammersley sequence and sample generation defined, we can finalize the pre-filter convolution shader:
    319 </p>
    320         
    321 <pre><code>
    322 #version 330 core
    323 out vec4 FragColor;
    324 in vec3 localPos;
    325 
    326 uniform samplerCube environmentMap;
    327 uniform float roughness;
    328 
    329 const float PI = 3.14159265359;
    330 
    331 float RadicalInverse_VdC(uint bits);
    332 vec2 Hammersley(uint i, uint N);
    333 vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness);
    334   
    335 void main()
    336 {		
    337     vec3 N = normalize(localPos);    
    338     vec3 R = N;
    339     vec3 V = R;
    340 
    341     const uint SAMPLE_COUNT = 1024u;
    342     float totalWeight = 0.0;   
    343     vec3 prefilteredColor = vec3(0.0);     
    344     for(uint i = 0u; i &lt; SAMPLE_COUNT; ++i)
    345     {
    346         vec2 Xi = Hammersley(i, SAMPLE_COUNT);
    347         vec3 H  = ImportanceSampleGGX(Xi, N, roughness);
    348         vec3 L  = normalize(2.0 * dot(V, H) * H - V);
    349 
    350         float NdotL = max(dot(N, L), 0.0);
    351         if(NdotL > 0.0)
    352         {
    353             prefilteredColor += texture(environmentMap, L).rgb * NdotL;
    354             totalWeight      += NdotL;
    355         }
    356     }
    357     prefilteredColor = prefilteredColor / totalWeight;
    358 
    359     FragColor = vec4(prefilteredColor, 1.0);
    360 }  
    361   
    362 </code></pre>
    363         
    364 <p>
    365   We pre-filter the environment, based on some input roughness that varies over each mipmap level of the pre-filter cubemap (from <code>0.0</code> to <code>1.0</code>), and store the result in <var>prefilteredColor</var>. The resulting <var>prefilteredColor</var> is divided by the total sample weight, where samples with less influence on the final result (for small <var>NdotL</var>) contribute less to the final weight.
    366 </p>
    367         
    368 <h3>Capturing pre-filter mipmap levels</h3>
    369 <p>
    370   What's left to do is let OpenGL pre-filter the environment map with different roughness values over multiple mipmap levels. This is actually fairly easy to do with the original setup of the <a href="https://learnopengl.com/PBR/IBL/Diffuse-irradiance" target="_blank">irradiance</a> chapter:
    371 </p>
    372         
    373 <pre><code>
    374 prefilterShader.use();
    375 prefilterShader.setInt("environmentMap", 0);
    376 prefilterShader.setMat4("projection", captureProjection);
    377 <function id='49'>glActiveTexture</function>(GL_TEXTURE0);
    378 <function id='48'>glBindTexture</function>(GL_TEXTURE_CUBE_MAP, envCubemap);
    379 
    380 <function id='77'>glBindFramebuffer</function>(GL_FRAMEBUFFER, captureFBO);
    381 unsigned int maxMipLevels = 5;
    382 for (unsigned int mip = 0; mip &lt; maxMipLevels; ++mip)
    383 {
    384     // reisze framebuffer according to mip-level size.
    385     unsigned int mipWidth  = 128 * std::pow(0.5, mip);
    386     unsigned int mipHeight = 128 * std::pow(0.5, mip);
    387     <function id='83'>glBindRenderbuffer</function>(GL_RENDERBUFFER, captureRBO);
    388     <function id='88'>glRenderbufferStorage</function>(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, mipWidth, mipHeight);
    389     <function id='22'>glViewport</function>(0, 0, mipWidth, mipHeight);
    390 
    391     float roughness = (float)mip / (float)(maxMipLevels - 1);
    392     prefilterShader.setFloat("roughness", roughness);
    393     for (unsigned int i = 0; i &lt; 6; ++i)
    394     {
    395         prefilterShader.setMat4("view", captureViews[i]);
    396         <function id='81'>glFramebufferTexture2D</function>(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, 
    397                                GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, prefilterMap, mip);
    398 
    399         <function id='10'>glClear</function>(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    400         renderCube();
    401     }
    402 }
    403 <function id='77'>glBindFramebuffer</function>(GL_FRAMEBUFFER, 0);   
    404 </code></pre>
    405         
    406 <p>
    407   The process is similar to the irradiance map convolution, but this time we scale the framebuffer's dimensions to the appropriate mipmap scale, each mip level reducing the dimensions by a scale of 2. Additionally, we specify the mip level we're rendering into in <fun><function id='81'>glFramebufferTexture2D</function></fun>'s last parameter and pass the roughness we're pre-filtering for to the pre-filter shader.
    408 </p>
    409         
    410 <p>
    411   This should give us a properly pre-filtered environment map that returns blurrier reflections the higher mip level we access it from. If we use the pre-filtered environment cubemap in the skybox shader and forcefully sample somewhat above its first mip level like so:
    412 </p>
    413         
    414 <pre><code>
    415 vec3 envColor = textureLod(environmentMap, WorldPos, 1.2).rgb; 
    416 </code></pre>
    417         
    418 <p>
    419   We get a result that indeed looks like a blurrier version of the original environment:
    420 </p>
    421         
    422 <img src="/img/pbr/ibl_prefilter_map_sample.png" alt="Visualizing a LOD mip level of the pre-filtered environment map in the skybox."/>
    423           
    424 <p>
    425   If it looks somewhat similar you've successfully pre-filtered the HDR environment map. Play around with different mipmap levels to see the pre-filter map gradually change from sharp to blurry reflections on increasing mip levels.
    426 </p>
    427        
    428       
    429 <h2>Pre-filter convolution artifacts</h2>
    430 <p>
    431   While the current pre-filter map works fine for most purposes, sooner or later you'll come across several render artifacts that are directly related to the pre-filter convolution. I'll list the most common here including how to fix them.
    432 </p>
    433           
    434 <h3>Cubemap seams at high roughness</h3>
    435 <p>
    436   Sampling the pre-filter map on surfaces with a rough surface means sampling the pre-filter map on some of its lower mip levels. When sampling cubemaps, OpenGL by default doesn't linearly interpolate <strong>across</strong> cubemap faces. Because the lower mip levels are both of a lower resolution and the pre-filter map is convoluted with a much larger sample lobe, the lack of <em>between-cube-face filtering</em> becomes quite apparent:
    437 </p>
    438           
    439 <img src="/img/pbr/ibl_prefilter_seams.png" alt="Visible cubemap seams in the pre-filter map."/>
    440             
    441 <p>
    442   Luckily for us, OpenGL gives us the option to properly filter across cubemap faces by enabling <var>GL_TEXTURE_CUBE_MAP_SEAMLESS</var>:
    443 </p>
    444             
    445 <pre><code>
    446 <function id='60'>glEnable</function>(GL_TEXTURE_CUBE_MAP_SEAMLESS);  
    447 </code></pre>
    448             
    449 <p>
    450   Simply enable this property somewhere at the start of your application and the seams will be gone.
    451 </p>
    452             
    453 <h3>Bright dots in the pre-filter convolution</h3>
    454 <p>
    455   Due to high frequency details and wildly varying light intensities in specular reflections, convoluting the specular reflections requires a large number of samples to properly account for the wildly varying nature of HDR environmental reflections. We already take a very large number of samples, but on some environments it may still not be enough at some of the rougher mip levels in which case you'll start seeing dotted patterns emerge around bright areas:
    456 </p>
    457             
    458             <img src="/img/pbr/ibl_prefilter_dots.png" alt="Visible dots on high frequency HDR maps in the deeper mip LOD levels of a pre-filter map."/>
    459               
    460 <p>
    461   One option is to further increase the sample count, but this won't be enough for all environments. As described by <a href="https://chetanjags.wordpress.com/2015/08/26/image-based-lighting/" target="_blank">Chetan Jags</a> we can reduce this artifact by (during the pre-filter convolution) not directly sampling the environment map, but sampling a mip level of the environment map based on the integral's PDF and the roughness:
    462 </p>
    463               
    464 <pre><code>
    465 float D   = DistributionGGX(NdotH, roughness);
    466 float pdf = (D * NdotH / (4.0 * HdotV)) + 0.0001; 
    467 
    468 float resolution = 512.0; // resolution of source cubemap (per face)
    469 float saTexel  = 4.0 * PI / (6.0 * resolution * resolution);
    470 float saSample = 1.0 / (float(SAMPLE_COUNT) * pdf + 0.0001);
    471 
    472 float mipLevel = roughness == 0.0 ? 0.0 : 0.5 * log2(saSample / saTexel); 
    473 </code></pre>
    474               
    475 <p>
    476   Don't forget to enable trilinear filtering on the environment map you want to sample its mip levels from:
    477 </p>
    478               
    479 <pre><code>
    480 <function id='48'>glBindTexture</function>(GL_TEXTURE_CUBE_MAP, envCubemap);
    481 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); 
    482 </code></pre>
    483               
    484 <p>
    485   And let OpenGL generate the mipmaps <strong>after</strong> the cubemap's base texture is set:
    486 </p>
    487 
    488 <pre><code>
    489 // convert HDR equirectangular environment map to cubemap equivalent
    490 [...]
    491 // then generate mipmaps
    492 <function id='48'>glBindTexture</function>(GL_TEXTURE_CUBE_MAP, envCubemap);
    493 <function id='51'>glGenerateMipmap</function>(GL_TEXTURE_CUBE_MAP);
    494 </code></pre>
    495               
    496 <p>
    497   This works surprisingly well and should remove most, if not all, dots in your pre-filter map on rougher surfaces. 
    498 </p>
    499 
    500 <h2>Pre-computing the BRDF</h2>
    501 <p>
    502   With the pre-filtered environment up and running, we can focus on the second part of the split-sum approximation: the BRDF. Let's briefly review the specular split sum approximation again:
    503 </p>
    504           
    505 \[
    506      L_o(p,\omega_o) = 
    507 		\int\limits_{\Omega} L_i(p,\omega_i) d\omega_i
    508 		*
    509 		\int\limits_{\Omega} f_r(p, \omega_i, \omega_o) n \cdot \omega_i d\omega_i
    510 \]
    511           
    512 <p>
    513   We've pre-computed the left part of the split sum approximation in the pre-filter map over different roughness levels. The right side requires us to convolute the BRDF equation over the angle \(n \cdot \omega_o\), the surface roughness, and Fresnel's \(F_0\). This is similar to integrating the specular BRDF with a solid-white environment or a constant radiance \(L_i\) of <code>1.0</code>. Convoluting the BRDF over 3 variables is a bit much, but we can try to move \(F_0\) out of the specular BRDF equation:
    514 </p>
    515         
    516 \[
    517 	\int\limits_{\Omega} f_r(p, \omega_i, \omega_o) n \cdot \omega_i d\omega_i = \int\limits_{\Omega} f_r(p, \omega_i, \omega_o) \frac{F(\omega_o, h)}{F(\omega_o, h)} n \cdot \omega_i d\omega_i 
    518 \]
    519           
    520 <p>
    521   With \(F\) being the Fresnel equation. Moving the Fresnel denominator to the BRDF gives us the following equivalent equation: 
    522 </p>
    523   
    524 \[        
    525 	\int\limits_{\Omega} \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} F(\omega_o, h)  n \cdot \omega_i d\omega_i
    526 \]
    527           
    528 <p>
    529   Substituting the right-most \(F\) with the Fresnel-Schlick approximation gives us:
    530   </p>
    531           
    532 \[        
    533 	\int\limits_{\Omega} \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (F_0 + (1 - F_0){(1 - \omega_o \cdot h)}^5)  n \cdot \omega_i d\omega_i
    534 \]
    535   
    536 <p>
    537   Let's replace \({(1 - \omega_o \cdot h)}^5\) by \(\alpha\) to make it easier to solve for \(F_0\):
    538 </p>
    539           
    540 \[        
    541 	\int\limits_{\Omega} \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (F_0 + (1 - F_0)\alpha)  n \cdot \omega_i d\omega_i
    542 \]
    543    
    544 \[        
    545 	\int\limits_{\Omega} \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (F_0 + 1*\alpha - F_0*\alpha)  n \cdot \omega_i d\omega_i
    546 \]        
    547               
    548 \[        
    549 	\int\limits_{\Omega} \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (F_0 * (1 - \alpha) + \alpha)  n \cdot \omega_i d\omega_i
    550 \]  
    551           
    552 <p>
    553   Then we split the Fresnel function \(F\) over two integrals:
    554 </p>
    555           
    556 \[        
    557 	\int\limits_{\Omega} \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (F_0 * (1 - \alpha))  n \cdot \omega_i d\omega_i
    558               +
    559 	\int\limits_{\Omega} \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (\alpha)  n \cdot \omega_i d\omega_i              
    560 \]  
    561               
    562 <p>
    563   This way, \(F_0\) is constant over the integral and we can take \(F_0\) out of the integral. Next,  we substitute \(\alpha\) back to its original form giving us the final split sum BRDF equation:
    564 </p>
    565               
    566 \[        
    567 	F_0 \int\limits_{\Omega} f_r(p, \omega_i, \omega_o)(1 - {(1 - \omega_o \cdot h)}^5)  n \cdot \omega_i d\omega_i
    568               +
    569 	\int\limits_{\Omega} f_r(p, \omega_i, \omega_o) {(1 - \omega_o \cdot h)}^5  n \cdot \omega_i d\omega_i              
    570 \]  
    571           
    572 <p>
    573    The two resulting integrals represent a scale and a bias to \(F_0\) respectively. Note that as \(f_r(p, \omega_i, \omega_o)\) already contains a term for \(F\) they both cancel out, removing \(F\) from \(f_r\). 
    574 </p>                  
    575   
    576   <p>
    577     In a similar fashion to the earlier convoluted environment maps, we can convolute the BRDF equations on their inputs: the angle between \(n\) and \(\omega_o\), and the roughness. We store the convoluted results in a 2D lookup texture (LUT) known as a <def>BRDF integration</def> map that we later use in our PBR lighting shader to get the final convoluted indirect specular result.
    578 </p>        
    579           
    580 <p>
    581   The BRDF convolution shader operates on a 2D plane, using its 2D texture coordinates directly as inputs to the BRDF convolution (<var>NdotV</var> and <var>roughness</var>). The convolution code is largely similar to the pre-filter convolution, except that it now processes the sample vector according to our BRDF's geometry function and Fresnel-Schlick's approximation:
    582 </p>
    583           
    584 <pre><code>          
    585 vec2 IntegrateBRDF(float NdotV, float roughness)
    586 {
    587     vec3 V;
    588     V.x = sqrt(1.0 - NdotV*NdotV);
    589     V.y = 0.0;
    590     V.z = NdotV;
    591 
    592     float A = 0.0;
    593     float B = 0.0;
    594 
    595     vec3 N = vec3(0.0, 0.0, 1.0);
    596 
    597     const uint SAMPLE_COUNT = 1024u;
    598     for(uint i = 0u; i &lt; SAMPLE_COUNT; ++i)
    599     {
    600         vec2 Xi = Hammersley(i, SAMPLE_COUNT);
    601         vec3 H  = ImportanceSampleGGX(Xi, N, roughness);
    602         vec3 L  = normalize(2.0 * dot(V, H) * H - V);
    603 
    604         float NdotL = max(L.z, 0.0);
    605         float NdotH = max(H.z, 0.0);
    606         float VdotH = max(dot(V, H), 0.0);
    607 
    608         if(NdotL > 0.0)
    609         {
    610             float G = GeometrySmith(N, V, L, roughness);
    611             float G_Vis = (G * VdotH) / (NdotH * NdotV);
    612             float Fc = pow(1.0 - VdotH, 5.0);
    613 
    614             A += (1.0 - Fc) * G_Vis;
    615             B += Fc * G_Vis;
    616         }
    617     }
    618     A /= float(SAMPLE_COUNT);
    619     B /= float(SAMPLE_COUNT);
    620     return vec2(A, B);
    621 }
    622 // ----------------------------------------------------------------------------
    623 void main() 
    624 {
    625     vec2 integratedBRDF = IntegrateBRDF(TexCoords.x, TexCoords.y);
    626     FragColor = integratedBRDF;
    627 }
    628 </code></pre>
    629               
    630 <p>
    631   As you can see, the BRDF convolution is a direct translation from the mathematics to code. We take both the angle \(\theta\) and the roughness as input, generate a sample vector with importance sampling, process it over the geometry and the derived Fresnel term of the BRDF, and output both a scale and a bias to \(F_0\) for each sample, averaging them in the end. 
    632 </p>
    633               
    634 <p>
    635   You may recall from the <a href="https://learnopengl.com/PBR/Theory" target="_blank">theory</a> chapter that the geometry term of the BRDF is slightly different when used alongside IBL as its \(k\) variable has a slightly different interpretation:
    636 </p>
    637               
    638 \[
    639     k_{direct} = \frac{(\alpha + 1)^2}{8}
    640 \]
    641     
    642 \[
    643     k_{IBL} = \frac{\alpha^2}{2}
    644 \]
    645               
    646 <p>
    647   Since the BRDF convolution is part of the specular IBL integral we'll use \(k_{IBL}\) for the Schlick-GGX geometry function:
    648 </p>
    649               
    650 <pre><code>
    651 float GeometrySchlickGGX(float NdotV, float roughness)
    652 {
    653     float a = roughness;
    654     float k = (a * a) / 2.0;
    655 
    656     float nom   = NdotV;
    657     float denom = NdotV * (1.0 - k) + k;
    658 
    659     return nom / denom;
    660 }
    661 // ----------------------------------------------------------------------------
    662 float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness)
    663 {
    664     float NdotV = max(dot(N, V), 0.0);
    665     float NdotL = max(dot(N, L), 0.0);
    666     float ggx2 = GeometrySchlickGGX(NdotV, roughness);
    667     float ggx1 = GeometrySchlickGGX(NdotL, roughness);
    668 
    669     return ggx1 * ggx2;
    670 }  
    671 </code></pre>
    672               
    673 <p>
    674   Note that while \(k\) takes <var>a</var> as its parameter we didn't square <var>roughness</var> as <var>a</var> as we originally did for other interpretations of <var>a</var>; likely as <var>a</var> is squared here already. I'm not sure whether this is an inconsistency on Epic Games' part or the original Disney paper, but directly translating <var>roughness</var> to <var>a</var> gives the BRDF integration map that is identical to Epic Games' version.
    675 </p>
    676           
    677 <p>
    678   Finally, to store the BRDF convolution result we'll generate a 2D texture of a 512 by 512 resolution:
    679 </p>
    680           
    681 <pre><code>
    682 unsigned int brdfLUTTexture;
    683 <function id='50'>glGenTextures</function>(1, &brdfLUTTexture);
    684 
    685 // pre-allocate enough memory for the LUT texture.
    686 <function id='48'>glBindTexture</function>(GL_TEXTURE_2D, brdfLUTTexture);
    687 <function id='52'>glTexImage2D</function>(GL_TEXTURE_2D, 0, GL_RG16F, 512, 512, 0, GL_RG, GL_FLOAT, 0);
    688 <function id='15'>glTexParameter</function>i(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    689 <function id='15'>glTexParameter</function>i(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    690 <function id='15'>glTexParameter</function>i(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    691 <function id='15'>glTexParameter</function>i(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 
    692 </code></pre>
    693           
    694 <p>
    695   Note that we use a 16-bit precision floating format as recommended by Epic Games. Be sure to set the wrapping mode to <var>GL_CLAMP_TO_EDGE</var> to prevent edge sampling artifacts.
    696 </p>
    697           
    698 <p>
    699   Then, we re-use the same framebuffer object and run this shader over an NDC screen-space quad:
    700 </p>
    701           
    702 <pre class="cpp"><code>
    703 <function id='77'>glBindFramebuffer</function>(GL_FRAMEBUFFER, captureFBO);
    704 <function id='83'>glBindRenderbuffer</function>(GL_RENDERBUFFER, captureRBO);
    705 <function id='88'>glRenderbufferStorage</function>(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 512, 512);
    706 <function id='81'>glFramebufferTexture2D</function>(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, brdfLUTTexture, 0);
    707 
    708 <function id='22'>glViewport</function>(0, 0, 512, 512);
    709 brdfShader.use();
    710 <function id='10'>glClear</function>(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    711 RenderQuad();
    712 
    713 <function id='77'>glBindFramebuffer</function>(GL_FRAMEBUFFER, 0);  
    714 </code></pre>
    715           
    716 <p>
    717   The convoluted BRDF part of the split sum integral should give you the following result:
    718 </p>
    719           
    720 <img src="/img/pbr/ibl_brdf_lut.png" alt="BRDF LUT"/>
    721 
    722 <p>
    723   With both the pre-filtered environment map and the BRDF 2D LUT we can re-construct the indirect specular integral according to the split sum approximation. The combined result then acts as the indirect or ambient specular light. 
    724 </p>
    725 
    726   <h2>Completing the IBL reflectance</h2>
    727 <p>
    728   To get the indirect specular part of the reflectance equation up and running we need to stitch both parts of the split sum approximation together. Let's start by adding the pre-computed lighting data to the top of our PBR shader:
    729 </p>
    730 
    731 <pre><code>
    732 uniform samplerCube prefilterMap;
    733 uniform sampler2D   brdfLUT;  
    734 </code></pre>
    735   
    736 <p>
    737   First, we get the indirect specular reflections of the surface by sampling the pre-filtered environment map using the reflection vector. Note that we sample the appropriate mip level based on the surface roughness, giving rougher surfaces <em>blurrier</em> specular reflections:
    738 </p>
    739   
    740 <pre><code>
    741 void main()
    742 {
    743     [...]
    744     vec3 R = reflect(-V, N);   
    745 
    746     const float MAX_REFLECTION_LOD = 4.0;
    747     vec3 prefilteredColor = textureLod(prefilterMap, R,  roughness * MAX_REFLECTION_LOD).rgb;    
    748     [...]
    749 }
    750 </code></pre>
    751   
    752 <p>
    753   In the pre-filter step we only convoluted the environment map up to a maximum of 5 mip levels (0 to 4), which we denote here as <var>MAX_REFLECTION_LOD</var> to ensure we don't sample a mip level where there's no (relevant) data.
    754 </p>
    755   
    756 <p>
    757 	Then we sample from the BRDF lookup texture given the material's roughness and the angle between the normal and view vector:
    758 </p>
    759   
    760 <pre><code>
    761 vec3 F        = FresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);
    762 vec2 envBRDF  = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
    763 vec3 specular = prefilteredColor * (F * envBRDF.x + envBRDF.y);
    764 </code></pre>
    765   
    766 <p>
    767   Given the scale and bias to \(F_0\) (here we're directly using the indirect Fresnel result <var>F</var>) from the BRDF lookup texture, we combine this with the left pre-filter portion of the IBL reflectance equation and re-construct the approximated integral result as <var>specular</var>. 
    768 </p>
    769   
    770 <p>
    771   This gives us the indirect specular part of the reflectance equation. Now, combine this with the diffuse IBL part of the reflectance equation from the <a href="https://learnopengl.com/PBR/IBL/Diffuse-irradiance" target="_blank">last</a> chapter and we get the full PBR IBL result:
    772 </p>
    773   
    774 <pre><code>
    775 vec3 F = FresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);
    776 
    777 vec3 kS = F;
    778 vec3 kD = 1.0 - kS;
    779 kD *= 1.0 - metallic;	  
    780   
    781 vec3 irradiance = texture(irradianceMap, N).rgb;
    782 vec3 diffuse    = irradiance * albedo;
    783   
    784 const float MAX_REFLECTION_LOD = 4.0;
    785 vec3 prefilteredColor = textureLod(prefilterMap, R,  roughness * MAX_REFLECTION_LOD).rgb;   
    786 vec2 envBRDF  = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
    787 vec3 specular = prefilteredColor * (F * envBRDF.x + envBRDF.y);
    788   
    789 vec3 ambient = (kD * diffuse + specular) * ao; 
    790 </code></pre>
    791   
    792 <p>
    793   Note that we don't multiply <var>specular</var> by <var>kS</var> as we already have a Fresnel multiplication in there.
    794 </p>
    795   
    796 <p>
    797   Now, running this exact code on the series of spheres that differ by their roughness and metallic properties, we finally get to see their true colors in the final PBR renderer: 
    798 </p>
    799   
    800   <img src="/img/pbr/ibl_specular_result.png" alt="Render in OpenGL of full PBR with IBL (image based lighting) on spheres with varying roughness and metallic properties."/>
    801     
    802 <p>
    803   We could even go wild, and use some cool textured <a href="http://freepbr.com" target="_blank">PBR materials</a>: 
    804 </p>
    805     
    806     <img src="/img/pbr/ibl_specular_result_textured.png" alt="Render in OpenGL of full PBR with IBL (image based lighting) on textured spheres."/>
    807       
    808 <p>
    809   Or load <a href="http://artisaverb.info/PBT.html" target="_blank">this awesome free 3D PBR model</a> by Andrew Maximov:
    810 </p>
    811       
    812       <img src="/img/pbr/ibl_specular_result_model.png" alt="Render in OpenGL of full PBR with IBL (image based lighting) on a 3D PBR model."/>
    813         
    814 <p>
    815   I'm sure we can all agree that our lighting now looks a lot more convincing. What's even better, is that our lighting looks physically correct regardless of which environment map we use. Below you'll see several different pre-computed HDR maps, completely changing the lighting dynamics, but still looking physically correct without changing a single lighting variable!
    816 </p>
    817         
    818 <img src="/img/pbr/ibl_specular_result_different_environments.png" alt="Render in OpenGL of full PBR with IBL (image based lighting) on a 3D PBR model over multiple different environments (with changing light conditions)."/>
    819           
    820   
    821 <p>
    822    Well, this PBR adventure turned out to be quite a long journey. There are a lot of steps and thus a lot that could go wrong so carefully work your way through the <a href="/code_viewer_gh.php?code=src/6.pbr/2.2.1.ibl_specular/ibl_specular.cpp" target="_blank">sphere scene</a> or <a href="/code_viewer_gh.php?code=src/6.pbr/2.2.2.ibl_specular_textured/ibl_specular_textured.cpp" target="_blank">textured scene</a> code samples (including all shaders) if you're stuck, or check and ask around in the comments. 
    823 </p>
    824   
    825 <h3>What's next?</h3>
    826 <p>
    827 	Hopefully, by the end of this tutorial you should have a pretty clear understanding of what PBR is about, and even have an actual PBR renderer up and running. In these tutorials, we've pre-computed all the relevant PBR image-based lighting data at the start of our application, before the render loop. This was fine for educational purposes, but not too great for any practical use of PBR. First, the pre-computation only really has to be done once, not at every startup. And second, the moment you use multiple environment maps you'll have to pre-compute each and every one of them at every startup which tends to build up.
    828 </p>
    829 
    830 <p>
    831 	For this reason you'd generally pre-compute an environment map into an irradiance and pre-filter map just once, and then store it on disk (note that the BRDF integration map isn't dependent on an environment map so you only need to calculate or load it once). This does mean you'll need to come up with a custom image format to store HDR cubemaps, including their mip levels. Or, you'll store (and load) it as one of the available formats (like .dds that supports storing mip levels). 
    832 </p>
    833 
    834 <p>
    835  Furthermore, we've described the <strong>total</strong> process in these tutorials, including generating the pre-computed IBL images to help further our understanding of the PBR pipeline. But,  you'll be just as fine by using several great tools like <a href="https://github.com/dariomanesku/cmftStudio" target="_blank">cmftStudio</a> or <a href="https://github.com/derkreature/IBLBaker" target="_blank">IBLBaker</a> to generate these pre-computed maps for you.
    836 </p>
    837 
    838 <p>
    839   One point we've skipped over is pre-computed cubemaps as <def>reflection probes</def>: cubemap interpolation and parallax correction. This is the process of placing several reflection probes in your scene that take a cubemap snapshot of the scene at that specific location, which we can then convolute as IBL data for that part of the scene. By interpolating between several of these probes based on the camera's vicinity we can achieve local high-detail image-based lighting that is simply limited by the amount of reflection probes we're willing to place. This way, the image-based lighting could correctly update when moving from a bright outdoor section of a scene to a darker indoor section for instance. I'll write a tutorial about reflection probes somewhere in the future, but for now I recommend the article by Chetan Jags below to give you a head start.
    840 </p>
    841 
    842   
    843 <h2>Further reading</h2>
    844 <ul>
    845   <li><a href="http://blog.selfshadow.com/publications/s2013-shading-course/karis/s2013_pbs_epic_notes_v2.pdf" target="_blank">Real Shading in Unreal Engine 4</a>: explains Epic Games' split sum approximation. This is the article the IBL PBR code is based of.</li>
    846   <li><a href="http://www.trentreed.net/blog/physically-based-shading-and-image-based-lighting/" target="_blank">Physically Based Shading and Image Based Lighting</a>: great blog post by Trent Reed about integrating specular IBL into a PBR pipeline in real time. </li>
    847        <li><a href="https://chetanjags.wordpress.com/2015/08/26/image-based-lighting/" target="_blank">Image Based Lighting</a>: very extensive write-up by Chetan Jags about specular-based image-based lighting and several of its caveats, including light probe interpolation.</li>
    848     <li><a href="https://seblagarde.files.wordpress.com/2015/07/course_notes_moving_frostbite_to_pbr_v32.pdf" target="_blank">Moving Frostbite to PBR</a>: well written and in-depth overview of integrating PBR into a AAA game engine by Sébastien Lagarde and Charles de Rousiers.</li>
    849   <li><a href="https://jmonkeyengine.github.io/wiki/jme3/advanced/pbr_part3.html" target="_blank">Physically Based Rendering – Part Three</a>: high level overview of IBL lighting and PBR by the JMonkeyEngine team.</li>
    850   <li><a href="https://placeholderart.wordpress.com/2015/07/28/implementation-notes-runtime-environment-map-filtering-for-image-based-lighting/" target="_blank">Implementation Notes: Runtime Environment Map Filtering for Image Based Lighting</a>: extensive write-up by Padraic Hennessy about pre-filtering HDR environment maps and significantly optimizing the sample process.</li>
    851 </ul>       
    852 
    853     </div>
    854