Loading…
gathering yarn balls…
Cat Game Research
Frontier
Dev-log transmissions from the expedition

The Blur That Knows Where the Edge Is

How a bilateral SSAO blur keeps contact shadows sharp and sky values from bleeding into foreground edges.

From a raw occlusion map to a usable image

Screen-space ambient occlusion (SSAO) produces a noisy grayscale field. The noise is not a bug; it is the cost of sampling a hemisphere of occluders at limited resolution. Before that field can be multiplied into the lighting buffer, it has to be smoothed. A plain box blur would hide the noise, but it would also smear the dark crevices of a foreground object onto a bright sky behind it. The answer is a bilateral blur: a filter that averages nearby values only when the geometry agrees.

"A blur should be a vote among neighbors, not a mandate across edges."

Cat Game Research · Engine Notes · August 2026

This post is a high-level field note from a recent session in which we re-examined one SSAO blur kernel, translated it into pseudocode, and then into a formal proof. No proprietary source code is shown. The goal is to explain the shape of the problem and the contract the filter must keep.

Why ambient occlusion needs a blur at all

SSAO estimates how much ambient light a pixel can receive by casting short rays through the depth buffer and counting how many of them hit something nearby. At a fixed sample count the result is speckled, especially in regions with thin features or rapid depth change. The human visual system does not object to a noisy occlusion map as much as it objects to a noisy image, so the engine applies a spatial low-pass filter before compositing.

The trap is that a low-pass filter cannot tell the difference between "noise inside one surface" and "two unrelated surfaces that happen to be adjacent on screen." Without an edge-stopping term, the blur will migrate dark pixels across object boundaries and create the familiar halo of smudged contact shadows.

3
edge-stopping weights
2D
local window
R³
per-sample cost source
O(1)
radius clamped to 6 px

The sampling window

The blur is evaluated once per output pixel, inside a square window centered on that pixel. The window is clamped to the image border, and its radius is bounded to a small number of pixels. Small radius means the filter stays local; large radius means it can suppress bigger blobs of noise at the cost of potentially missing thin geometry. We cap the radius because the per-pixel cost grows with the square of the window and because a contact-shadow blur should stay close to the contact.

Sampling Window Around a Center Pixelp
Figure 1. Each pixel is the center of its own window. Dashed border shows the bounded radius; shaded falloff hints at the spatial Gaussian weight.

Three votes, multiplied

Every candidate sample inside the window is scored with three independent weights. The final weight is the product of the three. If any one of them is small, the sample contributes little or nothing to the output.

Spatial weight

Nearby pixels matter more. This term is a Gaussian that falls off with the squared screen distance between the center and the sample. It gives the blur its soft, circular shape.

Depth weight

If the sample and the center are at very different depths, the surface has probably changed. This exponential term suppresses contributions across depth discontinuities.

Normal weight

If the surface normal at the sample points in a very different direction than the center normal, the sample is likely on a different facing surface. This term uses the cosine of the angle between normals, raised to a power.

The normal weight is intentionally sharp: raising the cosine to a high power makes the filter conservative. A slightly tilted neighbor still gets some influence, but a neighbor pointing the other way is quickly excluded. A tiny floor is added so that the overall weight never becomes exactly zero, which keeps the normalized output well-defined.

How the weights talk to the G-buffer

The blur needs more than the occlusion map. It also reads the position and normal channels that the G-buffer already contains. The depth comes from the position channel's w component; the normal is normalized on the fly, with a fallback to the center normal when the G-buffer stores a zero-length normal. This last fallback is important for sky pixels or missing samples: the filter should not crash, and it should not invent a random rejection criterion.

The Blur PipelineRaw AOnoisy inputBilateral blurspatial × depth × normalper-pixel weighted averageG-buffer informedG-buffer posdepth valueG-buffer normalsurface directionSmoothed AOedge-aware output
Figure 2. The blur consumes the raw occlusion map and two G-buffer channels to decide which neighbors are allowed to vote.

What an edge looks like to the filter

Imagine a foreground crate in front of a bright wall. The center pixel is on the crate. A neighbor inside the sampling window is on the wall. The depth difference is large, so the depth weight pushes the sample toward zero. The normals may also point in different directions, so the normal weight does the same. Only the spatial weight would keep the sample, but the product of the three is tiny. The result: the bright wall value does not leak into the crate's occlusion.

Depth Rejection at a Surface Edgecamera rayforegroundpn·rbackgroundqn·qlarge Δdepth ⇒ wd ≈ 0w = ws · wd · max(wn, floor)
Figure 3. A sample q on the background is rejected because its depth and normal differ from the center p on the foreground.

The model, written down

For a pixel p and a candidate sample q inside the bounded window, the filter assigns a combined weight. The output is the weighted average of all candidate occlusion values. If the radius is zero, the filter is a pass-through. If the total weight is zero, the output falls back to fully lit (1).

ws(p, q) = exp( -|p - q|2 / 4 )

wd(p, q) = exp( -| depth(q) - depth(p) | · 96 )

wn(p, q) = max( dot( n(p), n(q) ), 0 )8

w(p, q) = ws · wd · max( wn, 10-3 )

A(p) = ( Σ a(q) · w(p, q) ) / ( Σ w(p, q) )

The constants are not magic. The divisor 4 in the spatial term controls the Gaussian falloff. The multiplier 96 in the depth term sets how aggressive the filter is at small depth differences. The exponent 8 on the normal term makes the filter strongly prefer aligned surfaces. The floor 10-3 prevents a zero-weight catastrophe while still letting the weights reject bad neighbors. These numbers are tuned to the scale of the depth buffer and the desired visual response.

Why a floor and not a hard zero: if every neighbor in the window were rejected with weight zero, the normalized output would be undefined. The floor keeps the denominator positive while still making bad neighbors contribute only a trace.

From kernel to proof

During the session we stepped through the filter in three passes. First we wrote it as plain pseudocode, then as an algorithmic outline, and finally as a short mathematical proof. The proof gave us four useful properties: the output is bounded inside the same range as the input, depth discontinuities are suppressed, normal discontinuities are suppressed, and the spatial falloff is finite. The exercise was not academic; it surfaced the fallback normal and the weight floor as load-bearing details that are easy to overlook when reading the implementation.

This kind of reasoning is a debugging discipline. When a visual artifact appears, the kernel can be read as a small theorem rather than as an opaque sequence of texture fetches. Is a halo leaking across an edge? Check the depth and normal weights. Is the blur too aggressive? Check the radius cap and the spatial falloff. Is the center pixel wrong? Check the pass-through and fallback branches.

Why it belongs in a rendering engine

Real-time graphics is full of filters that could be replaced by more sophisticated alternatives if only time and memory were infinite. Bilateral blur is popular because the cost is bounded and the visual benefit is immediate. It converts a noisy screen-space signal into something that can be composited without halos. For a custom Vulkan engine, it also fits the broader design philosophy: explicit G-buffer, explicit dispatch, explicit weights. There is no black-box denoiser hiding its assumptions.

What comes next

The current kernel is separable in concept but not in implementation. A future pass could explore a two-pass approximation that reduces the per-pixel cost from quadratic in radius to linear. Another direction is temporal accumulation: instead of increasing the spatial blur, reuse previous frames' occlusion results under motion-vector guidance. Both paths would keep the same edge-stopping contract; only the sampling pattern would change.

← All transmissions