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 < 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 << 16u) | (bits >> 16u); 217 bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u); 218 bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u); 219 bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u); 220 bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 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 < 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 < 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) < 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 < 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 < 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 < 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 < 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