Posted September 15, 2025 by Yunasawa
Ambient Occlusion (AO) is a technique used to darken areas where light is naturally blocked by nearby geometry, helping create a stronger sense of depth and contact between objects. In practice, AO is often calculated by estimating how much of the surrounding hemisphere is visible from a given point on a surface.
There are two main types of AO algorithms:
Dynamic AO is commonly implemented with Screen-Space Ambient Occlusion (SSAO). This technique samples the depth buffer and uses the reconstructed geometry to estimate visibility for each pixel. The result is then applied as a shading factor across the screen.
However, in voxel-based or procedurally generated games, it’s often more efficient to calculate AO directly during voxel generation.
For reference, you can read a detailed breakdown of Minecraft-style AO here: 👉 0fps.net: Ambient Occlusion for Minecraft-like Worlds.
In my implementation, I simplified the method:
Instead of using 4 AO levels, I only use 3 (as shown in the picture).
For special case, if a vertex would normally have AO = 1, but there are voxels along its edges, I assign AO = 2.
🔶 Voxel AO Calculation
Each voxel requires checking up to 20 neighboring voxels to determine AO for all its vertices. For each face, specifically, you only need to check 8 neighboring voxels.
🔶 Storing AO in Vertex Data
I store the AO value in the w
component of the UV (TEXCOORD0).
Each face has 4 vertices, and I pack their AO values together so the shader can read them correctly.
Important: If the 4 vertices have different AO values stored incorrectly, the shader will display artifacts or “weird” shading.
🔶 Packing Strategy
Because UV is a half4, we can’t simply pack 4 AO values into a single floating-point component using integer bitmask tricks:
half
, float
) store bits differently from integers (byte
, short
, int
).My solution:
UV.w
.🔶 Shader Implementation
Shader Graph doesn’t have a native cast function from half
→ int
, so I use a custom HLSL function to perform the cast.
I tried other methods, like manipulating float bits directly on CPU and GPU, but those only work reliably for static meshes (e.g., Mesh inside a MeshFilter).
With Graphics.DrawMesh
/ dynamic meshes rendering, bitwise float tricks start glitching, so the byte
→half
packing is more robust.