XeGTAO ambient occlusion
Same shot three times: no AO, a basic 16-sample SSAO, and Intel's XeGTAO ported from HLSL to three.js.
The problem
Ambient light comes from the whole sky, so corners, contact points and gaps should get less of it. Without ambient occlusion every surface gets the same amount and objects look pasted onto the floor. The classic screen-space fix throws random samples into a hemisphere and counts how many land behind the depth buffer. It is cheap, but it halos around edges and goes soft or noisy fast.
How it works
XeGTAO is Intel's implementation of GTAO (horizon-based, from the 2016 Activision paper). Per pixel:
- Pick a few slice directions across the screen, rotated per pixel by a Hilbert curve + R2 noise pattern.
- March both ways along each slice and keep the highest horizon found within the radius.
- Integrate the cosine-weighted visible arc between the two horizons against the normal, then average the slices.
Before that, depth is turned into view depth and filtered into 5 mips, so far steps read coarser depth. After it, an edge-aware 3x3 blur removes noise but stops at depth edges (2 bits per side, packed).
What I hit
XeGTAO is DirectX: screen y points down, WebGL's points up. The slice step and every "top/bottom" neighbour in the edge and denoise code had to be flipped.
The source runs compute shaders. Mine are fragment passes, the denoiser works on one pixel instead of two, and the depth mips are 5 separate float textures, sampled at the nearest level.
My first comparison looked identical. The sun dominated, and AO only darkens ambient light. I raised the sky light's share instead of multiplying AO over everything.
My build
three.js (r187), WebGL2, full 1920x1080 AO. Per frame: sun pass with depth, sky pass, view depth + 4 mip passes, main pass, one denoise pass, then sun + sky x AO.
Defaults from the source: radius 0.5 x 1.457, falloff 0.615, sample power 2, final power 2.2. Presets: Low 1 slice x 2 steps, Medium 2x2, High 3x3, Ultra 9x3. High is 18 depth samples per pixel.
The baseline SSAO uses the same depth, normals and radius, 16 samples plus a 4x4 blur.
Trade-offs
- Clean contact shadows and corners from few samples
- Coarser mips keep long steps cheap
- Screen space only: nothing off screen or hidden behind the front surface occludes
- Without temporal AA the noise pattern is static; the source changes it every frame
- I rendered on a software GPU, so no timings here
When to use it
Any deferred or forward renderer that already has a depth buffer and wants believable contact shading for real-time scenes. Pair it with temporal AA if you can. For large-scale occlusion, add baked or traced GI on top.
Source and credits
Technique source: XeGTAO by GameTechDev (Intel), https://github.com/GameTechDev/XeGTAO, MIT license, commit a5b1686c7ea37788eeb3576b5be47f7c03db532c (repository archived). Ported from HLSL to GLSL for WebGL2: the depth prefilter and depth MIP filter, the main GTAO pass (edges, normals from depth, horizon search, arc integration), the edge-aware denoiser, the default constants, the Hilbert + R2 noise and the Low/Medium/High/Ultra slice and step counts. No files were copied unchanged.
Render framework: three.js by mrdoob and contributors, https://github.com/mrdoob/three.js, MIT license, branch dev at commit fa01dd6ad164c48554f1bb9456f6500db052ec4f. Used for the scene, shadows and render targets. It is not the source of the AO technique; its own AO passes were not used.
My own code: the greybox scene, the pass setup in three.js, the basic 16-sample hemisphere SSAO used as the baseline, the composite (sun + sky x AO, ACES), the camera, labels and video. No external models or assets. No IA model.