Skip to main content
GameDev.net gamedev.net

Soft Shadows and Ambient Occlusion

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

Use in your engine

Run the shader to adjust these controls.

What it demonstrates

Direct lighting alone leaves objects looking pasted onto a backdrop. What grounds them is occlusion: shadows where the light cannot reach, and darkening in the creases where the surroundings block ambient light. A distance field can answer both questions cheaply, because the field already knows how close geometry is without any ray needing to hit it.

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
// The Common pass is prepended to the Image pass before compilation, so this
// is shared source rather than a second shader. The scene itself is declared
// here and defined in the Image pass, which is where the lesson lives.
float mapScene(vec3 position);

vec3 cameraRay(vec2 fragCoord, vec3 origin, vec3 target, float lens) {
    vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
    vec3 forward = normalize(target - origin);
    vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), forward));
    vec3 up = cross(forward, right);
    return normalize(forward * lens + right * uv.x + up * uv.y);
}

float marchScene(vec3 origin, vec3 direction, float maxDistance) {
    float travelled = 0.0;
    for (int i = 0; i < 96; i++) {
        vec3 position = origin + direction * travelled;
        float dist = mapScene(position);
        if (dist < 0.001) return travelled;
        travelled += dist;
        if (travelled > maxDistance) break;
    }
    return -1.0;
}

vec3 sceneNormal(vec3 position, float epsilon) {
    vec2 offset = vec2(epsilon, 0.0);
    return normalize(vec3(
        mapScene(position + offset.xyy) - mapScene(position - offset.xyy),
        mapScene(position + offset.yxy) - mapScene(position - offset.yxy),
        mapScene(position + offset.yyx) - mapScene(position - offset.yyx)));
}

vec3 skyColor(vec3 direction) {
    return mix(vec3(0.05, 0.07, 0.12), vec3(0.16, 0.22, 0.34), direction.y * 0.5 + 0.5);
}
Main Image8
1 uniform float uShadowSharpness; // @param 2.0..64.0 = 14.0 "Shadow sharpness"
2 uniform float uAoStrength; // @param 0.0..1.0 = 0.75 "AO strength"
3 uniform int uAoSamples; // @param 1..8 = 5 "AO samples"
4
5 float mapScene(vec3 position) {
6 float angle = iTime * 0.2;
7 float c = cos(angle);
8 float s = sin(angle);
9 vec3 p = vec3(c * position.x - s * position.z, position.y, s * position.x + c * position.z);
10 float ball = length(p - vec3(-0.55, -0.35, 0.10)) - 0.55;
11 vec3 q = abs(p - vec3(0.70, -0.51, -0.20)) - vec3(0.34, 0.34, 0.34);
12 float box = length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - 0.05;
13 float ground = position.y + 0.90;
14 return min(ground, min(ball, box));
15 }
16
17 float softShadow(vec3 origin, vec3 direction, float sharpness) {
18 float shade = 1.0;
19 float travelled = 0.06;
20 for (int i = 0; i < 48; i++) {
21 float dist = mapScene(origin + direction * travelled);
22 shade = min(shade, clamp(sharpness * dist / travelled, 0.0, 1.0));
23 travelled += clamp(dist, 0.02, 0.35);
24 if (shade < 0.004 || travelled > 9.0) break;
25 }
26 return shade;
27 }
28
29 float ambientOcclusion(vec3 position, vec3 normal) {
30 float occlusion = 0.0;
31 float weight = 1.0;
32 for (int i = 1; i <= 8; i++) {
33 if (i > uAoSamples) break;
34 float height = 0.035 * float(i) * float(i);
35 float dist = mapScene(position + normal * height);
36 occlusion += weight * (height - dist);
37 weight *= 0.72;
38 }
39 return clamp(1.0 - 2.4 * occlusion, 0.0, 1.0);
40 }
41
42 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
43 vec3 origin = vec3(0.0, 0.85, -3.1);
44 vec3 direction = cameraRay(fragCoord, origin, vec3(0.0, -0.30, 0.0), 1.7);
45 float hit = marchScene(origin, direction, 18.0);
46 if (hit < 0.0) {
47 fragColor = vec4(skyColor(direction), 1.0);
48 return;
49 }
50
51 vec3 position = origin + direction * hit;
52 vec3 normal = sceneNormal(position, 0.0015);
53 vec3 toLight = normalize(vec3(0.55, 0.80, -0.45));
54 float isGround = smoothstep(0.03, -0.03, position.y + 0.89);
55 vec3 albedo = mix(vec3(0.80, 0.74, 0.66), vec3(0.32, 0.34, 0.39), isGround);
56
57 float shadow = softShadow(position + normal * 0.004, toLight, uShadowSharpness);
58 float occlusion = mix(1.0, ambientOcclusion(position, normal), uAoStrength);
59 float diffuse = max(dot(normal, toLight), 0.0);
60 vec3 halfway = normalize(toLight - direction);
61 float specular = pow(max(dot(normal, halfway), 0.0), 48.0);
62
63 vec3 ambient = vec3(0.16, 0.20, 0.28) * occlusion;
64 vec3 color = albedo * (ambient + diffuse * shadow);
65 color += vec3(1.0, 0.97, 0.92) * specular * shadow * 0.4;
66 color = mix(skyColor(direction), color, exp(-0.012 * hit * hit));
67 fragColor = vec4(color, 1.0);
68 }
69

Learn from this shader

How it works

The shadow is a second march from the surface toward the light. Rather than only asking whether something was hit, each step compares the field distance against how far the ray has travelled and keeps the smallest ratio. A ray passing close to geometry darkens proportionally, producing a penumbra that widens with distance from the occluder, and the sharpness control scales that ratio. Occlusion samples the field a few times straight out along the normal at increasing distances. In open space it returns the full sample height; in a crease it returns less, and that shortfall is the occlusion. Both need the start point nudged along the normal, or the first sample hits the surface it started from.

Try changing

Take Shadow sharpness low for a wide soft penumbra and high for a hard edge, and note the contact point stays dark either way. Drop AO strength to zero and watch the creases and the ground contact flatten out. Reduce AO samples to one to see the effect degrade into a thin outline.

Using it in a game

Distance field soft shadows and occlusion are standard in raymarched scenes and also power screen space AO approximations elsewhere. Both are extra scene evaluations per pixel on top of shading, so they are usually where a raymarched frame spends most of its budget.

Explore the techniques

Continue with curated explanations and progressively related examples.

LicenseMIT
Views0
Forks0

Discussion

Loading comments...