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.

  1. Storage Texture Allocation: A 3D texture (GL_TEXTURE_3D) is allocated with an appropriate internal format. Formats like GL_R32UI or GL_RGBA8 are standard, depending on whether the grid stores binary occupancy, average color, or normal vectors.
  2. Image Binding: The 3D texture is bound to an image unit via glBindImageTexture with read/write access.
  3. 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() or imageAtomicMax().
  • 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().