11 KiB
11 KiB
Image-Based Lighting (IBL)
Overview
- IBL assets (environment maps + BRDF LUT + SH coefficients) are managed by
IBLManager(src/core/assets/ibl_manager.{h,cpp}) and exposed to passes viaEngineContext::ibl. - Shaders share a common include,
shaders/ibl_common.glsl, which defines the IBL bindings for descriptor set 3 and helper functions used by deferred, forward, and background passes. - The engine currently supports:
- Specular environment from an equirectangular 2D texture with prefiltered mips (
sampler2D iblSpec2D). - Diffuse irradiance from 2nd-order SH (9 coefficients baked on the CPU).
- A 2D BRDF integration LUT used for the split-sum approximation.
- An optional separate background environment texture (
sampler2D iblBackground2D); when not provided, the system falls back to using the specular environment for background rendering.
- Specular environment from an equirectangular 2D texture with prefiltered mips (
Data Flow
- Init:
VulkanEngine::init_vulkan()creates anIBLManager, callsinit(context), and publishes it viaEngineContext::ibl.- The engine optionally loads default IBL assets (
IBLPathsinsrc/core/engine.cpp), typically a BRDF LUT plus a specular environment.ktx2.
- Loading (IBLManager):
IBLManager::load(const IBLPaths&)(synchronous, mostly used in tools/tests):- Specular:
- Tries
ktxutil::load_ktx2_cubemapfirst. If successful, uploads viaResourceManager::create_image_compressed_layerswithVK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT. - If cubemap loading fails, falls back to 2D
.ktx2viaktxutil::load_ktx2_2dandcreate_image_compressed. The image is treated as equirectangular with prefiltered mips. - When the specular
.ktx2is HDR (R16G16B16A16_SFLOATorR32G32B32A32_SFLOAT) and 2:1 aspect,IBLManagercomputes 9 SH coefficients on the CPU:- Integrates the environment over the sphere using real SH basis functions (L2) with solid-angle weighting.
- Applies Lambert band scaling (A0 = pi, A1 = 2pi/3, A2 = pi/4).
- Uploads the result as
vec4 sh[9]in a uniform buffer (_shBuffer).
- Tries
- Diffuse:
- If
IBLPaths::diffuseCubeis provided and valid, loads it as a cubemap viaload_ktx2_cubemap+create_image_compressed_layers. - Current shaders only use the SH buffer for diffuse; the diffuse cubemap is reserved for future variants.
- If
- Background:
- If
IBLPaths::background2Dis provided and valid, loads it as a 2D equirectangular.ktx2viaload_ktx2_2d+create_image_compressed. - This allows using a separate, potentially higher-resolution or unfiltered environment for the sky background while using a prefiltered version for specular IBL.
- If
- BRDF LUT:
- Loaded as 2D
.ktx2viaktxutil::load_ktx2_2dand uploaded withcreate_image_compressed.
- Loaded as 2D
- Fallbacks:
- If
diffuseCubeis missing but a specular env exists,_diffis aliased to_spec. - If
background2Dis missing but a specular env exists,_backgroundis aliased to_spec.
- If
- Specular:
IBLManager::unload()releases GPU images, the SH buffer, and the descriptor set layout.
- Descriptor layout:
IBLManager::ensureLayout()builds a descriptor set layout (set=3) with:- binding 0:
COMBINED_IMAGE_SAMPLER- specular environment (2D equirect). - binding 1:
COMBINED_IMAGE_SAMPLER- BRDF LUT 2D. - binding 2:
UNIFORM_BUFFER- SH coefficients (vec4 sh[9]). - binding 3:
COMBINED_IMAGE_SAMPLER- background environment (2D equirect, optional).
- binding 0:
- Passes request this layout from
EngineContext::ibland plug it into their pipeline set layouts:- Background:
vk_renderpass_background.cpp(set 3 used for env background). - Lighting:
vk_renderpass_lighting.cpp(deferred lighting pass, set 3). - Transparent:
vk_renderpass_transparent.cpp(forward/transparent materials, set 3).
- Background:
Asynchronous Loading
- Overview:
IBLManagerprovides an asynchronous loading path viaload_async()+pump_async()to avoid blocking the main/game loop during IBL environment switches or initial loading.- Heavy CPU work (KTX2 file I/O, decompression, SH coefficient baking) runs on a dedicated worker thread.
- GPU resource creation (image uploads, buffer allocation) is deferred to the main thread via
pump_async().
- API:
bool load_async(const IBLPaths &paths):- Queues an asynchronous IBL load job.
- Returns
falseif the job could not be queued (e.g., context not initialized). - If called while a previous job is still pending, the new request supersedes the old one (the old result is discarded when ready).
struct AsyncResult { bool completed; bool success; }:completed:truewhen an async job finished since the lastpump_async()call.success:truewhen the finished job successfully produced new GPU IBL resources.
AsyncResult pump_async():- Must be called on the main thread, typically once per frame after the previous frame's GPU work is idle.
- If a completed async job is pending:
- Destroys old IBL images and SH buffer via
destroy_images_and_sh(). - Creates new GPU images with
create_image_compressed(_layers)and uploads the SH buffer.
- Destroys old IBL images and SH buffer via
- Returns
AsyncResultindicating whether a job completed and its success status.
- Internal Architecture:
IBLManager::init()spawns a persistent worker thread that waits on a condition variable.- When
load_async()is called:- The request paths and a unique job ID are stored in
AsyncStateData. - The worker thread is signaled via condition variable.
- Any previous pending result is invalidated (superseded by the new job ID).
- The request paths and a unique job ID are stored in
- Worker thread execution:
- Calls
prepare_ibl_cpu()to load KTX2 files and bake SH coefficients. - Stores the prepared data (
PreparedIBLData) inAsyncStateData. - Marks the result as ready with the corresponding job ID.
- If the job ID no longer matches (superseded), the result is discarded.
- Calls
- Main thread integration (
pump_async()):- Checks if a result is ready.
- If ready, calls
commit_prepared()to create GPU resources from the prepared CPU data. - Clears the ready flag and returns the result status.
- Thread Safety:
- All shared state in
AsyncStateDatais protected by a mutex. - The worker thread only reads request data and writes result data.
- The main thread only reads result data and writes request data.
- GPU resource creation is strictly on the main thread.
- All shared state in
- Usage Example:
// Queue async IBL load (non-blocking) iblManager->load_async(IBLPaths{ .specularCube = "assets/ibl/studio_spec.ktx2", .brdfLut2D = "assets/ibl/brdf_lut.ktx2", .background2D = "assets/ibl/studio_bg.ktx2" }); // In main loop, after waiting for previous frame: auto result = iblManager->pump_async(); if (result.completed) { if (result.success) { // New IBL environment is now active } else { // Loading failed, handle error (e.g., keep previous IBL) } } - Benefits:
- No frame stalls when loading large HDR environment maps.
- Seamless IBL volume transitions (e.g., entering a building with different lighting).
- SH baking (CPU-intensive) happens off the main thread.
- Cleanup:
IBLManager::unload()shuts down the async worker thread (joins) and releases all GPU resources.- The destructor also calls
shutdown_async()to ensure clean termination.
Shader Side (shaders/ibl_common.glsl)
- Bindings:
layout(set=3, binding=0) uniform sampler2D iblSpec2D;layout(set=3, binding=1) uniform sampler2D iblBRDF;layout(std140, set=3, binding=2) uniform IBL_SH { vec4 sh[9]; } iblSH;layout(set=3, binding=3) uniform sampler2D iblBackground2D;
- Helpers:
vec3 sh_eval_irradiance(vec3 n):- Evaluates the 9 SH basis functions (L2) at direction
nusing the same real SH basis as the CPU bake. - Multiplies each basis value by the corresponding
iblSH.sh[i].rgbcoefficient and sums the result. - Coefficients are already convolved with the Lambert kernel on the CPU; the function returns diffuse irradiance directly.
- Evaluates the 9 SH basis functions (L2) at direction
vec2 dir_to_equirect(vec3 d):- Normalizes
d, computes(phi, theta)and returns equirectangular UV in[0,1]^2. - Used consistently by background, deferred, and forward pipelines.
- Normalizes
float ibl_lod_from_roughness(float roughness, float levels):- Computes the mip LOD for specular IBL using
roughness^2 * (levels - 1). - This biases mid-roughness reflections towards blurrier mips and avoids overly sharp reflections.
- Computes the mip LOD for specular IBL using
Usage in Passes
- Deferred lighting (
shaders/deferred_lighting.fragandshaders/deferred_lighting_nort.frag):- Include:
#include "input_structures.glsl"#include "ibl_common.glsl"
- IBL contribution (per pixel):
- Specular:
vec3 R = reflect(-V, N);float levels = float(textureQueryLevels(iblSpec2D));float lod = ibl_lod_from_roughness(roughness, levels);vec2 uv = dir_to_equirect(R);vec3 prefiltered = textureLod(iblSpec2D, uv, lod).rgb;vec2 brdf = texture(iblBRDF, vec2(max(dot(N,V),0.0), roughness)).rg;vec3 specIBL = prefiltered * (F0 * brdf.x + brdf.y);
- Diffuse:
vec3 diffIBL = (1.0 - metallic) * albedo * sh_eval_irradiance(N);
- Combined:
color += diffIBL + specIBL;
- Specular:
- Include:
- Forward/transparent (
shaders/mesh.frag):- Same include and IBL logic as deferred, applied after direct lighting.
- Uses the same
ibl_lod_from_roughnesshelper for LOD selection.
- Background (
shaders/background_env.frag):- Includes
ibl_common.glsland usesdir_to_equirect(worldDir)+textureLod(iblBackground2D, uv, 0.0)to render the environment at LOD 0. - When a dedicated background texture is provided via
IBLPaths::background2D, the background pass renders fromiblBackground2Dwhich may differ fromiblSpec2D.
- Includes
Authoring IBL Assets
- Specular environment:
- Preferred: prefiltered HDR cubemap in
.ktx2(BC6H orR16G16B16A16_SFLOAT) with multiple mips. - Alternative: prefiltered equirectangular 2D
.ktx2with width = 2 x height and full mip chain. - Make sure the mip chain is generated with a GGX importance sampling tool so the BRDF LUT + mip chain match.
- Preferred: prefiltered HDR cubemap in
- BRDF LUT:
- A standard 2D preintegrated GGX LUT (RG), usually stored as
R8G8_UNORMor BC5. - The LUT is sampled with
(NoV, roughness)coordinates.
- A standard 2D preintegrated GGX LUT (RG), usually stored as
- Diffuse:
- The engine currently uses SH coefficients baked from the specular equirectangular map. If you provide a separate diffuse cubemap, the CPU SH bake still uses the specular HDR; you can adjust this in
IBLManagerif you want SH to come from a different source.
- The engine currently uses SH coefficients baked from the specular equirectangular map. If you provide a separate diffuse cubemap, the CPU SH bake still uses the specular HDR; you can adjust this in
- Background:
- Optional: equirectangular 2D
.ktx2used exclusively for the sky background pass. - Useful when you want a sharper or unfiltered environment for the visible sky while using a prefiltered version for specular reflections.
- If not provided, the system falls back to using
specularCubefor background rendering.
- Optional: equirectangular 2D
Implementation Notes
- CPU SH bake:
- Implemented in
IBLManager::loadusing libktx to access raw HDR pixel data from.ktx2. - Uses a simple nested loop over pixels with solid-angle weighting and the same SH basis as
sh_eval_irradiance.
- Implemented in
- Fallbacks:
- Lighting and transparent passes create small fallback textures so that the IBL descriptor set is always valid, even when no IBL assets are loaded.
- Background pass builds a 1x1x6 black cube as a fallback env.
- When
background2Dis not provided,IBLManager::background()returns the same image asspecular().