Voxel Clipmap Updating in GLSL Compute Shaders
This article explores how voxel clipmap updates are coordinated across moving camera volumes using GLSL compute shaders. Voxel clipmaps maintain a sequence of concentric, nested 3D grids centered around a viewpoint to achieve multi-resolution terrain or volumetric representation. Efficiently moving these grids with the camera requires eliminating full-volume copies through toroidal addressing, isolating dirty regions into directional slabs, and issuing targeted compute shader dispatches that stream updated voxel geometry or signed distance fields directly on the GPU.
Snapping and LOD Grid Alignment
A voxel clipmap represents continuous space as a series of \(N\) nested cubic grids, where each subsequent Level of Detail (LOD) doubles in world-space extents and voxel cell size. To prevent visual aliasing and continuous rebuilding, the center of each clipmap level does not smoothly track the camera. Instead, each level snaps to its own world-space voxel grid:
\[\text{SnappedPos} = \lfloor \frac{\text{CameraPos}}{\text{VoxelSize}_L} \rfloor \times \text{VoxelSize}_L\]
Because outer levels feature larger voxels, their snapped origins update less frequently than inner levels. The camera's displacement is evaluated per LOD level on the CPU or an orchestration compute pass, producing a discrete integer translation vector \(\Delta \mathbf{v} = (\Delta x, \Delta y, \Delta z)\) measured in grid cells.
Toroidal Addressing via Modular Arithmetic
Moving a \(D \times D \times D\) 3D texture in VRAM via block memory transfers is computationally prohibitive. Instead, clipmaps treat the backing 3D texture or SSBO as a cyclic ring buffer across all three spatial dimensions.
When a grid shifts by \(\Delta \mathbf{v}\), the underlying texture coordinates do not shift physically; instead, coordinate access maps world space to storage space using a modulo operation:
ivec3 WorldToStorage(ivec3 worldVoxelCoord, int clipmapResolution) {
return (worldVoxelCoord % clipmapResolution + clipmapResolution) % clipmapResolution;
}This ensures that the physical memory stays anchored, while the logical representation translates infinitely in world space.
Invalidation and Dirty Region Identification
When an LOD level moves by \(\Delta \mathbf{v}\), voxels in the direction of movement leave the clipping volume, and new world regions enter. Rather than regenerating the entire grid, only newly exposed regions—often referred to as update slabs—are marked as dirty.
For a displacement vector \((\Delta x, \Delta y, \Delta z)\), the newly exposed volume is partitioned into up to three non-overlapping axis-aligned bounding boxes:
- X-Slab: A volume of thickness \(|\Delta x|\) covering the exposed face along the X-axis.
- Y-Slab: A volume of thickness \(|\Delta y|\) covering the remaining exposed region along the Y-axis.
- Z-Slab: A volume of thickness \(|\Delta z|\) covering the remaining exposed region along the Z-axis.
If \(|\Delta \mathbf{v}|\) along any axis meets or exceeds the grid dimension \(D\), the entire level is marked dirty and regenerated completely.
Orchestrating the Compute Dispatch
Once dirty bounding volumes are identified, GLSL compute shaders update the underlying data structures. This pipeline generally consists of two distinct compute stages:
1. Invalidation and Clearing
Before writing new data, the regions of storage memory mapping to the
dirty slabs are cleared or initialized to empty states. A compute shader
takes the bounds of each slab and invokes imageStore() to
clear voxel occupancy, density, or material handles.
2. Voxel Generation / Sampling
A generation compute shader generates procedural data (e.g., 3D noise, Signed Distance Fields) or injects dynamic geometry into the newly cleared voxel storage.
layout(local_size_x = 8, local_size_y = 8, local_size_z = 8) in;
layout(binding = 0, r32f) uniform writeonly image3D u_VoxelTexture;
uniform ivec3 u_SlabMinWorld;
uniform ivec3 u_SlabSize;
uniform int u_ClipmapResolution;
uniform float u_VoxelSize;
void main() {
ivec3 localID = ivec3(gl_GlobalInvocationID.xyz);
if (any(greaterThanEqual(localID, u_SlabSize))) {
return;
}
ivec3 worldVoxel = u_SlabMinWorld + localID;
vec3 worldPos = vec3(worldVoxel) * u_VoxelSize;
// Generate voxel data (e.g., terrain density)
float density = EvaluateTerrainSDF(worldPos);
// Map world voxel position to physical storage via toroidal wrapping
ivec3 storageCoord = (worldVoxel % u_ClipmapResolution + u_ClipmapResolution) % u_ClipmapResolution;
imageStore(u_VoxelTexture, storageCoord, vec4(density, 0.0, 0.0, 0.0));
}Dispatches use indirect compute buffers
(glDispatchComputeIndirect) so that the host can upload
multiple slab dimensions dynamically, or a lightweight management
compute shader can calculate the dispatch parameters directly on the GPU
based on camera position changes.
Synchronization and LOD Cascading
When parent and child clipmap levels update simultaneously, race conditions and visual seams can appear at level boundaries.
- Memory Barriers: To prevent read-after-write
conflicts, compute passes are delineated with
glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT | GL_TEXTURE_FETCH_BARRIER_BIT). - Coarse-to-Fine Seeding: If fine levels use downsampled data from coarser levels (or vice versa for mipmapping), parent compute dispatches must complete before child dispatches execute.
- Blend Boundaries: During rendering, shaders blend the outermost ring of voxels in level \(L\) with the corresponding voxels in level \(L+1\). To avoid edge flickering during movement, the compute shader enforces consistent generation evaluation along shared boundaries.