From af98b8751b9d78d23924f8fe12b5749220728098 Mon Sep 17 00:00:00 2001
From: Sergey Kosarevsky <sk@linderdaum.com>
Date: Mon, 18 Nov 2024 19:24:05 -0800
Subject: [PATCH] Added 'Chapter10'

---
 CMakeLists.txt                                |   9 +
 Chapter08/VKMesh08.h                          |  15 +-
 .../01_OffscreenRendering/CMakeLists.txt      |   9 +
 Chapter10/01_OffscreenRendering/src/main.cpp  | 190 +++++++
 Chapter10/01_OffscreenRendering/src/main.frag |  14 +
 Chapter10/01_OffscreenRendering/src/main.vert |  22 +
 Chapter10/02_ShadowMapping/CMakeLists.txt     |   9 +
 Chapter10/02_ShadowMapping/src/common.sp      |  25 +
 Chapter10/02_ShadowMapping/src/main.cpp       | 346 ++++++++++++
 Chapter10/02_ShadowMapping/src/main.frag      |  53 ++
 Chapter10/02_ShadowMapping/src/main.vert      |  20 +
 Chapter10/02_ShadowMapping/src/shadow.frag    |   4 +
 Chapter10/02_ShadowMapping/src/shadow.vert    |  10 +
 Chapter10/03_MSAA/CMakeLists.txt              |   9 +
 Chapter10/03_MSAA/src/main.cpp                | 122 +++++
 Chapter10/04_SSAO/CMakeLists.txt              |   9 +
 Chapter10/04_SSAO/src/SSAO.comp               |  70 +++
 Chapter10/04_SSAO/src/combine.frag            |  23 +
 Chapter10/04_SSAO/src/combine.vert            |   9 +
 Chapter10/04_SSAO/src/main.cpp                | 317 +++++++++++
 Chapter10/05_HDR/CMakeLists.txt               |   9 +
 Chapter10/05_HDR/src/Bloom.comp               |  68 +++
 Chapter10/05_HDR/src/BrightPass.comp          |  54 ++
 Chapter10/05_HDR/src/ToneMap.frag             | 113 ++++
 Chapter10/05_HDR/src/main.cpp                 | 445 ++++++++++++++++
 Chapter10/06_HDR_Adaptation/CMakeLists.txt    |   9 +
 .../06_HDR_Adaptation/src/Adaptation.comp     |  21 +
 Chapter10/06_HDR_Adaptation/src/main.cpp      | 495 ++++++++++++++++++
 Chapter10/Bistro.h                            |  78 +++
 Chapter10/Skybox.h                            |  48 ++
 deps/bootstrap.json                           |   2 +-
 31 files changed, 2619 insertions(+), 8 deletions(-)
 create mode 100644 Chapter10/01_OffscreenRendering/CMakeLists.txt
 create mode 100644 Chapter10/01_OffscreenRendering/src/main.cpp
 create mode 100644 Chapter10/01_OffscreenRendering/src/main.frag
 create mode 100644 Chapter10/01_OffscreenRendering/src/main.vert
 create mode 100644 Chapter10/02_ShadowMapping/CMakeLists.txt
 create mode 100644 Chapter10/02_ShadowMapping/src/common.sp
 create mode 100644 Chapter10/02_ShadowMapping/src/main.cpp
 create mode 100644 Chapter10/02_ShadowMapping/src/main.frag
 create mode 100644 Chapter10/02_ShadowMapping/src/main.vert
 create mode 100644 Chapter10/02_ShadowMapping/src/shadow.frag
 create mode 100644 Chapter10/02_ShadowMapping/src/shadow.vert
 create mode 100644 Chapter10/03_MSAA/CMakeLists.txt
 create mode 100644 Chapter10/03_MSAA/src/main.cpp
 create mode 100644 Chapter10/04_SSAO/CMakeLists.txt
 create mode 100644 Chapter10/04_SSAO/src/SSAO.comp
 create mode 100644 Chapter10/04_SSAO/src/combine.frag
 create mode 100644 Chapter10/04_SSAO/src/combine.vert
 create mode 100644 Chapter10/04_SSAO/src/main.cpp
 create mode 100644 Chapter10/05_HDR/CMakeLists.txt
 create mode 100644 Chapter10/05_HDR/src/Bloom.comp
 create mode 100644 Chapter10/05_HDR/src/BrightPass.comp
 create mode 100644 Chapter10/05_HDR/src/ToneMap.frag
 create mode 100644 Chapter10/05_HDR/src/main.cpp
 create mode 100644 Chapter10/06_HDR_Adaptation/CMakeLists.txt
 create mode 100644 Chapter10/06_HDR_Adaptation/src/Adaptation.comp
 create mode 100644 Chapter10/06_HDR_Adaptation/src/main.cpp
 create mode 100644 Chapter10/Bistro.h
 create mode 100644 Chapter10/Skybox.h

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9eab1b2..0c16808 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -168,3 +168,12 @@ add_subdirectory(Chapter09/01_AnimationPlayer)
 add_subdirectory(Chapter09/02_Skinning)
 add_subdirectory(Chapter09/03_Morphing)
 add_subdirectory(Chapter09/04_AnimationBlending)
+add_subdirectory(Chapter09/08_ImportLights)
+add_subdirectory(Chapter09/09_ImportCameras)
+
+add_subdirectory(Chapter10/01_OffscreenRendering)
+add_subdirectory(Chapter10/02_ShadowMapping)
+add_subdirectory(Chapter10/03_MSAA)
+add_subdirectory(Chapter10/04_SSAO)
+add_subdirectory(Chapter10/05_HDR)
+add_subdirectory(Chapter10/06_HDR_Adaptation)
diff --git a/Chapter08/VKMesh08.h b/Chapter08/VKMesh08.h
index be3ab2b..231f521 100644
--- a/Chapter08/VKMesh08.h
+++ b/Chapter08/VKMesh08.h
@@ -420,13 +420,14 @@ class VKMesh final
     frag_ = frag.valid() ? std::move(frag) : loadShaderModule(ctx, "Chapter08/02_SceneGraph/src/main.frag");
 
     pipeline_ = ctx->createRenderPipeline({
-        .vertexInput = meshData.streams,
-        .smVert      = vert_,
-        .smFrag      = frag_,
-        .color       = { { .format = colorFormat } },
-        .depthFormat = depthFormat,
-        .cullMode    = lvk::CullMode_None,
-        .samplesCount = numSamples,
+        .vertexInput      = meshData.streams,
+        .smVert           = vert_,
+        .smFrag           = frag_,
+        .color            = { { .format = colorFormat } },
+        .depthFormat      = depthFormat,
+        .cullMode         = lvk::CullMode_None,
+        .samplesCount     = numSamples,
+        .minSampleShading = numSamples > 1 ? 0.25f : 0.0f,
     });
 
     pipelineWireframe_ = ctx->createRenderPipeline({
diff --git a/Chapter10/01_OffscreenRendering/CMakeLists.txt b/Chapter10/01_OffscreenRendering/CMakeLists.txt
new file mode 100644
index 0000000..83287ae
--- /dev/null
+++ b/Chapter10/01_OffscreenRendering/CMakeLists.txt
@@ -0,0 +1,9 @@
+cmake_minimum_required(VERSION 3.16)
+
+project(Chapter10)
+
+include(../../CMake/CommonMacros.txt)
+
+SETUP_APP(Ch10_Sample01_OffscreenRendering "Chapter 10")
+
+target_link_libraries(Ch10_Sample01_OffscreenRendering PRIVATE SharedUtils)
diff --git a/Chapter10/01_OffscreenRendering/src/main.cpp b/Chapter10/01_OffscreenRendering/src/main.cpp
new file mode 100644
index 0000000..421cf92
--- /dev/null
+++ b/Chapter10/01_OffscreenRendering/src/main.cpp
@@ -0,0 +1,190 @@
+#include "shared/VulkanApp.h"
+
+#include "shared/Utils.h"
+
+#include <math.h>
+
+struct VertexData {
+  float pos[3];
+};
+
+const float t = (1.0f + sqrtf(5.0f)) / 2.0f;
+
+const VertexData vertices[] = {
+  {-1,  t,  0},
+  { 1,  t,  0},
+  {-1, -t,  0},
+  { 1, -t,  0},
+
+  { 0, -1,  t},
+  { 0,  1,  t},
+  { 0, -1, -t},
+  { 0,  1, -t},
+
+  { t,  0, -1},
+  { t,  0,  1},
+  {-t,  0, -1},
+  {-t,  0,  1},
+};
+
+const uint16_t indices[] = { 0, 11, 5, 0, 5, 1, 0, 1, 7, 0, 7, 10, 0, 10, 11, 1, 5, 9, 5, 11, 4,  11, 10, 2,  10, 7, 6, 7, 1, 8,
+                             3, 9,  4, 3, 4, 2, 3, 2, 6, 3, 6, 8,  3, 8,  9,  4, 9, 5, 2, 4,  11, 6,  2,  10, 8,  6, 7, 9, 8, 1 };
+
+int main()
+{
+  VulkanApp app({
+      .initialCameraPos    = vec3(0.0f, 3.0f, -4.5f),
+      .initialCameraTarget = vec3(0.0f, t, 0.0f),
+  });
+
+  std::unique_ptr<lvk::IContext> ctx(app.ctx_.get());
+
+  // 0. Vertices/indices
+  lvk::Holder<lvk::BufferHandle> bufferIndices  = ctx->createBuffer({
+       .usage     = lvk::BufferUsageBits_Index,
+       .storage   = lvk::StorageType_Device,
+       .size      = sizeof(indices),
+       .data      = indices,
+       .debugName = "Buffer: indices",
+  });
+  lvk::Holder<lvk::BufferHandle> bufferVertices = ctx->createBuffer({
+      .usage     = lvk::BufferUsageBits_Vertex,
+      .storage   = lvk::StorageType_Device,
+      .size      = sizeof(vertices),
+      .data      = vertices,
+      .debugName = "Buffer: vertices",
+  });
+
+  // 1. Shaders & pipeline
+  lvk::Holder<lvk::ShaderModuleHandle> vert = loadShaderModule(ctx, "Chapter10/01_OffscreenRendering/src/main.vert");
+  lvk::Holder<lvk::ShaderModuleHandle> frag = loadShaderModule(ctx, "Chapter10/01_OffscreenRendering/src/main.frag");
+
+  const lvk::VertexInput vdesc = {
+    .attributes    = { { .location = 0, .format = lvk::VertexFormat::Float3 } },
+    .inputBindings = { { .stride = sizeof(VertexData) } },
+  };
+
+  lvk::Holder<lvk::RenderPipelineHandle> pipeline = ctx->createRenderPipeline({
+      .vertexInput = vdesc,
+      .smVert      = vert,
+      .smFrag      = frag,
+      .color       = { { .format = ctx->getSwapchainFormat() } },
+      .depthFormat = app.getDepthFormat(),
+  });
+
+  // 2. Textures and texture views
+  constexpr uint8_t numMipLevels = lvk::calcNumMipLevels(512, 512);
+
+  lvk::Holder<lvk::TextureHandle> texture = ctx->createTexture({
+      .type         = lvk::TextureType_2D,
+      .format       = lvk::Format_RGBA_UN8,
+      .dimensions   = {512, 512},
+      .usage        = lvk::TextureUsageBits_Attachment | lvk::TextureUsageBits_Sampled,
+      .numMipLevels = numMipLevels,
+      .debugName    = "Texture",
+  });
+
+  lvk::Holder<lvk::TextureHandle> mipViews[numMipLevels];
+
+  for (uint32_t l = 0; l != numMipLevels; l++) {
+    mipViews[l] = ctx->createTextureView(texture, { .mipLevel = l });
+  }
+
+  const vec3 colors[10] = {
+    {1, 0, 0},
+    {0, 1, 0},
+    {0, 0, 1},
+
+    {1, 1, 0},
+    {0, 1, 1},
+    {1, 0, 1},
+
+    {1, 0, 0},
+    {0, 1, 0},
+    {0, 0, 1},
+
+    {0, 0, 0},
+  };
+  LVK_ASSERT(LVK_ARRAY_NUM_ELEMENTS(colors) == numMipLevels);
+  // generate custom mip-pyramid
+  lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
+  for (uint8_t i = 0; i != numMipLevels; i++) {
+    buf.cmdBeginRendering(lvk::RenderPass {
+        .color = {
+          {.loadOp = lvk::LoadOp_Clear, .level = i, .clearColor = {colors[i].r,colors[i].g, colors[i].b,1 }},
+        }
+      },
+        lvk::Framebuffer{ .color = { { .texture = texture }} });
+    buf.cmdEndRendering();
+  }
+  ctx->submit(buf);
+
+  float modelAngle = 0;
+  bool rotateModel = true;
+
+  app.run([&](uint32_t width, uint32_t height, float aspectRatio, float deltaSeconds) {
+    if (rotateModel)
+      modelAngle = fmodf(modelAngle - deltaSeconds, 2.0f * M_PI);
+
+    const mat4 view  = app.camera_.getViewMatrix();
+    const mat4 proj  = glm::perspective(glm::radians(60.0f), aspectRatio, 0.1f, 1000.0f);
+    const mat4 model = glm::rotate(glm::translate(mat4(1.0f), vec3(0, t, 0)), modelAngle, vec3(1.0f, 1.0f, 1.0f));
+
+    const lvk::Framebuffer framebuffer = {
+      .color        = { { .texture = ctx->getCurrentSwapchainTexture() } },
+      .depthStencil = { .texture = app.getDepthTexture() },
+    };
+
+    lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
+    buf.cmdBindVertexBuffer(0, bufferVertices);
+    buf.cmdBindIndexBuffer(bufferIndices, lvk::IndexFormat_UI16);
+    // 2. Render scene
+    buf.cmdBeginRendering(
+        lvk::RenderPass{
+            .color = { { .loadOp = lvk::LoadOp_Clear, .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
+            .depth = { .loadOp = lvk::LoadOp_Clear, .clearDepth = 1.0f }
+    },
+        framebuffer);
+    buf.cmdBindRenderPipeline(pipeline);
+    buf.cmdBindDepthState({ .compareOp = lvk::CompareOp_Less, .isDepthWriteEnabled = true });
+    {
+      buf.cmdPushDebugGroupLabel("Mesh", 0xff0000ff);
+      const struct PushConstants {
+        mat4 mvp;
+        uint32_t texture;
+      } pc = {
+        .mvp     = proj * view * model,
+        .texture = texture.index(),
+      };
+      buf.cmdPushConstants(pc);
+      buf.cmdDrawIndexed(LVK_ARRAY_NUM_ELEMENTS(indices));
+      buf.cmdPopDebugGroupLabel();
+    }
+    app.drawGrid(buf, proj, vec3(0, -0.01f, 0));
+    app.imgui_->beginFrame(framebuffer);
+    app.drawFPS();
+    app.drawMemo();
+
+    const ImGuiViewport* v = ImGui::GetMainViewport();
+    ImGui::SetNextWindowPos(ImVec2(10, 200));
+    ImGui::Begin("Control", nullptr, ImGuiWindowFlags_AlwaysAutoResize);
+    ImGui::Checkbox("Rotate model", &rotateModel);
+    ImGui::Separator();
+    ImGui::Text("Mip-pyramid 512x512");
+    const float windowWidth = v->WorkSize.x / 5;
+    for (uint32_t l = 0; l != LVK_ARRAY_NUM_ELEMENTS(mipViews); l++) {
+      ImGui::Image(mipViews[l].index(), ImVec2((int)windowWidth >> l, ((int)windowWidth >> l)));
+    }
+    ImGui::Separator();
+    ImGui::End();
+
+    app.imgui_->endFrame(buf);
+
+    buf.cmdEndRendering();
+    ctx->submit(buf, ctx->getCurrentSwapchainTexture());
+  });
+
+  ctx.release();
+
+  return 0;
+}
diff --git a/Chapter10/01_OffscreenRendering/src/main.frag b/Chapter10/01_OffscreenRendering/src/main.frag
new file mode 100644
index 0000000..f7351f9
--- /dev/null
+++ b/Chapter10/01_OffscreenRendering/src/main.frag
@@ -0,0 +1,14 @@
+//
+
+layout (location=0) in vec2 uv;
+
+layout (location=0) out vec4 out_FragColor;
+
+layout(push_constant) uniform PushConstants {
+  mat4 mvp;
+  uint texture;
+};
+
+void main() {
+  out_FragColor = textureBindless2D(texture, 0, uv);
+}
diff --git a/Chapter10/01_OffscreenRendering/src/main.vert b/Chapter10/01_OffscreenRendering/src/main.vert
new file mode 100644
index 0000000..b9fe7ba
--- /dev/null
+++ b/Chapter10/01_OffscreenRendering/src/main.vert
@@ -0,0 +1,22 @@
+//
+
+layout (location = 0) in  vec3 pos;
+layout (location = 0) out vec2 uv;
+
+layout(push_constant) uniform PushConstants {
+  mat4 mvp;
+};
+
+#define PI 3.1415926
+
+float atan2(float y, float x) {
+  return x == 0.0 ? sign(y) * PI/2 : atan(y, x);
+}
+
+void main() {
+  gl_Position = mvp * vec4(pos, 1.0);
+
+  float theta = atan2(pos.y, pos.x) / PI + 0.5;
+
+  uv = vec2(theta, pos.z);
+}
diff --git a/Chapter10/02_ShadowMapping/CMakeLists.txt b/Chapter10/02_ShadowMapping/CMakeLists.txt
new file mode 100644
index 0000000..8222ca1
--- /dev/null
+++ b/Chapter10/02_ShadowMapping/CMakeLists.txt
@@ -0,0 +1,9 @@
+cmake_minimum_required(VERSION 3.16)
+
+project(Chapter10)
+
+include(../../CMake/CommonMacros.txt)
+
+SETUP_APP(Ch10_Sample02_ShadowMapping "Chapter 10")
+
+target_link_libraries(Ch10_Sample02_ShadowMapping PRIVATE SharedUtils assimp bc7enc meshoptimizer)
diff --git a/Chapter10/02_ShadowMapping/src/common.sp b/Chapter10/02_ShadowMapping/src/common.sp
new file mode 100644
index 0000000..d0795f6
--- /dev/null
+++ b/Chapter10/02_ShadowMapping/src/common.sp
@@ -0,0 +1,25 @@
+//
+
+layout(std430, buffer_reference) readonly buffer PerFrameData {
+  mat4 view;
+  mat4 proj;
+  mat4 light;
+  vec4 lightAngles;
+  vec4 lightPos;
+  uint shadowTexture;
+  uint shadowSampler;
+  float depthBias;
+};
+
+layout(push_constant) uniform PushConstants {
+  mat4 model;
+  PerFrameData perFrame;
+  uint texture;
+} pc;
+
+struct PerVertex {
+  vec2 uv;
+  vec3 worldNormal;
+  vec3 worldPos;
+  vec4 shadowCoords;
+};
diff --git a/Chapter10/02_ShadowMapping/src/main.cpp b/Chapter10/02_ShadowMapping/src/main.cpp
new file mode 100644
index 0000000..18e9b94
--- /dev/null
+++ b/Chapter10/02_ShadowMapping/src/main.cpp
@@ -0,0 +1,346 @@
+#include "shared/VulkanApp.h"
+
+#include <assimp/cimport.h>
+#include <assimp/postprocess.h>
+#include <assimp/scene.h>
+
+#include "shared/LineCanvas.h"
+#include "shared/Utils.h"
+
+#include "shared/Scene/Scene.h"
+#include "shared/Scene/VtxData.h"
+
+#include "Chapter08/VKMesh08.h"
+
+#include <math.h>
+
+int main()
+{
+  VulkanApp app({
+      .initialCameraPos    = vec3(0.0f, 3.0f, -4.5f),
+      .initialCameraTarget = vec3(0.0f, 0.5f, 0.0f),
+  });
+
+  LineCanvas3D canvas3d;
+
+  std::unique_ptr<lvk::IContext> ctx(app.ctx_.get());
+
+  struct VertexData {
+    vec3 pos;
+    vec3 n;
+    vec2 tc;
+  };
+
+  // 0. Scene vertices/indices
+  std::vector<VertexData> vertices;
+  std::vector<uint32_t> indices;
+
+  // 1. Duck
+  {
+    const aiScene* scene = aiImportFile("data/rubber_duck/scene.gltf", aiProcess_Triangulate);
+
+    if (!scene || !scene->HasMeshes()) {
+      printf("Unable to load data/rubber_duck/scene.gltf\n");
+      exit(255);
+    }
+
+    const aiMesh* mesh = scene->mMeshes[0];
+    for (uint32_t i = 0; i != mesh->mNumVertices; i++) {
+      const aiVector3D v = mesh->mVertices[i];
+      const aiVector3D n = mesh->mNormals[i];
+      const aiVector3D t = mesh->mTextureCoords[0][i];
+      vertices.push_back({ .pos = vec3(v.x, v.y, v.z), .n = vec3(n.x, n.y, n.z), .tc = vec2(t.x, t.y) });
+    }
+    for (uint32_t i = 0; i != mesh->mNumFaces; i++) {
+      for (uint32_t j = 0; j != 3; j++)
+        indices.push_back(mesh->mFaces[i].mIndices[j]);
+    }
+    aiReleaseImport(scene);
+  }
+
+  const uint32_t duckNumIndices    = (uint32_t)indices.size();
+  const uint32_t planeVertexOffset = (uint32_t)vertices.size();
+
+  // 2. Plane
+  mergeVectors(indices, { 0, 1, 2, 2, 3, 0 });
+  mergeVectors(
+      vertices, {
+                    {vec3(-4, -4, 0), vec3(0, 0, 1), vec2(0, 0)},
+                    {vec3(-4, +4, 0), vec3(0, 0, 1), vec2(0, 1)},
+                    {vec3(+4, +4, 0), vec3(0, 0, 1), vec2(1, 1)},
+                    {vec3(+4, -4, 0), vec3(0, 0, 1), vec2(1, 0)},
+  });
+
+  lvk::Holder<lvk::BufferHandle> bufferIndices = ctx->createBuffer({
+      .usage     = lvk::BufferUsageBits_Index,
+      .storage   = lvk::StorageType_Device,
+      .size      = sizeof(uint32_t) * indices.size(),
+      .data      = indices.data(),
+      .debugName = "Buffer: indices",
+  });
+
+  lvk::Holder<lvk::BufferHandle> bufferVertices = ctx->createBuffer({
+      .usage     = lvk::BufferUsageBits_Vertex,
+      .storage   = lvk::StorageType_Device,
+      .size      = sizeof(VertexData) * vertices.size(),
+      .data      = vertices.data(),
+      .debugName = "Buffer: vertices",
+  });
+
+  // Textures
+  lvk::Holder<lvk::TextureHandle> duckTexture  = loadTexture(ctx, "data/rubber_duck/textures/Duck_baseColor.png");
+  lvk::Holder<lvk::TextureHandle> planeTexture = loadTexture(ctx, "data/wood.jpg");
+
+  struct PerFrameData {
+    mat4 view;
+    mat4 proj;
+    mat4 light;
+    vec4 lightAngles; // cos(inner), cos(outer)
+    vec4 lightPos;
+    uint32_t shadowTexture;
+    uint32_t shadowSampler;
+    float depthBias;
+  };
+  lvk::Holder<lvk::BufferHandle> bufferPerFrame = ctx->createBuffer({
+      .usage     = lvk::BufferUsageBits_Uniform,
+      .storage   = lvk::StorageType_Device,
+      .size      = sizeof(PerFrameData),
+      .debugName = "Buffer: per-frame",
+  });
+
+  lvk::Holder<lvk::TextureHandle> shadowMap = ctx->createTexture({
+      .type       = lvk::TextureType_2D,
+      .format     = lvk::Format_Z_UN16,
+      .dimensions = {1024, 1024},
+      .usage      = lvk::TextureUsageBits_Attachment | lvk::TextureUsageBits_Sampled,
+      .debugName  = "Shadow map",
+  });
+
+  lvk::Holder<lvk::SamplerHandle> samplerShadow = ctx->createSampler({
+      .wrapU               = lvk::SamplerWrap_Clamp,
+      .wrapV               = lvk::SamplerWrap_Clamp,
+      .depthCompareOp      = lvk::CompareOp_LessEqual,
+      .depthCompareEnabled = true,
+      .debugName           = "Sampler: shadow",
+  });
+
+  lvk::Holder<lvk::ShaderModuleHandle> vert       = loadShaderModule(ctx, "Chapter10/02_ShadowMapping/src/main.vert");
+  lvk::Holder<lvk::ShaderModuleHandle> frag       = loadShaderModule(ctx, "Chapter10/02_ShadowMapping/src/main.frag");
+  lvk::Holder<lvk::ShaderModuleHandle> vertShadow = loadShaderModule(ctx, "Chapter10/02_ShadowMapping/src/shadow.vert");
+  lvk::Holder<lvk::ShaderModuleHandle> fragShadow = loadShaderModule(ctx, "Chapter10/02_ShadowMapping/src/shadow.frag");
+
+  const lvk::VertexInput vdesc = {
+      .attributes    = {{ .location = 0, .format = lvk::VertexFormat::Float3, .offset = offsetof(VertexData, pos) },
+                        { .location = 1, .format = lvk::VertexFormat::Float3, .offset = offsetof(VertexData, n) },
+                        { .location = 2, .format = lvk::VertexFormat::Float2, .offset = offsetof(VertexData, tc) }, },
+      .inputBindings = { { .stride = sizeof(VertexData) } },
+    };
+
+  lvk::Holder<lvk::RenderPipelineHandle> pipeline = ctx->createRenderPipeline({
+      .vertexInput = vdesc,
+      .smVert      = vert,
+      .smFrag      = frag,
+      .color       = { { .format = ctx->getSwapchainFormat() } },
+      .depthFormat = app.getDepthFormat(),
+  });
+
+  lvk::Holder<lvk::RenderPipelineHandle> pipelineShadow = ctx->createRenderPipeline({
+      .vertexInput =
+          lvk::VertexInput{
+                           .attributes    = { { .location = 0, .format = lvk::VertexFormat::Float3, .offset = offsetof(VertexData, pos) } },
+                           .inputBindings = { { .stride = sizeof(VertexData) } },
+                           },
+      .smVert      = vertShadow,
+      .smFrag      = fragShadow,
+      .depthFormat = ctx->getFormat(shadowMap),
+  });
+
+  float g_LightFOV        = 45.0f;
+  float g_LightInnerAngle = 10.0f;
+  float g_LightNear       = 0.8f;
+  float g_LightFar        = 8.0f;
+
+  float g_LightDist   = 4.0f;
+  float g_LightXAngle = 240.0f;
+  float g_LightYAngle = 0.0f;
+
+  float g_LightDepthBias = -0.005f;
+
+  bool g_RotateModel = true;
+  bool g_RotateLight = true;
+  bool g_DrawFrustum = true;
+
+  float g_ModelAngle = 0;
+  float g_LightAngle = 0;
+
+  const char* comboBoxItems[]     = { "First person", "Light source" };
+  const char* cameraType          = comboBoxItems[0];
+  const char* currentComboBoxItem = cameraType;
+
+  app.run([&](uint32_t width, uint32_t height, float aspectRatio, float deltaSeconds) {
+    if (g_RotateModel)
+      g_ModelAngle = fmodf(g_ModelAngle - 50.0f * deltaSeconds, 360.0f);
+    if (g_RotateLight)
+      g_LightYAngle = fmodf(g_LightYAngle + 50.0f * deltaSeconds, 360.0f);
+
+    // 0. Calculate light and camera parameters
+    const mat4 rotY     = glm::rotate(mat4(1.f), glm::radians(g_LightYAngle), vec3(0, 1, 0));
+    const mat4 rotX     = glm::rotate(rotY, glm::radians(g_LightXAngle), vec3(1, 0, 0));
+    const vec4 lightPos = rotX * vec4(0, 0, g_LightDist, 1.0f);
+
+    const mat4 lightProj = glm::perspective(glm::radians(g_LightFOV), 1.0f, g_LightNear, g_LightFar);
+    const mat4 lightView = glm::lookAt(vec3(lightPos), vec3(0), vec3(0, 1, 0));
+
+    const bool showLightCamera = cameraType == comboBoxItems[1];
+
+    const mat4 view = showLightCamera ? lightView : app.camera_.getViewMatrix();
+    const mat4 proj = showLightCamera ? glm::perspective(glm::radians(g_LightFOV), aspectRatio, g_LightNear, g_LightFar)
+                                      : glm::perspective(glm::radians(60.0f), aspectRatio, 0.1f, 1000.0f);
+    const mat4 m1   = glm::rotate(mat4(1.0f), glm::radians(-90.0f), vec3(1, 0, 0));
+    const mat4 m2   = glm::rotate(mat4(1.0f), glm::radians(g_ModelAngle), vec3(0.0f, 1.0f, 0.0f));
+
+    const lvk::Framebuffer framebuffer = {
+      .color        = { { .texture = ctx->getCurrentSwapchainTexture() } },
+      .depthStencil = { .texture = app.getDepthTexture() },
+    };
+
+    lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
+    buf.cmdBindVertexBuffer(0, bufferVertices);
+    buf.cmdBindIndexBuffer(bufferIndices, lvk::IndexFormat_UI32);
+    struct PushConstants {
+      mat4 model;
+      uint64_t perFrameBuffer;
+      uint32_t texture;
+    };
+    // 1. Render shadow map
+    buf.cmdUpdateBuffer(
+        bufferPerFrame, PerFrameData{
+                            .view = lightView,
+                            .proj = lightProj,
+                        });
+    buf.cmdBeginRendering(
+        lvk::RenderPass{
+            .depth = {.loadOp = lvk::LoadOp_Clear, .clearDepth = 1.0f}
+    },
+        lvk::Framebuffer{ .depthStencil = { .texture = shadowMap } });
+
+    buf.cmdBindRenderPipeline(pipelineShadow);
+    buf.cmdPushConstants(PushConstants{
+        .model          = m2 * m1,
+        .perFrameBuffer = ctx->gpuAddress(bufferPerFrame),
+    });
+    buf.cmdBindDepthState({ .compareOp = lvk::CompareOp_Less, .isDepthWriteEnabled = true });
+    buf.cmdDrawIndexed(duckNumIndices);
+    buf.cmdEndRendering();
+    // 2. Render scene
+    const mat4 scaleBias = 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, 1.0);
+    buf.cmdUpdateBuffer(
+        bufferPerFrame,
+        PerFrameData{
+            .view  = view,
+            .proj  = proj,
+            .light = scaleBias * lightProj * lightView,
+            .lightAngles =
+                vec4(cosf(glm::radians(0.5f * g_LightFOV)), cosf(glm::radians(0.5f * (g_LightFOV - g_LightInnerAngle))), 1.0f, 1.0f),
+            .lightPos      = lightPos,
+            .shadowTexture = shadowMap.index(),
+            .shadowSampler = samplerShadow.index(),
+            .depthBias     = g_LightDepthBias,
+        });
+
+    buf.cmdBeginRendering(
+        lvk::RenderPass{
+            .color = { { .loadOp = lvk::LoadOp_Clear, .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
+            .depth = { .loadOp = lvk::LoadOp_Clear, .clearDepth = 1.0f }
+    },
+        framebuffer, { .textures = { { lvk::TextureHandle(shadowMap) } } });
+    buf.cmdBindRenderPipeline(pipeline);
+    buf.cmdBindDepthState({ .compareOp = lvk::CompareOp_Less, .isDepthWriteEnabled = true });
+    {
+      buf.cmdPushDebugGroupLabel("Mesh", 0xff0000ff);
+      buf.cmdPushConstants(PushConstants{
+          .model          = m2 * m1,
+          .perFrameBuffer = ctx->gpuAddress(bufferPerFrame),
+          .texture        = duckTexture.index(),
+      });
+      buf.cmdDrawIndexed(duckNumIndices);
+      buf.cmdPopDebugGroupLabel();
+    }
+    {
+      buf.cmdPushDebugGroupLabel("Plane", 0xff0000ff);
+      buf.cmdPushConstants(PushConstants{
+          .model          = m1,
+          .perFrameBuffer = ctx->gpuAddress(bufferPerFrame),
+          .texture        = planeTexture.index(),
+      });
+      buf.cmdDrawIndexed(6, 1, 0, planeVertexOffset);
+      buf.cmdPopDebugGroupLabel();
+    }
+    if (!showLightCamera)
+      app.drawGrid(buf, proj, vec3(0, -0.01f, 0));
+    app.imgui_->beginFrame(framebuffer);
+    app.drawFPS();
+    app.drawMemo();
+
+    const ImGuiViewport* v = ImGui::GetMainViewport();
+    ImGui::SetNextWindowPos(ImVec2(10, 200));
+    ImGui::Begin("Control", nullptr, ImGuiWindowFlags_AlwaysAutoResize);
+    ImGui::Checkbox("Rotate model", &g_RotateModel);
+    ImGui::Separator();
+    ImGui::Text("Light parameters", nullptr);
+    const float indentSize = 16.0f;
+    ImGui::Indent(indentSize);
+    ImGui::Checkbox("Rotate light", &g_RotateLight);
+    ImGui::Checkbox("Draw light frustum", &g_DrawFrustum);
+    ImGui::SliderFloat("Depth bias", &g_LightDepthBias, -0.01f, 0.0f);
+    ImGui::SliderFloat("Proj::Light FOV", &g_LightFOV, 15.0f, 120.0f);
+    ImGui::SliderFloat("Proj::Light inner angle", &g_LightInnerAngle, 1.0f, 15.0f);
+    ImGui::SliderFloat("Proj::Near", &g_LightNear, 0.1f, 3.0f);
+    ImGui::SliderFloat("Proj::Far", &g_LightFar, 1.0f, 20.0f);
+    ImGui::SliderFloat("Pos::Dist", &g_LightDist, 1.0f, 10.0f);
+    ImGui::SliderFloat("Pos::AngleX", &g_LightXAngle, 0, 360.0f);
+    ImGui::BeginDisabled(g_RotateLight);
+    ImGui::SliderFloat("Pos::AngleY", &g_LightYAngle, 0, 360.0f);
+    ImGui::EndDisabled();
+    ImGui::Unindent(indentSize);
+    ImGui::Separator();
+    ImGui::Image(shadowMap.index(), ImVec2(512, 512));
+    ImGui::Separator();
+    // camera controls
+    {
+      if (ImGui::BeginCombo("Camera", currentComboBoxItem)) // the second parameter is the label previewed before opening the combo.
+      {
+        for (int n = 0; n < IM_ARRAYSIZE(comboBoxItems); n++) {
+          const bool isSelected = (currentComboBoxItem == comboBoxItems[n]);
+          if (ImGui::Selectable(comboBoxItems[n], isSelected))
+            currentComboBoxItem = comboBoxItems[n];
+          if (isSelected)
+            ImGui::SetItemDefaultFocus(); // initial focus when opening the combo (scrolling + for keyboard navigation support)
+        }
+        ImGui::EndCombo();
+      }
+      if (currentComboBoxItem && strcmp(currentComboBoxItem, cameraType)) {
+        printf("Selected new camera type: %s\n", currentComboBoxItem);
+        cameraType = currentComboBoxItem;
+      }
+    }
+
+    ImGui::End();
+
+    if (!showLightCamera && g_DrawFrustum) {
+      canvas3d.clear();
+      canvas3d.setMatrix(proj * view);
+      canvas3d.frustum(lightView, lightProj, vec4(1, 0, 0, 1));
+      canvas3d.render(*ctx.get(), framebuffer, buf);
+    }
+
+    app.imgui_->endFrame(buf);
+
+    buf.cmdEndRendering();
+    ctx->submit(buf, ctx->getCurrentSwapchainTexture());
+  });
+
+  ctx.release();
+
+  return 0;
+}
diff --git a/Chapter10/02_ShadowMapping/src/main.frag b/Chapter10/02_ShadowMapping/src/main.frag
new file mode 100644
index 0000000..e7425c5
--- /dev/null
+++ b/Chapter10/02_ShadowMapping/src/main.frag
@@ -0,0 +1,53 @@
+//
+
+#include <Chapter10/02_ShadowMapping/src/common.sp>
+
+layout (location=0) in PerVertex vtx;
+
+layout (location=0) out vec4 out_FragColor;
+
+float PCF3(vec3 uvw) {
+  float size = 1.0 / textureBindlessSize2D(pc.perFrame.shadowTexture).x;
+  float shadow = 0.0;
+  for (int v=-1; v<=+1; v++)
+    for (int u=-1; u<=+1; u++)
+      shadow += textureBindless2DShadow(pc.perFrame.shadowTexture, pc.perFrame.shadowSampler, uvw + size * vec3(u, v, 0));
+  return shadow / 9;
+}
+
+float shadow(vec4 s) {
+  s = s / s.w;
+  if (s.z > -1.0 && s.z < 1.0) {
+    float shadowSample = PCF3(vec3(s.x, 1.0 - s.y, s.z + pc.perFrame.depthBias));
+    return mix(0.3, 1.0, shadowSample);
+  }
+  return 1.0;
+}
+
+float spotLightFactor(vec3 worldPos)
+{
+  vec3 dirLight = normalize(worldPos - pc.perFrame.lightPos.xyz);
+  vec3 dirSpot  = normalize(-pc.perFrame.lightPos.xyz); // light is always looking at (0, 0, 0)
+
+  float rho = dot(dirLight, dirSpot);
+
+  float outerAngle = pc.perFrame.lightAngles.x;
+  float innerAngle = pc.perFrame.lightAngles.y;
+
+  if (rho > outerAngle)
+    return smoothstep(outerAngle, innerAngle, rho);
+
+  return 0.0;
+}
+
+void main() {
+  vec3 n = normalize(vtx.worldNormal);
+  vec3 l = normalize(pc.perFrame.lightPos.xyz);
+
+  float NdotL = clamp(dot(n, l), 0.1, 1.0);
+
+  float Ka = 0.1;
+  float Kd = NdotL * shadow(vtx.shadowCoords) * spotLightFactor(vtx.worldPos);
+
+  out_FragColor = textureBindless2D(pc.texture, 0, vtx.uv) * clamp(Ka + Kd, 0.3, 1.0);
+}
diff --git a/Chapter10/02_ShadowMapping/src/main.vert b/Chapter10/02_ShadowMapping/src/main.vert
new file mode 100644
index 0000000..b98ea1c
--- /dev/null
+++ b/Chapter10/02_ShadowMapping/src/main.vert
@@ -0,0 +1,20 @@
+//
+
+#include <Chapter10/02_ShadowMapping/src/common.sp>
+
+layout (location = 0) in vec3 pos;
+layout (location = 1) in vec3 normal;
+layout (location = 2) in vec2 uv;
+
+layout (location=0) out PerVertex vtx;
+
+void main() {
+  gl_Position = pc.perFrame.proj * pc.perFrame.view * pc.model * vec4(pos, 1.0);
+
+  mat3 normalMatrix = transpose( inverse(mat3(pc.model)) );
+
+  vtx.uv = uv;
+  vtx.worldNormal = normalMatrix * normal;
+  vtx.worldPos = (pc.model * vec4(pos, 1.0)).xyz;
+  vtx.shadowCoords = pc.perFrame.light * pc.model * vec4(pos, 1.0);
+}
diff --git a/Chapter10/02_ShadowMapping/src/shadow.frag b/Chapter10/02_ShadowMapping/src/shadow.frag
new file mode 100644
index 0000000..574edb7
--- /dev/null
+++ b/Chapter10/02_ShadowMapping/src/shadow.frag
@@ -0,0 +1,4 @@
+//
+
+void main() {
+}
diff --git a/Chapter10/02_ShadowMapping/src/shadow.vert b/Chapter10/02_ShadowMapping/src/shadow.vert
new file mode 100644
index 0000000..80824f9
--- /dev/null
+++ b/Chapter10/02_ShadowMapping/src/shadow.vert
@@ -0,0 +1,10 @@
+//
+
+#include <Chapter10/02_ShadowMapping/src/common.sp>
+
+layout (location = 0) in vec3 pos;
+
+void main() {
+  gl_Position = pc.perFrame.proj * pc.perFrame.view * pc.model * vec4(pos, 1.0);
+}
+
diff --git a/Chapter10/03_MSAA/CMakeLists.txt b/Chapter10/03_MSAA/CMakeLists.txt
new file mode 100644
index 0000000..e1196ba
--- /dev/null
+++ b/Chapter10/03_MSAA/CMakeLists.txt
@@ -0,0 +1,9 @@
+cmake_minimum_required(VERSION 3.16)
+
+project(Chapter10)
+
+include(../../CMake/CommonMacros.txt)
+
+SETUP_APP(Ch10_Sample03_MSAA "Chapter 10")
+
+target_link_libraries(Ch10_Sample03_MSAA PRIVATE SharedUtils assimp bc7enc meshoptimizer)
diff --git a/Chapter10/03_MSAA/src/main.cpp b/Chapter10/03_MSAA/src/main.cpp
new file mode 100644
index 0000000..a80c8de
--- /dev/null
+++ b/Chapter10/03_MSAA/src/main.cpp
@@ -0,0 +1,122 @@
+#include "shared/VulkanApp.h"
+
+#include "Chapter10/Bistro.h"
+
+int main()
+{
+  MeshData meshData;
+  Scene scene;
+  loadBistro(meshData, scene);
+
+  VulkanApp app({
+      .initialCameraPos    = vec3(-19.261f, 8.465f, -7.317f),
+      .initialCameraTarget = vec3(0, +2.5f, 0),
+  });
+
+  app.positioner_.maxSpeed_ = 1.5f;
+
+  std::unique_ptr<lvk::IContext> ctx(app.ctx_.get());
+
+  lvk::Holder<lvk::TextureHandle> texSkyboxIrradiance = loadTexture(ctx, "data/immenstadter_horn_2k_irradiance.ktx", lvk::TextureType_Cube);
+
+  const uint32_t kNumSamples = 8;
+
+  const lvk::Dimensions sizeFb = ctx->getDimensions(ctx->getCurrentSwapchainTexture());
+
+  lvk::Holder<lvk::TextureHandle> msaaColor = ctx->createTexture({
+      .format     = ctx->getSwapchainFormat(),
+      .dimensions = sizeFb,
+      .numSamples = kNumSamples,
+      .usage      = lvk::TextureUsageBits_Attachment,
+      .debugName  = "msaaColor",
+  });
+  lvk::Holder<lvk::TextureHandle> msaaDepth = ctx->createTexture({
+      .format     = app.getDepthFormat(),
+      .dimensions = sizeFb,
+      .numSamples = kNumSamples,
+      .usage      = lvk::TextureUsageBits_Attachment,
+      .debugName  = "msaaDepth",
+  });
+
+  bool enableMSAA        = true;
+  bool drawWireframe     = false;
+  bool drawBoundingBoxes = false;
+
+  const VKMesh mesh(ctx, meshData, scene, ctx->getSwapchainFormat(), app.getDepthFormat());
+  const VKMesh meshMSAA(ctx, meshData, scene, ctx->getSwapchainFormat(), app.getDepthFormat(), kNumSamples);
+
+  LineCanvas3D canvas3d;
+
+  app.run([&](uint32_t width, uint32_t height, float aspectRatio, float deltaSeconds) {
+    const mat4 view = app.camera_.getViewMatrix();
+    const mat4 proj = glm::perspective(45.0f, aspectRatio, 0.01f, 1000.0f);
+
+    lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
+    {
+      const lvk::Framebuffer framebufferOffscreen = {
+        .color        = { { .texture        = enableMSAA ? msaaColor : ctx->getCurrentSwapchainTexture(),
+                            .resolveTexture = enableMSAA ? ctx->getCurrentSwapchainTexture() : lvk::TextureHandle{} } },
+        .depthStencil = { .texture = enableMSAA ? msaaDepth : app.getDepthTexture() },
+      };
+      // 1. Render scene
+      buf.cmdBeginRendering(
+          lvk::RenderPass{
+              .color = { { .loadOp     = lvk::LoadOp_Clear,
+                           .storeOp    = enableMSAA ? lvk::StoreOp_MsaaResolve : lvk::StoreOp_Store,
+                           .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
+              .depth = { .loadOp = lvk::LoadOp_Clear, .clearDepth = 1.0f }
+      },
+          framebufferOffscreen);
+      buf.cmdPushDebugGroupLabel("Mesh", 0xff0000ff);
+      (enableMSAA ? meshMSAA : mesh).draw(*ctx.get(), buf, view, proj, texSkyboxIrradiance, drawWireframe);
+      buf.cmdPopDebugGroupLabel();
+      app.drawGrid(buf, proj, vec3(0, -1.0f, 0), enableMSAA ? kNumSamples : 1);
+      canvas3d.render(*ctx.get(), framebufferOffscreen, buf, enableMSAA ? kNumSamples : 1);
+      buf.cmdEndRendering();
+
+      // 2. Render UI
+      const lvk::Framebuffer framebufferMain = {
+        .color = { { .texture = ctx->getCurrentSwapchainTexture() } },
+      };
+      buf.cmdBeginRendering(
+          lvk::RenderPass{
+              .color = { { .loadOp = lvk::LoadOp_Load, .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
+          },
+          framebufferMain);
+
+      app.imgui_->beginFrame(framebufferMain);
+      app.drawFPS();
+      app.drawMemo();
+
+      canvas3d.clear();
+      canvas3d.setMatrix(proj * view);
+      // render all bounding boxes (red)
+      if (drawBoundingBoxes) {
+        for (auto& p : scene.meshForNode) {
+          const BoundingBox box = meshData.boxes[p.second];
+          canvas3d.box(scene.globalTransform[p.first], box, vec4(1, 0, 0, 1));
+        }
+      }
+
+      {
+        const ImGuiViewport* v = ImGui::GetMainViewport();
+        ImGui::SetNextWindowPos(ImVec2(10, 200));
+        ImGui::Begin(
+            "MSAA", nullptr, ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize);
+        ImGui::Checkbox("Enable MSAA", &enableMSAA);
+        ImGui::Checkbox("Draw wireframe", &drawWireframe);
+        ImGui::Checkbox("Draw bounding boxes", &drawBoundingBoxes);
+        ImGui::End();
+      }
+
+      app.imgui_->endFrame(buf);
+
+      buf.cmdEndRendering();
+    }
+    ctx->submit(buf, ctx->getCurrentSwapchainTexture());
+  });
+
+  ctx.release();
+
+  return 0;
+}
diff --git a/Chapter10/04_SSAO/CMakeLists.txt b/Chapter10/04_SSAO/CMakeLists.txt
new file mode 100644
index 0000000..547048e
--- /dev/null
+++ b/Chapter10/04_SSAO/CMakeLists.txt
@@ -0,0 +1,9 @@
+cmake_minimum_required(VERSION 3.16)
+
+project(Chapter10)
+
+include(../../CMake/CommonMacros.txt)
+
+SETUP_APP(Ch10_Sample04_SSAO "Chapter 10")
+
+target_link_libraries(Ch10_Sample04_SSAO PRIVATE SharedUtils assimp bc7enc meshoptimizer)
diff --git a/Chapter10/04_SSAO/src/SSAO.comp b/Chapter10/04_SSAO/src/SSAO.comp
new file mode 100644
index 0000000..793959d
--- /dev/null
+++ b/Chapter10/04_SSAO/src/SSAO.comp
@@ -0,0 +1,70 @@
+//
+layout (local_size_x = 16, local_size_y = 16) in;
+
+layout (set = 0, binding = 0) uniform texture2D kTextures2D[];
+layout (set = 0, binding = 1) uniform sampler   kSamplers[];
+
+layout (set = 0, binding = 2, rgba8) uniform writeonly image2D kTextures2DOut[];
+
+layout(push_constant) uniform PushConstants {
+  uint texDepth;
+  uint texRotation;
+  uint texOut;
+  uint smpl;
+  float zNear;
+  float zFar;
+  float radius;
+  float attScale;
+  float distScale;
+} pc;
+
+ivec2 textureBindlessSize2D(uint textureid) {
+  return textureSize(nonuniformEXT(kTextures2D[textureid]), 0);
+}
+
+vec4 textureBindless2D(uint textureid, vec2 uv) {
+  return textureLod(nonuniformEXT(sampler2D(kTextures2D[textureid], kSamplers[pc.smpl])), uv, 0);
+}
+
+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)
+);
+
+float scaleZ(float smpl) {
+  return (pc.zFar * pc.zNear) / (smpl * (pc.zFar-pc.zNear) - pc.zFar);
+}
+
+void main() {
+  const vec2 size = textureBindlessSize2D(pc.texDepth).xy;
+
+  const vec2 xy   = gl_GlobalInvocationID.xy;
+  const vec2 uv   = (gl_GlobalInvocationID.xy + vec2(0.5)) / size;
+
+  if (xy.x > size.x || xy.y > size.y)
+    return;
+    
+  const float Z     = scaleZ( textureBindless2D(pc.texDepth, uv).x );
+  const vec3  plane = textureBindless2D(pc.texRotation, xy / 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 = scaleZ( textureBindless2D( pc.texDepth, uv + pc.radius*rSample.xy / Z ).x );
+    float dist    = max(zSample - Z, 0.0) / pc.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 + 0.45, 0.0, 1.0) * pc.attScale;
+
+  imageStore(kTextures2DOut[pc.texOut], ivec2(xy), vec4( vec3(att), 1.0 ) );
+}
diff --git a/Chapter10/04_SSAO/src/combine.frag b/Chapter10/04_SSAO/src/combine.frag
new file mode 100644
index 0000000..45607f9
--- /dev/null
+++ b/Chapter10/04_SSAO/src/combine.frag
@@ -0,0 +1,23 @@
+//
+
+layout (location=0) in vec2 uv;
+
+layout (location=0) out vec4 out_FragColor;
+
+layout(push_constant) uniform PushConstants {
+  uint texColor;
+  uint texSSAO;
+  uint smpl;
+  float scale;
+  float bias;
+} pc;
+
+void main() {
+  vec4  color = textureBindless2D(pc.texColor, pc.smpl, uv);
+  float ssao  = clamp( textureBindless2D(pc.texSSAO,  pc.smpl, uv).x + pc.bias, 0.0, 1.0 );
+
+  out_FragColor = vec4(
+    mix(color, color * ssao, pc.scale).rgb,
+    1.0
+  );
+}
diff --git a/Chapter10/04_SSAO/src/combine.vert b/Chapter10/04_SSAO/src/combine.vert
new file mode 100644
index 0000000..4d00cbb
--- /dev/null
+++ b/Chapter10/04_SSAO/src/combine.vert
@@ -0,0 +1,9 @@
+//
+
+layout (location=0) out vec2 uv;
+
+void main() {
+  // generate a triangle covering the entire screen
+  uv = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
+  gl_Position = vec4(uv * vec2(2, -2) + vec2(-1, 1), 0.0, 1.0);
+}
diff --git a/Chapter10/04_SSAO/src/main.cpp b/Chapter10/04_SSAO/src/main.cpp
new file mode 100644
index 0000000..584cc8d
--- /dev/null
+++ b/Chapter10/04_SSAO/src/main.cpp
@@ -0,0 +1,317 @@
+#include "shared/VulkanApp.h"
+
+#include "Chapter10/Bistro.h"
+#include "Chapter10/Skybox.h"
+
+int main()
+{
+  MeshData meshData;
+  Scene scene;
+  loadBistro(meshData, scene);
+
+  VulkanApp app({
+      .initialCameraPos    = vec3(-19.261f, 8.465f, -7.317f),
+      .initialCameraTarget = vec3(0, +2.5f, 0),
+  });
+
+  app.positioner_.maxSpeed_ = 1.5f;
+
+  std::unique_ptr<lvk::IContext> ctx(app.ctx_.get());
+
+  lvk::Holder<lvk::ShaderModuleHandle> compSSAO        = loadShaderModule(ctx, "Chapter10/04_SSAO/src/SSAO.comp");
+  lvk::Holder<lvk::ComputePipelineHandle> pipelineSSAO = ctx->createComputePipeline({
+      .smComp = compSSAO,
+  });
+
+  const uint32_t kHorizontal = 1;
+  const uint32_t kVertical   = 0;
+
+  lvk::Holder<lvk::ShaderModuleHandle> compBlur         = loadShaderModule(ctx, "data/shaders/Blur.comp");
+  lvk::Holder<lvk::ComputePipelineHandle> pipelineBlurX = ctx->createComputePipeline({
+      .smComp   = compBlur,
+      .specInfo = {.entries = { { .constantId = 0, .size = sizeof(uint32_t) } }, .data = &kHorizontal, .dataSize = sizeof(uint32_t)},
+  });
+  lvk::Holder<lvk::ComputePipelineHandle> pipelineBlurY = ctx->createComputePipeline({
+      .smComp   = compBlur,
+      .specInfo = {.entries = { { .constantId = 0, .size = sizeof(uint32_t) } }, .data = &kVertical, .dataSize = sizeof(uint32_t)},
+  });
+
+  lvk::Holder<lvk::ShaderModuleHandle> vertCombine       = loadShaderModule(ctx, "data/shaders/QuadFlip.vert");
+  lvk::Holder<lvk::ShaderModuleHandle> fragCombine       = loadShaderModule(ctx, "Chapter10/04_SSAO/src/combine.frag");
+  lvk::Holder<lvk::RenderPipelineHandle> pipelineCombine = ctx->createRenderPipeline({
+      .smVert = vertCombine,
+      .smFrag = fragCombine,
+      .color  = { { .format = ctx->getSwapchainFormat() } },
+  });
+
+  lvk::Holder<lvk::TextureHandle> texSSAO   = ctx->createTexture({
+        .format     = ctx->getSwapchainFormat(),
+        .dimensions = ctx->getDimensions(ctx->getCurrentSwapchainTexture()),
+        .usage      = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+        .debugName  = "texSSAO",
+  });
+  lvk::Holder<lvk::TextureHandle> texBlur[] = {
+    ctx->createTexture({
+        .format     = ctx->getSwapchainFormat(),
+        .dimensions = ctx->getDimensions(ctx->getCurrentSwapchainTexture()),
+        .usage      = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+        .debugName  = "texBlur0",
+    }),
+    ctx->createTexture({
+        .format     = ctx->getSwapchainFormat(),
+        .dimensions = ctx->getDimensions(ctx->getCurrentSwapchainTexture()),
+        .usage      = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+        .debugName  = "texBlur1",
+    }),
+  };
+
+  const lvk::Dimensions sizeFb        = ctx->getDimensions(ctx->getCurrentSwapchainTexture());
+  const lvk::Dimensions sizeOffscreen = { sizeFb.width, sizeFb.height };
+
+  const uint32_t kNumSamples = 8;
+
+  lvk::Holder<lvk::TextureHandle> msaaColor = ctx->createTexture({
+      .format     = ctx->getSwapchainFormat(),
+      .dimensions = sizeFb,
+      .numSamples = kNumSamples,
+      .usage      = lvk::TextureUsageBits_Attachment,
+      .debugName  = "msaaColor",
+  });
+  lvk::Holder<lvk::TextureHandle> msaaDepth = ctx->createTexture({
+      .format     = app.getDepthFormat(),
+      .dimensions = sizeFb,
+      .numSamples = kNumSamples,
+      .usage      = lvk::TextureUsageBits_Attachment,
+      .debugName  = "msaaDepth",
+  });
+
+  lvk::Holder<lvk::TextureHandle> offscreenColor = ctx->createTexture({
+      .format     = ctx->getSwapchainFormat(),
+      .dimensions = sizeOffscreen,
+      .usage      = lvk::TextureUsageBits_Attachment | lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+      .debugName  = "offscreenColor",
+  });
+  lvk::Holder<lvk::TextureHandle> offscreenDepth = ctx->createTexture({
+      .format     = app.getDepthFormat(),
+      .dimensions = sizeOffscreen,
+      .usage      = lvk::TextureUsageBits_Attachment | lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+      .debugName  = "offscreenDepth",
+  });
+
+  lvk::Holder<lvk::TextureHandle> texRotations = loadTexture(ctx, "data/rot_texture.bmp");
+
+  lvk::Holder<lvk::SamplerHandle> samplerClamp = ctx->createSampler({
+      .wrapU = lvk::SamplerWrap_Clamp,
+      .wrapV = lvk::SamplerWrap_Clamp,
+      .wrapW = lvk::SamplerWrap_Clamp,
+  });
+
+  bool drawWireframe = false;
+  bool enableBlur    = true;
+  int numBlurPasses  = 1;
+
+  enum DrawMode {
+    DrawMode_ColorSSAO = 0,
+    DrawMode_Color     = 1,
+    DrawMode_SSAO      = 2,
+  };
+
+  int drawMode         = DrawMode_ColorSSAO;
+  float depthThreshold = 30.0f; // bilateral blur
+
+  struct {
+    uint32_t texDepth;
+    uint32_t texRotation;
+    uint32_t texOut;
+    uint32_t sampler;
+    float zNear;
+    float zFar;
+    float radius;
+    float attScale;
+    float distScale;
+  } pcSSAO = {
+    .texDepth    = offscreenDepth.index(),
+    .texRotation = texRotations.index(),
+    .texOut      = texSSAO.index(),
+    .sampler     = samplerClamp.index(),
+    .zNear       = 0.01f,
+    .zFar        = 1000.0f,
+    .radius      = 0.03f,
+    .attScale    = 0.95f,
+    .distScale   = 1.7f,
+  };
+
+  struct {
+    uint32_t texColor;
+    uint32_t texSSAO;
+    uint32_t sampler;
+    float scale;
+    float bias;
+  } pcCombine = {
+    .texColor = offscreenColor.index(),
+    .texSSAO  = texSSAO.index(),
+    .sampler  = samplerClamp.index(),
+    .scale    = 1.5f,
+    .bias     = 0.16f,
+  };
+
+  const Skybox skyBox(
+      ctx, "data/immenstadter_horn_2k_prefilter.ktx", "data/immenstadter_horn_2k_irradiance.ktx", ctx->getSwapchainFormat(),
+      app.getDepthFormat(), kNumSamples);
+  const VKMesh mesh(ctx, meshData, scene, ctx->getSwapchainFormat(), app.getDepthFormat(), kNumSamples);
+
+  app.run([&](uint32_t width, uint32_t height, float aspectRatio, float deltaSeconds) {
+    const mat4 view = app.camera_.getViewMatrix();
+    const mat4 proj = glm::perspective(45.0f, aspectRatio, pcSSAO.zNear, pcSSAO.zFar);
+
+    lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
+    {
+      // 1. Render scene
+      buf.cmdBeginRendering(
+          lvk::RenderPass{
+              .color = { { .loadOp = lvk::LoadOp_Clear, .storeOp = lvk::StoreOp_MsaaResolve, .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
+              .depth = { .loadOp = lvk::LoadOp_Clear, .storeOp = lvk::StoreOp_MsaaResolve, .clearDepth = 1.0f }
+      },
+          lvk::Framebuffer{
+              .color        = { { .texture = msaaColor, .resolveTexture = offscreenColor } },
+              .depthStencil = { .texture = msaaDepth, .resolveTexture = offscreenDepth },
+          });
+      skyBox.draw(buf, view, proj);
+      {
+        buf.cmdPushDebugGroupLabel("Mesh", 0xff0000ff);
+        mesh.draw(*ctx.get(), buf, view, proj, skyBox.texSkyboxIrradiance, drawWireframe);
+        buf.cmdPopDebugGroupLabel();
+      }
+      app.drawGrid(buf, proj, vec3(0, -1.0f, 0), kNumSamples);
+      buf.cmdEndRendering();
+
+      // 2. Compute SSAO
+      buf.cmdBindComputePipeline(pipelineSSAO);
+      buf.cmdPushConstants(pcSSAO);
+      buf.cmdDispatchThreadGroups(
+          {
+              .width  = 1 + (uint32_t)sizeFb.width / 16,
+              .height = 1 + (uint32_t)sizeFb.height / 16,
+      },
+          { .textures = {
+                lvk::TextureHandle(offscreenDepth),
+                lvk::TextureHandle(texSSAO),
+            } });
+
+      // 3. Blur SSAO
+      if (enableBlur) {
+        const lvk::Dimensions blurDim = {
+          .width  = 1 + (uint32_t)sizeFb.width / 16,
+          .height = 1 + (uint32_t)sizeFb.height / 16,
+        };
+        struct BlurPC {
+          uint32_t texDepth;
+          uint32_t texIn;
+          uint32_t texOut;
+          float depthThreshold;
+        };
+        struct BlurPass {
+          lvk::TextureHandle texIn;
+          lvk::TextureHandle texOut;
+        };
+        std::vector<BlurPass> passes;
+        {
+          passes.reserve(2 * numBlurPasses);
+          passes.push_back({ texSSAO, texBlur[0] });
+          for (int i = 0; i != numBlurPasses - 1; i++) {
+            passes.push_back({ texBlur[0], texBlur[1] });
+            passes.push_back({ texBlur[1], texBlur[0] });
+          }
+          passes.push_back({ texBlur[0], texSSAO });
+        }
+        for (uint32_t i = 0; i != passes.size(); i++) {
+          const BlurPass p = passes[i];
+          buf.cmdBindComputePipeline(i & 1 ? pipelineBlurX : pipelineBlurY);
+          buf.cmdPushConstants(BlurPC{
+              .texDepth       = offscreenDepth.index(),
+              .texIn          = p.texIn.index(),
+              .texOut         = p.texOut.index(),
+              .depthThreshold = pcSSAO.zFar * depthThreshold,
+          });
+          buf.cmdDispatchThreadGroups(
+              blurDim, {
+                           .textures = {p.texIn, p.texOut, lvk::TextureHandle(offscreenDepth)}
+          });
+        }
+      }
+
+      // 3. Render scene with SSAO into the swapchain image
+      if (drawMode == DrawMode_SSAO) {
+        buf.cmdCopyImage(texSSAO, ctx->getCurrentSwapchainTexture(), ctx->getDimensions(offscreenColor));
+      } else if (drawMode == DrawMode_Color) {
+        buf.cmdCopyImage(offscreenColor, ctx->getCurrentSwapchainTexture(), ctx->getDimensions(offscreenColor));
+      }
+
+      const lvk::RenderPass renderPassMain = {
+        .color = { { .loadOp = lvk::LoadOp_Load, .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
+      };
+      const lvk::Framebuffer framebufferMain = {
+        .color = { { .texture = ctx->getCurrentSwapchainTexture() } },
+      };
+
+      buf.cmdBeginRendering(renderPassMain, framebufferMain, { .textures = { lvk::TextureHandle(texSSAO) } });
+
+      if (drawMode == DrawMode_ColorSSAO) {
+        buf.cmdBindRenderPipeline(pipelineCombine);
+        buf.cmdPushConstants(pcCombine);
+        buf.cmdBindDepthState({});
+        buf.cmdDraw(3);
+      }
+
+      app.imgui_->beginFrame(framebufferMain);
+      app.drawFPS();
+      app.drawMemo();
+
+      // render UI
+      {
+        const ImGuiViewport* v  = ImGui::GetMainViewport();
+        const float windowWidth = v->WorkSize.x / 5;
+        ImGui::SetNextWindowPos(ImVec2(10, 200));
+        ImGui::SetNextWindowSize(ImVec2(windowWidth, v->WorkSize.y - 210));
+        ImGui::Begin(
+            "SSAO", nullptr, ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize);
+        ImGui::Checkbox("Draw wireframe", &drawWireframe);
+        ImGui::Checkbox("Enable blur", &enableBlur);
+        ImGui::BeginDisabled(!enableBlur);
+        ImGui::SliderFloat("Blur depth threshold", &depthThreshold, 0.0f, 50.0f);
+        ImGui::SliderInt("Blur num passes", &numBlurPasses, 1, 5);
+        ImGui::EndDisabled();
+        ImGui::Text("Draw mode:");
+        const float indentSize = 16.0f;
+        ImGui::Indent(indentSize);
+        ImGui::RadioButton("Color + SSAO", &drawMode, DrawMode_ColorSSAO);
+        ImGui::RadioButton("Color only", &drawMode, DrawMode_Color);
+        ImGui::RadioButton("SSAO only", &drawMode, DrawMode_SSAO);
+        ImGui::Unindent(indentSize);
+        ImGui::Separator();
+        ImGui::BeginDisabled(drawMode != DrawMode_ColorSSAO);
+        ImGui::SliderFloat("SSAO scale", &pcCombine.scale, 0.0f, 2.0f);
+        ImGui::SliderFloat("SSAO bias", &pcCombine.bias, 0.0f, 0.3f);
+        ImGui::EndDisabled();
+        ImGui::Separator();
+        ImGui::BeginDisabled(drawMode == DrawMode_Color);
+        ImGui::SliderFloat("SSAO radius", &pcSSAO.radius, 0.01f, 0.1f);
+        ImGui::SliderFloat("SSAO attenuation scale", &pcSSAO.attScale, 0.5f, 1.5f);
+        ImGui::SliderFloat("SSAO distance scale", &pcSSAO.distScale, 0.0f, 2.0f);
+        ImGui::EndDisabled();
+        ImGui::Separator();
+        ImGui::Image(texSSAO.index(), ImVec2(windowWidth, windowWidth / aspectRatio));
+        ImGui::End();
+      }
+
+      app.imgui_->endFrame(buf);
+
+      buf.cmdEndRendering();
+    }
+    ctx->submit(buf, ctx->getCurrentSwapchainTexture());
+  });
+
+  ctx.release();
+
+  return 0;
+}
diff --git a/Chapter10/05_HDR/CMakeLists.txt b/Chapter10/05_HDR/CMakeLists.txt
new file mode 100644
index 0000000..5f71634
--- /dev/null
+++ b/Chapter10/05_HDR/CMakeLists.txt
@@ -0,0 +1,9 @@
+cmake_minimum_required(VERSION 3.16)
+
+project(Chapter10)
+
+include(../../CMake/CommonMacros.txt)
+
+SETUP_APP(Ch10_Sample05_HDR "Chapter 10")
+
+target_link_libraries(Ch10_Sample05_HDR PRIVATE SharedUtils assimp bc7enc meshoptimizer)
diff --git a/Chapter10/05_HDR/src/Bloom.comp b/Chapter10/05_HDR/src/Bloom.comp
new file mode 100644
index 0000000..3506e7e
--- /dev/null
+++ b/Chapter10/05_HDR/src/Bloom.comp
@@ -0,0 +1,68 @@
+//
+layout (local_size_x = 16, local_size_y = 16) in;
+
+layout (set = 0, binding = 0) uniform texture2D kTextures2D[];
+layout (set = 0, binding = 1) uniform sampler   kSamplers[];
+
+layout (set = 0, binding = 2, rgba8) uniform writeonly image2D kTextures2DOut[];
+
+layout(push_constant) uniform PushConstants {
+  uint texIn;
+  uint texOut;
+  uint smpl;
+} pc;
+
+ivec2 textureBindlessSize2D(uint textureid) {
+  return textureSize(nonuniformEXT(kTextures2D[textureid]), 0);
+}
+
+vec4 textureBindless2D(uint textureid, vec2 uv) {
+  return textureLod(nonuniformEXT(sampler2D(kTextures2D[textureid], kSamplers[pc.smpl])), uv, 0);
+}
+
+layout (constant_id = 0) const bool kIsHorizontal = true;
+
+const int kFilterSize = 17;
+
+// https://drdesten.github.io/web/tools/gaussian_kernel/
+const float gaussWeights[kFilterSize] = float[](
+  0.00001525878906,
+  0.0002441406250,
+  0.001831054688,
+  0.008544921875,
+  0.02777099609,
+  0.06665039063,
+  0.1221923828,
+  0.1745605469,
+  0.1963806152,
+  0.1745605469,
+  0.1221923828,
+  0.06665039063,
+  0.02777099609,
+  0.008544921875,
+  0.001831054688,
+  0.0002441406250,
+  0.00001525878906
+);
+
+void main() {
+  const vec2 size = textureBindlessSize2D(pc.texIn).xy;
+  const vec2 xy   = gl_GlobalInvocationID.xy;
+
+  if (xy.x > size.x || xy.y > size.y)
+    return;
+
+  const vec2 texCoord = (gl_GlobalInvocationID.xy + vec2(0.5)) / size;
+
+  const float texScaler = 1.0 / (kIsHorizontal ? size.x : size.y);
+
+  vec3 c = vec3(0.0);
+
+  for ( int i = 0; i != kFilterSize; i++ ) {
+    float offset = float(i - kFilterSize/2);
+    vec2 uv = texCoord + texScaler * (kIsHorizontal ? vec2(offset, 0) : vec2(0, offset));
+    c += textureBindless2D(pc.texIn, uv).rgb * gaussWeights[i];
+  }
+
+  imageStore(kTextures2DOut[pc.texOut], ivec2(xy), vec4(c, 1.0) );
+}
diff --git a/Chapter10/05_HDR/src/BrightPass.comp b/Chapter10/05_HDR/src/BrightPass.comp
new file mode 100644
index 0000000..ce5c649
--- /dev/null
+++ b/Chapter10/05_HDR/src/BrightPass.comp
@@ -0,0 +1,54 @@
+//
+layout (local_size_x = 16, local_size_y = 16) in;
+
+layout (set = 0, binding = 0) uniform texture2D kTextures2D[];
+layout (set = 0, binding = 1) uniform sampler   kSamplers[];
+
+layout (set = 0, binding = 2, rgba16) uniform writeonly image2D kTextures2DOutRGBA[];
+layout (set = 0, binding = 2, r16) uniform writeonly image2D kTextures2DOutR[];
+
+layout(push_constant) uniform PushConstants {
+  uint texColor;
+  uint texOut;       // rgba16
+  uint texLuminance; // r16
+  uint smpl;
+  float exposure;
+} pc;
+
+ivec2 textureBindlessSize2D(uint textureid) {
+  return textureSize(nonuniformEXT(kTextures2D[textureid]), 0);
+}
+
+vec4 textureBindless2D(uint textureid, vec2 uv) {
+  return textureLod(nonuniformEXT(sampler2D(kTextures2D[textureid], kSamplers[pc.smpl])), uv, 0);
+}
+
+void main() {
+  const vec2 sizeIn  = textureBindlessSize2D(pc.texColor).xy;
+  const vec2 sizeOut = textureBindlessSize2D(pc.texOut).xy;
+
+  const vec2 xy   = gl_GlobalInvocationID.xy;
+  const vec2 uv0   = (gl_GlobalInvocationID.xy + vec2(0)) / sizeOut;
+  const vec2 uv1   = (gl_GlobalInvocationID.xy + vec2(1)) / sizeOut;
+
+  if (xy.x > sizeIn.x || xy.y > sizeIn.y)
+    return;
+
+  vec2 dxdy = (uv1-uv0) / 3;
+
+  vec4 color = vec4(0);
+
+  // 3x3 box filter
+  for (int v = 0; v != 3; v++) {
+    for (int u = 0; u != 3; u++) {
+      color += textureBindless2D(pc.texColor, uv0 + vec2(u, v) * dxdy );
+    }
+  }
+
+  float luminance = pc.exposure * dot(color.rgb / 9, vec3(0.2126, 0.7152, 0.0722));
+
+  vec3 rgb = luminance > 1.0 ? color.rgb : vec3(0);
+
+  imageStore(kTextures2DOutRGBA[pc.texOut],    ivec2(xy), vec4( rgb, 1.0 ) );
+  imageStore(kTextures2DOutR[pc.texLuminance], ivec2(xy), vec4(luminance ) );
+}
diff --git a/Chapter10/05_HDR/src/ToneMap.frag b/Chapter10/05_HDR/src/ToneMap.frag
new file mode 100644
index 0000000..48875ea
--- /dev/null
+++ b/Chapter10/05_HDR/src/ToneMap.frag
@@ -0,0 +1,113 @@
+//
+layout (location=0) in vec2 uv;
+layout (location=0) out vec4 out_FragColor;
+
+const int ToneMappingMode_None = 0;
+const int ToneMappingMode_Reinhard = 1;
+const int ToneMappingMode_Uchimura = 2;
+const int ToneMappingMode_KhronosPBR = 3;
+
+layout(push_constant) uniform PushConstants {
+  uint texColor;
+  uint texLuminance;
+  uint texBloom;
+  uint smpl;
+  int drawMode;
+
+  float exposure;
+  float bloomStrength;
+
+  // Reinhard
+  float maxWhite;
+
+  // Uchimura
+  float P;  // max display brightness
+  float a;  // contrast
+  float m;  // linear section start
+  float l;  // linear section length
+  float c;  // black tightness
+  float b;  // pedestal
+
+  // Khronos PBR
+  float startCompression;  // highlight compression start
+  float desaturation;      // desaturation speed
+} pc;
+
+// Uchimura 2017, "HDR theory and practice"
+// http://cdn2.gran-turismo.com/data/www/pdi_publications/PracticalHDRandWCGinGTS_20181222.pdf
+// Math: https://www.desmos.com/calculator/gslcdxvipg
+// Source: https://www.slideshare.net/nikuque/hdr-theory-and-practicce-jp
+vec3 uchimura(vec3 x, float P, float a, float m, float l, float c, float b) {
+  float l0 = ((P - m) * l) / a;
+  float L0 = m - m / a;
+  float L1 = m + (1.0 - m) / a;
+  float S0 = m + l0;
+  float S1 = m + a * l0;
+  float C2 = (a * P) / (P - S1);
+  float CP = -C2 / P;
+
+  vec3 w0 = vec3(1.0 - smoothstep(0.0, m, x));
+  vec3 w2 = vec3(step(m + l0, x));
+  vec3 w1 = vec3(1.0 - w0 - w2);
+
+  vec3 T = vec3(m * pow(x / m, vec3(c)) + b);
+  vec3 S = vec3(P - (P - S1) * exp(CP * (x - S0)));
+  vec3 L = vec3(m + a * (x - m));
+
+  return T * w0 + L * w1 + S * w2;
+}
+
+float luminance(vec3 v) {
+  return dot(v, vec3(0.2126, 0.7152, 0.0722));
+}
+
+// "Tone Mapping" by Matt Taylor: https://64.github.io/tonemapping/
+vec3 reinhard2(vec3 v, float maxWhite) {
+  float l_old = luminance(v);
+  float l_new = l_old * (1.0 + (l_old / (maxWhite * maxWhite))) / (1.0 + l_old);
+  return v * (l_new / l_old);
+}
+
+// Khronos PBR Neutral Tone Mapper:
+// https://github.com/KhronosGroup/ToneMapping/blob/main/PBR_Neutral/README.md#pbr-neutral-specification
+// https://github.com/KhronosGroup/ToneMapping/blob/main/PBR_Neutral/pbrNeutral.glsl
+vec3 PBRNeutralToneMapping(vec3 color, float startCompression, float desaturation) {
+  startCompression -= 0.04;
+
+  float x = min(color.r, min(color.g, color.b));
+  float offset = x < 0.08 ? x - 6.25 * x * x : 0.04;
+  color -= offset;
+
+  float peak = max(color.r, max(color.g, color.b));
+  if (peak < startCompression) return color;
+
+  const float d = 1. - startCompression;
+  float newPeak = 1. - d * d / (peak + d - startCompression);
+  color *= newPeak / peak;
+
+  float g = 1. - 1. / (desaturation * (peak - newPeak) + 1.);
+  return mix(color, newPeak * vec3(1, 1, 1), g);
+}
+
+void main() {
+  vec3 color = textureBindless2D(pc.texColor, pc.smpl, uv).rgb;
+  vec3 bloom = textureBindless2D(pc.texBloom, pc.smpl, uv).rgb;
+  float avgLuminance = textureBindless2D(pc.texLuminance, pc.smpl, vec2(0.5)).r;
+
+  if (pc.drawMode != ToneMappingMode_None) {
+    float midGray = 0.5;
+    color *= pc.exposure * midGray / (avgLuminance + 0.001);
+  }
+
+  if (pc.drawMode == ToneMappingMode_Reinhard) {
+    color = reinhard2(pc.exposure * color, pc.maxWhite);
+  }
+  if (pc.drawMode == ToneMappingMode_Uchimura) {
+    color = uchimura(pc.exposure * color, pc.P, pc.a, pc.m, pc.l, pc.c, pc.b);
+  }
+  if (pc.drawMode == ToneMappingMode_KhronosPBR) {
+    color = PBRNeutralToneMapping(pc.exposure * color, pc.startCompression, pc.desaturation);
+  }
+
+  out_FragColor = vec4(color + pc.bloomStrength * bloom, 1.0);
+}
diff --git a/Chapter10/05_HDR/src/main.cpp b/Chapter10/05_HDR/src/main.cpp
new file mode 100644
index 0000000..c57b3ea
--- /dev/null
+++ b/Chapter10/05_HDR/src/main.cpp
@@ -0,0 +1,445 @@
+#include "shared/VulkanApp.h"
+
+#include "Chapter10/Bistro.h"
+#include "Chapter10/Skybox.h"
+
+#include <implot/implot.h>
+
+// Uchimura 2017, "HDR theory and practice"
+// Math: https://www.desmos.com/calculator/gslcdxvipg
+// Source: https://www.slideshare.net/nikuque/hdr-theory-and-practicce-jp
+float uchimura(float x, float P, float a, float m, float l, float c, float b)
+{
+  float l0 = ((P - m) * l) / a;
+  float L0 = m - m / a;
+  float L1 = m + (1.0f - m) / a;
+  float S0 = m + l0;
+  float S1 = m + a * l0;
+  float C2 = (a * P) / (P - S1);
+  float CP = -C2 / P;
+
+  float w0 = float(1.0f - glm::smoothstep(0.0f, m, x));
+  float w2 = float(glm::step(m + l0, x));
+  float w1 = float(1.0f - w0 - w2);
+
+  float T = float(m * pow(x / m, float(c)) + b);
+  float S = float(P - (P - S1) * exp(CP * (x - S0)));
+  float L = float(m + a * (x - m));
+
+  return T * w0 + L * w1 + S * w2;
+}
+
+float reinhard2(float v, float maxWhite)
+{
+  return v * (1.0f + (v / (maxWhite * maxWhite))) / (1.0f + v);
+}
+
+// Khronos PBR Neutral Tone Mapper
+// https://github.com/KhronosGroup/ToneMapping/blob/main/PBR_Neutral/README.md#pbr-neutral-specification
+// https://github.com/KhronosGroup/ToneMapping/blob/main/PBR_Neutral/pbrNeutral.glsl
+float PBRNeutralToneMapping(float color, float startCompression, float desaturation)
+{
+  startCompression -= 0.04f;
+
+  float x      = color;
+  float offset = x < 0.08f ? x - 6.25f * x * x : 0.04f;
+  color -= offset;
+
+  float peak = color;
+  if (peak < startCompression)
+    return color;
+
+  const float d = 1. - startCompression;
+  float newPeak = 1. - d * d / (peak + d - startCompression);
+  color *= newPeak / peak;
+
+  float g = 1.0f - 1.0f / (desaturation * (peak - newPeak) + 1.0f);
+  return glm::mix(color, newPeak, g);
+}
+
+int main()
+{
+  MeshData meshData;
+  Scene scene;
+  loadBistro(meshData, scene);
+
+  VulkanApp app({
+      .initialCameraPos    = vec3(-19.261f, 8.465f, -7.317f),
+      .initialCameraTarget = vec3(0, +2.5f, 0),
+  });
+
+  app.positioner_.maxSpeed_ = 1.5f;
+
+  std::unique_ptr<lvk::IContext> ctx(app.ctx_.get());
+
+  const uint32_t kNumSamples         = 8;
+  const lvk::Format kOffscreenFormat = lvk::Format_RGBA_F16;
+
+  const Skybox skyBox(
+      ctx, "data/immenstadter_horn_2k_prefilter.ktx", "data/immenstadter_horn_2k_irradiance.ktx", kOffscreenFormat, app.getDepthFormat(),
+      kNumSamples);
+  const VKMesh mesh(ctx, meshData, scene, kOffscreenFormat, app.getDepthFormat(), kNumSamples);
+
+  lvk::Holder<lvk::ShaderModuleHandle> compBrightPass        = loadShaderModule(ctx, "Chapter10/05_HDR/src/BrightPass.comp");
+  lvk::Holder<lvk::ComputePipelineHandle> pipelineBrightPass = ctx->createComputePipeline({ .smComp = compBrightPass });
+
+  const uint32_t kHorizontal                             = 1;
+  const uint32_t kVertical                               = 0;
+  lvk::Holder<lvk::ShaderModuleHandle> compBloomPass     = loadShaderModule(ctx, "Chapter10/05_HDR/src/Bloom.comp");
+  lvk::Holder<lvk::ComputePipelineHandle> pipelineBloomX = ctx->createComputePipeline({
+      .smComp   = compBloomPass,
+      .specInfo = {.entries = { { .constantId = 0, .size = sizeof(uint32_t) } }, .data = &kHorizontal, .dataSize = sizeof(uint32_t)},
+  });
+  lvk::Holder<lvk::ComputePipelineHandle> pipelineBloomY = ctx->createComputePipeline({
+      .smComp   = compBloomPass,
+      .specInfo = {.entries = { { .constantId = 0, .size = sizeof(uint32_t) } }, .data = &kVertical, .dataSize = sizeof(uint32_t)},
+  });
+
+  lvk::Holder<lvk::ShaderModuleHandle> vertToneMap = loadShaderModule(ctx, "data/shaders/QuadFlip.vert");
+  lvk::Holder<lvk::ShaderModuleHandle> fragToneMap = loadShaderModule(ctx, "Chapter10/05_HDR/src/ToneMap.frag");
+
+  lvk::Holder<lvk::RenderPipelineHandle> pipelineToneMap = ctx->createRenderPipeline({
+      .smVert = vertToneMap,
+      .smFrag = fragToneMap,
+      .color  = { { .format = ctx->getSwapchainFormat() } },
+  });
+
+  lvk::Holder<lvk::SamplerHandle> samplerClamp = ctx->createSampler({
+      .wrapU = lvk::SamplerWrap_Clamp,
+      .wrapV = lvk::SamplerWrap_Clamp,
+      .wrapW = lvk::SamplerWrap_Clamp,
+  });
+
+  const lvk::Dimensions sizeFb = ctx->getDimensions(ctx->getCurrentSwapchainTexture());
+
+  lvk::Holder<lvk::TextureHandle> msaaColor = ctx->createTexture({
+      .format     = kOffscreenFormat,
+      .dimensions = sizeFb,
+      .numSamples = kNumSamples,
+      .usage      = lvk::TextureUsageBits_Attachment,
+      .debugName  = "msaaColor",
+  });
+  lvk::Holder<lvk::TextureHandle> msaaDepth = ctx->createTexture({
+      .format     = app.getDepthFormat(),
+      .dimensions = sizeFb,
+      .numSamples = kNumSamples,
+      .usage      = lvk::TextureUsageBits_Attachment,
+      .debugName  = "msaaDepth",
+  });
+
+  const lvk::Dimensions sizeBloom = { 512, 512 };
+
+  lvk::Holder<lvk::TextureHandle> texBrightPass = ctx->createTexture({
+      .format     = kOffscreenFormat,
+      .dimensions = sizeBloom,
+      .usage      = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+      .debugName  = "texBrightPass",
+  });
+  lvk::Holder<lvk::TextureHandle> texBloomPass  = ctx->createTexture({
+       .format     = kOffscreenFormat,
+       .dimensions = sizeBloom,
+       .usage      = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+       .debugName  = "texBloomPass",
+  });
+
+  const lvk::ComponentMapping swizzle = { .r = lvk::Swizzle_R, .g = lvk::Swizzle_R, .b = lvk::Swizzle_R, .a = lvk::Swizzle_1 };
+
+  lvk::Holder<lvk::TextureHandle> texLuminanceViews[10] = { ctx->createTexture({
+      .format       = lvk::Format_R_F16,
+      .dimensions   = sizeBloom,
+      .usage        = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+      .numMipLevels = lvk::calcNumMipLevels(sizeBloom.width, sizeBloom.height),
+      .swizzle      = swizzle,
+      .debugName    = "texLuminance",
+  }) };
+
+  for (uint32_t l = 1; l != LVK_ARRAY_NUM_ELEMENTS(texLuminanceViews); l++) {
+    texLuminanceViews[l] = ctx->createTextureView(texLuminanceViews[0], { .mipLevel = l, .swizzle = swizzle });
+  }
+
+  lvk::Holder<lvk::TextureHandle> offscreenColor = ctx->createTexture({
+      .format     = kOffscreenFormat,
+      .dimensions = sizeFb,
+      .usage      = lvk::TextureUsageBits_Attachment | lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+      .debugName  = "offscreenColor",
+  });
+
+  lvk::Holder<lvk::TextureHandle> texBloom[] = {
+    ctx->createTexture({
+        .format     = kOffscreenFormat,
+        .dimensions = sizeBloom,
+        .usage      = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+        .debugName  = "texBloom0",
+    }),
+    ctx->createTexture({
+        .format     = kOffscreenFormat,
+        .dimensions = sizeBloom,
+        .usage      = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+        .debugName  = "texBloom1",
+    }),
+  };
+
+  bool drawWireframe  = false;
+  bool drawCurves     = true;
+  bool enableBloom    = true;
+  float bloomStrength = 0.1f;
+  int numBloomPasses  = 2;
+
+  enum ToneMappingMode {
+    ToneMapping_None       = 0,
+    ToneMapping_Reinhard   = 1,
+    ToneMapping_Uchimura   = 2,
+    ToneMapping_KhronosPBR = 3,
+  };
+
+  ImPlotContext* implotCtx = ImPlot::CreateContext();
+
+  struct {
+    uint32_t texColor;
+    uint32_t texLuminance;
+    uint32_t texBloom;
+    uint32_t sampler;
+    int drawMode = ToneMapping_Uchimura;
+
+    float exposure      = 1.0f;
+    float bloomStrength = 0.1f;
+
+    // Reinhard
+    float maxWhite = 1.0f;
+
+    // Uchimura
+    float P = 1.0f;  // max display brightness
+    float a = 1.05f; // contrast
+    float m = 0.1f;  // linear section start
+    float l = 0.8f;  // linear section length
+    float c = 3.0f;  // black tightness
+    float b = 0.0f;  // pedestal
+
+    // Khronos PBR
+    float startCompression = 0.8f;  // highlight compression start
+    float desaturation     = 0.15f; // desaturation speed
+  } pcHDR = {
+    .texColor     = offscreenColor.index(),
+    .texLuminance = texLuminanceViews[LVK_ARRAY_NUM_ELEMENTS(texLuminanceViews) - 1].index(), // 1x1
+    .texBloom     = texBloomPass.index(),
+    .sampler      = samplerClamp.index(),
+  };
+
+  app.run([&](uint32_t width, uint32_t height, float aspectRatio, float deltaSeconds) {
+    const mat4 view = app.camera_.getViewMatrix();
+    const mat4 proj = glm::perspective(45.0f, aspectRatio, 0.01f, 1000.0f);
+
+    lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
+    {
+      // 1. Render scene
+      buf.cmdBeginRendering(
+          lvk::RenderPass{
+              .color = { { .loadOp = lvk::LoadOp_Clear, .storeOp = lvk::StoreOp_MsaaResolve, .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
+              .depth = { .loadOp = lvk::LoadOp_Clear, .clearDepth = 1.0f }
+      },
+          lvk::Framebuffer{
+              .color        = { { .texture = msaaColor, .resolveTexture = offscreenColor } },
+              .depthStencil = { .texture = msaaDepth },
+          });
+      skyBox.draw(buf, view, proj);
+      {
+        buf.cmdPushDebugGroupLabel("Mesh", 0xff0000ff);
+        mesh.draw(*ctx.get(), buf, view, proj, skyBox.texSkyboxIrradiance, drawWireframe);
+        buf.cmdPopDebugGroupLabel();
+      }
+      app.drawGrid(buf, proj, vec3(0, -1.0f, 0), kNumSamples, kOffscreenFormat);
+      buf.cmdEndRendering();
+
+      // 2. Bright pass - extract luminance and bright areas
+      const struct {
+        uint32_t texColor;
+        uint32_t texOut;
+        uint32_t texLuminance;
+        uint32_t sampler;
+        float exposure;
+      } pcBrightPass = {
+        .texColor     = offscreenColor.index(),
+        .texOut       = texBrightPass.index(),
+        .texLuminance = texLuminanceViews[0].index(),
+        .sampler      = samplerClamp.index(),
+        .exposure     = pcHDR.exposure,
+      };
+      buf.cmdBindComputePipeline(pipelineBrightPass);
+      buf.cmdPushConstants(pcBrightPass);
+      buf.cmdDispatchThreadGroups(
+          sizeBloom.divide2D(16), {
+                                      .textures = {lvk::TextureHandle(offscreenColor), lvk::TextureHandle(texLuminanceViews[0])}
+      });
+      buf.cmdGenerateMipmap(texLuminanceViews[0]);
+
+      // 2.1. Bloom
+      struct BlurPC {
+        uint32_t texIn;
+        uint32_t texOut;
+        uint32_t sampler;
+      };
+      struct StreaksPC {
+        uint32_t texIn;
+        uint32_t texOut;
+        uint32_t texRotationPattern;
+        uint32_t sampler;
+      };
+      struct BlurPass {
+        lvk::TextureHandle texIn;
+        lvk::TextureHandle texOut;
+      };
+      std::vector<BlurPass> passes;
+      {
+        passes.reserve(2 * numBloomPasses);
+        passes.push_back({ texBrightPass, texBloom[0] });
+        for (int i = 0; i != numBloomPasses - 1; i++) {
+          passes.push_back({ texBloom[0], texBloom[1] });
+          passes.push_back({ texBloom[1], texBloom[0] });
+        }
+        passes.push_back({ texBloom[0], texBloomPass });
+      }
+      for (uint32_t i = 0; i != passes.size(); i++) {
+        const BlurPass p = passes[i];
+        buf.cmdBindComputePipeline(i & 1 ? pipelineBloomX : pipelineBloomY);
+        buf.cmdPushConstants(BlurPC{
+            .texIn   = p.texIn.index(),
+            .texOut  = p.texOut.index(),
+            .sampler = samplerClamp.index(),
+        });
+        if (enableBloom)
+          buf.cmdDispatchThreadGroups(
+              sizeBloom.divide2D(16), {
+                                          .textures = {p.texIn, p.texOut, lvk::TextureHandle(texBrightPass)}
+          });
+      }
+
+      // 3. Render tone-mapped scene into a swapchain image
+      const lvk::RenderPass renderPassMain = {
+        .color = { { .loadOp = lvk::LoadOp_Load, .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
+      };
+      const lvk::Framebuffer framebufferMain = {
+        .color = { { .texture = ctx->getCurrentSwapchainTexture() } },
+      };
+
+      // transition the entire mip-pyramid
+      buf.cmdBeginRendering(renderPassMain, framebufferMain, { .textures = { lvk::TextureHandle(texLuminanceViews[0]) } });
+
+      buf.cmdBindRenderPipeline(pipelineToneMap);
+      buf.cmdPushConstants(pcHDR);
+      buf.cmdBindDepthState({});
+      buf.cmdDraw(3); // fullscreen triangle
+
+      app.imgui_->beginFrame(framebufferMain);
+      app.drawFPS();
+      app.drawMemo();
+
+      // render UI
+      {
+        const ImGuiViewport* v  = ImGui::GetMainViewport();
+        const float windowWidth = v->WorkSize.x / 5;
+        ImGui::SetNextWindowPos(ImVec2(10, 200));
+        ImGui::SetNextWindowSize(ImVec2(windowWidth, v->WorkSize.y - 210));
+        ImGui::Begin("HDR", nullptr, ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize);
+        ImGui::Checkbox("Draw wireframe", &drawWireframe);
+        ImGui::Checkbox("Draw tone mapping curves", &drawCurves);
+        ImGui::Separator();
+        const float indentSize = 32.0f;
+        ImGui::Text("Tone mapping params:");
+        ImGui::SliderFloat("Exposure", &pcHDR.exposure, 0.1f, 2.0f);
+        ImGui::Checkbox("Enable bloom", &enableBloom);
+        pcHDR.bloomStrength = enableBloom ? bloomStrength : 0.0f;
+        ImGui::BeginDisabled(!enableBloom);
+        ImGui::Indent(indentSize);
+        ImGui::SliderFloat("Bloom strength", &bloomStrength, 0.0f, 1.0f);
+        ImGui::SliderInt("Bloom num passes", &numBloomPasses, 1, 5);
+        ImGui::Unindent(indentSize);
+        ImGui::EndDisabled();
+        ImGui::Text("Tone mapping mode:");
+        ImGui::RadioButton("None", &pcHDR.drawMode, ToneMapping_None);
+        ImGui::RadioButton("Reinhard", &pcHDR.drawMode, ToneMapping_Reinhard);
+        if (pcHDR.drawMode == ToneMapping_Reinhard) {
+          ImGui::Indent(indentSize);
+          ImGui::BeginDisabled(pcHDR.drawMode != ToneMapping_Reinhard);
+          ImGui::SliderFloat("Max white", &pcHDR.maxWhite, 0.5f, 2.0f);
+          ImGui::EndDisabled();
+          ImGui::Unindent(indentSize);
+        }
+        ImGui::RadioButton("Uchimura", &pcHDR.drawMode, ToneMapping_Uchimura);
+        if (pcHDR.drawMode == ToneMapping_Uchimura) {
+          ImGui::Indent(indentSize);
+          ImGui::BeginDisabled(pcHDR.drawMode != ToneMapping_Uchimura);
+          ImGui::SliderFloat("Max brightness", &pcHDR.P, 1.0f, 2.0f);
+          ImGui::SliderFloat("Contrast", &pcHDR.a, 0.0f, 5.0f);
+          ImGui::SliderFloat("Linear section start", &pcHDR.m, 0.0f, 1.0f);
+          ImGui::SliderFloat("Linear section length", &pcHDR.l, 0.0f, 1.0f);
+          ImGui::SliderFloat("Black tightness", &pcHDR.c, 1.0f, 3.0f);
+          ImGui::SliderFloat("Pedestal", &pcHDR.b, 0.0f, 1.0f);
+          ImGui::EndDisabled();
+          ImGui::Unindent(indentSize);
+        }
+        ImGui::RadioButton("Khronos PBR Neutral", &pcHDR.drawMode, ToneMapping_KhronosPBR);
+        if (pcHDR.drawMode == ToneMapping_KhronosPBR) {
+          ImGui::Indent(indentSize);
+          ImGui::SliderFloat("Highlight compression start", &pcHDR.startCompression, 0.0f, 1.0f);
+          ImGui::SliderFloat("Desaturation speed", &pcHDR.desaturation, 0.0f, 1.0f);
+          ImGui::Unindent(indentSize);
+        }
+        ImGui::Separator();
+
+        ImGui::Text("Average luminance 1x1:");
+        ImGui::Image(pcHDR.texLuminance, ImVec2(128, 128));
+        ImGui::Separator();
+        ImGui::Text("Bright pass:");
+        ImGui::Image(texBrightPass.index(), ImVec2(windowWidth, windowWidth / aspectRatio));
+        ImGui::Text("Bloom pass:");
+        ImGui::Image(texBloomPass.index(), ImVec2(windowWidth, windowWidth / aspectRatio));
+        ImGui::Separator();
+        ImGui::Text("Luminance pyramid 512x512");
+        for (uint32_t l = 0; l != LVK_ARRAY_NUM_ELEMENTS(texLuminanceViews); l++) {
+          ImGui::Image(texLuminanceViews[l].index(), ImVec2((int)windowWidth >> l, ((int)windowWidth >> l)));
+        }
+        ImGui::Separator();
+        ImGui::End();
+
+        if (drawCurves) {
+          const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize |
+                                         ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav;
+          ImGui::SetNextWindowBgAlpha(0.8f);
+          ImGui::SetNextWindowPos({ width * 0.6f, height * 0.7f }, ImGuiCond_Appearing);
+          ImGui::SetNextWindowSize({ width * 0.4f, height * 0.3f });
+          ImGui::Begin("Tone mapping curve", nullptr, flags);
+          const int kNumGraphPoints = 1001;
+          float xs[kNumGraphPoints];
+          float ysUnchimura[kNumGraphPoints];
+          float ysReinhard2[kNumGraphPoints];
+          float ysKhronosPBR[kNumGraphPoints];
+          for (int i = 0; i != kNumGraphPoints; i++) {
+            xs[i]           = float(i) / kNumGraphPoints;
+            ysUnchimura[i]  = uchimura(xs[i], pcHDR.P, pcHDR.a, pcHDR.m, pcHDR.l, pcHDR.c, pcHDR.b);
+            ysReinhard2[i]  = reinhard2(xs[i], pcHDR.maxWhite);
+            ysKhronosPBR[i] = PBRNeutralToneMapping(xs[i], pcHDR.startCompression, pcHDR.desaturation);
+          }
+          if (ImPlot::BeginPlot("Tone mapping curves", { width * 0.4f, height * 0.3f }, ImPlotFlags_NoInputs)) {
+            ImPlot::SetupAxes("Input", "Output");
+            ImPlot::PlotLine("Uchimura", xs, ysUnchimura, kNumGraphPoints);
+            ImPlot::PlotLine("Reinhard", xs, ysReinhard2, kNumGraphPoints);
+            ImPlot::PlotLine("Khronos PBR", xs, ysKhronosPBR, kNumGraphPoints);
+            ImPlot::EndPlot();
+          }
+          ImGui::End();
+        }
+      }
+
+      app.imgui_->endFrame(buf);
+
+      buf.cmdEndRendering();
+    }
+    ctx->submit(buf, ctx->getCurrentSwapchainTexture());
+  });
+
+  ImPlot::DestroyContext(implotCtx);
+
+  ctx.release();
+
+  return 0;
+}
diff --git a/Chapter10/06_HDR_Adaptation/CMakeLists.txt b/Chapter10/06_HDR_Adaptation/CMakeLists.txt
new file mode 100644
index 0000000..d5ef24e
--- /dev/null
+++ b/Chapter10/06_HDR_Adaptation/CMakeLists.txt
@@ -0,0 +1,9 @@
+cmake_minimum_required(VERSION 3.16)
+
+project(Chapter10)
+
+include(../../CMake/CommonMacros.txt)
+
+SETUP_APP(Ch10_Sample06_HDR_Adaptation "Chapter 10")
+
+target_link_libraries(Ch10_Sample06_HDR_Adaptation PRIVATE SharedUtils assimp bc7enc meshoptimizer)
diff --git a/Chapter10/06_HDR_Adaptation/src/Adaptation.comp b/Chapter10/06_HDR_Adaptation/src/Adaptation.comp
new file mode 100644
index 0000000..c1aef6c
--- /dev/null
+++ b/Chapter10/06_HDR_Adaptation/src/Adaptation.comp
@@ -0,0 +1,21 @@
+/**/
+layout (local_size_x = 1, local_size_y = 1) in;
+
+layout (set = 0, binding = 2, r16) uniform readonly  image2D kTextures2DIn[];
+layout (set = 0, binding = 2, r16) uniform writeonly image2D kTextures2DOut[];
+
+layout(push_constant) uniform PushConstants {
+  uint texCurrSceneLuminance;
+  uint texPrevAdaptedLuminance;
+  uint texAdaptedOut;
+  float adaptationSpeed;
+} pc;
+
+void main() {
+  float lumCurr = imageLoad(kTextures2DIn[pc.texCurrSceneLuminance  ], ivec2(0, 0)).x;
+  float lumPrev = imageLoad(kTextures2DIn[pc.texPrevAdaptedLuminance], ivec2(0, 0)).x;
+
+  float newAdaptation = lumPrev + (lumCurr - lumPrev) * (1.0 - pow(0.98, pc.adaptationSpeed));
+
+  imageStore(kTextures2DOut[pc.texAdaptedOut], ivec2(0, 0), vec4(newAdaptation));
+}
diff --git a/Chapter10/06_HDR_Adaptation/src/main.cpp b/Chapter10/06_HDR_Adaptation/src/main.cpp
new file mode 100644
index 0000000..d61f966
--- /dev/null
+++ b/Chapter10/06_HDR_Adaptation/src/main.cpp
@@ -0,0 +1,495 @@
+#include "shared/VulkanApp.h"
+
+#include "Chapter10/Bistro.h"
+#include "Chapter10/Skybox.h"
+
+#include <implot/implot.h>
+
+// Uchimura 2017, "HDR theory and practice"
+// Math: https://www.desmos.com/calculator/gslcdxvipg
+// Source: https://www.slideshare.net/nikuque/hdr-theory-and-practicce-jp
+float uchimura(float x, float P, float a, float m, float l, float c, float b)
+{
+  float l0 = ((P - m) * l) / a;
+  float L0 = m - m / a;
+  float L1 = m + (1.0f - m) / a;
+  float S0 = m + l0;
+  float S1 = m + a * l0;
+  float C2 = (a * P) / (P - S1);
+  float CP = -C2 / P;
+
+  float w0 = float(1.0f - glm::smoothstep(0.0f, m, x));
+  float w2 = float(glm::step(m + l0, x));
+  float w1 = float(1.0f - w0 - w2);
+
+  float T = float(m * pow(x / m, float(c)) + b);
+  float S = float(P - (P - S1) * exp(CP * (x - S0)));
+  float L = float(m + a * (x - m));
+
+  return T * w0 + L * w1 + S * w2;
+}
+
+float reinhard2(float v, float maxWhite)
+{
+  return v * (1.0f + (v / (maxWhite * maxWhite))) / (1.0f + v);
+}
+
+// Khronos PBR Neutral Tone Mapper
+// https://github.com/KhronosGroup/ToneMapping/blob/main/PBR_Neutral/README.md#pbr-neutral-specification
+// https://github.com/KhronosGroup/ToneMapping/blob/main/PBR_Neutral/pbrNeutral.glsl
+float PBRNeutralToneMapping(float color, float startCompression, float desaturation)
+{
+  startCompression -= 0.04f;
+
+  float x      = color;
+  float offset = x < 0.08f ? x - 6.25f * x * x : 0.04f;
+  color -= offset;
+
+  float peak = color;
+  if (peak < startCompression)
+    return color;
+
+  const float d = 1. - startCompression;
+  float newPeak = 1. - d * d / (peak + d - startCompression);
+  color *= newPeak / peak;
+
+  float g = 1.0f - 1.0f / (desaturation * (peak - newPeak) + 1.0f);
+  return glm::mix(color, newPeak, g);
+}
+
+int main()
+{
+  MeshData meshData;
+  Scene scene;
+  loadBistro(meshData, scene);
+
+  VulkanApp app({
+      .initialCameraPos    = vec3(-19.261f, 8.465f, -7.317f),
+      .initialCameraTarget = vec3(0, +2.5f, 0),
+  });
+
+  app.positioner_.maxSpeed_ = 1.5f;
+
+  std::unique_ptr<lvk::IContext> ctx(app.ctx_.get());
+
+  const uint32_t kNumSamples         = 8;
+  const lvk::Format kOffscreenFormat = lvk::Format_RGBA_F16;
+
+  const Skybox skyBox(
+      ctx, "data/immenstadter_horn_2k_prefilter.ktx", "data/immenstadter_horn_2k_irradiance.ktx", kOffscreenFormat, app.getDepthFormat(),
+      kNumSamples);
+  const VKMesh mesh(ctx, meshData, scene, kOffscreenFormat, app.getDepthFormat(), kNumSamples);
+
+  lvk::Holder<lvk::ShaderModuleHandle> compBrightPass        = loadShaderModule(ctx, "Chapter10/05_HDR/src/BrightPass.comp");
+  lvk::Holder<lvk::ComputePipelineHandle> pipelineBrightPass = ctx->createComputePipeline({ .smComp = compBrightPass });
+
+  lvk::Holder<lvk::ShaderModuleHandle> compAdaptationPass        = loadShaderModule(ctx, "Chapter10/06_HDR_Adaptation/src/Adaptation.comp");
+  lvk::Holder<lvk::ComputePipelineHandle> pipelineAdaptationPass = ctx->createComputePipeline({ .smComp = compAdaptationPass });
+
+  const uint32_t kHorizontal = 1;
+  const uint32_t kVertical   = 0;
+
+  lvk::Holder<lvk::ShaderModuleHandle> compBloomPass     = loadShaderModule(ctx, "Chapter10/05_HDR/src/Bloom.comp");
+  lvk::Holder<lvk::ComputePipelineHandle> pipelineBloomX = ctx->createComputePipeline({
+      .smComp   = compBloomPass,
+      .specInfo = {.entries = { { .constantId = 0, .size = sizeof(uint32_t) } }, .data = &kHorizontal, .dataSize = sizeof(uint32_t)},
+  });
+  lvk::Holder<lvk::ComputePipelineHandle> pipelineBloomY = ctx->createComputePipeline({
+      .smComp   = compBloomPass,
+      .specInfo = {.entries = { { .constantId = 0, .size = sizeof(uint32_t) } }, .data = &kVertical, .dataSize = sizeof(uint32_t)},
+  });
+
+  lvk::Holder<lvk::ShaderModuleHandle> vertToneMap = loadShaderModule(ctx, "data/shaders/QuadFlip.vert");
+  lvk::Holder<lvk::ShaderModuleHandle> fragToneMap = loadShaderModule(ctx, "Chapter10/05_HDR/src/ToneMap.frag");
+
+  lvk::Holder<lvk::RenderPipelineHandle> pipelineToneMap = ctx->createRenderPipeline({
+      .smVert = vertToneMap,
+      .smFrag = fragToneMap,
+      .color  = { { .format = ctx->getSwapchainFormat() } },
+  });
+
+  lvk::Holder<lvk::SamplerHandle> samplerClamp = ctx->createSampler({
+      .wrapU = lvk::SamplerWrap_Clamp,
+      .wrapV = lvk::SamplerWrap_Clamp,
+      .wrapW = lvk::SamplerWrap_Clamp,
+  });
+
+  const lvk::Dimensions sizeFb = ctx->getDimensions(ctx->getCurrentSwapchainTexture());
+
+  lvk::Holder<lvk::TextureHandle> msaaColor = ctx->createTexture({
+      .format     = kOffscreenFormat,
+      .dimensions = sizeFb,
+      .numSamples = kNumSamples,
+      .usage      = lvk::TextureUsageBits_Attachment,
+      .debugName  = "msaaColor",
+  });
+  lvk::Holder<lvk::TextureHandle> msaaDepth = ctx->createTexture({
+      .format     = app.getDepthFormat(),
+      .dimensions = sizeFb,
+      .numSamples = kNumSamples,
+      .usage      = lvk::TextureUsageBits_Attachment,
+      .debugName  = "msaaDepth",
+  });
+
+  const lvk::Dimensions sizeBloom = { 512, 512 };
+
+  lvk::Holder<lvk::TextureHandle> texBrightPass = ctx->createTexture({
+      .format     = kOffscreenFormat,
+      .dimensions = sizeBloom,
+      .usage      = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+      .debugName  = "texBrightPass",
+  });
+  lvk::Holder<lvk::TextureHandle> texBloomPass  = ctx->createTexture({
+       .format     = kOffscreenFormat,
+       .dimensions = sizeBloom,
+       .usage      = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+       .debugName  = "texBloomPass",
+  });
+
+  const lvk::ComponentMapping swizzle = { .r = lvk::Swizzle_R, .g = lvk::Swizzle_R, .b = lvk::Swizzle_R, .a = lvk::Swizzle_1 };
+
+  lvk::Holder<lvk::TextureHandle> texLuminanceViews[10] = { ctx->createTexture({
+      .format       = lvk::Format_R_F16,
+      .dimensions   = sizeBloom,
+      .usage        = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+      .numMipLevels = lvk::calcNumMipLevels(sizeBloom.width, sizeBloom.height),
+      .swizzle      = swizzle,
+      .debugName    = "texLuminance",
+  }) };
+
+  for (uint32_t l = 1; l != LVK_ARRAY_NUM_ELEMENTS(texLuminanceViews); l++) {
+    texLuminanceViews[l] = ctx->createTextureView(texLuminanceViews[0], { .mipLevel = l, .swizzle = swizzle });
+  }
+
+  const uint16_t brightPixel = glm::packHalf1x16(50.0f);
+
+  // ping-pong textures for iterative luminance adaptation
+  const lvk::TextureDesc luminanceTextureDesc{
+    .format     = lvk::Format_R_F16,
+    .dimensions = {1, 1},
+    .usage      = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+    .swizzle    = swizzle,
+    .data       = &brightPixel,
+  };
+  lvk::Holder<lvk::TextureHandle> texAdaptedLuminance[2] = {
+    ctx->createTexture(luminanceTextureDesc, "texAdaptedLuminance0"),
+    ctx->createTexture(luminanceTextureDesc, "texAdaptedLuminance1"),
+  };
+
+  lvk::Holder<lvk::TextureHandle> offscreenColor = ctx->createTexture({
+      .format     = kOffscreenFormat,
+      .dimensions = sizeFb,
+      .usage      = lvk::TextureUsageBits_Attachment | lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+      .debugName  = "offscreenColor",
+  });
+
+  lvk::Holder<lvk::TextureHandle> texBloom[] = {
+    ctx->createTexture({
+        .format     = kOffscreenFormat,
+        .dimensions = sizeBloom,
+        .usage      = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+        .debugName  = "texBloom0",
+    }),
+    ctx->createTexture({
+        .format     = kOffscreenFormat,
+        .dimensions = sizeBloom,
+        .usage      = lvk::TextureUsageBits_Sampled | lvk::TextureUsageBits_Storage,
+        .debugName  = "texBloom1",
+    }),
+  };
+
+  bool drawWireframe  = false;
+  bool drawCurves     = false;
+  bool enableBloom    = true;
+  float bloomStrength = 0.01f;
+  int numBloomPasses  = 2;
+  float adaptationSpeed = 1.0f;
+
+  enum ToneMappingMode {
+    ToneMapping_None       = 0,
+    ToneMapping_Reinhard   = 1,
+    ToneMapping_Uchimura   = 2,
+    ToneMapping_KhronosPBR = 3,
+  };
+
+  ImPlotContext* implotCtx = ImPlot::CreateContext();
+
+  struct {
+    uint32_t texColor;
+    uint32_t texLuminance;
+    uint32_t texBloom;
+    uint32_t sampler;
+    int drawMode = ToneMapping_Uchimura;
+
+    float exposure      = 1.0f;
+    float bloomStrength = 0.1f;
+
+    // Reinhard
+    float maxWhite = 1.0f;
+
+    // Uchimura
+    float P = 1.0f;  // max display brightness
+    float a = 1.05f; // contrast
+    float m = 0.1f;  // linear section start
+    float l = 0.8f;  // linear section length
+    float c = 3.0f;  // black tightness
+    float b = 0.0f;  // pedestal
+
+    // Khronos PBR
+    float startCompression = 0.8f;  // highlight compression start
+    float desaturation     = 0.15f; // desaturation speed
+  } pcHDR = {
+    .texColor     = offscreenColor.index(),
+    .texLuminance = texAdaptedLuminance[0].index(), // 1x1
+    .texBloom     = texBloomPass.index(),
+    .sampler      = samplerClamp.index(),
+  };
+
+  app.run([&](uint32_t width, uint32_t height, float aspectRatio, float deltaSeconds) {
+    const mat4 view = app.camera_.getViewMatrix();
+    const mat4 proj = glm::perspective(45.0f, aspectRatio, 0.01f, 1000.0f);
+
+    lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
+    {
+      // 1. Render scene
+      buf.cmdBeginRendering(
+          lvk::RenderPass{
+              .color = { { .loadOp = lvk::LoadOp_Clear, .storeOp = lvk::StoreOp_MsaaResolve, .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
+              .depth = { .loadOp = lvk::LoadOp_Clear, .clearDepth = 1.0f }
+      },
+          lvk::Framebuffer{
+              .color        = { { .texture = msaaColor, .resolveTexture = offscreenColor } },
+              .depthStencil = { .texture = msaaDepth },
+          });
+      skyBox.draw(buf, view, proj);
+      {
+        buf.cmdPushDebugGroupLabel("Mesh", 0xff0000ff);
+        mesh.draw(*ctx.get(), buf, view, proj, skyBox.texSkyboxIrradiance, drawWireframe);
+        buf.cmdPopDebugGroupLabel();
+      }
+      app.drawGrid(buf, proj, vec3(0, -1.0f, 0), kNumSamples, kOffscreenFormat);
+      buf.cmdEndRendering();
+
+      // 2. Bright pass - extract luminance and bright areas
+      const struct {
+        uint32_t texColor;
+        uint32_t texOut;
+        uint32_t texLuminance;
+        uint32_t sampler;
+        float exposure;
+      } pcBrightPass = {
+        .texColor     = offscreenColor.index(),
+        .texOut       = texBrightPass.index(),
+        .texLuminance = texLuminanceViews[0].index(),
+        .sampler      = samplerClamp.index(),
+        .exposure     = pcHDR.exposure,
+      };
+      buf.cmdBindComputePipeline(pipelineBrightPass);
+      buf.cmdPushConstants(pcBrightPass);
+      buf.cmdDispatchThreadGroups(
+          sizeBloom.divide2D(16), {
+                                      .textures = {lvk::TextureHandle(offscreenColor), lvk::TextureHandle(texLuminanceViews[0])}
+      });
+      buf.cmdGenerateMipmap(texLuminanceViews[0]);
+
+      // 2.1. Bloom
+      struct BlurPC {
+        uint32_t texIn;
+        uint32_t texOut;
+        uint32_t sampler;
+      };
+      struct StreaksPC {
+        uint32_t texIn;
+        uint32_t texOut;
+        uint32_t texRotationPattern;
+        uint32_t sampler;
+      };
+      struct BlurPass {
+        lvk::TextureHandle texIn;
+        lvk::TextureHandle texOut;
+      };
+      std::vector<BlurPass> passes;
+      {
+        passes.reserve(2 * numBloomPasses);
+        passes.push_back({ texBrightPass, texBloom[0] });
+        for (int i = 0; i != numBloomPasses - 1; i++) {
+          passes.push_back({ texBloom[0], texBloom[1] });
+          passes.push_back({ texBloom[1], texBloom[0] });
+        }
+        passes.push_back({ texBloom[0], texBloomPass });
+      }
+      for (uint32_t i = 0; i != passes.size(); i++) {
+        const BlurPass p = passes[i];
+        buf.cmdBindComputePipeline(i & 1 ? pipelineBloomX : pipelineBloomY);
+        buf.cmdPushConstants(BlurPC{
+            .texIn   = p.texIn.index(),
+            .texOut  = p.texOut.index(),
+            .sampler = samplerClamp.index(),
+        });
+        if (enableBloom)
+          buf.cmdDispatchThreadGroups(
+              sizeBloom.divide2D(16), {
+                                          .textures = {p.texIn, p.texOut, lvk::TextureHandle(texBrightPass)}
+          });
+      }
+
+      // 3. Light adaptation pass
+      const struct {
+        uint32_t texCurrSceneLuminance;
+        uint32_t texPrevAdaptedLuminance;
+        uint32_t texNewAdaptedLuminance;
+        float adaptationSpeed;
+      } pcAdaptationPass = {
+        .texCurrSceneLuminance   = texLuminanceViews[LVK_ARRAY_NUM_ELEMENTS(texLuminanceViews) - 1].index(), // 1x1,
+        .texPrevAdaptedLuminance = texAdaptedLuminance[0].index(),
+        .texNewAdaptedLuminance  = texAdaptedLuminance[1].index(),
+        .adaptationSpeed         = 100.0f * deltaSeconds * adaptationSpeed,
+      };
+      buf.cmdBindComputePipeline(pipelineAdaptationPass);
+      buf.cmdPushConstants(pcAdaptationPass);
+      buf.cmdDispatchThreadGroups(
+          {
+              1, 1, 1
+      },
+          { .textures = {
+                lvk::TextureHandle(texLuminanceViews[0]), // transition the entire mip-pyramid
+                lvk::TextureHandle(texAdaptedLuminance[0]),
+                lvk::TextureHandle(texAdaptedLuminance[1]),
+            } });
+
+      // 4. Render tone-mapped scene into a swapchain image
+      const lvk::RenderPass renderPassMain = {
+        .color = { { .loadOp = lvk::LoadOp_Load, .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
+      };
+      const lvk::Framebuffer framebufferMain = {
+        .color = { { .texture = ctx->getCurrentSwapchainTexture() } },
+      };
+
+      // transition the entire mip-pyramid
+      buf.cmdBeginRendering(renderPassMain, framebufferMain, { .textures = { lvk::TextureHandle(texAdaptedLuminance[1]) } });
+
+      buf.cmdBindRenderPipeline(pipelineToneMap);
+      buf.cmdPushConstants(pcHDR);
+      buf.cmdBindDepthState({});
+      buf.cmdDraw(3); // fullscreen triangle
+
+      app.imgui_->beginFrame(framebufferMain);
+      app.drawFPS();
+      app.drawMemo();
+
+      // render UI
+      {
+        const ImGuiViewport* v  = ImGui::GetMainViewport();
+        const float windowWidth = v->WorkSize.x / 5;
+        ImGui::SetNextWindowPos(ImVec2(10, 200));
+        ImGui::SetNextWindowSize(ImVec2(windowWidth, v->WorkSize.y - 210));
+        ImGui::Begin("HDR", nullptr, ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize);
+        ImGui::Checkbox("Draw wireframe", &drawWireframe);
+        ImGui::Checkbox("Draw tone mapping curves", &drawCurves);
+        ImGui::Separator();
+        const float indentSize = 32.0f;
+        ImGui::Text("Tone mapping params:");
+        ImGui::SliderFloat("Exposure", &pcHDR.exposure, 0.1f, 2.0f);
+        ImGui::SliderFloat("Adaptation speed", &adaptationSpeed, 0.1f, 2.0f);
+        ImGui::Checkbox("Enable bloom", &enableBloom);
+        pcHDR.bloomStrength = enableBloom ? bloomStrength : 0.0f;
+        ImGui::BeginDisabled(!enableBloom);
+        ImGui::Indent(indentSize);
+        ImGui::SliderFloat("Bloom strength", &bloomStrength, 0.0f, 1.0f);
+        ImGui::SliderInt("Bloom num passes", &numBloomPasses, 1, 5);
+        ImGui::Unindent(indentSize);
+        ImGui::EndDisabled();
+        ImGui::Text("Tone mapping mode:");
+        ImGui::RadioButton("None", &pcHDR.drawMode, ToneMapping_None);
+        ImGui::RadioButton("Reinhard", &pcHDR.drawMode, ToneMapping_Reinhard);
+        if (pcHDR.drawMode == ToneMapping_Reinhard) {
+          ImGui::Indent(indentSize);
+          ImGui::BeginDisabled(pcHDR.drawMode != ToneMapping_Reinhard);
+          ImGui::SliderFloat("Max white", &pcHDR.maxWhite, 0.5f, 2.0f);
+          ImGui::EndDisabled();
+          ImGui::Unindent(indentSize);
+        }
+        ImGui::RadioButton("Uchimura", &pcHDR.drawMode, ToneMapping_Uchimura);
+        if (pcHDR.drawMode == ToneMapping_Uchimura) {
+          ImGui::Indent(indentSize);
+          ImGui::BeginDisabled(pcHDR.drawMode != ToneMapping_Uchimura);
+          ImGui::SliderFloat("Max brightness", &pcHDR.P, 1.0f, 2.0f);
+          ImGui::SliderFloat("Contrast", &pcHDR.a, 0.0f, 5.0f);
+          ImGui::SliderFloat("Linear section start", &pcHDR.m, 0.0f, 1.0f);
+          ImGui::SliderFloat("Linear section length", &pcHDR.l, 0.0f, 1.0f);
+          ImGui::SliderFloat("Black tightness", &pcHDR.c, 1.0f, 3.0f);
+          ImGui::SliderFloat("Pedestal", &pcHDR.b, 0.0f, 1.0f);
+          ImGui::EndDisabled();
+          ImGui::Unindent(indentSize);
+        }
+        ImGui::RadioButton("Khronos PBR Neutral", &pcHDR.drawMode, ToneMapping_KhronosPBR);
+        if (pcHDR.drawMode == ToneMapping_KhronosPBR) {
+          ImGui::Indent(indentSize);
+          ImGui::SliderFloat("Highlight compression start", &pcHDR.startCompression, 0.0f, 1.0f);
+          ImGui::SliderFloat("Desaturation speed", &pcHDR.desaturation, 0.0f, 1.0f);
+          ImGui::Unindent(indentSize);
+        }
+        ImGui::Separator();
+
+        ImGui::Text("Average luminance 1x1:");
+        ImGui::Image(texLuminanceViews[LVK_ARRAY_NUM_ELEMENTS(texLuminanceViews) - 1].index(), ImVec2(128, 128));
+        ImGui::Text("Adapted luminance 1x1:");
+        ImGui::Image(texAdaptedLuminance[0].index(), ImVec2(128, 128));
+        ImGui::Separator();
+        ImGui::Text("Bright pass:");
+        ImGui::Image(texBrightPass.index(), ImVec2(windowWidth, windowWidth / aspectRatio));
+        ImGui::Text("Bloom pass:");
+        ImGui::Image(texBloomPass.index(), ImVec2(windowWidth, windowWidth / aspectRatio));
+        ImGui::Separator();
+        ImGui::Text("Luminance pyramid 512x512");
+        for (uint32_t l = 0; l != LVK_ARRAY_NUM_ELEMENTS(texLuminanceViews); l++) {
+          ImGui::Image(texLuminanceViews[l].index(), ImVec2((int)windowWidth >> l, ((int)windowWidth >> l)));
+        }
+        ImGui::Separator();
+        ImGui::End();
+
+        if (drawCurves) {
+          const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize |
+                                         ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav;
+          ImGui::SetNextWindowBgAlpha(0.8f);
+          ImGui::SetNextWindowPos({ width * 0.6f, height * 0.7f }, ImGuiCond_Appearing);
+          ImGui::SetNextWindowSize({ width * 0.4f, height * 0.3f });
+          ImGui::Begin("Tone mapping curve", nullptr, flags);
+          const int kNumGraphPoints = 1001;
+          float xs[kNumGraphPoints];
+          float ysUnchimura[kNumGraphPoints];
+          float ysReinhard2[kNumGraphPoints];
+          float ysKhronosPBR[kNumGraphPoints];
+          for (int i = 0; i != kNumGraphPoints; i++) {
+            xs[i]           = float(i) / kNumGraphPoints;
+            ysUnchimura[i]  = uchimura(xs[i], pcHDR.P, pcHDR.a, pcHDR.m, pcHDR.l, pcHDR.c, pcHDR.b);
+            ysReinhard2[i]  = reinhard2(xs[i], pcHDR.maxWhite);
+            ysKhronosPBR[i] = PBRNeutralToneMapping(xs[i], pcHDR.startCompression, pcHDR.desaturation);
+          }
+          if (ImPlot::BeginPlot("Tone mapping curves", { width * 0.4f, height * 0.3f }, ImPlotFlags_NoInputs)) {
+            ImPlot::SetupAxes("Input", "Output");
+            ImPlot::PlotLine("Uchimura", xs, ysUnchimura, kNumGraphPoints);
+            ImPlot::PlotLine("Reinhard", xs, ysReinhard2, kNumGraphPoints);
+            ImPlot::PlotLine("Khronos PBR", xs, ysKhronosPBR, kNumGraphPoints);
+            ImPlot::EndPlot();
+          }
+          ImGui::End();
+        }
+      }
+
+      app.imgui_->endFrame(buf);
+
+      buf.cmdEndRendering();
+    }
+    ctx->submit(buf, ctx->getCurrentSwapchainTexture());
+
+    // swap ping-bong textures
+    std::swap(texAdaptedLuminance[0], texAdaptedLuminance[1]);
+  });
+
+  ImPlot::DestroyContext(implotCtx);
+
+  ctx.release();
+
+  return 0;
+}
diff --git a/Chapter10/Bistro.h b/Chapter10/Bistro.h
new file mode 100644
index 0000000..c651a9e
--- /dev/null
+++ b/Chapter10/Bistro.h
@@ -0,0 +1,78 @@
+#pragma once
+
+#include "shared/Scene/MergeUtil.h"
+#include "shared/Scene/Scene.h"
+#include "shared/Scene/VtxData.h"
+
+#include "Chapter08/SceneUtils.h"
+#include "Chapter08/VKMesh08.h"
+
+const char* fileNameCachedMeshes    = ".cache/ch08_bistro.meshes";
+const char* fileNameCachedMaterials = ".cache/ch08_bistro.materials";
+const char* fileNameCachedHierarchy = ".cache/ch08_bistro.scene";
+
+void loadBistro(MeshData& meshData, Scene& scene) {
+  if (!isMeshDataValid(fileNameCachedMeshes) || !isMeshHierarchyValid(fileNameCachedHierarchy) ||
+      !isMeshMaterialsValid(fileNameCachedMaterials)) {
+    printf("No cached mesh data found. Precaching...\n\n");
+
+    MeshData meshData_Exterior;
+    MeshData meshData_Interior;
+    Scene ourScene_Exterior;
+    Scene ourScene_Interior;
+
+    // don't generate LODs because meshoptimizer fails on the Bistro mesh
+    loadMeshFile("deps/src/bistro/Exterior/exterior.obj", meshData_Exterior, ourScene_Exterior, false);
+    loadMeshFile("deps/src/bistro/Interior/interior.obj", meshData_Interior, ourScene_Interior, false);
+
+    // merge some meshes
+    printf("[Unmerged] scene items: %u\n", (uint32_t)ourScene_Exterior.hierarchy.size());
+    mergeNodesWithMaterial(ourScene_Exterior, meshData_Exterior, "Foliage_Linde_Tree_Large_Orange_Leaves");
+    printf("[Merged orange leaves] scene items: %u\n", (uint32_t)ourScene_Exterior.hierarchy.size());
+    mergeNodesWithMaterial(ourScene_Exterior, meshData_Exterior, "Foliage_Linde_Tree_Large_Green_Leaves");
+    printf("[Merged green leaves]  scene items: %u\n", (uint32_t)ourScene_Exterior.hierarchy.size());
+    mergeNodesWithMaterial(ourScene_Exterior, meshData_Exterior, "Foliage_Linde_Tree_Large_Trunk");
+    printf("[Merged trunk]  scene items: %u\n", (uint32_t)ourScene_Exterior.hierarchy.size());
+
+    // merge everything into one big scene
+    MeshData meshData;
+    Scene ourScene;
+
+    mergeScenes(
+        ourScene,
+        {
+            &ourScene_Exterior,
+            &ourScene_Interior,
+        },
+        {},
+        {
+            static_cast<uint32_t>(meshData_Exterior.meshes.size()),
+            static_cast<uint32_t>(meshData_Interior.meshes.size()),
+        });
+    mergeMeshData(meshData, { &meshData_Exterior, &meshData_Interior });
+    mergeMaterialLists(
+        {
+            &meshData_Exterior.materials,
+            &meshData_Interior.materials,
+        },
+        {
+            &meshData_Exterior.textureFiles,
+            &meshData_Interior.textureFiles,
+        },
+        meshData.materials, meshData.textureFiles);
+
+    ourScene.localTransform[0] = glm::scale(vec3(0.01f)); // scale the Bistro
+    markAsChanged(ourScene, 0);
+
+    recalculateBoundingBoxes(meshData);
+
+    saveMeshData(fileNameCachedMeshes, meshData);
+    saveMeshDataMaterials(fileNameCachedMaterials, meshData);
+    saveScene(fileNameCachedHierarchy, ourScene);
+  }
+
+  const MeshFileHeader header = loadMeshData(fileNameCachedMeshes, meshData);
+  loadMeshDataMaterials(fileNameCachedMaterials, meshData);
+
+  loadScene(fileNameCachedHierarchy, scene);
+}
diff --git a/Chapter10/Skybox.h b/Chapter10/Skybox.h
new file mode 100644
index 0000000..eacd7f3
--- /dev/null
+++ b/Chapter10/Skybox.h
@@ -0,0 +1,48 @@
+#pragma once
+
+class Skybox
+{
+public:
+  Skybox(
+      const std::unique_ptr<lvk::IContext>& ctx, const char* skyboxTexture, const char* skyboxIrradiance, lvk::Format colorFormat,
+      lvk::Format depthFormat, uint32_t numSamples = 1)
+  {
+    texSkybox           = loadTexture(ctx, skyboxTexture, lvk::TextureType_Cube);
+    texSkyboxIrradiance = loadTexture(ctx, skyboxIrradiance, lvk::TextureType_Cube);
+
+    vertSkybox     = loadShaderModule(ctx, "Chapter08/02_SceneGraph/src/skybox.vert");
+    fragSkybox     = loadShaderModule(ctx, "Chapter08/02_SceneGraph/src/skybox.frag");
+    pipelineSkybox = ctx->createRenderPipeline({
+        .smVert       = vertSkybox,
+        .smFrag       = fragSkybox,
+        .color        = { { .format = colorFormat } },
+        .depthFormat  = depthFormat,
+        .samplesCount = numSamples,
+    });
+  }
+
+  void draw(lvk::ICommandBuffer& buf, const mat4& view, const mat4& proj) const
+  {
+    buf.cmdPushDebugGroupLabel("Skybox", 0xff0000ff);
+    buf.cmdBindRenderPipeline(pipelineSkybox);
+    const struct {
+      mat4 view;
+      mat4 proj;
+      uint32_t texSkybox;
+    } pc = {
+      .view      = view,
+      .proj      = proj,
+      .texSkybox = texSkybox.index(),
+    };
+    buf.cmdPushConstants(pc);
+    buf.cmdBindDepthState({ .isDepthWriteEnabled = false });
+    buf.cmdDraw(36);
+    buf.cmdPopDebugGroupLabel();
+  }
+
+  lvk::Holder<lvk::TextureHandle> texSkybox;
+  lvk::Holder<lvk::TextureHandle> texSkyboxIrradiance;
+  lvk::Holder<lvk::ShaderModuleHandle> vertSkybox;
+  lvk::Holder<lvk::ShaderModuleHandle> fragSkybox;
+  lvk::Holder<lvk::RenderPipelineHandle> pipelineSkybox;
+};
diff --git a/deps/bootstrap.json b/deps/bootstrap.json
index aa80fde..3306bf4 100644
--- a/deps/bootstrap.json
+++ b/deps/bootstrap.json
@@ -4,7 +4,7 @@
     "source": {
         "type": "git",
         "url": "https://github.com/corporateshark/lightweightvk.git",
-        "revision": "85f753c7b7ff92b3f45704446632436db7a6191b"
+        "revision": "d653552f0f7e6f1fd19752e4f2f9258d6151408b"
     }
 },
 {