Screen-Space Ambient Occlusion
by GameDev.net · GLSL ES 3.00 (WebGL2) · 30 Aug 2026
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.
Shader inputs
void mainImage(out vec4 fragColor, in vec2 fragCoord)
Called once per pixel. Write the colour to fragColor.
-
iResolutionvec3 - Viewport size in pixels (z is the pixel aspect ratio).
-
iTimefloat - Seconds since the shader started.
-
iTimeDeltafloat - Seconds since the previous frame.
-
iFrameRatefloat - Frames per second, smoothed.
-
iFrameint - Frames rendered since the start.
-
iMousevec4 - Mouse position: xy while held, zw of the last click.
-
iDatevec4 - Year, month, day, and seconds within the day.
-
iChannel0sampler2D - Texture bound to channel 0.
-
iChannel1sampler2D - Texture bound to channel 1.
-
iChannel2sampler2D - Texture bound to channel 2.
-
iChannel3sampler2D - Texture bound to channel 3.
-
iChannelResolutionvec3[4] - Pixel size of each bound channel texture.
-
iChannelTimefloat[4] - Playback time of each channel, in seconds.
-
iSampleRatefloat - 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
Main Image4
uniform float uRadius; // @param 4.0..64.0 = 24.0 "Sample radius"
uniform float uBias; // @param 0.0..0.20 = 0.035 "Depth bias"
uniform float uStrength; // @param 0.2..4.0 = 1.7 "AO strength"
const int KERNEL_SIZE = 12;
float hashPixel(vec2 value) {
return fract(sin(dot(value, vec2(12.9898, 78.233))) * 43758.5453);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec4 packed = texture(iChannel0, uv);
float depth = packed.a * SCENE_FAR;
if (depth >= SCENE_FAR - 0.01) {
vec3 sky = mix(vec3(0.08, 0.12, 0.20), vec3(0.38, 0.55, 0.72), uv.y);
fragColor = vec4(sky, 1.0);
return;
}
vec3 normal = normalize(packed.rgb * 2.0 - 1.0);
vec3 position = reconstructViewPosition(uv, depth);
float rotation = hashPixel(fragCoord) * 6.2831853;
float occlusion = 0.0;
float validSamples = 0.0;
for (int i = 0; i < KERNEL_SIZE; ++i) {
float fraction = (float(i) + 0.5) / float(KERNEL_SIZE);
float angle = fraction * 31.4159265 + rotation;
vec2 direction = vec2(cos(angle), sin(angle)) * sqrt(fraction);
vec2 sampleUv = uv + direction * uRadius / iResolution.xy;
if (any(lessThan(sampleUv, vec2(0.0))) || any(greaterThan(sampleUv, vec2(1.0)))) continue;
validSamples += 1.0;
vec4 neighbour = texture(iChannel0, sampleUv);
float sampleDepth = neighbour.a * SCENE_FAR;
vec3 samplePosition = reconstructViewPosition(sampleUv, sampleDepth);
vec3 delta = samplePosition - position;
float distanceToSample = length(delta);
float blocked = step(uBias, dot(delta, normal));
float rangeWeight = 1.0 - smoothstep(0.0, 1.8, distanceToSample);
rangeWeight *= step(sampleDepth, SCENE_FAR - 0.01);
occlusion += blocked * rangeWeight;
}
float ao = pow(1.0 - occlusion / max(validSamples, 1.0), uStrength);
vec3 lightDirection = normalize(vec3(-0.45, 0.75, 0.35));
float diffuse = max(dot(normal, lightDirection), 0.0);
vec3 base = mix(vec3(0.19, 0.24, 0.30), vec3(0.72, 0.42, 0.24), normal.y * 0.5 + 0.5);
vec3 color = base * (0.16 * ao + 0.84 * diffuse);
color += vec3(0.22, 0.35, 0.48) * 0.12 * ao;
color = pow(color, vec3(0.4545));
fragColor = vec4(color, 1.0);
}
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.
Discussion