Diffuse-irradiance.html (40528B)
1 <h1 id="content-title">Diffuse irradiance</h1> 2 <h1 id="content-url" style='display:none;'>PBR/IBL/Diffuse-irradiance</h1> 3 <p> 4 IBL, or <def>image based lighting</def>, is a collection of techniques to light objects, not by direct analytical lights as in the <a href="https://learnopengl.com/PBR/Lighting" target="_blank">previous</a> chapter, but by treating the surrounding environment as one big light source. This is generally accomplished by manipulating a cubemap environment map (taken from the real world or generated from a 3D scene) such that we can directly use it in our lighting equations: treating each cubemap texel as a light emitter. This way we can effectively capture an environment's global lighting and general feel, giving objects a better sense of <em>belonging</em> in their environment. 5 </p> 6 7 <p> 8 As image based lighting algorithms capture the lighting of some (global) environment, its input is considered a more precise form of ambient lighting, even a crude approximation of global illumination. This makes IBL interesting for PBR as objects look significantly more physically accurate when we take the environment's lighting into account. 9 </p> 10 11 <p> 12 To start introducing IBL into our PBR system let's again take a quick look at the reflectance equation: 13 </p> 14 15 16 \[ 17 L_o(p,\omega_o) = \int\limits_{\Omega} 18 (k_d\frac{c}{\pi} + k_s\frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)}) 19 L_i(p,\omega_i) n \cdot \omega_i d\omega_i 20 \] 21 22 <p> 23 As described before, our main goal is to solve the integral of all incoming light directions \(w_i\) over the hemisphere \(\Omega\) . Solving the integral in the previous chapter was easy as we knew beforehand the exact few light directions \(w_i\) that contributed to the integral. 24 This time however, <strong>every</strong> incoming light direction \(w_i\) from the surrounding environment could potentially have some radiance making it less trivial to solve the integral. This gives us two main requirements for solving the integral: 25 </p> 26 27 <ul> 28 <li>We need some way to retrieve the scene's radiance given any direction vector \(w_i\).</li> 29 <li>Solving the integral needs to be fast and real-time.</li> 30 </ul> 31 32 <p> 33 Now, the first requirement is relatively easy. We've already hinted it, but one way of representing an environment or scene's irradiance is in the form of a (processed) environment cubemap. Given such a cubemap, we can visualize every texel of the cubemap as one single emitting light source. By sampling this cubemap with any direction vector \(w_i\), we retrieve the scene's radiance from that direction. 34 </p> 35 36 <p> 37 Getting the scene's radiance given any direction vector \(w_i\) is then as simple as: 38 </p> 39 40 <pre><code> 41 vec3 radiance = texture(_cubemapEnvironment, w_i).rgb; 42 </code></pre> 43 44 <p> 45 Still, solving the integral requires us to sample the environment map from not just one direction, but all possible directions \(w_i\) over the hemisphere \(\Omega\) which is far too expensive for each fragment shader invocation. To solve the integral in a more efficient fashion we'll want to <em>pre-process</em> or <def>pre-compute</def> most of the computations. For this we'll have to delve a bit deeper into the reflectance equation: 46 </p> 47 48 \[ 49 L_o(p,\omega_o) = \int\limits_{\Omega} 50 (k_d\frac{c}{\pi} + k_s\frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)}) 51 L_i(p,\omega_i) n \cdot \omega_i d\omega_i 52 \] 53 54 <p> 55 Taking a good look at the reflectance equation we find that the diffuse \(k_d\) and specular \(k_s\) term of the BRDF are independent from each other and we can split the integral in two: 56 </p> 57 58 \[ 59 L_o(p,\omega_o) = 60 \int\limits_{\Omega} (k_d\frac{c}{\pi}) L_i(p,\omega_i) n \cdot \omega_i d\omega_i 61 + 62 \int\limits_{\Omega} (k_s\frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)}) 63 L_i(p,\omega_i) n \cdot \omega_i d\omega_i 64 \] 65 66 <p> 67 By splitting the integral in two parts we can focus on both the diffuse and specular term individually; the focus of this chapter being on the diffuse integral. 68 </p> 69 70 <p> 71 Taking a closer look at the diffuse integral we find that the diffuse lambert term is a constant term (the color \(c\), the refraction ratio \(k_d\), and \(\pi\) are constant over the integral) and not dependent on any of the integral variables. Given this, we can move the constant term out of the diffuse integral: 72 </p> 73 74 \[ 75 L_o(p,\omega_o) = 76 k_d\frac{c}{\pi} \int\limits_{\Omega} L_i(p,\omega_i) n \cdot \omega_i d\omega_i 77 \] 78 79 <p> 80 This gives us an integral that only depends on \(w_i\) (assuming \(p\) is at the center of the environment map). With this knowledge, we can calculate or <em>pre-compute</em> a new cubemap that stores in each sample direction (or texel) \(w_o\) the diffuse integral's result by <def>convolution</def>. 81 </p> 82 83 <p> 84 Convolution is applying some computation to each entry in a data set considering all other entries in the data set; the data set being the scene's radiance or environment map. Thus for every sample direction in the cubemap, we take all other sample directions over the hemisphere \(\Omega\) into account. 85 </p> 86 87 <p> 88 To convolute an environment map we solve the integral for each output \(w_o\) sample direction by discretely sampling a large number of directions \(w_i\) over the hemisphere \(\Omega\) and averaging their radiance. The hemisphere we build the sample directions \(w_i\) from is oriented towards the output \(w_o\) sample direction we're convoluting. 89 </p> 90 91 <img src="/img/pbr/ibl_hemisphere_sample.png" class="clean" alt="Convoluting a cubemap on a hemisphere for a PBR irradiance map."/> 92 93 <p> 94 This pre-computed cubemap, that for each sample direction \(w_o\) stores the integral result, can be thought of as the pre-computed sum of all indirect diffuse light of the scene hitting some surface aligned along direction \(w_o\). Such a cubemap is known as an <def>irradiance map</def> seeing as the convoluted cubemap effectively allows us to directly sample the scene's (pre-computed) irradiance from any direction \(w_o\). 95 </p> 96 97 <note> 98 The radiance equation also depends on a position \(p\), which we've assumed to be at the center of the irradiance map. This does mean all diffuse indirect light must come from a single environment map which may break the illusion of reality (especially indoors). Render engines solve this by placing <def>reflection probes</def> all over the scene where each reflection probes calculates its own irradiance map of its surroundings. This way, the irradiance (and radiance) at position \(p\) is the interpolated irradiance between its closest reflection probes. For now, we assume we always sample the environment map from its center. 99 </note> 100 101 <p> 102 Below is an example of a cubemap environment map and its resulting irradiance map (courtesy of <a href="http://www.indiedb.com/features/using-image-based-lighting-ibl" target="_blank">wave engine</a>), averaging the scene's radiance for every direction \(w_o\). 103 </p> 104 105 <img src="/img/pbr/ibl_irradiance.png" class="clean" alt="The effect of convoluting a cubemap environment map."/> 106 107 <p> 108 By storing the convoluted result in each cubemap texel (in the direction of \(w_o\)), the irradiance map displays somewhat like an average color or lighting display of the environment. Sampling any direction from this environment map will give us the scene's irradiance in that particular direction. 109 </p> 110 111 112 <h2>PBR and HDR</h2> 113 <p> 114 We've briefly touched upon it in the <a href="https://learnopengl.com/PBR/Lighting" target="_blank">previous</a> chapter: taking the high dynamic range of your scene's lighting into account in a PBR pipeline is incredibly important. As PBR bases most of its inputs on real physical properties and measurements it makes sense to closely match the incoming light values to their physical equivalents. Whether we make educated guesses on each light's radiant flux or use their <a href="https://en.wikipedia.org/wiki/Lumen_(unit)" target="_blank">direct physical equivalent</a>, the difference between a simple light bulb or the sun is significant either way. Without working in an <a href="https://learnopengl.com/Advanced-Lighting/HDR" target="_blank">HDR</a> render environment it's impossible to correctly specify each light's relative intensity. 115 </p> 116 117 <p> 118 So, PBR and HDR go hand in hand, but how does it all relate to image based lighting? We've seen in the previous chapter that it's relatively easy to get PBR working in HDR. However, seeing as for image based lighting we base the environment's indirect light intensity on the color values of an environment cubemap we need some way to store the lighting's high dynamic range into an environment map. 119 </p> 120 121 <p> 122 The environment maps we've been using so far as cubemaps (used as <a href="https://learnopengl.com/Advanced-OpenGL/Cubemaps" target="_blank">skyboxes</a> for instance) are in low dynamic range (LDR). We directly used their color values from the individual face images, ranged between <code>0.0</code> and <code>1.0</code>, and processed them as is. While this may work fine for visual output, when taking them as physical input parameters it's not going to work. 123 </p> 124 125 <h3>The radiance HDR file format</h3> 126 <p> 127 Enter the radiance file format. The radiance file format (with the <code>.hdr</code> extension) stores a full cubemap with all 6 faces as floating point data. This allows us to specify color values outside the <code>0.0</code> to <code>1.0</code> range to give lights their correct color intensities. The file format also uses a clever trick to store each floating point value, not as a 32 bit value per channel, but 8 bits per channel using the color's alpha channel as an exponent (this does come with a loss of precision). This works quite well, but requires the parsing program to re-convert each color to their floating point equivalent. 128 </p> 129 130 <p> 131 There are quite a few radiance HDR environment maps freely available from sources like <a href="http://www.hdrlabs.com/sibl/archive.html" target="_blank">sIBL archive</a> of which you can see an example below: 132 </p> 133 134 <img src="/img/pbr/ibl_hdr_radiance.png" alt="Example of an equirectangular map"/> 135 136 <p> 137 This may not be exactly what you were expecting, as the image appears distorted and doesn't show any of the 6 individual cubemap faces of environment maps we've seen before. This environment map is projected from a sphere onto a flat plane such that we can more easily store the environment into a single image known as an <def>equirectangular map</def>. This does come with a small caveat as most of the visual resolution is stored in the horizontal view direction, while less is preserved in the bottom and top directions. In most cases this is a decent compromise as with almost any renderer you'll find most of the interesting lighting and surroundings in the horizontal viewing directions. 138 </p> 139 140 <h3>HDR and stb_image.h</h3> 141 <p> 142 Loading radiance HDR images directly requires some knowledge of the <a href="http://radsite.lbl.gov/radiance/refer/Notes/picture_format.html" target="_blank">file format</a> which isn't too difficult, but cumbersome nonetheless. Lucky for us, the popular one header library <a href="https://github.com/nothings/stb/blob/master/stb_image.h" target="_blank">stb_image.h</a> supports loading radiance HDR images directly as an array of floating point values which perfectly fits our needs. With <code>stb_image</code> added to your project, loading an HDR image is now as simple as follows: 143 </p> 144 145 <pre><code> 146 #include "stb_image.h" 147 [...] 148 149 stbi_set_flip_vertically_on_load(true); 150 int width, height, nrComponents; 151 float *data = stbi_loadf("newport_loft.hdr", &width, &height, &nrComponents, 0); 152 unsigned int hdrTexture; 153 if (data) 154 { 155 <function id='50'>glGenTextures</function>(1, &hdrTexture); 156 <function id='48'>glBindTexture</function>(GL_TEXTURE_2D, hdrTexture); 157 <function id='52'>glTexImage2D</function>(GL_TEXTURE_2D, 0, GL_RGB16F, width, height, 0, GL_RGB, GL_FLOAT, data); 158 159 <function id='15'>glTexParameter</function>i(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); 160 <function id='15'>glTexParameter</function>i(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); 161 <function id='15'>glTexParameter</function>i(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); 162 <function id='15'>glTexParameter</function>i(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 163 164 stbi_image_free(data); 165 } 166 else 167 { 168 std::cout << "Failed to load HDR image." << std::endl; 169 } 170 </code></pre> 171 172 <p> 173 <code>stb_image.h</code> automatically maps the HDR values to a list of floating point values: 32 bits per channel and 3 channels per color by default. This is all we need to store the equirectangular HDR environment map into a 2D floating point texture. 174 </p> 175 176 <h3>From Equirectangular to Cubemap</h3> 177 <p> 178 It is possible to use the equirectangular map directly for environment lookups, but these operations can be relatively expensive in which case a direct cubemap sample is more performant. Therefore, in this chapter we'll first convert the equirectangular image to a cubemap for further processing. Note that in the process we also show how to sample an equirectangular map as if it was a 3D environment map in which case you're free to pick whichever solution you prefer. 179 </p> 180 181 <p> 182 To convert an equirectangular image into a cubemap we need to render a (unit) cube and project the equirectangular map on all of the cube's faces from the inside and take 6 images of each of the cube's sides as a cubemap face. The vertex shader of this cube simply renders the cube as is and passes its local position to the fragment shader as a 3D sample vector: 183 </p> 184 185 <pre><code> 186 #version 330 core 187 layout (location = 0) in vec3 aPos; 188 189 out vec3 localPos; 190 191 uniform mat4 projection; 192 uniform mat4 view; 193 194 void main() 195 { 196 localPos = aPos; 197 gl_Position = projection * view * vec4(localPos, 1.0); 198 } 199 </code></pre> 200 201 <p> 202 For the fragment shader, we color each part of the cube as if we neatly folded the equirectangular map onto each side of the cube. To accomplish this, we take the fragment's sample direction as interpolated from the cube's local position and then use this direction vector and some trigonometry magic (spherical to cartesian) to sample the equirectangular map as if it's a cubemap itself. We directly store the result onto the cube-face's fragment which should be all we need to do: 203 </p> 204 205 <pre><code> 206 #version 330 core 207 out vec4 FragColor; 208 in vec3 localPos; 209 210 uniform sampler2D equirectangularMap; 211 212 const vec2 invAtan = vec2(0.1591, 0.3183); 213 vec2 SampleSphericalMap(vec3 v) 214 { 215 vec2 uv = vec2(atan(v.z, v.x), asin(v.y)); 216 uv *= invAtan; 217 uv += 0.5; 218 return uv; 219 } 220 221 void main() 222 { 223 vec2 uv = SampleSphericalMap(normalize(localPos)); // make sure to normalize localPos 224 vec3 color = texture(equirectangularMap, uv).rgb; 225 226 FragColor = vec4(color, 1.0); 227 } 228 229 </code></pre> 230 231 <p> 232 If you render a cube at the center of the scene given an HDR equirectangular map you'll get something that looks like this: 233 </p> 234 235 <img src="/img/pbr/ibl_equirectangular_projection.png" alt="OpenGL render of an equirectangular map converted to a cubemap."/> 236 237 <p> 238 This demonstrates that we effectively mapped an equirectangular image onto a cubic shape, but doesn't yet help us in converting the source HDR image to a cubemap texture. To accomplish this we have to render the same cube 6 times, looking at each individual face of the cube, while recording its visual result with a <a href="https://learnopengl.com/Advanced-OpenGL/Framebuffers" target="_blank">framebuffer</a> object: 239 </p> 240 241 <pre><code> 242 unsigned int captureFBO, captureRBO; 243 <function id='76'>glGenFramebuffers</function>(1, &captureFBO); 244 <function id='82'>glGenRenderbuffers</function>(1, &captureRBO); 245 246 <function id='77'>glBindFramebuffer</function>(GL_FRAMEBUFFER, captureFBO); 247 <function id='83'>glBindRenderbuffer</function>(GL_RENDERBUFFER, captureRBO); 248 <function id='88'>glRenderbufferStorage</function>(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 512, 512); 249 <function id='89'>glFramebufferRenderbuffer</function>(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, captureRBO); 250 </code></pre> 251 252 <p> 253 Of course, we then also generate the corresponding cubemap color textures, pre-allocating memory for each of its 6 faces: 254 </p> 255 256 <pre><code> 257 unsigned int envCubemap; 258 <function id='50'>glGenTextures</function>(1, &envCubemap); 259 <function id='48'>glBindTexture</function>(GL_TEXTURE_CUBE_MAP, envCubemap); 260 for (unsigned int i = 0; i < 6; ++i) 261 { 262 // note that we store each face with 16 bit floating point values 263 <function id='52'>glTexImage2D</function>(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 264 512, 512, 0, GL_RGB, GL_FLOAT, nullptr); 265 } 266 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); 267 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); 268 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); 269 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR); 270 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 271 </code></pre> 272 273 <p> 274 Then what's left to do is capture the equirectangular 2D texture onto the cubemap faces. 275 </p> 276 277 <p> 278 I won't go over the details as the code details topics previously discussed in the <a href="https://learnopengl.com/Advanced-OpenGL/Framebuffers" target="_blank">framebuffer</a> and <a href="https://learnopengl.com/Advanced-Lighting/Shadows/Point-Shadows" target="_blank">point shadows</a> chapters, but it effectively boils down to setting up 6 different view matrices (facing each side of the cube), set up a projection matrix with a fov of <code>90</code> degrees to capture the entire face, and render a cube 6 times storing the results in a floating point framebuffer: 279 </p> 280 281 <pre><code> 282 glm::mat4 captureProjection = <function id='58'>glm::perspective</function>(<function id='63'>glm::radians</function>(90.0f), 1.0f, 0.1f, 10.0f); 283 glm::mat4 captureViews[] = 284 { 285 <function id='62'>glm::lookAt</function>(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)), 286 <function id='62'>glm::lookAt</function>(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(-1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)), 287 <function id='62'>glm::lookAt</function>(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 1.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f)), 288 <function id='62'>glm::lookAt</function>(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, -1.0f, 0.0f), glm::vec3(0.0f, 0.0f, -1.0f)), 289 <function id='62'>glm::lookAt</function>(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 0.0f, 1.0f), glm::vec3(0.0f, -1.0f, 0.0f)), 290 <function id='62'>glm::lookAt</function>(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 0.0f, -1.0f), glm::vec3(0.0f, -1.0f, 0.0f)) 291 }; 292 293 // convert HDR equirectangular environment map to cubemap equivalent 294 equirectangularToCubemapShader.use(); 295 equirectangularToCubemapShader.setInt("equirectangularMap", 0); 296 equirectangularToCubemapShader.setMat4("projection", captureProjection); 297 <function id='49'>glActiveTexture</function>(GL_TEXTURE0); 298 <function id='48'>glBindTexture</function>(GL_TEXTURE_2D, hdrTexture); 299 300 <function id='22'>glViewport</function>(0, 0, 512, 512); // don't forget to configure the viewport to the capture dimensions. 301 <function id='77'>glBindFramebuffer</function>(GL_FRAMEBUFFER, captureFBO); 302 for (unsigned int i = 0; i < 6; ++i) 303 { 304 equirectangularToCubemapShader.setMat4("view", captureViews[i]); 305 <function id='81'>glFramebufferTexture2D</function>(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, 306 GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, envCubemap, 0); 307 <function id='10'>glClear</function>(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); 308 309 renderCube(); // renders a 1x1 cube 310 } 311 <function id='77'>glBindFramebuffer</function>(GL_FRAMEBUFFER, 0); 312 </code></pre> 313 314 <p> 315 We take the color attachment of the framebuffer and switch its texture target around for every face of the cubemap, directly rendering the scene into one of the cubemap's faces. Once this routine has finished (which we only have to do once), the cubemap <var>envCubemap</var> should be the cubemapped environment version of our original HDR image. 316 </p> 317 318 <p> 319 Let's test the cubemap by writing a very simple skybox shader to display the cubemap around us: 320 </p> 321 322 <pre><code> 323 #version 330 core 324 layout (location = 0) in vec3 aPos; 325 326 uniform mat4 projection; 327 uniform mat4 view; 328 329 out vec3 localPos; 330 331 void main() 332 { 333 localPos = aPos; 334 335 mat4 rotView = mat4(mat3(view)); // remove translation from the view matrix 336 vec4 clipPos = projection * rotView * vec4(localPos, 1.0); 337 338 gl_Position = clipPos.xyww; 339 } 340 </code></pre> 341 342 <p> 343 Note the <code>xyww</code> trick here that ensures the depth value of the rendered cube fragments always end up at <code>1.0</code>, the maximum depth value, as described in the <a href="https://learnopengl.com/Advanced-OpenGL/Cubemaps" target="_blank">cubemap</a> chapter. Do note that we need to change the depth comparison function to <var>GL_LEQUAL</var>: 344 </p> 345 346 <pre><code> 347 <function id='66'>glDepthFunc</function>(GL_LEQUAL); 348 </code></pre> 349 350 <p> 351 The fragment shader then directly samples the cubemap environment map using the cube's local fragment position: 352 </p> 353 354 <pre><code> 355 #version 330 core 356 out vec4 FragColor; 357 358 in vec3 localPos; 359 360 uniform samplerCube environmentMap; 361 362 void main() 363 { 364 vec3 envColor = texture(environmentMap, localPos).rgb; 365 366 envColor = envColor / (envColor + vec3(1.0)); 367 envColor = pow(envColor, vec3(1.0/2.2)); 368 369 FragColor = vec4(envColor, 1.0); 370 } 371 </code></pre> 372 373 <p> 374 We sample the environment map using its interpolated vertex cube positions that directly correspond to the correct direction vector to sample. Seeing as the camera's translation components are ignored, rendering this shader over a cube should give you the environment map as a non-moving background. Also, as we directly output the environment map's HDR values to the default LDR framebuffer, we want to properly tone map the color values. Furthermore, almost all HDR maps are in linear color space by default so we need to apply <a href="https://learnopengl.com/Advanced-Lighting/Gamma-Correction" target="_blank">gamma correction</a> before writing to the default framebuffer. 375 </p> 376 377 <p> 378 Now rendering the sampled environment map over the previously rendered spheres should look something like this: 379 </p> 380 381 <img src="/img/pbr/ibl_hdr_environment_mapped.png" alt="Render the converted cubemap as a skybox."/> 382 383 <p> 384 Well... it took us quite a bit of setup to get here, but we successfully managed to read an HDR environment map, convert it from its equirectangular mapping to a cubemap, and render the HDR cubemap into the scene as a skybox. Furthermore, we set up a small system to render onto all 6 faces of a cubemap, which we'll need again when <def>convoluting</def> the environment map. You can find the source code of the entire conversion process <a href="/code_viewer_gh.php?code=src/6.pbr/2.1.1.ibl_irradiance_conversion/ibl_irradiance_conversion.cpp" target="_blank">here</a>. 385 </p> 386 387 <h2>Cubemap convolution</h2> 388 <p> 389 As described at the start of the chapter, our main goal is to solve the integral for all diffuse indirect lighting given the scene's irradiance in the form of a cubemap environment map. We know that we can get the radiance of the scene \(L(p, w_i)\) in a particular direction by sampling an HDR environment map in direction \(w_i\). To solve the integral, we have to sample the scene's radiance from all possible directions within the hemisphere \(\Omega\) for each fragment. 390 </p> 391 392 <p> 393 It is however computationally impossible to sample the environment's lighting from every possible direction in \(\Omega\), the number of possible directions is theoretically infinite. We can however, approximate the number of directions by taking a finite number of directions or samples, spaced uniformly or taken randomly from within the hemisphere, to get a fairly accurate approximation of the irradiance; effectively solving the integral \(\int\) discretely 394 </p> 395 396 <p> 397 It is however still too expensive to do this for every fragment in real-time as the number of samples needs to be significantly large for decent results, so we want to <def>pre-compute</def> this. Since the orientation of the hemisphere decides where we capture the irradiance, we can pre-calculate the irradiance for every possible hemisphere orientation oriented around all outgoing directions \(w_o\): 398 </p> 399 400 \[ 401 L_o(p,\omega_o) = 402 k_d\frac{c}{\pi} \int\limits_{\Omega} L_i(p,\omega_i) n \cdot \omega_i d\omega_i 403 \] 404 405 <p> 406 Given any direction vector \(w_i\) in the lighting pass, we can then sample the pre-computed irradiance map to retrieve the total diffuse irradiance from direction \(w_i\). To determine the amount of indirect diffuse (irradiant) light at a fragment surface, we retrieve the total irradiance from the hemisphere oriented around its surface normal. Obtaining the scene's irradiance is then as simple as: 407 </p> 408 409 <pre><code> 410 vec3 irradiance = texture(irradianceMap, N).rgb; 411 </code></pre> 412 413 <p> 414 Now, to generate the irradiance map, we need to convolute the environment's lighting as converted to a cubemap. Given that for each fragment the surface's hemisphere is oriented along the normal vector \(N\), convoluting a cubemap equals calculating the total averaged radiance of each direction \(w_i\) in the hemisphere \(\Omega\) oriented along \(N\). 415 </p> 416 417 <img src="/img/pbr/ibl_hemisphere_sample_normal.png" class="clean" alt="Convoluting a cubemap on a hemisphere (oriented around the normal) for a PBR irradiance map."/> 418 419 <p> 420 Thankfully, all of the cumbersome setup of this chapter isn't all for nothing as we can now directly take the converted cubemap, convolute it in a fragment shader, and capture its result in a new cubemap using a framebuffer that renders to all 6 face directions. As we've already set this up for converting the equirectangular environment map to a cubemap, we can take the exact same approach but use a different fragment shader: 421 </p> 422 423 <pre><code> 424 #version 330 core 425 out vec4 FragColor; 426 in vec3 localPos; 427 428 uniform samplerCube environmentMap; 429 430 const float PI = 3.14159265359; 431 432 void main() 433 { 434 // the sample direction equals the hemisphere's orientation 435 vec3 normal = normalize(localPos); 436 437 vec3 irradiance = vec3(0.0); 438 439 [...] // convolution code 440 441 FragColor = vec4(irradiance, 1.0); 442 } 443 </code></pre> 444 445 <p> 446 With <var>environmentMap</var> being the HDR cubemap as converted from the equirectangular HDR environment map. 447 </p> 448 449 <p> 450 There are many ways to convolute the environment map, but for this chapter we're going to generate a fixed amount of sample vectors for each cubemap texel along a hemisphere \(\Omega\) oriented around the sample direction and average the results. The fixed amount of sample vectors will be uniformly spread inside the hemisphere. Note that an integral is a continuous function and discretely sampling its function given a fixed amount of sample vectors will be an approximation. The more sample vectors we use, the better we approximate the integral. 451 </p> 452 453 <p> 454 The integral \(\int\) of the reflectance equation revolves around the solid angle \(dw\) which is rather difficult to work with. Instead of integrating over the solid angle \(dw\) we'll integrate over its equivalent spherical coordinates \(\theta\) and \(\phi\). 455 </p> 456 457 458 <img src="/img/pbr/ibl_spherical_integrate.png" class="clean" alt="Converting the solid angle over the equivalent polar azimuth and inclination angle for PBR"/> 459 460 <p> 461 We use the polar azimuth \(\phi\) angle to sample around the ring of the hemisphere between \(0\) and \(2\pi\), and use the inclination zenith \(\theta\) angle between \(0\) and \(\frac{1}{2}\pi\) to sample the increasing rings of the hemisphere. This will give us the updated reflectance integral: 462 </p> 463 464 \[ 465 L_o(p,\phi_o, \theta_o) = 466 k_d\frac{c}{\pi} \int_{\phi = 0}^{2\pi} \int_{\theta = 0}^{\frac{1}{2}\pi} L_i(p,\phi_i, \theta_i) \cos(\theta) \sin(\theta) d\phi d\theta 467 \] 468 469 <p> 470 Solving the integral requires us to take a fixed number of discrete samples within the hemisphere \(\Omega\) and averaging their results. This translates the integral to the following discrete version as based on the <a href="https://en.wikipedia.org/wiki/Riemann_sum" target="_blank">Riemann sum</a> given \(n1\) and \(n2\) discrete samples on each spherical coordinate respectively: 471 </p> 472 473 \[ 474 L_o(p,\phi_o, \theta_o) = 475 k_d \frac{c\pi}{n1 n2} \sum_{\phi = 0}^{n1} \sum_{\theta = 0}^{n2} L_i(p,\phi_i, \theta_i) \cos(\theta) \sin(\theta) d\phi d\theta 476 \] 477 478 479 <p> 480 As we sample both spherical values discretely, each sample will approximate or average an area on the hemisphere as the image before shows. Note that (due to the general properties of a spherical shape) the hemisphere's discrete sample area gets smaller the higher the zenith angle \(\theta\) as the sample regions converge towards the center top. To compensate for the smaller areas, we weigh its contribution by scaling the area by \(\sin \theta\). 481 </p> 482 483 <p> 484 Discretely sampling the hemisphere given the integral's spherical coordinates translates to the following fragment code: 485 </p> 486 487 <pre><code> 488 vec3 irradiance = vec3(0.0); 489 490 vec3 up = vec3(0.0, 1.0, 0.0); 491 vec3 right = normalize(cross(up, normal)); 492 up = normalize(cross(normal, right)); 493 494 float sampleDelta = 0.025; 495 float nrSamples = 0.0; 496 for(float phi = 0.0; phi < 2.0 * PI; phi += sampleDelta) 497 { 498 for(float theta = 0.0; theta < 0.5 * PI; theta += sampleDelta) 499 { 500 // spherical to cartesian (in tangent space) 501 vec3 tangentSample = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta)); 502 // tangent space to world 503 vec3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * N; 504 505 irradiance += texture(environmentMap, sampleVec).rgb * cos(theta) * sin(theta); 506 nrSamples++; 507 } 508 } 509 irradiance = PI * irradiance * (1.0 / float(nrSamples)); 510 </code></pre> 511 512 <p> 513 We specify a fixed <var>sampleDelta</var> delta value to traverse the hemisphere; decreasing or increasing the sample delta will increase or decrease the accuracy respectively. 514 </p> 515 516 <p> 517 From within both loops, we take both spherical coordinates to convert them to a 3D Cartesian sample vector, convert the sample from tangent to world space oriented around the normal, and use this sample vector to directly sample the HDR environment map. We add each sample result to <var>irradiance</var> which at the end we divide by the total number of samples taken, giving us the average sampled irradiance. Note that we scale the sampled color value by <code>cos(theta)</code> due to the light being weaker at larger angles and by <code>sin(theta)</code> to account for the smaller sample areas in the higher hemisphere areas. 518 </p> 519 520 <p> 521 Now what's left to do is to set up the OpenGL rendering code such that we can convolute the earlier captured <var>envCubemap</var>. First we create the irradiance cubemap (again, we only have to do this once before the render loop): 522 </p> 523 524 <pre><code> 525 unsigned int irradianceMap; 526 <function id='50'>glGenTextures</function>(1, &irradianceMap); 527 <function id='48'>glBindTexture</function>(GL_TEXTURE_CUBE_MAP, irradianceMap); 528 for (unsigned int i = 0; i < 6; ++i) 529 { 530 <function id='52'>glTexImage2D</function>(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 32, 32, 0, 531 GL_RGB, GL_FLOAT, nullptr); 532 } 533 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); 534 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); 535 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); 536 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR); 537 <function id='15'>glTexParameter</function>i(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 538 </code></pre> 539 540 <p> 541 As the irradiance map averages all surrounding radiance uniformly it doesn't have a lot of high frequency details, so we can store the map at a low resolution (32x32) and let OpenGL's linear filtering do most of the work. Next, we re-scale the capture framebuffer to the new resolution: 542 </p> 543 544 <pre class="cpp"><code> 545 <function id='77'>glBindFramebuffer</function>(GL_FRAMEBUFFER, captureFBO); 546 <function id='83'>glBindRenderbuffer</function>(GL_RENDERBUFFER, captureRBO); 547 <function id='88'>glRenderbufferStorage</function>(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 32, 32); 548 </code></pre> 549 550 <p> 551 Using the convolution shader, we render the environment map in a similar way to how we captured the environment cubemap: 552 </p> 553 554 <pre><code> 555 irradianceShader.use(); 556 irradianceShader.setInt("environmentMap", 0); 557 irradianceShader.setMat4("projection", captureProjection); 558 <function id='49'>glActiveTexture</function>(GL_TEXTURE0); 559 <function id='48'>glBindTexture</function>(GL_TEXTURE_CUBE_MAP, envCubemap); 560 561 <function id='22'>glViewport</function>(0, 0, 32, 32); // don't forget to configure the viewport to the capture dimensions. 562 <function id='77'>glBindFramebuffer</function>(GL_FRAMEBUFFER, captureFBO); 563 for (unsigned int i = 0; i < 6; ++i) 564 { 565 irradianceShader.setMat4("view", captureViews[i]); 566 <function id='81'>glFramebufferTexture2D</function>(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, 567 GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, irradianceMap, 0); 568 <function id='10'>glClear</function>(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); 569 570 renderCube(); 571 } 572 <function id='77'>glBindFramebuffer</function>(GL_FRAMEBUFFER, 0); 573 </code></pre> 574 575 <p> 576 Now after this routine we should have a pre-computed irradiance map that we can directly use for our diffuse image based lighting. To see if we successfully convoluted the environment map we'll substitute the environment map for the irradiance map as the skybox's environment sampler: 577 </p> 578 579 <img src="/img/pbr/ibl_irradiance_map_background.png" alt="Displaying a PBR irradiance map as the skybox background."/> 580 581 <p> 582 If it looks like a heavily blurred version of the environment map you've successfully convoluted the environment map. 583 </p> 584 585 <h2>PBR and indirect irradiance lighting</h2> 586 <p> 587 The irradiance map represents the diffuse part of the reflectance integral as accumulated from all surrounding indirect light. Seeing as the light doesn't come from direct light sources, but from the surrounding environment, we treat both the diffuse and specular indirect lighting as the ambient lighting, replacing our previously set constant term. 588 </p> 589 590 <p> 591 First, be sure to add the pre-calculated irradiance map as a cube sampler: 592 </p> 593 594 <pre><code> 595 uniform samplerCube irradianceMap; 596 </code></pre> 597 598 <p> 599 Given the irradiance map that holds all of the scene's indirect diffuse light, retrieving the irradiance influencing the fragment is as simple as a single texture sample given the surface normal: 600 </p> 601 602 <pre><code> 603 // vec3 ambient = vec3(0.03); 604 vec3 ambient = texture(irradianceMap, N).rgb; 605 </code></pre> 606 607 <p> 608 However, as the indirect lighting contains both a diffuse and specular part (as we've seen from the split version of the reflectance equation) we need to weigh the diffuse part accordingly. Similar to what we did in the previous chapter, we use the Fresnel equation to determine the surface's indirect reflectance ratio from which we derive the refractive (or diffuse) ratio: 609 </p> 610 611 <pre><code> 612 vec3 kS = fresnelSchlick(max(dot(N, V), 0.0), F0); 613 vec3 kD = 1.0 - kS; 614 vec3 irradiance = texture(irradianceMap, N).rgb; 615 vec3 diffuse = irradiance * albedo; 616 vec3 ambient = (kD * diffuse) * ao; 617 </code></pre> 618 619 <p> 620 As the ambient light comes from all directions within the hemisphere oriented around the normal <var>N</var>, there's no single halfway vector to determine the Fresnel response. To still simulate Fresnel, we calculate the Fresnel from the angle between the normal and view vector. However, earlier we used the micro-surface halfway vector, influenced by the roughness of the surface, as input to the Fresnel equation. As we currently don't take roughness into account, the surface's reflective ratio will always end up relatively high. Indirect light follows the same properties of direct light so we expect rougher surfaces to reflect less strongly on the surface edges. Because of this, the indirect Fresnel reflection strength looks off on rough non-metal surfaces (slightly exaggerated for demonstration purposes): 621 </p> 622 <img src="/img/pbr/lighting_fresnel_no_roughness.png" alt="The Fresnel equation for IBL without taking roughness into account."/> 623 624 <p> 625 We can alleviate the issue by injecting a roughness term in the Fresnel-Schlick equation as described by <a href="https://seblagarde.wordpress.com/2011/08/17/hello-world/" target="_blank">Sébastien Lagarde</a>: 626 </p> 627 628 <pre><code> 629 vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) 630 { 631 return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); 632 } 633 </code></pre> 634 635 <p> 636 By taking account of the surface's roughness when calculating the Fresnel response, the ambient code ends up as: 637 </p> 638 639 <pre><code> 640 vec3 kS = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness); 641 vec3 kD = 1.0 - kS; 642 vec3 irradiance = texture(irradianceMap, N).rgb; 643 vec3 diffuse = irradiance * albedo; 644 vec3 ambient = (kD * diffuse) * ao; 645 </code></pre> 646 647 <p> 648 As you can see, the actual image based lighting computation is quite simple and only requires a single cubemap texture lookup; most of the work is in pre-computing or convoluting the irradiance map. 649 </p> 650 651 <p> 652 If we take the initial scene from the PBR <a href="https://learnopengl.com/PBR/Lighting" target="_blank">lighting</a> chapter, where each sphere has a vertically increasing metallic and a horizontally increasing roughness value, and add the diffuse image based lighting it'll look a bit like this: 653 </p> 654 655 <img src="/img/pbr/ibl_irradiance_result.png" alt="Result of convoluting an irradiance map in OpenGL used by the PBR shader."/> 656 657 <p> 658 It still looks a bit weird as the more metallic spheres <strong>require</strong> some form of reflection to properly start looking like metallic surfaces (as metallic surfaces don't reflect diffuse light) which at the moment are only (barely) coming from the point light sources. Nevertheless, you can already tell the spheres do feel more <em>in place</em> within the environment (especially if you switch between environment maps) as the surface response reacts accordingly to the environment's ambient lighting. 659 </p> 660 661 <p> 662 You can find the complete source code of the discussed topics <a href="/code_viewer_gh.php?code=src/6.pbr/2.1.2.ibl_irradiance/ibl_irradiance.cpp" target="_blank">here</a>. In the <a href="https://learnopengl.com/PBR/IBL/Specular-IBL" target="_blank">next</a> chapter we'll add the indirect specular part of the reflectance integral at which point we're really going to see the power of PBR. 663 </p> 664 665 <h2>Further reading</h2> 666 <ul> 667 <li><a href="http://www.codinglabs.net/article_physically_based_rendering.aspx" target="_blank">Coding Labs: Physically based rendering</a>: an introduction to PBR and how and why to generate an irradiance map. </li> 668 <li><a href="http://www.scratchapixel.com/lessons/mathematics-physics-for-computer-graphics/mathematics-of-shading" target="_blank">The Mathematics of Shading</a>: a brief introduction by ScratchAPixel on several of the mathematics described in this tutorial, specifically on polar coordinates and integrals.</li> 669 </ul> 670 671 </div> 672