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:
samplerCubeArraysupports 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.