Builtin Library
README.md
ambientOcclusion system
Screen-space ambient occlusion — the contact darkening in corners, creases and where objects meet.
Without it, every concave region is lit exactly like a flat one: a box resting on a floor has no seam, a corner is as bright as the wall, and an untextured scene reads as plastic. Occlusion is the cheapest shading cue there is per millisecond, because it needs no extra geometry pass — only the depth and normal the frame already produced.
Three techniques are shipped. All three answer the same question — what fraction of the hemisphere above this pixel is visible — and all three cost the same taps to answer it.
Using it
The scene's occlusion is a component — author it once and it persists with the scene:
local id = entity.spawn("ambient_occlusion").id
entity(id).component.add("AmbientOcclusion", {
technique = "gtao", -- ssao | hbao | gtao
intensity = 0.9, -- how far the darkening goes, 0..1
radius = 1.5, -- world-space reach
quality = "high", -- low | medium | high | ultra
})
Every field is live: edit one in the inspector and the shading follows. Setting
intensity to 0 turns it off without losing the rest.
For code that adjusts occlusion at runtime — a quality slider, a cutscene — reach for the module directly (same options, no component):
local ao = require("@builtin::systems.ambientOcclusion.ao")
ao.set({ technique = "hbao", intensity = 0.9 }) -- any subset; the rest is kept
ao.get() -- the settings in force
ao.techniques() -- the three, each with what it does
ao.qualityLevels() -- the sampling shape each level costs
ao.active() -- is the pass running
ao.clear() -- off, settings kept
The three techniques
| technique | what it measures |
|---|---|
ssao | Taps scattered over a disk, each weighed on its own: how far the neighbour rises above the tangent plane, over how far away it is. |
hbao | One horizon per direction — a ray marched outward until it finds the steepest thing along it — averaged as sines. |
gtao | The cosine-weighted arc between the two horizons of a slice, in closed form. This is the visibility the other two estimate. |
They are scaled to agree. A point in a right-angle corner sees exactly half the hemisphere, and each technique is normalised so it reads that corner as half:
| technique | flat plane | right-angle corner | occlusion |
|---|---|---|---|
ssao | 1.000 | 0.566 | 0.434 |
hbao | 1.000 | 0.592 | 0.408 |
gtao | 1.000 | 0.594 | 0.406 |
That is what lets intensity mean the same thing whichever technique is
selected, so switching one for another changes how the estimate is made rather
than how dark the scene gets.
What the knobs do
| field | effect |
|---|---|
technique | Which estimator runs: ssao | hbao | gtao. |
intensity | How far the darkening goes, in [0, 1]. 0 turns it off and releases the pass. |
radius | World-space reach — roughly "how big is a crease worth darkening". Constant in world space, so it does not swell as the camera approaches. |
power | Contrast curve on the visibility term. Above 1 deepens the dark end. |
thickness | World-space thickness assumed for an occluder, so a thin foreground object stops occluding everything behind it out to the full radius. |
bias | How far a neighbour must rise above the surface before it counts as an occluder rather than as the surface itself, in [0, 1]. Raise it if a flat surface shades itself in patches; lower it if fine creases disappear. |
quality | Sampling shape. See the table below. |
| quality | slices × steps | taps per pixel | resolution |
|---|---|---|---|
low | 2 × 3 | 12 | half |
medium | 3 × 4 | 24 | half |
high | 4 × 6 | 48 | full |
ultra | 6 × 8 | 96 | full |
Every technique spends the same budget: slices × steps × 2 taps. gtao walks
each slice both ways, hbao treats each of the two directions as its own ray,
and ssao takes that many independent taps of the disk.
The lower two levels compute occlusion on a half-resolution grid — a quarter of the pixels, each still costing its level's taps. Occlusion is low-frequency almost everywhere, and the filter that removes the sampling noise lifts the coarse grid back to the frame in the same pass, weighting each texel by the depth where it sits so silhouettes survive the upsample. What a half-resolution grid loses is the innermost pixel of a sharp crease; everything wider than a couple of pixels comes back the same.
How it renders
One compute pass after lighting writes the per-pixel visibility, a second filters it and multiplies it into the frame, and a blit puts the result on screen. The filter is depth-aware: a plain blur would bleed occlusion across silhouettes, so each tap is weighted by how close its depth is to the centre pixel's.
Every technique jitters its sampling by a per-pixel offset, which trades banding for noise; the filter is the half of that trade that removes the noise.
Depth, normal and camera come from the render target being drawn (@scene.depth,
@scene.normal, @frame.camera), so the result is correct in offscreen
captures and render-to-texture cameras at their own resolutions, not only the
live viewport. Sky pixels are passed through — there is no surface to occlude.
The normal buffer is the deferred G-buffer, so this needs the deferred path (the default).
Seeing the occlusion on its own
While occlusion is running, ambientOcclusion is a capture view: the filtered
visibility with no scene colour, white where the hemisphere is open and dark
where it is closed.
capture { pass = "ambientOcclusion" }
It is the fastest way to tell a shading problem from an occlusion one, and to
compare two techniques or two radius values without the lighting in the way.
Files
ambientOcclusion.package/
AmbientOcclusion.component/ -- the scene's occlusion, authored and persisted
ao.module/ -- ao.set / .get / .techniques / .qualityLevels / .active / .clear
aoShared.module/ -- the prelude, filter and passes the techniques share
ssao.renderFeature/ -- taps weighed on their own
hbao.renderFeature/ -- one horizon per direction
gtao.renderFeature/ -- the arc between two horizons