Devlog #1.4: Voxel-style Ambient Occlusion


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:

  • Static AO – Precomputed during asset creation, where occlusion is baked directly into the geometry or textures.
  • Dynamic AO – Computed at runtime, usually based on changing or dynamic data.

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:

  • Floating-point types (half, float) store bits differently from integers (byte, short, int).
  • Directly manipulating bits as floats can break the representation and produce incorrect results.

My solution:

  • Pack AO values into a single byte, using 2 bits per vertex (supports up to 4 AO levels).
  • Cast that byte into a half and store it in UV.w.
  • In the shader, cast it back to an integer and extract each 2-bit AO value per vertex.

🔶 Shader Implementation

Shader Graph doesn’t have a native cast function from halfint, 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 bytehalf packing is more robust.

Leave a comment

Log in with itch.io to leave a comment.