In this chapter, we will show how to create a framework for a number of techniques to implement image-based effects via Open Graphics Library (OpenGL) and Vulkan and integrate them with the rest of our scene-rendering code. Most of these techniques are actually part of the postprocessing pipeline, such as ambient occlusion, High Dynamic Range (HDR) tone mapping and light adaptation, and temporal antialiasing. The idea is to render a scene and then apply an effect to it, hence the name. Besides that, shadow mapping uses somewhat similar machinery of offscreen framebuffers underneath. We will implement a very basic shadow mapping algorithm here as the first example and then return to the topic of shadow mapping, with more advanced techniques, in the next chapter.
This chapter covers the postprocessing pipeline and has the following recipes:
To run the recipes from this chapter, you will need a computer with a video card supporting OpenGL 4.6 with ARB_bindless_texture and Vulkan 1.2. Read Chapter 1, Establishing a Build Environment, if you want to learn how to build demonstration applications from this book.
This chapter relies on the geometry-loading code explained in the previous chapter, Chapter 7, Graphics Rendering Pipeline, so make sure you read it before proceeding any further and run the Chapter7/SceneConverter tool before running the demos from this chapter.
All Vulkan demos from this chapter require multiple rendering passes and are using multiple input and output (I/O) textures and framebuffers. To specify these memory dependencies without using Vulkan subpasses, we insert pipeline barriers in between the rendering commands. Inserting barriers into a command buffer is performed in the shared/vkFramework/Barriers.h file, which declared a number of helper classes that essentially just emit the appropriate vkCmdPipelineBarrier() function. These helper classes are used in the form of Renderer from the previous chapters. We declare a variable, initialize that barrier with an appropriate texture handle and flags, and add this variable to the list of renderers. Make sure you read Barriers.h before proceeding with this chapter.
Before we can proceed with generic postprocessing effects, let's implement some basic OpenGL machinery for offscreen rendering using framebuffer objects. We will rely on this code throughout the remaining chapters of this book to implement various rendering and postprocessing techniques.
The code for this recipe is located in the shared/glFramework/GLFramebuffer.h. file. It would be helpful to quickly go through the entire code before reading the rest of this recipe.
Let's implement a simple GLFramebuffer class to handle all the underlying OpenGL framebuffer objects' manipulations and attachments:
class GLFramebuffer {
private:
int width_, height_;
GLuint handle_ = 0;
std::unique_ptr<GLTexture> texColor_;
std::unique_ptr<GLTexture> texDepth_;
public:
GLFramebuffer(
int width, int height,
GLenum formatColor, GLenum formatDepth)
: width_(width), height_(height) {
glCreateFramebuffers(1, &handle_);
if (formatColor) {
texColor_ = std::make_unique<GLTexture>( GL_TEXTURE_2D, width, height, formatColor);
glTextureParameteri(texColor_->getHandle(), GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTextureParameteri(texColor_->getHandle(), GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glNamedFramebufferTexture( handle_, GL_COLOR_ATTACHMENT0, texColor_->getHandle(), 0);
}
if (formatDepth) {
texDepth_ = std::make_unique<GLTexture>( GL_TEXTURE_2D, width, height, formatDepth);
glNamedFramebufferTexture( handle_, GL_DEPTH_ATTACHMENT, texDepth_->getHandle(), 0);
}
const GLenum status = glCheckNamedFramebufferStatus( handle_, GL_FRAMEBUFFER);
assert(status == GL_FRAMEBUFFER_COMPLETE);
}
~GLFramebuffer() {
glDeleteFramebuffers(1, &handle_);
}
void bind() {
glBindFramebuffer(GL_FRAMEBUFFER, handle_);
glViewport(0, 0, width_, height_);
}
void unbind() {
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
Note
The value of the target parameter to glBindFramebuffer() is hardcoded to be GL_FRAMEBUFFER, which makes both read and write framebuffers be set to the same framebuffer object. Additional functionality where read and write framebuffers can be separate is not used in this book. However, it might be useful in situations where you want to make OpenGL reading commands such as glReadPixels() and rendering commands use different framebuffer objects.
GLuint getHandle() const { return handle_; }
const GLTexture& getTextureColor() const
{ return *texColor_.get(); }
const GLTexture& getTextureDepth() const
{ return *texDepth_.get(); }
};
This class is used as a building block for all our offscreen-rendering OpenGL demos. We demonstrate how to use this class later in this chapter, in the Implementing shadow maps in OpenGL recipe.
All postprocessing recipes in this chapter require you to render a fullscreen quad using a specific fragment shader for each effect. While the fragment shaders should be very specific to each effect, the vertex shader can be the same. Furthermore, while we can trivially render a quad using a classic vertex buffer object approach, this might be cumbersome to manage in situations where we should mix and match tens or hundreds of shader combinations in different parts of the rendering pipeline. In this recipe, we show a very simple way to generate a quad right in the vertex shader in a similar way to how we generated a cube in Chapter 3, Getting Started with OpenGL and Vulkan.
Check out the Implementing programmable vertex pulling in OpenGL recipe from Chapter 3, Getting Started with OpenGL and Vulkan.
Let's go through the code of our fullscreen quad vertex shader. The shader can be found in the datashaderschapter08GL02_FullScreenQuad.vert file:
#version 460 core
layout (location=0) out vec2 uv;
void main() {
float u = float(((uint(gl_VertexID)+2u) / 3u) % 2u);
float v = float(((uint(gl_VertexID)+1u) / 3u) % 2u);
gl_Position = vec4(-1.0+u*2.0, -1.0+v*2.0, 0., 1.);
uv = vec2(u, v);
}
GLuint dummyVAO;
glCreateVertexArrays(1, &dummyVAO);
glBindVertexArray(dummyVAO);
...
glDrawArrays(GL_TRIANGLES, 0, 6);
In the subsequent recipes, we will learn how to combine this vertex shader with different fragment shaders to render different postprocessing effects.
While rendering a fullscreen quad seems straightforward and simple, and it is indeed good for educational purposes, rendering a fullscreen triangle might be faster in many real-world scenarios:
Figure 8.1 – Rendering a fullscreen triangle
These two OpenGL Shading Language (GLSL) functions will generate the OpenGL position and UV coordinates for a screen-covering triangle using just three values of the vertex index, going from 0 to 2:
vec4 fsTrianglePosition(int vtx) {
float x = -1.0 + float((vtx & 1) << 2);
float y = -1.0 + float((vtx & 2) << 1);
return vec4(x, y, 0.0, 1.0);
}
vec2 fsTriangleUV(int vtx) {
float u = (vtx == 1) ? 2.0 : 0.0; // 0, 2, 0
float v = (vtx == 2) ? 2.0 : 0.0; // 0, 0, 2
return vec2(u, v);
}
As an exercise, feel free to update the code using this code snippet. The triangle shader program should be invoked via glDrawArrays(GL_TRIANGLES, 0, 3).
As we learned from the previous chapters, we can render complex scenes with varying sets of materials, including physically based rendering (PBR) materials. While these techniques can produce very nice images, the visual realism of our scenes was severely lacking. Shadow mapping is one of the cornerstones of getting more realistic rendering results. In this recipe, we will give initial guidance on how to approach basic shadow mapping in OpenGL. Considering OpenGL is significantly less verbose compared to Vulkan, this recipe's main focus will be the shadow-mapping algorithm details, while its Vulkan counterpart is focused on the Vulkan application programming interface (API) details to get you started with shadow mapping.
The Chapter8/GL01_ShadowMapping demo application for this recipe implements basic steps for a projective shadow-mapping pipeline. It would be helpful to quickly go through the code before reading this recipe.
The projective shadow-mapping idea is quite straightforward. The scene is rendered from the light's point of view. The objects closest to the light are lit, while everything else is in the shadow. To determine the set of closest objects, a depth buffer can be used. To do that, we require a way to render our scene into an offscreen framebuffer.
The rendering process consists of three phases: calculating the light's projection and view matrices, rendering the entire scene from the light's point of view into an offscreen framebuffer, and rendering the entire scene again using the offscreen framebuffer's depth texture to apply the shadow map. Let's go through the C++ part of the code from Chapter8/GL01_ShadowMapping/src/main.cpp to see how this can be done:
struct PerFrameData {
mat4 view;
mat4 proj;
mat4 light;
vec4 cameraPos;
vec4 lightAngles; // cos(inner), cos(outer)
vec4 lightPos;
};
float g_LightAngle = 60.0f;
float g_LightInnerAngle = 10.0f;
float g_LightNear = 1.0f;
float g_LightFar = 20.0f;
float g_LightDist = 12.0f;
float g_LightXAngle = -1.0f;
float g_LightYAngle = -2.0f;
We now have the main() function with our standard GLApp object, which handles all the window-creation and input routines. All the shader programs loading happens right here. There are GL01_grid.* shaders for the grid rendering, GL01_scene.* for the scene rendering, and GL01_shadow.vert shaders for the shadow-map rendering:
int main(void) {
GLApp app;
GLShader shdGridVert( "data/shaders/chapter05/GL01_grid.vert");
GLShader shdGridFrag( "data/shaders/chapter05/GL01_grid.frag");
GLProgram progGrid(shdGridVert, shdGridFrag);
GLShader shdModelVert( "data/shaders/chapter08/GL01_scene.vert");
GLShader shdModelFrag( "data/shaders/chapter08/GL01_scene.frag");
GLProgram progModel(shdModelVert, shdModelFrag);
GLShader shdShadowVert( "data/shaders/chapter08/GL01_shadow.vert");
GLShader shdShadowFrag( "data/shaders/chapter08/GL01_shadow.frag");
GLProgram progShadowMap( shdShadowVert, shdShadowFrag);
We should create a uniform buffer for our per-frame values and set up some OpenGL state:
const GLsizeiptr kUniformBufferSize = sizeof(PerFrameData);
GLBuffer perFrameDataBuffer( kUniformBufferSize, nullptr, GL_DYNAMIC_STORAGE_BIT);
glBindBufferRange(GL_UNIFORM_BUFFER, 0, perFrameDataBuffer.getHandle(), 0, kUniformBufferSize);
glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
Now, let's create a couple of meshes for our scene. The first one is our basic Duck mesh loaded from .gltf. The second one is just a plane that will receive a shadow. The plane is created right from the vertices. The GLMeshPVP class is used to store our meshes and feed them into OpenGL using a programmable-vertex-pulling (PVP) approach:
// 1. Duck
GLMeshPVP mesh("data/rubber_duck/scene.gltf");
GLTexture texAlbedoDuck(GL_TEXTURE_2D, "data/rubber_duck/textures/Duck_baseColor.png");
// 2. Plane
const std::vector<uint32_t> indices = { 0, 1, 2, 2, 3, 0 };
const std::vector<VertexData> vertices = { {vec3(-2, -2, 0), vec3(0,0,1), vec2(0,0)}, {vec3(-2, +2, 0), vec3(0,0,1), vec2(0,1)}, {vec3(+2, +2, 0), vec3(0,0,1), vec2(1,1)}, {vec3(+2, -2, 0), vec3(0,0,1), vec2(1,0)}, };
GLMeshPVP plane(indices, vertices.data(), uint32_t(sizeof(VertexData) * vertices.size()));
GLTexture texAlbedoPlane(GL_TEXTURE_2D, "data/ch2_sample3_STB.jpg");
const std::vector<GLMeshPVP*> meshesToDraw = { &mesh, &plane };
const mat4 m(1.0f);
GLBuffer modelMatrices(sizeof(mat4), value_ptr(m), GL_DYNAMIC_STORAGE_BIT);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, modelMatrices.getHandle());
ImGuiGLRenderer rendererUI;
CanvasGL canvas;
GLFramebuffer shadowMap(1024, 1024, GL_RGBA8, GL_DEPTH_COMPONENT24);
That was the entire setup process necessary to render our scene. We skipped the keyboard and mouse-handling code because it is identical to all the previous demos. Now, let's take a look at the main loop and how everything is updated:
while (!glfwWindowShouldClose(app.getWindow())) {
positioner.update(deltaSeconds, mouseState.pos, mouseState.pressedLeft);
const double newTimeStamp = glfwGetTime();
deltaSeconds = static_cast<float>(newTimeStamp - timeStamp);
timeStamp = newTimeStamp;
int width, height;
glfwGetFramebufferSize(app.getWindow(), &width, &height);
const float ratio = width / (float)height;
if (g_RotateModel)
angle += deltaSeconds;
const mat4 scale = glm::scale( mat4(1.0f), vec3(3.0f));
const mat4 rot = glm::rotate(mat4(1.0f), glm::radians(-90.0f), vec3(1.0f, 0.0f, 0.0f));
const mat4 pos = glm::translate( mat4(1.0f), vec3(0.0f, 0.0f, +1.0f));
const mat4 m = glm::rotate(scale * rot * pos, angle, vec3(0.0f, 0.0f, 1.0f));
glNamedBufferSubData(modelMatrices.getHandle(), 0, sizeof(mat4), value_ptr(m));
const glm::mat4 rotY = glm::rotate( mat4(1.f), g_LightYAngle, glm::vec3(0, 1, 0));
const glm::mat4 rotX = glm::rotate( rotY, g_LightXAngle, glm::vec3(1, 0, 0));
const glm::vec4 lightPos = rotX * glm::vec4(0, 0, g_LightDist, 1.0f);
const mat4 lightProj = glm::perspective( glm::radians(g_LightAngle), 1.0f, g_LightNear, g_LightFar);
const mat4 lightView = glm::lookAt( glm::vec3(lightPos), vec3(0), vec3(0, 1, 0));
Rendering to the shadow map is similar to an ordinary scene rendering. We update the per-frame data so that our current view and projection matrices are set to represent the light's point of view. Before rendering, we clear the color and depth buffers of the shadow-map framebuffer using OpenGL's direct state access functions. The PerFrameData::light field is not used in the shadow shader, so we can leave this field uninitialized:
glEnable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
const PerFrameData = { .view = lightView, .proj = lightProj, .cameraPos = glm::vec4(camera.getPosition(), 1.0f) };
glNamedBufferSubData( perFrameDataBuffer.getHandle(), 0, kUniformBufferSize, &perFrameData);
shadowMap.bind();
glClearNamedFramebufferfv(shadowMap.getHandle(), GL_COLOR, 0, glm::value_ptr(vec4(0.0f, 0.0f, 0.0f, 1.0f)));
glClearNamedFramebufferfi(shadowMap.getHandle(), GL_DEPTH_STENCIL, 0, 1.0f, 0);
progShadowMap.useProgram();
for (const auto& m : meshesToDraw)
m->drawElements();
shadowMap.unbind();
glViewport(0, 0, width, height);
glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);
const mat4 proj = glm::perspective( 45.0f, ratio, 0.5f, 5000.0f);
const mat4 view = camera.getViewMatrix();
const PerFrameData = { .view = view, .proj = proj, .light = lightProj * lightView, .cameraPos = glm::vec4(camera.getPosition(), 1.0f), .lightAngles = vec4( cosf(radians(0.5f * g_LightAngle)), cosf(radians(0.5f * (g_LightAngle-g_LightInnerAngle))), 1.0f, 1.0f), .lightPos = lightPos };
glNamedBufferSubData( perFrameDataBuffer.getHandle(), 0, kUniformBufferSize, &perFrameData);
const GLuint textures[] = { texAlbedoDuck.getHandle(), texAlbedoPlane.getHandle() };
glBindTextureUnit( 1, shadowMap.getTextureDepth().getHandle());
progModel.useProgram();
for (size_t i = 0; i != meshesToDraw.size(); i++)
{
glBindTextureUnit(0, textures[i]);
meshesToDraw[i]->drawElements();
}
glEnable(GL_BLEND);
progGrid.useProgram();
glDrawArraysInstancedBaseInstance( GL_TRIANGLES, 0, 6, 1, 0);
renderCameraFrustumGL(canvas, lightView, lightProj, vec4(0.0f, 1.0f, 0.0f, 1.0f));
canvas.flush();
ImGuiIO& io = ImGui::GetIO();
io.DisplaySize = ImVec2((float)width, (float)height);
ImGui::NewFrame();
ImGui::Begin("Control", nullptr);
ImGui::Checkbox("Rotate", &g_RotateModel);
ImGui::End();
ImGui::Begin("Light parameters", nullptr);
ImGui::SliderFloat("Proj::Light angle", &g_LightAngle, 15.0f, 170.0f);
ImGui::SliderFloat("Proj::Light inner angle", &g_LightInnerAngle, 1.0f, 15.0f);
ImGui::SliderFloat("Proj::Near", &g_LightNear, 0.1f, 5.0f);
ImGui::SliderFloat("Proj::Far", &g_LightFar, 0.1f, 100.0f);
ImGui::SliderFloat("Pos::Dist", &g_LightDist, 0.5f, 100.0f);
ImGui::SliderFloat("Pos::AngleX", &g_LightXAngle, -3.15f, +3.15f);
ImGui::SliderFloat("Pos::AngleY", &g_LightYAngle, -3.15f, +3.15f);
ImGui::End();
imguiTextureWindowGL("Color", shadowMap.getTextureColor().getHandle());
imguiTextureWindowGL("Depth", shadowMap.getTextureDepth().getHandle());
ImGui::Render();
rendererUI.render( width, height, ImGui::GetDrawData());
app.swapBuffers();
}
return 0;
}
Rendering a texture into a separate ImGui window is very handy for debugging and can be done via the following snippet. Note that our texture coordinates are vertically flipped:
void imguiTextureWindowGL(
const char* title, uint32_t texId)
{
ImGui::Begin(title, nullptr);
const ImVec2 vMin = ImGui::GetWindowContentRegionMin();
const ImVec2 vMax = ImGui::GetWindowContentRegionMax();
ImGui::Image((void*)(intptr_t)texId, ImVec2(vMax.x - vMin.x, vMax.y - vMin.y), ImVec2(0.0f, 1.0f), ImVec2(1.0f, 0.0f) );
ImGui::End();
}
The demo application should render a shadowed scene, as in the following screenshot:
Figure 8.2 – Rendering a shadow-mapped duck
Let's take a look at the GLSL shaders necessary to render the shadow map and apply it to a scene. We have a set of vertex and fragment shaders necessary to render the scene into the shadow map, which can be found in the data/shaders/chapter08/GL01_shadow.frag and data/shaders/chapter08/GL01_shadow.vert files:
#version 460 core
#include <data/shaders/chapter04/GLBufferDeclarations.h>
vec3 getPos(int i) {
return vec3(in_Vertices[i].p[0], in_Vertices[i].p[1], in_Vertices[i].p[2]);
}
void main() {
mat4 MVP = proj * view * in_ModelMatrices[gl_BaseInstance];
gl_Position = MVP * vec4(getPos(gl_VertexID), 1.0);
}
#version 460 core
layout (location=0) out vec4 out_FragColor;
void main() {
out_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
};
The resulting shadow map looks like this. Here, the content of the depth buffer is rendered as an R channel:
Figure 8.3 – A shadow map (cropped)
The remaining part of the application is a set of shaders used to apply this shadow map to our scene. They can be found in data/shaders/chapter08/GL01_scene.frag and data/shaders/chapter08/GL01_scene.vert. Let's take a look at the vertex shader:
#version 460 core
#include <data/shaders/chapter08/GLBufferDeclarations.h>
layout(std140, binding = 0) uniform PerFrameData {
mat4 view;
mat4 proj;
mat4 light;
vec4 cameraPos;
vec4 lightAngles;
vec4 lightPos;
};
vec3 getPosition(int i) {
return vec3(in_Vertices[i].p[0], in_Vertices[i].p[1], in_Vertices[i].p[2]);
}
vec2 getTexCoord(int i) {
return vec2(in_Vertices[i].tc[0], in_Vertices[i].tc[1]);
}
struct PerVertex {
vec2 uv;
vec4 shadowCoord;
vec3 worldPos;
};
layout (location=0) out PerVertex vtx;
const mat4 scaleBias = mat4( 0.5, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.5, 0.5, 0.5, 1.0);
void main() {
mat4 model = in_ModelMatrices[gl_BaseInstance];
mat4 MVP = proj * view * model;
vec3 pos = getPosition(gl_VertexID);
gl_Position = MVP * vec4(pos, 1.0);
vtx.uv = getTexCoord(gl_VertexID);
vtx.shadowCoord = scaleBias * light * model * vec4(pos, 1.0);
vtx.worldPos = (model * vec4(pos, 1.0)).xyz;
}
Note
One interesting remark is that in Vulkan, the clip-space Z axis goes from 0 to 1, making the scale-bias matrix different. This is a rather common mistake when porting existing OpenGL shaders to Vulkan. Another option is to use the ARB_clip_control extension to change OpenGL clip-space to use the 0..1 range and use the same matrix for both OpenGL and Vulkan:
const mat4 scaleBiasVulkan = mat4(
0.5, 0.0, 0.0, 0.0,
0.0, 0.5, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.5, 0.5, 0.0, 0.1);
The fragment shader is more interesting. Let's take a look at what is happening there. As you may have already noticed from Figure 8.3, we have a soft shadow effect implemented using a percentage-closer filtering (PCF) technique. PCF is a method to reduce the aliasing of shadow mapping by averaging the results of multiple depth comparisons in the fragment shader. Here's how to do it:
#version 460 core
layout(std140, binding = 0) uniform PerFrameData {
mat4 view;
mat4 proj;
mat4 light;
vec4 cameraPos;
vec4 lightAngles;
vec4 lightPos;
};
struct PerVertex {
vec2 uv;
vec4 shadowCoord;
vec3 worldPos;
};
layout (location=0) in PerVertex vtx;
layout (location=0) out vec4 out_FragColor;
layout (binding = 0) uniform sampler2D texture0;
layout (binding = 1) uniform sampler2D textureShadow;
float PCF(
int kernelSize, vec2 shadowCoord, float depth) {
float size = 1.0 / float(textureSize(textureShadow, 0).x);
float shadow = 0.0;
int range = kernelSize / 2;
for (int v = -range; v <= range; v++)
for (int u = -range; u <= range; u++)
shadow += (depth >= texture(textureShadow, shadowCoord+size*vec2(u, v)).r) ? 1.0 : 0.0;
return shadow / (kernelSize * kernelSize);
}
float shadowFactor(vec4 shadowCoord) {
vec4 shadowCoords4 = shadowCoord / shadowCoord.w;
if (shadowCoords4.z > -1.0 && shadowCoords4.z < 1.0)
{
float depthBias = -0.001;
float shadowSample = PCF( 13, shadowCoords4.xy, shadowCoords4.z + depthBias );
return mix(1.0, 0.3, shadowSample);
}
return 1.0;
}
float lightFactor(vec3 worldPos) {
vec3 dirLight = normalize(lightPos.xyz - worldPos);
// the light is always looking at (0, 0, 0) vec3 dirSpot = normalize(-lightPos.xyz);
float rho = dot(-dirLight, dirSpot);
float outerAngle = lightAngles.x;
float innerAngle = lightAngles.y;
if (rho > outerAngle)
return smoothstep(outerAngle, innerAngle, rho);
return 0.0;
}
void main() {
vec3 albedo = texture(texture0, vtx.uv).xyz;
out_FragColor = vec4(albedo * shadowFactor(vtx.shadowCoord) * lightFactor(vtx.worldPos), 1.0);
};
This concludes our basic OpenGL shadow-mapping example and the first use case of offscreen rendering. Let's switch gears back to the main topic of this chapter and implement some image-based postprocessing effects.
The technique used to calculate the light's view and projection matrices described in this recipe is suitable only for spot and—partially —omnidirectional lights. For directional lights, which influence the entire visible scene, the values of view and projection matrices will depend on the geometry of the scene and how it intersects the main camera frustum. We will touch on this topic in Chapter 10, Advanced Rendering Techniques and Optimizations, and generate an outdoor shadow map for the Bistro scene.
In this recipe, we focused on the basic shadow-mapping math, while the actual OpenGL code was fairly trivial and easy to follow. In the subsequent recipes, we will demonstrate similar functionality via the Vulkan API. We also implemented a Vulkan version of this demo in the Chapter08/VK01_ShadowMapping project. Make sure you read the remaining Vulkan-related recipes in this chapter before digging into that source code.
Screen Space Ambient Occlusion (SSAO) is an image-based technique to roughly approximate global illumination in real time. Ambient occlusion itself is a very crude approximation of global illumination. It can be thought of as the amount of open "sky" visible from a point on a surface and not occluded by any local adjacent geometry. In its simplest form, we can estimate this amount by sampling several points in the neighborhood of our point of interest and checking their visibility from the central point.
The Chapter8/GL02_SSAO demo application for this recipe implements basic steps for SSAO. Check out two previous recipes in this chapter, Implementing offscreen rendering in OpenGL and Implementing fullscreen quad rendering, before proceeding with this one.
Instead of tracing the depth buffer's height field, we use an even simpler approach where every selected neighborhood point is projected onto the depth buffer. The projected point is used as a potential occluder. The O(dZ) occlusion factor for such a point is calculated from the difference (dZ)between the projected depth value and the depth of the current fragment based on the following formula:
O(dZ)= (dZ > 0) ? 1/(1+dZ^2) : 0
These occlusion factors are averaged and used as the SSAO value for the current fragment. Before applying the resulting SSAO to the scene, it is blurred to reduce aliasing artifacts.
The SSAO shader operates purely on the depth buffer without any additional scene data, which makes this implementation a simple drop-in code snippet to start your own exploration of SSAO. Let's go through the C++ part of the code to see how to implement it:
struct PerFrameData {
mat4 view;
mat4 proj;
vec4 cameraPos;
};
struct SSAOParams {
float scale_ = 1.0f;
float bias_ = 0.2f;
float zNear = 0.1f;
float zFar = 1000.0f;
float radius = 0.2f;
float attScale = 1.0f;
float distScale = 0.5f;
} g_SSAOParams;
static_assert(sizeof(SSAOParams) <= sizeof(PerFrameData));
bool g_EnableSSAO = true;
bool g_EnableBlur = true;
We skip all the keyboard and mouse-handling code because it is similar to the previous demos and jump straight into the main() function:
int main(void) {
GLApp app;
GLShader shdGridVert( "data/shaders/chapter05/GL01_grid.vert");
GLShader shdGridFragt( "data/shaders/chapter05/GL01_grid.frag");
GLProgram progGrid(shdGridVert, shdGridFrag);
GLShader shaderVert( "data/shaders/chapter07/GL01_mesh.vert");
GLShader shaderFrag( "data/shaders/chapter07/GL01_mesh.frag");
GLProgram program(shaderVert, shaderFrag);
GLShader shdFullScreenQuadVert( "data/shaders/chapter08/GL02_FullScreenQuad.vert");
GLShader shdSSAOFrag( "data/shaders/chapter08/GL02_SSAO.frag");
GLShader shdCombineSSAOFrag( "data/shaders/chapter08/GL02_SSAO_combine.frag");
GLProgram progSSAO( shdFullScreenQuadVert, shdSSAOFrag);
GLProgram progCombineSSAO( shdFullScreenQuadVert, shdCombineSSAOFrag);
GLShader shdBlurXFrag( "data/shaders/chapter08/GL02_BlurX.frag");
GLShader shdBlurYFrag( "data/shaders/chapter08/GL02_BlurY.frag");
GLProgram progBlurX( shdFullScreenQuadVert, shdBlurXFrag);
GLProgram progBlurY( shdFullScreenQuadVerx, shdBlurYFrag);
const GLsizeiptr kUniformBufferSize = sizeof(PerFrameData);
GLBuffer perFrameDataBuffer(kUniformBufferSize, nullptr, GL_DYNAMIC_STORAGE_BIT);
glBindBufferRange(GL_UNIFORM_BUFFER, kBufferIndex_PerFrameUniforms, perFrameDataBuffer.getHandle(), 0, kUniformBufferSize);
glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glEnable(GL_DEPTH_TEST);
GLTexture rotationPattern( GL_TEXTURE_2D, "data/rot_texture.bmp");
GLSceneData sceneData1(data/meshes/test.meshes", "data/meshes/test.scene", "data/meshes/test.materials");
GLSceneData sceneData2("data/meshes/test2.meshes", "data/meshes/test2.scene", "data/meshes/test2.materials");
GLMesh mesh1(sceneData1);
GLMesh mesh2(sceneData2);
ImGuiGLRenderer rendererUI;
positioner.maxSpeed_ = 1.0f;
double timeStamp = glfwGetTime();
float deltaSeconds = 0.0f;
int width, height;
glfwGetFramebufferSize( app.getWindow(), &width, &height);
GLFramebuffer framebuffer( width, height, GL_RGBA8, GL_DEPTH_COMPONENT24);
GLFramebuffer ssao(1024, 1024, GL_RGBA8, 0);
GLFramebuffer blur(1024, 1024, GL_RGBA8, 0);
while (!glfwWindowShouldClose(app.getWindow()))
{
... skipped positioner/deltatime updates
glfwGetFramebufferSize( app.getWindow(), &width, &height);
const float ratio = width / (float)height;
glClearNamedFramebufferfv(framebuffer.getHandle(), GL_COLOR, 0, glm::value_ptr(vec4(0.0f, 0.0f, 0.0f, 1.0f)));
glClearNamedFramebufferfi(framebuffer.getHandle(), GL_DEPTH_STENCIL, 0, 1.0f, 0);
const mat4 proj = glm::perspective(45.0f, ratio, g_SSAOParams.zNear, g_SSAOParams.zFar);
const mat4 view = camera.getViewMatrix();
const PerFrameData = { .view = view, .proj = proj, .cameraPos = glm::vec4(camera.getPosition(), 1.0f)
};
glNamedBufferSubData( perFrameDataBuffer.getHandle(), 0, kUniformBufferSize, &perFrameData);
glDisable(GL_BLEND);
glEnable(GL_DEPTH_TEST);
framebuffer.bind();
// 1.1 Bistro
program.useProgram();
mesh1.draw(sceneData1);
mesh2.draw(sceneData2);
// 1.2 Grid
glEnable(GL_BLEND);
progGrid.useProgram();
glDrawArraysInstancedBaseInstance( GL_TRIANGLES, 0, 6, 1, 0);
framebuffer.unbind();
glDisable(GL_DEPTH_TEST);
glClearNamedFramebufferfv(ssao.getHandle(), GL_COLOR, 0, glm::value_ptr(vec4(0.0f, 0.0f, 0.0f, 1.0f)));
glNamedBufferSubData( perFrameDataBuffer.getHandle(), 0, sizeof(g_SSAOParams), &g_SSAOParams);
ssao.bind();
progSSAO.useProgram();
glBindTextureUnit( 0, framebuffer.getTextureDepth().getHandle());
glBindTextureUnit(1, rotationPattern.getHandle());
glDrawArrays(GL_TRIANGLES, 0, 6);
ssao.unbind();
After running this fragment, the ssao framebuffer will contain something similar to this:
Figure 8.4 – Raw SSAO buffer
if (g_EnableBlur) {
blur.bind();
progBlurX.useProgram();
glBindTextureUnit( 0, ssao.getTextureColor().getHandle());
glDrawArrays(GL_TRIANGLES, 0, 6);
blur.unbind();
ssao.bind();
progBlurY.useProgram();
glBindTextureUnit( 0, blur.getTextureColor().getHandle());
glDrawArrays(GL_TRIANGLES, 0, 6);
ssao.unbind();
}
The blurred SSAO image should look like this:
Figure 8.5 – Blurred SSAO buffer
glViewport(0, 0, width, height);
if (g_EnableSSAO) {
progCombineSSAO.useProgram();
glBindTextureUnit( 0, framebuffer.getTextureColor().getHandle());
glBindTextureUnit( 1, ssao.getTextureColor().getHandle());
glDrawArrays(GL_TRIANGLES, 0, 6);
}
else {
glBlitNamedFramebuffer( framebuffer.getHandle(), 0, 0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_LINEAR);
}
ImGuiIO& io = ImGui::GetIO();
io.DisplaySize = ImVec2((float)width, (float)height);
ImGui::NewFrame();
ImGui::Begin("Control", nullptr);
ImGui::Checkbox("Enable SSAO", &g_EnableSSAO);
ImGui::PushItemFlag( ImGuiItemFlags_Disabled, !g_EnableSSAO);
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * g_EnableSSAO ? 1.f : 0.2f);
ImGui::Checkbox("Enable blur", &g_EnableBlur);
ImGui::SliderFloat( "SSAO scale", &g_SSAOParams.scale_, 0.0f,2.0f);
ImGui::SliderFloat( "SSAO bias", &g_SSAOParams.bias_, 0.0f, 0.3f);
ImGui::PopItemFlag();
ImGui::PopStyleVar();
ImGui::Separator();
ImGui::SliderFloat("SSAO radius", &g_SSAOParams.radius, 0.05f, 0.5f);
ImGui::SliderFloat("SSAO attenuation scale", &g_SSAOParams.attScale, 0.5f, 1.5f);
ImGui::SliderFloat("SSAO distance scale", &g_SSAOParams.distScale, 0.0f, 1.0f);
ImGui::End();
imguiTextureWindowGL("Color", framebuffer.getTextureColor().getHandle());
imguiTextureWindowGL("Depth", framebuffer.getTextureDepth().getHandle());
imguiTextureWindowGL("SSAO", ssao.getTextureColor().getHandle());
ImGui::Render();
rendererUI.render( width, height, ImGui::GetDrawData());
app.swapBuffers();
}
return 0;
}
This demo application should render the following image:
Figure 8.6 – SSAO demo
We have now got an overall feel of the rendering process and can start looking into the GLSL shaders code.
The GL02_SSAO.frag fragment shader takes input as the scene-depth buffer and the rotation vectors texture that contains 16 random vec3 vectors. This technique was proposed by Crytek in the early days of real-time SSAO algorithms:
Figure 8.7 – Random vectors' texture (4x4 pixels)
#version 460 core
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 outColor;
layout(binding = 0) uniform sampler2D texDepth;
layout(binding = 1) uniform sampler2D texRotation;
const vec3 offsets[8] = vec3[8]( vec3(-0.5,-0.5,-0.5), vec3( 0.5, -0.5,-0.5), vec3(-0.5, 0.5,-0.5), vec3( 0.5, 0.5,-0.5), vec3(-0.5, -0.5, 0.5), vec3( 0.5,-0.5, 0.5), vec3(-0.5, 0.5, 0.5), vec3( 0.5, 0.5, 0.5));
layout(std140, binding = 0) uniform SSAOParams {
float scale;
float bias;
float zNear;
float zFar;
float radius;
float attScale;
float distScale;
};
void main() {
float size = 1.0 / float(textureSize(texDepth, 0).x);
float Z = (zFar * zNear) / (texture(texDepth, uv).x * (zFar-zNear)-zFar);
vec3 plane = texture( texRotation, uv * size / 4.0).xyz - vec3(1.0);
float att = 0.0;
for ( int i = 0; i < 8; i++ ) {
vec3 rSample = reflect(offsets[i], plane);
float zSample = texture( texDepth, uv + radius*rSample.xy / Z ).x;
zSample = (zFar*zNear) / (zSample * (zFar-zNear) - zFar);
float dist = max(zSample - Z, 0.0) / distScale;
float occl = 15.0 * max(dist * (2.0 - dist), 0.0);
att += 1.0 / (1.0 + occl*occl);
}
att = clamp(att*att/64. + 0.45, 0., 1.) * attScale;
outColor = vec4(vec3(att), 1.0);
}
While this method does not get close to the best SSAO implementations, it is very simple with regard to its input parameters and can operate just on a naked depth buffer.
Let's quickly look into how to blur the SSAO values. The set of blurring fragment shaders is located in shaders/chapter08/GL02_BlurX.frag and shaders/chapter08/GL02_BlurY.frag. Their difference is just the direction of the blur, so we can show only one:
#version 460 core
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 outColor;
layout(binding = 0) uniform sampler2D texSSAO;
const vec2 gaussFilter[11] = vec2[]( vec2(-5.0, 3.0/133.0), vec2(-4.0, 6.0/133.0), vec2(-3.0, 10.0/133.0), vec2(-2.0, 15.0/133.0), vec2(-1.0, 20.0/133.0), vec2( 0.0, 25.0/133.0), vec2( 1.0, 20.0/133.0), vec2( 2.0, 15.0/133.0), vec2( 3.0, 10.0/133.0), vec2( 4.0, 6.0/133.0), vec2( 5.0, 3.0/133.0));
void main() {
vec3 color = vec3(0.0);
float scale = 1.0 / textureSize(texSSAO, 0).x;
for ( int i = 0; i < 11; i++ ) {
vec2 coord = vec2( uv.x + gaussFilter[i].x * scale, uv.y);
color += textureLod( texSSAO, coord, 0).rgb * gaussFilter[i].y;
}
outColor = vec4(color, 1.0);
}
To combine the SSAO effect with the rendered scene, the following GLSL fragment shader should be used: shaders/chapter08/GL02_SSAO_combine.frag. The scale and bias values are controlled from ImGui:
#version 460 core
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 outColor;
layout(binding = 0) uniform sampler2D texScene;
layout(binding = 1) uniform sampler2D texSSAO;
layout(std140, binding = 0) uniform SSAOParams {
float scale;
float bias;
};
void main() {
vec4 color = texture(texScene, uv);
float ssao = clamp(texture(texSSAO, uv).r + bias, 0.0, 1.0);
outColor = vec4(mix(color, color * ssao, scale).rgb, 1.0);
}
With all this knowledge, you should be able to add a similar SSAO effect to your rendering engine.
In the next recipe, we will learn how to implement a more complex postprocessing scheme for HDR rendering and tone mapping.
While running the demo, you may have noticed that the SSAO effect behaves somewhat weirdly on transparent surfaces. That is quite understandable since our transparency rendering is done via punch-through transparency whereby a part of transparent surface pixels is discarded proportionally to the transparency value. These holes expose the depth values beneath the transparent surface, hence our SSAO implementation works partially. In a real-world rendering engine, you might want to calculate the SSAO effect after the opaque objects have been fully rendered and before any transparent objects influence the depth buffer.
In all our previous examples, the resulting color values in the framebuffer were clamped between 0.0 and 1.0. Furthermore, we used 1 byte per color component, making only 256 shades of brightness possible, which means the ratio between the darkest and the brightest regions in the image cannot be larger than 256:1. This might seem sufficient for many applications, but what happens if we have a really bright region illuminated by the Sun or multiple lights? Everything will be clamped at 1.0, and any additional information in the higher values of brightness, or luminance, will be lost. These HDR brightness values can be remapped back into a Low Dynamic Range (LDR) 0..1 interval using a tone-mapping technique.
The source code for this demo is located in Chapter8/GL03_HDR/src/main.cpp.
To implement HDR rendering, we need to store HDR values in framebuffers. This can be done using our existing GLFramebuffer framework and providing appropriate OpenGL color texture formats. OpenGL has a GL_RGBA16F 16-bit floating-point red-green-blue (RGB) format that can be used for rendering.
Once the scene is rendered into a floating-point framebuffer, we can calculate the average luminance value of the HDR image and use it to guide the tone-mapping calculation. Furthermore, we can detect high values of luminance in the image and use those areas to simulate the bloom of real-world cameras. See https://en.wikipedia.org/wiki/Bloom_(shader_effect) for more on this.
Let's go through the C++ code to understand the entire pipeline:
struct PerFrameData {
mat4 view;
mat4 proj;
vec4 cameraPos;
};
struct HDRParams {
float exposure_ = 0.9f;
float maxWhite_ = 1.17f;
float bloomStrength_ = 1.1f;
} g_HDRParams;
static_assert( sizeof(HDRParams) <= sizeof(PerFrameData));
int main(void) {
GLApp app;
GLShader shdGridVert( "data/shaders/chapter05/GL01_grid.vert");
GLShader shdGridFrag( "data/shaders/chapter05/GL01_grid.frag");
GLProgram progGrid(shdGridVert, shdGridFra);
GLShader shdFullScreenQuadVert( "data/shaders/chapter08/GL02_FullScreenQuad.vert");
GLShader shdCombineHDR( "data/shaders/chapter08/GL03_HDR.frag");
GLProgram progCombineHDR( shdFullScreenQuadVert, shdCombineHDR);
GLShader shdBlurX( "data/shaders/chapter08/GL02_BlurX.frag");
GLShader shdBlurY( "data/shaders/chapter08/GL02_BlurY.frag");
GLProgram progBlurX( shdFullScreenQuadVertex, shdBlurX);
GLProgram progBlurY( shdFullScreenQuadVertex, shdBlurY);
GLShader shdToLuminance( "data/shaders/chapter08/GL03_ToLuminance.frag");
GLProgram progToLuminance( shdFullScreenQuadVertex, shdToLuminance);
GLShader shdBrightPass( "data/shaders/chapter08/GL03_BrightPass.frag");
GLProgram progBrightPass( shdFullScreenQuadVertex, shdBrightPass);
GLShader shaderVert( "data/shaders/chapter08/GL03_scene_IBL.vert");
GLShader shaderFrag( "data/shaders/chapter08/GL03_scene_IBL.frag");
GLProgram program(shaderVert, shaderFrag);
const GLsizeiptr kUniformBufferSize = sizeof(PerFrameData);
GLBuffer perFrameDataBuffer(kUniformBufferSize, nullptr, GL_DYNAMIC_STORAGE_BIT);
glBindBufferRange(GL_UNIFORM_BUFFER, kBufferIndex_PerFrameUniforms, perFrameDataBuffer.getHandle(), 0, kUniformBufferSize);
glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glEnable(GL_DEPTH_TEST);
GLSceneData sceneData1("data/meshes/test.meshes", "data/meshes/test.scene", "data/meshes/test.materials");
GLSceneData sceneData2("data/meshes/test2.meshes", "data/meshes/test2.scene", "data/meshes/test2.materials");
GLMesh mesh1(sceneData1);
GLMesh mesh2(sceneData2);
int width, height;
glfwGetFramebufferSize( app.getWindow(), &width, &height);
GLFramebuffer framebuffer(width, height, GL_RGBA16F, GL_DEPTH_COMPONENT24);
GLFramebuffer luminance(64, 64, GL_R16F, 0);
GLFramebuffer brightPass(256, 256, GL_RGBA16F, 0);
GLFramebuffer bloom1(256, 256, GL_RGBA16F, 0);
GLFramebuffer bloom2(256, 256, GL_RGBA16F, 0);
GLuint luminance1x1;
glGenTextures(1, &luminance1x1);
glTextureView(luminance1x1, GL_TEXTURE_2D, luminance.getTextureColor().getHandle(), GL_R16F, 6, 1, 0, 1);
const GLint Mask[] = { GL_RED, GL_RED, GL_RED, GL_RED };
glTextureParameteriv( luminance1x1, GL_TEXTURE_SWIZZLE_RGBA, Mask);
Because our mesh-rendering shader applies IBL to the scene, let's render this IBL cube map as a sky box. The cube map for this demo was downloaded from https://hdrihaven.com/hdri/?h=immenstadter_horn. The irradiance map was generated using our Util01_FilterEnvmap tool from Chapter 6, Physically Based Rendering Using the glTF2 Shading Model:
GLTexture envMap(GL_TEXTURE_CUBE_MAP, "data/immenstadter_horn_2k.hdr");
GLTexture envMapIrradiance(GL_TEXTURE_CUBE_MAP, "data/immenstadter_horn_2k_irradiance.hdr");
GLShader shdCubeVertex( "data/shaders/chapter08/GL03_cube.vert");
GLShader shdCubeFragment( "data/shaders/chapter08/GL03_cube.frag");
GLProgram progCube(shdCubeVertex, shdCubeFragment);
GLuint dummyVAO;
glCreateVertexArrays(1, &dummyVAO);
const GLuint pbrTextures[] = { envMap.getHandle(), envMapIrradiance.getHandle() };
glBindTextures(5, 2, pbrTextures);
ImGuiGLRenderer rendererUI;
while (!glfwWindowShouldClose(app.getWindow())) {
...camera positioner/time update code skipped here
int width, height;
glfwGetFramebufferSize( app.getWindow(), &width, &height);
const float ratio = width / (float)height;
glClearNamedFramebufferfv(framebuffer.getHandle(), GL_COLOR, 0, glm::value_ptr(vec4(0.0f, 0.0f, 0.0f, 1.0f)));
glClearNamedFramebufferfi(framebuffer.getHandle(), GL_DEPTH_STENCIL, 0, 1.0f, 0);
const mat4 p = glm::perspective(45.0f, ratio, 0.1f, 1000.0f);
const mat4 view = camera.getViewMatrix();
const PerFrameData = { .view = view, .proj = p, .cameraPos = glm::vec4(camera.getPosition(), 1.0f) };
glNamedBufferSubData( perFrameDataBuffer.getHandle(), 0, kUniformBufferSize, &perFrameData);
glDisable(GL_BLEND);
glEnable(GL_DEPTH_TEST);
framebuffer.bind();
// 1.0 Cube map
progCube.useProgram();
glBindTextureUnit(1, envMap.getHandle());
glDepthMask(false);
glBindVertexArray(dummyVAO);
glDrawArrays(GL_TRIANGLES, 0, 36);
glDepthMask(true);
// 1.1 Bistro
program.useProgram();
mesh1.draw(sceneData1);
mesh2.draw(sceneData2);
// 1.2 Grid
glEnable(GL_BLEND);
progGrid.useProgram();
glDrawArraysInstancedBaseInstance( GL_TRIANGLES, 0, 6, 1, 0);
framebuffer.unbind();
glDisable(GL_BLEND);
glDisable(GL_DEPTH_TEST);
brightPass.bind();
progBrightPass.useProgram();
glBindTextureUnit( 0, framebuffer.getTextureColor().getHandle());
glDrawArrays(GL_TRIANGLES, 0, 6);
brightPass.unbind();
Then, the main framebuffer is downscaled to 64x64 pixels and converted to luminance using the chapter08/GL03_ToLuminance.frag shader. After the luminance pass has finished, we automatically update the mipmap chain of the luminance framebuffer's color texture. This provides correct data for the luminance1x1 texture view:
luminance.bind();
progToLuminance.useProgram();
glBindTextureUnit( 0, framebuffer.getTextureColor().getHandle());
glDrawArrays(GL_TRIANGLES, 0, 6);
luminance.unbind();
glGenerateTextureMipmap( luminance.getTextureColor().getHandle());
glBlitNamedFramebuffer( brightPass.getHandle(), bloom2.getHandle(), 0, 0, 256, 256, 0, 0, 256, 256, GL_COLOR_BUFFER_BIT, GL_LINEAR);
for (int i = 0; i != 4; i++) {
// Horizontal blur
bloom1.bind();
progBlurX.useProgram();
glBindTextureUnit( 0, bloom2.getTextureColor().getHandle());
glDrawArrays(GL_TRIANGLES, 0, 6);
bloom1.unbind();
// Vertical blur
bloom2.bind();
progBlurY.useProgram();
glBindTextureUnit( 0, bloom1.getTextureColor().getHandle());
glDrawArrays(GL_TRIANGLES, 0, 6);
bloom2.unbind();
}
glViewport(0, 0, width, height);
if (g_EnableHDR) {
glNamedBufferSubData( perFrameDataBuffer.getHandle(), 0, sizeof(g_HDRParams), &g_HDRParams);
progCombineHDR.useProgram();
glBindTextureUnit( 0, framebuffer.getTextureColor().getHandle());
glBindTextureUnit(1, luminance1x1);
glBindTextureUnit( 2, bloom2.getTextureColor().getHandle());
glDrawArrays(GL_TRIANGLES, 0, 6);
}
else {
glBlitNamedFramebuffer(framebuffer.getHandle(), 0, 0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_LINEAR);
}
ImGui::GetIO().DisplaySize = ImVec2((float)width, (float)height);
ImGui::NewFrame();
ImGui::Begin("Control", nullptr);
ImGui::Checkbox("Enable HDR", &g_EnableHDR);
ImGui::PushItemFlag(ImGuiItemFlags_Disabled, !g_EnableHDR);
ImGui::PushStyleVar( ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * g_EnableHDR ? 1.0f : 0.2f);
ImGui::Separator();
ImGui::Text("Average luminance:");
ImGui::Image( (void*)(intptr_t)luminance1x1, ImVec2(128, 128), ImVec2(0.0f, 1.0f), ImVec2(1.0f, 0.0f));
ImGui::Separator();
ImGui::SliderFloat("Exposure", &g_HDRParams.exposure_, 0.1f, 2.0f);
ImGui::SliderFloat("Max White", &g_HDRParams.maxWhite_, 0.5f, 2.0f);
ImGui::SliderFloat("Bloom strength", &g_HDRParams.bloomStrength_, 0.0f, 2.0f);
ImGui::PopItemFlag();
ImGui::PopStyleVar();
ImGui::End();
imguiTextureWindowGL("Color", framebuffer.getTextureColor().getHandle());
imguiTextureWindowGL("Luminance", luminance.getTextureColor().getHandle());
imguiTextureWindowGL("Bright Pass", brightPass.getTextureColor().getHandle());
imguiTextureWindowGL( "Bloom", bloom2.getTextureColor().getHandle());
ImGui::Render();
rendererUI.render( width, height, ImGui::GetDrawData());
app.swapBuffers();
}
glDeleteTextures(1, &luminance1x1);
return 0;
}
The C++ part was rather short, and the overall pipeline looked quite similar to the previous recipe. Now, let's look into the GLSL shaders code.
Let's quickly recap on the chapter08/GL03_scene_IBL.frag mesh rendering shader and check the modifications we used here to apply diffuse IBL to the scene.
#version 460 core
#extension GL_ARB_bindless_texture : require
#extension GL_ARB_gpu_shader_int64 : enable
#include <data/shaders/chapter07/MaterialData.h>
layout(std140, binding = 0) uniform PerFrameData {
mat4 view;
mat4 proj;
vec4 cameraPos;
};
layout(std430, binding = 2)
restrict readonly buffer Materials {
MaterialData in_Materials[];
};
layout (location=0) in vec2 v_tc;
layout (location=1) in vec3 v_worldNormal;
layout (location=2) in vec3 v_worldPos;
layout (location=3) in flat uint matIdx;
layout (location=0) out vec4 out_FragColor;
layout (binding = 5) uniform samplerCube texEnvMap;
layout (binding = 6) uniform samplerCube texEnvMapIrradiance;
layout (binding = 7) uniform sampler2D texBRDF_LUT;
#include <data/shaders/chapter07/AlphaTest.h>
#include <data/shaders/chapter06/PBR.sp>
void main() {
MaterialData mtl = in_Materials[matIdx];
vec4 albedo = mtl.albedoColor_;
vec3 normalSample = vec3(0.0, 0.0, 0.0);
// fetch albedo
if (mtl.albedoMap_ > 0)
albedo = texture(sampler2D( unpackUint2x32(mtl.albedoMap_)), v_tc);
if (mtl.normalMap_ > 0)
normalSample = texture(sampler2D( unpackUint2x32(mtl.normalMap_)), v_tc).xyz;
runAlphaTest(albedo.a, mtl.alphaTest_);
// world-space normal
vec3 n = normalize(v_worldNormal);
// normal mapping: skip missing normal maps
if (length(normalSample) > 0.5)
n = perturbNormal( n, normalize(cameraPos.xyz - v_worldPos.xyz), normalSample, v_tc);
vec3 f0 = vec3(0.04);
vec3 diffuseColor = albedo.rgb * (vec3(1.0) - f0);
vec3 diffuse = texture( texEnvMapIrradiance, n.xyz).rgb * diffuseColor;
out_FragColor = vec4(diffuse, 1.0);
};
This shader renders a fully shaded Bistro scene into the 16-bit framebuffer. Let's look into how to extract bright areas of the rendered image using the chapter08/GL03_BrightPass.frag shader and convert the scene to luminance using chapter08/GL03_ToLuminance.frag:
#version 460 core
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 outColor;
layout(binding = 0) uniform sampler2D texScene;
void main() {
vec4 color = texture(texScene, uv);
float luminance = dot(color, vec4(0.33, 0.34, 0.33, 0.0));
outColor = luminance >= 1.0 ? color : vec4(vec3(0.0), 1.0);
}
#version 460 core
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 outColor;
layout(binding = 0) uniform sampler2D texScene;
void main() {
vec4 color = texture(texScene, uv);
float luminance = dot(color, vec4(0.3, 0.6, 0.1, 0.0));
outColor = vec4(vec3(luminance), 1.0);
}
The tone-mapping process is implemented in the chapter08/GL03_HDR.frag shader. Let's go through its GLSL code:
#version 460 core
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 outColor;
layout(binding = 0) uniform sampler2D texScene;
layout(binding = 1) uniform sampler2D texLuminance;
layout(binding = 2) uniform sampler2D texBloom;
layout(std140, binding = 0) uniform HDRParams {
float exposure;
float maxWhite;
float bloomStrength;
};
vec3 Reinhard2(vec3 x) {
return (x * (1.0 + x / (maxWhite * maxWhite))) / (1.0 + x);
}
void main() {
vec3 color = texture(texScene, uv).rgb;
vec3 bloom = texture(texBloom, uv).rgb;
float avgLuminance = texture(texLuminance, vec2(0.5, 0.5)).x;
float midGray = 0.5;
color *= exposure * midGray / (avgLuminance + 0.001);
color = Reinhard2(color);
outColor = vec4(color + bloomStrength * bloom, 1.0);
}
This demo renders the Bistro scene with a sky box, as in the following screenshot. Note where Bloom causes the bright sky color to bleed over the edges of the buildings:
Figure 8.8 – A tone-mapped HDR scene
When you move the camera around, you can see how the scene brightness is adjusted based on the current luminance. If you look at the sky, you will be able to see the details in bright areas, but the rest of the scene will become dark. If you look at the dark corners of the buildings, the sky will go into white. The overall exposure can be manually shifted using the ImGui slider.
One downside of this approach is that changes in exposure happen momentarily. You look at a different area of the scene, and in the next frame, you have the exposure instantly changed. This is not how human vision works in reality. It takes time for our eyes to adapt from bright to dark areas. In the next recipe, we will learn how to extend the HDR postprocessing pipeline and simulate light adaptation.
Strictly speaking, applying a tone-mapping operator directly to RGB channel values is very crude. The more correct model would be to tone-map the luminance and then apply it back to RGB values. However, for many practical purposes, this simple approximation is sufficient.
In the previous recipe, Implementing HDR rendering and tone mapping, we learned how to do the basic stages of an HDR pipeline. Let's extend this and add a realistic light-adaptation process to simulate how the human-vision system adapts to bright light.
Make sure you go through the previous recipe, Implementing HDR rendering and tone mapping, before taking on this one.
The source code for this demo is located at Chapter8/GL04_HDR_Adaptation.
In order to add a light-adaptation step to our previous HDR tone-mapping demo, let's introduce a few additions to the C++ code:
struct HDRParams {
float exposure_ = 0.9f;
float maxWhite_ = 1.17f;
float bloomStrength_ = 1.1f;
float adaptationSpeed_ = 0.1f;
} g_HDRParams;
static_assert( sizeof(HDRParams) <= sizeof(PerFrameData));
int main(void) {
GLApp app;
...
GLShader shdAdaptation( "data/shaders/chapter08/GL03_Adaptation.comp");
GLProgram progAdaptation(shdAdaptation);
GLFramebuffer framebuffer(width, height, GL_RGBA16F, GL_DEPTH_COMPONENT24);
GLFramebuffer luminance(64, 64, GL_RGBA16F, 0);
GLFramebuffer brightPass(256, 256, GL_RGBA16F, 0);
GLFramebuffer bloom1(256, 256, GL_RGBA16F, 0);
GLFramebuffer bloom2(256, 256, GL_RGBA16F, 0);
GLuint luminance1x1;
glGenTextures(1, &luminance1x1);
glTextureView(luminance1x1, GL_TEXTURE_2D, luminance.getTextureColor().getHandle(), GL_RGBA16F, 6, 1, 0, 1);
GLTexture luminance1( GL_TEXTURE_2D, 1, 1, GL_RGBA16F);
GLTexture luminance2( GL_TEXTURE_2D, 1, 1, GL_RGBA16F);
const GLTexture* luminances[] = { &luminance1, &luminance2 };
const vec4 brightPixel(vec3(50.0f), 1.0f);
glTextureSubImage2D(luminance1.getHandle(), 0, 0, 0, 1, 1, GL_RGBA, GL_FLOAT, glm::value_ptr(brightPixel));
while (!glfwWindowShouldClose(app.getWindow())) {
...
// pass HDR params to shaders
glNamedBufferSubData( perFrameDataBuffer.getHandle(), 0, sizeof(g_HDRParams), &g_HDRParams);
// 2.1 Downscale and convert to luminance
luminance.bind();
progToLuminance.useProgram();
glBindTextureUnit( 0, framebuffer.getTextureColor().getHandle());
glDrawArrays(GL_TRIANGLES, 0, 6);
luminance.unbind();
glGenerateTextureMipmap( luminance.getTextureColor().getHandle());
The OpenGL memory model requires the insertion of explicit memory barriers to make sure that a compute shader can access correct data in a texture after it was written by a render pass. More details regarding the OpenGL memory model can be found at https://www.khronos.org/opengl/wiki/Memory_Model:
glMemoryBarrier( GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
progAdaptation.useProgram();
There are two ways to bind our textures to compute shader-image units. Either way is possible but in the first case, all the access modes will be automatically set to GL_READ_WRITE:
#if 0
const GLuint imageTextures[] = { luminances[0]->getHandle(), luminance1x1, luminances[1]->getHandle() };
glBindImageTextures(0, 3, imageTextures);
#else
glBindImageTexture(0, luminances[0]->getHandle(), 0, GL_TRUE, 0, GL_READ_ONLY, GL_RGBA16F);
glBindImageTexture(1, luminance1x1, 0, GL_TRUE, 0, GL_READ_ONLY, GL_RGBA16F);
glBindImageTexture(2, luminances[1]->getHandle(), 0, GL_TRUE, 0, GL_WRITE_ONLY, GL_RGBA16F);
#endif
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT);
The further C++ workflow remains intact, except for the site where we pass the average luminance texture into the final tone-mapping shader. Instead of using luminance1x1 directly, we should use one of the ping-pong luminance textures we created earlier:
glViewport(0, 0, width, height);
if (g_EnableHDR) {
progCombineHDR.useProgram();
glBindTextureUnit( 0, framebuffer.getTextureColor().getHandle());
glBindTextureUnit( 1, luminances[1]->getHandle());
glBindTextureUnit( 2, bloom2.getTextureColor().getHandle());
glDrawArrays(GL_TRIANGLES, 0, 6);
}
else {
...
}
... ImGui code skipped ...
std::swap(luminances[0], luminances[1]);
}
glDeleteTextures(1, &luminance1x1);
return 0;
}
Those are all the changes necessary for our previous C++ code. The addition to the GLSL part of our HDR pipeline is the light-adaptation compute shader. This is located in the file shaders/chapter08/GL03_Adaptation.comp folder and is described next:
#version 460 core
layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
layout(rgba16f, binding=0) uniform readonly image2D imgLuminancePrev;
layout(rgba16f, binding=1) uniform readonly image2D imgLuminanceCurr;
layout(rgba16f, binding=2) uniform writeonly image2D imgLuminanceAdapted;
layout(std140, binding = 0) uniform HDRParams {
float exposure;
float maxWhite;
float bloomStrength;
float adaptationSpeed;
};
void main() {
float lumPrev = imageLoad(imgLuminancePrev, ivec2(0, 0)).x;
float lumCurr = imageLoad(imgLuminanceCurr, ivec2(0, 0)).x;
float newAdaptation = lumPrev + (lumCurr-lumPrev) * (1.0 - pow(0.98, 30.0 * adaptationSpeed));
imageStore(imgLuminanceAdapted, ivec2(0, 0), vec4(vec3(newAdaptation), 1.0));
}
This technique enables a pleasing smooth light-adaptation effect when the scene luminance changes abruptly. Try running the Chapter8/GL04_HDR_Adaptation demo application and pointing the camera at bright areas in the sky and dark areas in the corners. The light-adaptation speed can be user-controlled from ImGui, as in the following screenshot:
Figure 8.9 – An HDR tone-mapped scene with light adaptation
Now, let's switch from OpenGL to Vulkan and learn how to deal with postprocessing effects with the more verbose API.
HDR rendering is a huge topic, and we barely scratched its surface in this chapter. If you want to learn more advanced state-of-the-art HDR lighting techniques, we recommend watching the Game Developers Conference (GDC) 2010 session Uncharted 2: HDR Lighting by John Hable: https://www.gdcvault.com/play/1012351/Uncharted-2-HDR.
In the previous recipes of this chapter, we learned about some popular image-based postprocessing effects and how to implement them in OpenGL. Now, we can focus on the Vulkan API to approach one solution to a similar problem.
In this recipe, we use the Renderer class from the Working with rendering passes recipe from the previous chapter, Chapter 7, Graphics Rendering Pipeline, to wrap a sequence of rendering operations in a single composite item. A helper class to perform fullscreen per-pixel image processing is also presented here.
Make sure you read the previous OpenGL-related recipes in this chapter as they focus on the algorithm flow, and from this point on, we will be focused on the Vulkan API details and how to wrap them in a manageable way.
Most of the effects in this chapter use a number of rendering passes to calculate the output image. The CompositeRenderer class is a collection of renderers acting as one renderer:
struct CompositeRenderer: public Renderer {
CompositeRenderer(VulkanRenderContext& c)
: Renderer(c) {}
void fillCommandBuffer(VkCommandBuffer cmdBuffer, size_t currentImage, VkFramebuffer fb1 = VK_NULL_HANDLE, VkRenderPass rp1 = VK_NULL_HANDLE) override
{
for (auto& r: renderers_) {
if (!r.enabled_) continue;
VkRenderPass rp = rp1;
VkFramebuffer fb = fb1;
if (r.renderer_.renderPass_.handle != VK_NULL_HANDLE)
rp = r.renderer_.renderPass_.handle;
if (r.renderer_.framebuffer_ != VK_NULL_HANDLE)
fb = r.renderer_.framebuffer_;
r.renderer_.fillCommandBuffer( cmdBuffer, currentImage, fb, rp);
}
}
void updateBuffers(size_t currentImage) override {
for (auto& r: renderers_)
r.renderer_.updateBuffers(currentImage);
}
protected:
std::vector<RenderItem> renderers_;
};
We now have a method to produce a sequence of postprocessing operations, but we have not yet defined a postprocessing operation itself. Let's approach this task step by step.
The postprocessing framework has a single operation as its building block: the fullscreen quadrangle rendered with a shader that takes some textures (possibly, other framebuffers) as an input:
struct OffscreenMeshRenderer: public BufferProcessor {
OffscreenMeshRenderer( VulkanRenderContext& ctx, VulkanBuffer uniformBuffer, const std::pair<BufferAttachment, BufferAttachment>& meshBuffer, const std::vector<TextureAttachment>& usedTextures, const std::vector<VulkanTexture>& outputs, const std::vector<const char*>& shaderFiles, bool firstPass = false)
: BufferProcessor(ctx, DescriptorSetInfo { .buffers = { uniformBufferAttachment(uniformBuffer, 0, 0, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT), meshBuffer.first, meshBuffer.second, }, .textures = usedTextures }, outputs, shaderFiles, meshBuffer.first.size, ctx.resources.addRenderPass(outputs, RenderPassCreateInfo { .clearColor_ = firstPass, .clearDepth_ = firstPass, .flags_ = (uint8_t)((firstPass ? eRenderPassBit_First : eRenderPassBit_OffscreenInternal) | eRenderPassBit_Offscreen) })) {}};
struct VulkanShaderProcessor: public Renderer {
VulkanShaderProcessor(VulkanRenderContext& ctx, const PipelineInfo& pInfo, const DescriptorSetInfo& dsInfo, const std::vector<const char*>& shaders, const std::vector<VulkanTexture>& outputs, uint32_t indexBufferSize = 6 * 4, RenderPass screenRenderPass = RenderPass())
: Renderer(ctx)
, indexBufferSize(indexBufferSize) {
descriptorSetLayout_ = ctx.resources.addDescriptorSetLayout(dsInfo);
descriptorSets_ = { ctx.resources.addDescriptorSet( ctx.resources.addDescriptorPool(dsInfo), descriptorSetLayout_) };
ctx.resources.updateDescriptorSet( descriptorSets_[0], dsInfo);
initPipeline(shaders, initRenderPass( pInfo, outputs, screenRenderPass, ctx.screenRenderPass_NoDepth));
}
void fillCommandBuffer(VkCommandBuffer cmdBuffer, size_t currentImage, VkFramebuffer fb = VK_NULL_HANDLE, VkRenderPass rp = VK_NULL_HANDLE) override
{
beginRenderPass((rp != VK_NULL_HANDLE) ? rp : renderPass_.handle, (fb != VK_NULL_HANDLE) ? fb : framebuffer_, cmdBuffer, 0);
vkCmdDraw(cmdBuffer, indexBufferSize / sizeof(uint32_t), 1, 0, 0);
vkCmdEndRenderPass(cmdBuffer);
}
private:
uint32_t indexBufferSize;
};
Let's check how to render a fullscreen quad using this approach:
struct QuadProcessor: public VulkanShaderProcessor { QuadProcessor(VulkanRenderContext& ctx, const DescriptorSetInfo& dsInfo, const std::vector<VulkanTexture>& outputs, const char* shaderFile):
VulkanShaderProcessor( ctx, ctx.pipelineParametersForOutputs(outputs), dsInfo, std::vector<const char*>{ "data/shaders/chapter08/quad.vert", shaderFile }, outputs, 6 * 4, outputs.empty() ? ctx.screenRenderPass : RenderPass())
{}
};
layout(location = 0) out vec2 texCoord;
int indices[6] = int[] ( 0, 1, 2, 0, 2, 3 );
vec2 positions[4] = vec2[]( vec2(1.0, -1.0), vec2(1.0, 1.0), vec2(-1.0, 1.0), vec2(-1.0, -1.0));
vec2 texcoords[4] = vec2[]( vec2(1.0, 1.0), vec2(1.0, 0.0), vec2(0.0, 0.0), vec2(0.0, 1.0));
void main() {
int idx = indices[gl_VertexIndex];
gl_Position = vec4(positions[idx], 0.0, 1.0);
texCoord = texcoords[idx];
}
A postprocessed image can be output to the screen using the QuadRenderer class from the previous chapter, Chapter 7, Graphics Rendering Pipeline, or the new class QuadProcessor described previously.
Earlier in this chapter, we learned how to implement SSAO in OpenGL and went through every detail of our code. This recipe shows how to implement this technique using the Vulkan API.
We will skip the GLSL shaders here because they are almost identical to those for OpenGL, and rather focus on the C++ parts of the code that deal with Vulkan.
Make sure you read the Implementing SSAO in OpenGL recipe to understand the data flow of our SSAO implementation and the previous recipe, Writing postprocessing effects in Vulkan, to have a good grasp of the basic classes we use in our Vulkan wrapper.
The source code for this demo can be found in Chapter08/VK02_SSAO.
The SSAOProcessor class wraps multiple QuadProcessor instances into a single streamlined reusable pipeline for SSAO rendering:
struct SSAOProcessor: public CompositeRenderer {
SSAOProcessor( VulkanRenderContext&ctx, VulkanTexture colorTex, VulkanTexture depthTex, VulkanTexture outputTex)
: CompositeRenderer(ctx)
, rotateTex(ctx.resources.loadTexture2D( "data/rot_texture.bmp"))
, SSAOTex(ctx.resources.addColorTexture( SSAOWidth, SSAOHeight))
, SSAOBlurXTex(ctx.resources.addColorTexture( SSAO_W, SSAO_H))
, SSAOBlurYTex(ctx.resources.addColorTexture( SSAO_W, SSAO_H))
, SSAO(ctx, { .textures = { fsTextureAttachment(depthTex), fsTextureAttachment(rotateTex) } }, { SSAOTex }, "data/shaders/chapter08/SSAO.frag")
, BlurX(ctx, { .textures = { fsTextureAttachment(SSAOTex) } }, { SSAOBlurXTex }, "data/shaders/chapter08/SSAOBlurX.frag")
, BlurY(ctx, { .textures = { fsTextureAttachment(SSAOBlurXTex) } }, { SSAOBlurYTex }, "data/shaders/chapter08/SSAOBlurY.frag")
, SSAOFinal(ctx, { .textures = { fsTextureAttachment(colorTex), fsTextureAttachment(SSAOBlurYTex) } }, { outputTex }, "data/shaders/chapter08/SSAOFinal.frag")
{
renderers_.emplace_back(SSAO, false);
renderers_.emplace_back(BlurX, false);
renderers_.emplace_back(BlurY, false);
renderers_.emplace_back(SSAOFinal, false);
}
VulkanTexture getSSAO() const { return SSAOTex; }
VulkanTexture getBlurX() const
{ return SSAOBlurXTex; }
VulkanTexture getBlurY() const
{ return SSAOBlurYTex; }
private:
VulkanTexture rotateTex;
VulkanTexture SSAOTex, SSAOBlurXTex, SSAOBlurYTex;
QuadProcessor SSAO, BlurX, BlurY, SSAOFinal;
};
Here is a snippet from the source code of Chapter08/VK02_SSAO/src/main.cpp that uses the SSAOProcessor class presented previously:
struct MyApp: public CameraApp {
MyApp()
: CameraApp(-80, -80)
, envMap(ctx_.resources.loadCubeMap(envMapFile))
, irrMap(ctx_.resources.loadCubeMap(irrMapFile))
, colorTex(ctx_.resources.addColorTexture())
, depthTex(ctx_.resources.addDepthTexture())
, finalTex(ctx_.resources.addColorTexture())
, sceneData(ctx_, "data/meshes/test.meshes", "data/meshes/test.scene", "data/meshes/test.materials", envMap, irrMap)
, multiRenderer(ctx_, sceneData, "data/shaders/chapter07/VK01.vert", "data/shaders/chapter07/VK01.frag", { colorTex, depthTex }
, ctx_.resources.addRenderPass({colorTex, depthTex}
, RenderPassCreateInfo { .clearColor_ = true, .clearDepth_ = true, .flags_ = eRenderPassBit_First | eRenderPassBit_Offscreen }))
, SSAO(ctx_, colorTex, depthTex, finalTex)
, quads(ctx_, { colorTex, depthTex, finalTex })
, imgui(ctx_)
{
onScreenRenderers_.emplace_back(multiRenderer);
onScreenRenderers_.emplace_back(SSAO);
onScreenRenderers_.emplace_back(quads, false);
onScreenRenderers_.emplace_back(imgui, false);
}
void draw3D() override {
const mat4 m1 = glm::rotate( mat4(1.f), glm::pi<float>(), vec3(1, 0, 0));
multiRenderer.setMatrices(getDefaultProjection(), camera.getViewMatrix(), m1);
quads.clear();
quads.quad(-1.0f, -1.0f, 1.0f, 1.0f, 2);
}
private:
VulkanTexture envMap, irrMap;
VulkanTexture colorTex, depthTex, finalTex;
VKSceneData sceneData;
MultiRenderer;
SSAOProcessor SSAO;
QuadRenderer quads;
GuiRenderer imgui;
};
We can now add the SSAO effect to different Vulkan demos just by moving the instance of SSAOProcessor. While this might be pretty neat for learning and demonstration, it is far from the most performant solution and can be difficult to synchronize in postprocessing pipelines that include tens of different effects. We will touch on this topic a bit more in Chapter 10, Advanced Rendering Techniques and Optimizations.
Just recently, in the previous recipe, we learned how to implement one OpenGL postprocessing effect in Vulkan, and here, we will learn how to implement HDR rendering and tone mapping using the Vulkan API.
Make sure you read the Implementing HDR rendering and tone mapping recipe to understand the HDR rendering pipeline, and the Writing postprocessing effects in Vulkan recipe to have a good grasp of the basic classes we use in our Vulkan postprocessors.
The complete source code for this recipe can be found in Chapter8/VK03_HDR.
We define an HDRPostprocessor class derived from QuadProcessor that performs a per-pixel tone-mapping operation on an input framebuffer. The tone-mapping parameter pointers can be tweaked by the application code in the UI window.
One C++-related subtlety must be addressed in the initialization of the HDRProstprocessor class:
struct HDRPostprocessor: public QuadProcessor {
HDRPostprocessor(VulkanRenderContext& ctx, VulkanTexture input, VulkanTexture output)
: QuadProcessor(ctx, DescriptorSetInfo { .buffers = { uniformBufferAttachment( bufferAllocator(ctx.resources, tonemapUniforms_, sizeof(UniformBuffer)), 0, 0, VK_SHADER_STAGE_FRAGMENT_BIT) },
.textures = { fsTextureAttachment(input) } }, { output }, "data/shaders/chapter08/VK_ToneMap.frag")
{}
void updateBuffers(size_t currentImage) override {
uploadBufferData( ctx_.vkDev, tonemapUniforms_.memory, 0, &ubo, sizeof(ubo));
}
inline float* getGammaPtr() { return &ubo.gamma; }
float* getExposurePtr() { return &ubo.exposure; }
private:
struct UniformBuffer {
float gamma;
float exposure;
} ubo;
VulkanBuffer tonemapUniforms_;
};
VulkanBuffer bufferAllocator( VulkanResources& resources, VulkanBuffer& buffer, VkDeviceSize size)
{
return (buffer = resources.addUniformBuffer(size));
}
The per-pixel processing is performed by the fragment shader. The descriptor-set layout from the constructor of HDRPostprocessor is repeated in the GLSL code.
The Chapter08/VK03_HDR sample application loads the exterior part of the Bistro scene, renders this scene into the HDRLuminance buffer, and finally uses the HDRPostprocessor class to compose the final image. A small UI is provided to modify the tone-mapping parameters:
const VkFormat LuminanceFormat = VK_FORMAT_R16G16B16A16_SFLOAT;
struct MyApp: public CameraApp {
MyApp(): CameraApp(-80, -80), HDRDepth(ctx_.resources.addDepthTexture()), HDRLuminance( ctx_.resources.addColorTexture(0, 0, LuminanceFormat)), hdrTex(ctx_.resources.addColorTexture()),
sceneData(ctx_, "data/meshes/test.meshes", "data/meshes/test.scene", "data/meshes/test.materials", ctx_.resources.loadCubeMap(envMapFile), ctx_.resources.loadCubeMap(irrMapFile)),
multiRenderer(ctx_, sceneData, "data/shaders/chapter07/VK01.vert", "data/shaders/chapter07/VK01.frag", { HDRLuminance, HDRDepth },
ctx_.resources.addRenderPass( { HDRLuminance, HDRDepth }, RenderPassCreateInfo { .clearColor_ = true, .clearDepth_ = true, .flags_ = eRenderPassBit_Offscreen }) ),
hdrPP(ctx_, HDRLuminance, { hdrTex }),
quads(ctx_, { hdrTex, HDRLuminance }),
imgui(ctx_)
{
onScreenRenderers_.emplace_back(multiRenderer);
onScreenRenderers_.emplace_back(hdrPP);
onScreenRenderers_.emplace_back(quads, false);
onScreenRenderers_.emplace_back(imgui, false);
quads.clear();
quads.quad(-1.0f, 0.0f, 1.0f, 1.0f, 0);
quads.quad(-1.0f, -1.0f, 1.0f, 0.0f, 1);
}
void drawUI() override {
ImGui::Begin("Settings", nullptr);
ImGui::SliderFloat("Gamma: ", hdrPP.getGammaPtr(), 0.1f, 10.0f);
ImGui::SliderFloat("Exposure: ", hdrPP.getExposurePtr(), 0., 10.);
ImGui::End();
}
void draw3D() override {
const mat4 p = getDefaultProjection();
const mat4 view =camera.getViewMatrix();
const mat4 m1 = glm::rotate( mat4(1.f), glm::pi<float>(), vec3(1, 0, 0));
multiRenderer.setMatrices(p, view, m1);
}
private:
VulkanTexture HDRDepth, HDRLuminance;
VulkanTexture hdrTex;
VKSceneData sceneData;
MultiRenderer;
HDRPostprocessor hdrPP;
GuiRenderer imgui;
QuadRenderer quads;
};
The application functionality is similar to that of the OpenGL one from the Implementing HDR rendering and tone mapping recipe. Implementing light adaptation in Vulkan is left as an exercise to our readers.
This Chapter8/VK03_HDR Vulkan demo uses an asynchronous texture-loading approach, as described in the Loading texture assets asynchronously recipe from Chapter 10, Advanced Rendering Techniques and Optimizations. If the calls to the asynchronous texture loader are removed, the demo just loads the textures upfront. As a side note, Vulkan allows the use of dedicated transfer queues for asynchronous uploading of the textures into the GPU, but this requires some more tweaking in the Vulkan device and queue-initialization code. We leave this as an exercise to our readers.