Skip to main content
GameDev.net gamedev.net

Screen-Space Ambient Occlusion

by GameDev.net · GLSL ES 3.00 (WebGL2) · 30 Aug 2026

Use in your engine

Run the shader to adjust these controls.

What it demonstrates

Screen-space ambient occlusion estimates small-scale contact shadowing from a rendered depth and normal representation. Buffer A procedurally intersects a floor and several objects, then stores view-space normals in RGB and linear view depth in alpha. The full-screen image pass uses that G-buffer on channel 0; no external assets are involved.

Source Revision 1

Author notes are linked to specific lines.

Shader inputs

void mainImage(out vec4 fragColor, in vec2 fragCoord)

Called once per pixel. Write the colour to fragColor.

iResolution vec3
Viewport size in pixels (z is the pixel aspect ratio).
iTime float
Seconds since the shader started.
iTimeDelta float
Seconds since the previous frame.
iFrameRate float
Frames per second, smoothed.
iFrame int
Frames rendered since the start.
iMouse vec4
Mouse position: xy while held, zw of the last click.
iDate vec4
Year, month, day, and seconds within the day.
iChannel0 sampler2D
Texture bound to channel 0.
iChannel1 sampler2D
Texture bound to channel 1.
iChannel2 sampler2D
Texture bound to channel 2.
iChannel3 sampler2D
Texture bound to channel 3.
iChannelResolution vec3[4]
Pixel size of each bound channel texture.
iChannelTime float[4]
Playback time of each channel, in seconds.
iSampleRate float
Audio sample rate, always 44100.
Common
const float SCENE_FAR = 12.0;
const float CAMERA_FOCAL = 1.8;

float sphereHit(vec3 rayOrigin, vec3 rayDirection, vec3 center, float radius) {
    vec3 offset = rayOrigin - center;
    float b = dot(offset, rayDirection);
    float c = dot(offset, offset) - radius * radius;
    float h = b * b - c;
    if (h < 0.0) return SCENE_FAR;
    float root = sqrt(h);
    float nearHit = -b - root;
    float farHit = -b + root;
    return nearHit > 0.0 ? nearHit : (farHit > 0.0 ? farHit : SCENE_FAR);
}

void traceScene(vec3 rayOrigin, vec3 rayDirection, out float depth, out vec3 normal) {
    float nearest = SCENE_FAR;
    normal = vec3(0.0, 0.0, 1.0);
    if (rayDirection.y < -0.001) {
        float floorHit = (-1.0 - rayOrigin.y) / rayDirection.y;
        if (floorHit > 0.0 && floorHit < nearest) {
            nearest = floorHit;
            normal = vec3(0.0, 1.0, 0.0);
        }
    }
    vec3 centers[3] = vec3[3](
        vec3(-1.05, -0.28, -4.1),
        vec3(0.75, -0.48, -3.25),
        vec3(0.15, 0.35, -5.0)
    );
    float radii[3] = float[3](0.72, 0.52, 0.68);
    for (int i = 0; i < 3; ++i) {
        float hit = sphereHit(rayOrigin, rayDirection, centers[i], radii[i]);
        if (hit < nearest) {
            nearest = hit;
            normal = normalize(rayOrigin + rayDirection * hit - centers[i]);
        }
    }
    if (nearest >= SCENE_FAR) {
        depth = SCENE_FAR;
    } else {
        depth = -(rayOrigin + rayDirection * nearest).z;
    }
}

vec3 cameraRay(vec2 fragCoord) {
    vec2 screen = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
    return normalize(vec3(screen, -CAMERA_FOCAL));
}

vec3 reconstructViewPosition(vec2 uv, float depth) {
    vec2 screen = (2.0 * uv - 1.0) * vec2(iResolution.x / iResolution.y, 1.0);
    return vec3(screen * depth / CAMERA_FOCAL, -depth);
}
Buffer A1
1 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2 float depth;
3 vec3 normal;
4 traceScene(vec3(0.0), cameraRay(fragCoord), depth, normal);
5 fragColor = vec4(normal * 0.5 + 0.5, depth / SCENE_FAR);
6 }
7
Main Image4
1 uniform float uRadius; // @param 4.0..64.0 = 24.0 "Sample radius"
2 uniform float uBias; // @param 0.0..0.20 = 0.035 "Depth bias"
3 uniform float uStrength; // @param 0.2..4.0 = 1.7 "AO strength"
4
5 const int KERNEL_SIZE = 12;
6
7 float hashPixel(vec2 value) {
8 return fract(sin(dot(value, vec2(12.9898, 78.233))) * 43758.5453);
9 }
10
11 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
12 vec2 uv = fragCoord / iResolution.xy;
13 vec4 packed = texture(iChannel0, uv);
14 float depth = packed.a * SCENE_FAR;
15 if (depth >= SCENE_FAR - 0.01) {
16 vec3 sky = mix(vec3(0.08, 0.12, 0.20), vec3(0.38, 0.55, 0.72), uv.y);
17 fragColor = vec4(sky, 1.0);
18 return;
19 }
20 vec3 normal = normalize(packed.rgb * 2.0 - 1.0);
21 vec3 position = reconstructViewPosition(uv, depth);
22 float rotation = hashPixel(fragCoord) * 6.2831853;
23 float occlusion = 0.0;
24 float validSamples = 0.0;
25 for (int i = 0; i < KERNEL_SIZE; ++i) {
26 float fraction = (float(i) + 0.5) / float(KERNEL_SIZE);
27 float angle = fraction * 31.4159265 + rotation;
28 vec2 direction = vec2(cos(angle), sin(angle)) * sqrt(fraction);
29 vec2 sampleUv = uv + direction * uRadius / iResolution.xy;
30 if (any(lessThan(sampleUv, vec2(0.0))) || any(greaterThan(sampleUv, vec2(1.0)))) continue;
31 validSamples += 1.0;
32 vec4 neighbour = texture(iChannel0, sampleUv);
33 float sampleDepth = neighbour.a * SCENE_FAR;
34 vec3 samplePosition = reconstructViewPosition(sampleUv, sampleDepth);
35 vec3 delta = samplePosition - position;
36 float distanceToSample = length(delta);
37 float blocked = step(uBias, dot(delta, normal));
38 float rangeWeight = 1.0 - smoothstep(0.0, 1.8, distanceToSample);
39 rangeWeight *= step(sampleDepth, SCENE_FAR - 0.01);
40 occlusion += blocked * rangeWeight;
41 }
42 float ao = pow(1.0 - occlusion / max(validSamples, 1.0), uStrength);
43 vec3 lightDirection = normalize(vec3(-0.45, 0.75, 0.35));
44 float diffuse = max(dot(normal, lightDirection), 0.0);
45 vec3 base = mix(vec3(0.19, 0.24, 0.30), vec3(0.72, 0.42, 0.24), normal.y * 0.5 + 0.5);
46 vec3 color = base * (0.16 * ao + 0.84 * diffuse);
47 color += vec3(0.22, 0.35, 0.48) * 0.12 * ao;
48 color = pow(color, vec3(0.4545));
49 fragColor = vec4(color, 1.0);
50 }
51

Inputs for this pass

  • iChannel0 Buffer A
  • iChannel1 Empty
  • iChannel2 Empty
  • iChannel3 Empty

Learn from this shader

How it works

Each pixel reconstructs its view-space position from depth. A small rotated kernel reads neighbouring positions, asks whether they sit above the current surface along its normal, and rejects samples outside the frame or across an implausibly large depth gap. Occlusion is divided by the number of valid in-frame taps, so a clipped kernel does not brighten the image boundary. The resulting visibility darkens indirect light while leaving direct lighting readable. It is an approximation: hidden geometry and anything outside the frame cannot contribute.

Try changing

Increase Radius to spread the contact shadows, then notice halos where foreground and background depths meet. Raise Bias to suppress self-occlusion on smooth surfaces, or lower it until noise appears. Strength changes the response after sampling and therefore cannot restore missing geometric detail.

Using it in a game

This is a full-screen post-process with opaque output alpha. Replace channel 0 with view-space normals and linear view depth from the camera that supplies the matching projection. Unity, Unreal, Godot, and raw WebGL encode depth differently, may use reversed depth, and commonly reconstruct from an inverse projection matrix, so adapt the conversion rather than pasting this source unchanged. Temporal filtering and depth-aware blur are typical production additions.

Explore the techniques

Continue with curated explanations and progressively related examples.

LicenseMIT
Views2
Forks0

Discussion

Loading comments...