Global Illumination

Table of Contents

Overview

The main goal of this project was to improve the lighting quality in our game engine by adding a source of indirect global illumination. Realistic lighting is a crucial aspect of creating immersive and visually appealing virtual environments, and global illumination (GI) plays a significant role in achieving this. GI simulates the way light bounces and reflects off surfaces, creating indirect lighting effects that contribute to the overall realism and atmosphere of a scene.

The GI method I chose to implement was grid-placed light probes containing order 3 spherical harmonic coefficients. This approach is a classic and practical way of approximating global illumination, as it strikes a balance between visual quality and performance. Light probes are strategically placed throughout the scene, capturing and encoding the incident lighting information using spherical harmonics, a mathematical representation that allows for efficient storage and interpolation of lighting data.

By incorporating this technique into our engine, we aimed to enhance the visual fidelity of our scenes, creating more realistic and natural-looking lighting conditions that better mimic the behaviour of light in the real world.

Results

Here is a showcase of some test scenes, with and without global illumination enabled:

In this Cornell box scene, the impact of global illumination is clearly visible. Without GI, the objects appear flat and lack the color bleeding effects that occur when light bounces off colored surfaces. With GI enabled, the red and green walls cast a tint on the nearby objects, creating a more realistic and visually pleasing result. The added indirect lighting helps to soften shadows and enhance the overall sense of depth and dimensionality in the scene.

The box scene with a top opening demonstrates the importance of indirect lighting in enclosed environments. Without GI, only the areas directly exposed to the light source are illuminated, leaving the rest of the interior in complete darkness. With GI enabled, the light entering through the opening bounces off the interior surfaces, spreading illumination to areas that are not in direct line of sight of the light source. This creates a more realistic and natural-looking result, as it mimics how light behaves in real-world enclosed spaces, enhancing the overall sense of depth and realism in the scene.

Performance-wise, this method of implementing global illumination is relatively cheap in terms of runtime overhead, as the precomputed lighting data can be efficiently accessed and interpolated during rendering.

Techical Implementation

The process of baking a light probe could be broken down into a 3 step process:

  • Render the scene into a cube map as our radiance map.
  • Convert the radiance map to a irradiance map.
  • Project the irradiance map into spherical harmonic values.

In total I ended up with this function:

void Light_Probe_Manager::update_probe(int index, std::function<void(Cube_Map_Render_Target*)> render_func)
{
    Light_Probe& light_probe = light_probes[index];

    cube_map_radiance_target.SetPosition(light_probe.position);

    render_func(&cube_map_radiance_target);

    CubeMapRenderTarget* cube_map = &cube_map_radiance_target;
#if ENABLE_IRRADIANCE
    convert_radiance_to_irradiance_map(&cube_map_radiance_target, &cube_map_irradiance_target);
    cube_map = &cube_map_irradiance_target;
#endif

    float r[9] = {};
    float g[9] = {};
    float b[9] = {};
    project_cube_map_to_sh(r, g, b, cube_map);

    memcpy(light_probe.r, r, sizeof(r));
    memcpy(light_probe.g, g, sizeof(g));
    memcpy(light_probe.b, b, sizeof(b));
}
Then to use this in a pixel shader I sample the 8 closest light probes and tri-linearly interpolate in between them to get smooth transitions between light probes.
I ended up with this as my sampling code:
float3 color[8];
for (int i = 0; i < 8; i++)
{
    Light_Probe probe = light_probe_buffer[indices[i]];

    float basis[9];
    sh_eval_basis_2(dir, basis);

    color[i].r = sh_dot_order3(basis, probe.r);
    color[i].g = sh_dot_order3(basis, probe.g);
    color[i].b = sh_dot_order3(basis, probe.b);
}
An example of non-interpolated and interpolated: