Baking Dynamic Ambient Occlusion in Procedural Voxels
This article explores the primary techniques used to dynamically calculate and bake ambient occlusion (AO) into procedurally generated voxel meshes at runtime. Procedural voxel games cannot rely on pre-baked lightmaps due to dynamically altered terrain, making real-time, low-overhead shading essential for visual depth. The following sections detail the standard corner-neighbor evaluation algorithm, quad triangulation fixes, adaptations required for greedy meshing, and GPU-driven compute approaches.
Neighbor-Block Sampling (The 4-State Vertex Model)
The foundational and most common runtime technique evaluates the solid state of neighboring voxels directly surrounding each vertex of an exposed face.
When generating a quad for a voxel face, each of the four vertices touches three neighboring voxel positions in the plane perpendicular to the face:
- Two edge-adjacent neighbors (sharing an edge with the face).
- One corner-diagonal neighbor (sharing only a corner with the face).
For any given vertex, the ambient occlusion value is calculated using a lookup table or a simple summation of occluding blocks:
- If both edge-adjacent blocks are solid, the corner block is fully occluded regardless of its state, yielding maximum occlusion (value of 3).
- If neither edge block is solid, the corner block determines whether the value is 0 (open air) or 1 (corner blocked).
- If only one edge block is solid, the value scales proportionally (value of 1 or 2 depending on the corner).
This yields four discrete shading tiers (0 to 3), which are mapped to brightness factors (such as 1.0, 0.7, 0.5, and 0.2) and baked directly into vertex data.
Triangulation and Anisotropy Correction
A critical issue in vertex-based voxel AO is interpolation anisotropy. Because a quad consists of two triangles, the direction of the splitting diagonal affects how the vertex colors interpolate across the face.
If vertices with identical AO values are placed across the wrong diagonal, the quad will render with a visible visual seam or inconsistent shading gradient. To resolve this:
- Compare the AO values of the diagonal vertex pairs: \((AO_{00} + AO_{11})\) versus \((AO_{10} + AO_{01})\).
- Flip the quad's shared interior edge so that it connects the two vertices with the higher combined occlusion values.
- This ensures smooth, symmetric shading across the quad surface without requiring additional geometry.
Integrating AO with Greedy Meshing
Greedy meshing merges coplanar, adjacent voxel faces into larger single quads to drastically reduce vertex counts and draw calls. However, ambient occlusion naturally varies across individual block boundaries, which conflicts with quad merging.
Developers handle this conflict through three main strategies:
- Restricted Merging: Only merge adjacent faces if both their block types and their calculated per-vertex AO values are identical. This preserves accurate vertex-based shading at the cost of a slightly higher polygon count.
- Texture Atlas Baking: Instead of baking AO into vertex colors, generate or slice a small, low-resolution texture atlas containing pre-computed AO gradients, applying UVs from the merged quad across the continuous surface.
- Fragment-Shader Reconstruction: Pass unmerged face bounds as custom vertex attributes or instanced data, allowing the fragment shader to mathematically calculate edge proximity and distance-to-corner shading analytically.
Compact Data Packing
To minimize memory bandwidth during procedural mesh generation, AO data is rarely stored as floating-point colors.
Because the neighbor-sampling method produces four discrete levels per vertex, an AO value requires only 2 bits of storage. All four vertices of a quad can be stored using 8 bits (1 byte) total. This byte is commonly packed alongside face normal indicators and texture IDs into a single 32-bit unsigned integer per vertex, minimizing memory overhead and accelerating mesh upload times from the CPU to the GPU.
GPU Compute and Local Distance Fields
For smooth, high-fidelity AO that avoids the rigid look of per-vertex discrete levels, modern engines offload meshing and AO to compute shaders:
- Voxel Neighborhood 3D Textures: Voxel chunk data is held in a 3D texture. A compute shader evaluates the local density field within a small spherical kernel (e.g., a \(3 \times 3 \times 3\) or \(5 \times 5 \times 5\) window) around each surface point.
- Signed Distance Field (SDF) Tracing: If the procedural generation pipeline generates a distance field rather than raw block binaries, short-range cone tracing through the local SDF provides smooth, realistic contact shadows baked directly into the output mesh buffers during the surface extraction pass (such as Marching Cubes or Surface Nets).