GLSL samplerCubeArray for Parallax-Corrected Probes

This article explains how the GLSL samplerCubeArray type provides the architectural foundation for multi-probe localized image-based lighting (IBL). By aggregating multiple cubemaps into a single texture array, graphics pipelines can implement parallax-corrected reflection probes across complex scenes without the CPU overhead of binding individual textures per object or light probe.

The Challenge of Multi-Probe Image-Based Lighting

Standard cubemap reflections sample environment maps assuming the captured scene is infinitely distant. For indoor or localized environments, this creates visible divergence: reflections slide across surfaces instead of anchoring to the surrounding walls, floors, and ceilings.

To fix this, engines use parallax correction, typically modeling the probe's influence area as a bounding box (axis-aligned bounding box or oriented bounding box). The reflection vector is redirected to intersect the proxy geometry before sampling the cubemap:

\[\vec{R}_{\text{corrected}} = \vec{P} + t \cdot \vec{R} - \vec{C}_{\text{probe}}\]

Where \(\vec{P}\) is the fragment position, \(\vec{R}\) is the reflection vector, \(t\) is the distance to the proxy boundary, and \(\vec{C}_{\text{probe}}\) is the probe’s world-space position.

In complex scenes, dozens or hundreds of localized probes are required. Managing individual samplerCube instances for each probe creates major rendering bottlenecks:

  • High draw-call counts or constant state changes due to re-binding textures.
  • Shader complexity limitations, as hardware limits the number of simultaneously bound texture units.
  • Inability to smoothly blend between overlapping probes in a single forward or deferred pass without multiple passes.

How samplerCubeArray Solves the Problem

The samplerCubeArray opaque type (introduced natively in OpenGL 4.0 / GL_ARB_texture_cube_map_array and OpenGL ES 3.2) stores multiple cubemaps of identical dimensions, formats, and mipmap chains within a single GPU texture allocation (GL_TEXTURE_CUBE_MAP_ARRAY).

1. Unified 4D Texture Coordinates

Unlike a standard cubemap sampled with a 3D direction vector (vec3), a cubemap array is sampled using a 4D vector:

vec4 sampleCoord = vec4(correctedRayDirection, float(probeIndex));
vec4 reflectionColor = texture(u_ProbeCubemaps, sampleCoord);

The first three components (x, y, z) represent the non-normalized or normalized direction vector pointing to the cubemap surface. The fourth component w is a floating-point value that casts to an integer representing the array slice (the specific probe cubemap).

2. Elimination of State Switching

All active reflection probes within a scene, zone, or camera frustum can be packed into a single texture array. The CPU uploads and binds this array to a single texture unit once per frame. This allows modern forward+ or clustered deferred rendering pipelines to compute lighting for all nearby probes in a single shading pass.

3. Smooth Multi-Probe Blending

Parallax-corrected probes often overlap to allow smooth transitions as the camera moves between rooms. With samplerCubeArray, a shader can determine the weights of the two or three nearest probes, apply parallax correction uniquely per probe, sample each cubemap using its respective array slice index, and linearly blend the outputs directly inside the shader:

vec4 colorA = texture(u_ProbeCubemaps, vec4(correctedRayA, float(indexA)));
vec4 colorB = texture(u_ProbeCubemaps, vec4(correctedRayB, float(indexB)));
vec4 finalReflection = mix(colorA, colorB, blendFactor);

Implementation Example in GLSL

Below is a GLSL implementation demonstrating parallax correction applied to an array of cubemaps sampled via samplerCubeArray.

#version 450 core

in vec3 v_WorldPos;
in vec3 v_Normal;

out vec4 FragColor;

layout(binding = 0) uniform samplerCubeArray u_ProbeCubemaps;

struct Probe {
    vec3 position;
    vec3 boxMin;
    vec3 boxMax;
    int arrayIndex;
};

layout(std140, binding = 1) uniform ProbeBlock {
    Probe u_Probes[8];
    int u_NumProbes;
};

uniform vec3 u_CameraPos;

vec3 computeParallaxCorrection(Probe probe, vec3 worldPos, vec3 rayDir) {
    // Intersect the reflection ray with the probe's axis-aligned bounding box
    vec3 nrdir = normalize(rayDir);
    vec3 tFirst = (probe.boxMax - worldPos) / nrdir;
    vec3 tSecond = (probe.boxMin - worldPos) / nrdir;
    
    vec3 tMax = max(tFirst, tSecond);
    float t = min(min(tMax.x, tMax.y), tMax.z);
    
    // Position of intersection on the proxy boundary
    vec3 intersectPosition = worldPos + nrdir * t;
    
    // Corrected vector pointing from probe origin to intersection
    return intersectPosition - probe.position;
}

void main() {
    vec3 V = normalize(v_WorldPos - u_CameraPos);
    vec3 N = normalize(v_Normal);
    vec3 R = reflect(V, N);

    // Evaluate the first active probe containing the fragment
    Probe activeProbe = u_Probes[0]; 
    
    vec3 correctedDir = computeParallaxCorrection(activeProbe, v_WorldPos, R);
    
    // Sample using the 4D vector: vec4(direction, layerIndex)
    vec4 envSample = texture(u_ProbeCubemaps, vec4(correctedDir, float(activeProbe.arrayIndex)));

    FragColor = envSample;
}

Practical Considerations

  • Mipmap Filtering: samplerCubeArray supports full trilinear and anisotropic filtering across the spatial dimensions of each cubemap face. Filtering is strictly confined within each cubemap layer and does not blend across different array indices.
  • Roughness and Specular IBL: Rough surfaces require blurred prefiltered environment maps at different mip levels. Sampling with textureLod(u_ProbeCubemaps, vec4(correctedDir, float(layer)), roughness * maxMipLevel) enables roughness-based specular reflection directly from the array.
  • Memory Footprint: Cubemap arrays enforce identical resolution across all slices. High-resolution textures across dozens of probes will quickly consume VRAM. Common production architectures group cubemap arrays by resolution tiers or dynamically stream local cluster probes into array slices based on spatial proximity.