Screen-Space Reflection
by GameDev.net · GLSL ES 3.00 (WebGL2) · 30 Aug 2026
Run the shader to adjust these controls.
What it demonstrates
Screen-space reflection traces reflected view-space rays through information already rendered on screen. Buffer A produces view-space normals and linear depth for a procedural floor and spheres. Buffer B independently traces and shades the same scene without sampling Buffer A. The image pass receives geometry on channel 0 and scene colour on channel 1, keeping the demo isolated from external textures.
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 = 14.0;
const float CAMERA_FOCAL = 1.7;
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 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.05 / 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.15, -0.25, -4.1),
vec3(0.72, -0.47, -3.25),
vec3(0.35, 0.25, -5.6)
);
float radii[3] = float[3](0.80, 0.58, 0.72);
for (int i = 0; i < 3; ++i) {
float hit = sphereHit(vec3(0.0), rayDirection, centers[i], radii[i]);
if (hit < nearest) {
nearest = hit;
normal = normalize(rayDirection * hit - centers[i]);
}
}
depth = nearest >= SCENE_FAR ? SCENE_FAR : -(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);
}
vec2 projectViewPosition(vec3 position) {
vec2 screen = position.xy * CAMERA_FOCAL / max(-position.z, 0.001);
return 0.5 + 0.5 * screen / vec2(iResolution.x / iResolution.y, 1.0);
}
vec3 skyColor(vec3 direction) {
float horizon = smoothstep(-0.25, 0.65, direction.y);
return mix(vec3(0.07, 0.09, 0.14), vec3(0.36, 0.58, 0.82), horizon);
}
Buffer A1
Buffer B
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec3 rayDirection = cameraRay(fragCoord);
float depth;
vec3 normal;
traceScene(rayDirection, depth, normal);
if (depth >= SCENE_FAR - 0.01) {
fragColor = vec4(skyColor(rayDirection), 1.0);
return;
}
vec3 position = rayDirection * depth / -rayDirection.z;
vec3 lightDirection = normalize(vec3(-0.5, 0.8, 0.35));
float diffuse = max(dot(normal, lightDirection), 0.0);
float checker = mod(floor(position.x * 1.4) + floor(position.z * 1.4), 2.0);
vec3 floorColor = mix(vec3(0.08, 0.11, 0.15), vec3(0.24, 0.29, 0.34), checker);
vec3 objectColor = mix(vec3(0.18, 0.42, 0.68), vec3(0.82, 0.31, 0.16), normal.y * 0.5 + 0.5);
float floorMask = 1.0 - step(0.02, abs(position.y + 1.05));
vec3 base = mix(objectColor, floorColor, floorMask);
vec3 color = base * (0.18 + 0.82 * diffuse) + skyColor(normal) * 0.12;
fragColor = vec4(pow(color, vec3(0.4545)), 1.0);
}
Main Image5
uniform int uSteps; // @param 8..40 = 28 "Ray steps"
uniform float uMaxDistance; // @param 0.5..8.0 = 5.0 "Max distance"
uniform float uThickness; // @param 0.01..0.60 = 0.18 "Hit thickness"
uniform float uBias; // @param 0.0..0.20 = 0.04 "Depth bias"
uniform float uIntensity; // @param 0.0..1.0 = 0.78 "Reflection intensity"
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec4 packed = texture(iChannel0, uv);
vec3 scene = texture(iChannel1, uv).rgb;
float depth = packed.a * SCENE_FAR;
if (depth >= SCENE_FAR - 0.01) {
fragColor = vec4(scene, 1.0);
return;
}
vec3 normal = normalize(packed.rgb * 2.0 - 1.0);
vec3 position = reconstructViewPosition(uv, depth);
vec3 reflectionDirection = reflect(normalize(position), normal);
vec3 reflectedColor = skyColor(reflectionDirection);
float hitMask = 0.0;
vec2 lastUv = uv;
for (int i = 0; i < 40; ++i) {
if (i >= uSteps) break;
float distanceAlongRay = (float(i) + 1.0) * uMaxDistance / float(uSteps);
vec3 rayPosition = position + normal * 0.06 + reflectionDirection * distanceAlongRay;
if (rayPosition.z > -0.05) break;
vec2 projectedUv = projectViewPosition(rayPosition);
if (any(lessThan(projectedUv, vec2(0.0))) || any(greaterThan(projectedUv, vec2(1.0)))) break;
float sceneDepth = texture(iChannel0, projectedUv).a * SCENE_FAR;
float depthDelta = -rayPosition.z - sceneDepth;
lastUv = projectedUv;
if (depthDelta > uBias && depthDelta < uThickness) {
reflectedColor = texture(iChannel1, projectedUv).rgb;
hitMask = 1.0;
break;
}
}
float edgeDistance = min(min(lastUv.x, lastUv.y), min(1.0 - lastUv.x, 1.0 - lastUv.y));
float edgeFade = smoothstep(0.0, 0.12, edgeDistance);
float fresnel = pow(1.0 - max(dot(-normalize(position), normal), 0.0), 3.0);
float floorMask = 1.0 - step(0.02, abs(position.y + 1.05));
float reflectiveSurface = mix(0.35, 1.0, floorMask);
float weight = uIntensity * reflectiveSurface * mix(0.35, 1.0, fresnel) * mix(0.55, edgeFade, hitMask);
fragColor = vec4(mix(scene, reflectedColor, weight), 1.0);
}
Inputs for this pass
- iChannel0 Buffer A
- iChannel1 Buffer B
- iChannel2 Empty
- iChannel3 Empty
Learn from this shader
How it works
The image pass reconstructs a view-space surface position, reflects the camera ray around its normal, and advances that ray in bounded steps. Every candidate position is projected to UV, then its ray depth is compared with Buffer A. A small crossing interval identifies a likely hit and retrieves colour from Buffer B. Proximity to the known floor plane selects its material and stronger reflectivity, rather than treating every upward-facing sphere point as floor. Boundary and Fresnel fades hide unstable cases without pretending the missing data exists.
Try changing
Increase Steps to reduce gaps while watching the cost rise. Thickness can recover missed thin objects, but excessive values attach reflections to unrelated surfaces. Bias prevents the ray from immediately hitting its source. Max distance controls how far the search travels before falling back to the environment colour.
Using it in a game
This is an opaque full-screen pass. Replace channel 0 with view-space normals and linear camera depth, and channel 1 with scene colour from the same frame and projection. Off-screen, occluded, or back-facing objects can never appear, so production SSR commonly adds hierarchical depth, temporal reuse, roughness-aware filtering, and an environment-map fallback. Unity, Unreal, Godot, and raw WebGL use different depth conventions and projection matrices; adapt reconstruction and projection rather than copying this source unchanged.
Explore the techniques
Continue with curated explanations and progressively related examples.
Discussion