Light Propagation in Voxel Cone Tracing with GLSL
This article explains the light propagation algorithm across 3D voxel grids in Voxel Cone Tracing (VCT) using the OpenGL Shading Language (GLSL). It covers the direct radiance injection phase, the generation of anisotropic 3D mipmap chains to simulate directional light transfer, and the mathematical implementation of cone tracing compute shaders for gathering indirect diffuse and specular illumination in real time.
1. Direct Radiance Injection
The light propagation pipeline begins by discretizing scene geometry into a continuous 3D texture using conservative rasterization. Once surface voxels are generated with material properties (albedo, normal, and roughness), direct illumination is computed for every occupied voxel.
A GLSL compute shader or fragment shader evaluates shadow maps or
analytical lights for each voxel. The resulting outgoing radiance \(L_o\) and opacity \(\alpha\) are written into a 3D texture
(layout(rgba8) uniform image3D voxelTexture or
rgba16f for high dynamic range):
// Direct illumination injection
vec3 directLight = calculateDirectLighting(voxelPos, normal, lightDir);
vec4 radianceOpacity = vec4(directLight * albedo.rgb, 1.0);
imageStore(voxelRadianceGrid, ivec3(voxelPos), radianceOpacity);2. Anisotropic Mipmap Generation
Standard isotropic 3D mipmapping averages adjacent voxels uniformly, which causes severe light leaking through thin walls. To propagate light accurately across spatial dimensions, VCT uses anisotropic mipmapping. Each mip level stores directional occlusion and radiance values along the six primary axes: \(+X, -X, +Y, -Y, +Z, -Z\).
A downsampling compute shader reads eight child voxels from level \(n-1\) and blends them into one parent voxel at level \(n\). The blend weights respect the direction of light travel:
layout(local_size_x = 8, local_size_y = 8, local_size_z = 8) in;
uniform sampler3D srcMipLevel;
layout(rgba16f) uniform writeonly image3D dstMipLevelPositiveX;
void main() {
ivec3 dstCoord = ivec3(gl_GlobalInvocationID.xyz);
ivec3 srcCoord = dstCoord * 2;
// Fetch adjacent values along the propagation axis
vec4 c0 = texelFetch(srcMipLevel, srcCoord + ivec3(0, 0, 0), 0);
vec4 c1 = texelFetch(srcMipLevel, srcCoord + ivec3(1, 0, 0), 0);
// Front-to-back directional accumulation for +X propagation
vec4 accumulated = c0 + c1 * (1.0 - c0.a);
imageStore(dstMipLevelPositiveX, dstCoord, accumulated);
}This directional aggregation allows secondary light transport to be simulated hierarchically. Lower-resolution mip levels inherently represent filtered, wider-angle radiance distributed across larger spatial distances.
3. Gathering Radiance via Cone Tracing
Light propagation to arbitrary surface points during final rendering is calculated by marching cones through the anisotropic 3D texture hierarchy. A cone is defined by an origin point \(\mathbf{o}\) (offset along the surface normal to avoid self-occlusion), a normalized direction vector \(\mathbf{d}\), and an aperture angle \(\theta\).
As the ray advances, the cone diameter increases proportionally to distance \(t\):
\[\text{diameter} = 2 \cdot t \cdot \tan\left(\frac{\theta}{2}\right)\]
The algorithm samples the 3D texture at a mip level \(L\) derived from the cone diameter relative to the base voxel size:
\[L = \log_2\left(\frac{\text{diameter}}{\text{voxelSize}}\right)\]
At each step, accumulated color and opacity are updated using front-to-back compositing:
\[\mathbf{C}_{\text{acc}} \leftarrow \mathbf{C}_{\text{acc}} + (1 - \alpha_{\text{acc}}) \cdot \mathbf{C}_{\text{sample}} \cdot \alpha_{\text{sample}}\]
\[\alpha_{\text{acc}} \leftarrow \alpha_{\text{acc}} + (1 - \alpha_{\text{acc}}) \cdot \alpha_{\text{sample}}\]
The step size is dynamically scaled with the current cone diameter to ensure continuous integration without oversampling.
4. GLSL Implementation of Cone Sampling
The following GLSL function executes the light gathering algorithm across the 3D texture hierarchy:
uniform sampler3D voxelGrid;
uniform float voxelGridResolution; // e.g., 256.0
vec4 coneTrace(vec3 origin, vec3 direction, float aperture) {
float voxelSize = 1.0 / voxelGridResolution;
float dist = voxelSize * 2.0; // Offset start past surface boundary
vec4 accumulated = vec4(0.0);
while (dist < 1.732 && accumulated.a < 0.95) { // 1.732 is unit cube diagonal
vec3 samplePos = origin + direction * dist;
// Ensure sample remains inside grid boundaries [0, 1]
if (any(lessThan(samplePos, vec3(0.0))) || any(greaterThan(samplePos, vec3(1.0)))) {
break;
}
float diameter = 2.0 * dist * tan(aperture * 0.5);
float mipLevel = log2(max(diameter * voxelGridResolution, 1.0));
// Sample anisotropic or filtered 3D texture
vec4 voxelSample = textureLod(voxelGrid, samplePos, mipLevel);
// Front-to-back alpha blending
accumulated.rgb += (1.0 - accumulated.a) * voxelSample.rgb * voxelSample.a;
accumulated.a += (1.0 - accumulated.a) * voxelSample.a;
// Step forward by a fraction of current diameter to avoid gaps
dist += max(diameter * 0.5, voxelSize);
}
return accumulated;
}For diffuse indirect illumination, multiple cones (typically 5 to 9) are traced over the hemisphere oriented along the surface normal. For specular reflections, a single cone oriented along the reflection vector \(\mathbf{r} = \text{reflect}(-\mathbf{v}, \mathbf{n})\) is traced, with the aperture angle determined by surface roughness. Repeated execution across frames allows multi-bounce global illumination to settle progressively.