Real-Time GPU Voxelization with GLSL 3D Storage Images
Voxelizing scene geometry converts standard continuous polygonal
meshes into a discrete, volumetric 3D texture representation directly on
the GPU. In modern OpenGL and GLSL pipelines, this is accomplished
through a hardware-accelerated rasterization pass. By disabling standard
depth and color attachments, utilizing a geometry shader to select the
dominant projection axis, and issuing direct write operations into a 3D
storage image (image3D) within the fragment shader,
arbitrary triangle meshes can be rapidly mapped into a voxel grid in a
single pass.
Pipeline and Resource Setup
GPU voxelization bypasses the conventional framebuffer write pipeline. Instead of rendering to a 2D color attachment, data is injected into an unbound 3D texture storage target.
- Storage Texture Allocation: A 3D texture
(
GL_TEXTURE_3D) is allocated with an appropriate internal format. Formats likeGL_R32UIorGL_RGBA8are standard, depending on whether the grid stores binary occupancy, average color, or normal vectors. - Image Binding: The 3D texture is bound to an image
unit via
glBindImageTexturewith read/write access. - Pipeline Configuration:
- Bind an empty Framebuffer Object (FBO) with no color attachments, sized to the resolution of the voxel grid (e.g., \(N \times N\)).
- Disable depth testing (
glDisable(GL_DEPTH_TEST)) and backface culling (glDisable(GL_CULL_FACE)) so that all internal and overlapping fragments are evaluated. - Disable color writes using
glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE).
Dominant Axis Projection in the Geometry Shader
Standard rasterizers project 3D primitives onto a 2D plane. If a triangle is nearly perpendicular to the projection plane, it covers minimal screen space, causing missed fragments and gaps (pinholing) in the resulting voxel grid. To resolve this, a geometry shader dynamically projects each triangle along its dominant normal axis.
The geometry shader determines the face normal and selects the projection plane with the largest projected area:
#version 460 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
in vec3 vWorldPos[];
out vec3 fVoxelPos;
uniform mat4 uVoxelViewProjectionX;
uniform mat4 uVoxelViewProjectionY;
uniform mat4 uVoxelViewProjectionZ;
void main() {
vec3 edge1 = vWorldPos[1] - vWorldPos[0];
vec3 edge2 = vWorldPos[2] - vWorldPos[0];
vec3 normal = abs(cross(edge1, edge2));
// Determine the dominant projection axis
mat4 projection;
if (normal.z >= normal.x && normal.z >= normal.y) {
projection = uVoxelViewProjectionZ;
} else if (normal.x >= normal.y && normal.x >= normal.z) {
projection = uVoxelViewProjectionX;
} else {
projection = uVoxelViewProjectionY;
}
// Transform vertices using the selected dominant axis projection
for (int i = 0; i < 3; ++i) {
fVoxelPos = vWorldPos[i];
gl_Position = projection * vec4(vWorldPos[i], 1.0);
EmitVertex();
}
EndPrimitive();
}Conservative rasterization can also be enabled via hardware
extensions (e.g., GL_NV_conservative_raster) to guarantee
that any pixel partially intersected by a triangle produces a
fragment.
Writing to the 3D Image in the Fragment Shader
The fragment shader receives the interpolated world-space or volume-space coordinates and writes data directly to the bound 3D image buffer using GLSL storage operations.
Because fragments write to arbitrary 3D grid cells, standard output
bindings are replaced with
layout(binding = 0) uniform image3D:
#version 460 core
layout(binding = 0, r32ui) uniform uimage3D uVoxelGrid;
in vec3 fVoxelPos;
uniform vec3 uVoxelGridMin;
uniform vec3 uVoxelGridSize;
uniform ivec3 uVoxelResolution;
void main() {
// Transform continuous world position to discrete voxel coordinates
vec3 normalizedPos = (fVoxelPos - uVoxelGridMin) / uVoxelGridSize;
ivec3 voxelCoords = ivec3(normalizedPos * vec3(uVoxelResolution));
// Boundary check to prevent writing out of bounds
if (any(lessThan(voxelCoords, ivec3(0))) || any(greaterThanEqual(voxelCoords, uVoxelResolution))) {
return;
}
// Atomic write to avoid data races across overlapping fragments
// Writes a non-zero occupancy flag or bitmask
imageAtomicOr(uVoxelGrid, voxelCoords, 1u);
}Handling Data Races and Color Accumulation
When storing attributes such as diffuse color or normal directions
rather than simple binary occupancy, multiple fragments often hit the
same voxel cell simultaneously. Using simple imageStore()
calls introduces data races and non-deterministic results.
To resolve this issue:
- Atomic Bit-Packing: Pack properties such as RGBA8
into a single 32-bit unsigned integer and compute updates with
imageAtomicExchange()orimageAtomicMax(). - Atomic Moving Averages: Utilize atomic counters within an auxiliary 3D buffer to track fragment hits per voxel, allowing colors to be blended and accumulated in a subsequent compute pass.
- Spin-Locks: For high-precision requirements,
implement fragment-level atomic spin-locks within a separate integer
buffer to execute localized critical sections before committing data via
imageStore().