Skip to main content
GameDev.net gamedev.net

Screen-Space Refraction

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 refraction distorts a previously rendered scene using the surface data of a transparent object. Buffer A draws a first-party procedural room pattern. Buffer B ray-intersects a glass sphere and packs its view-space normal, linear depth, and coverage mask. The image pass reads scene colour from channel 0 and glass data from channel 1.

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 GLASS_FAR = 8.0;
const float CAMERA_FOCAL = 1.8;

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

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

vec3 backgroundScene(vec2 uv, float time) {
    vec2 centered = uv - 0.5;
    vec3 color = mix(vec3(0.035, 0.07, 0.12), vec3(0.22, 0.44, 0.62), uv.y);
    vec2 gridUv = centered * vec2(iResolution.x / iResolution.y, 1.0) * 8.0;
    vec2 gridLine = smoothstep(vec2(0.46), vec2(0.49), abs(fract(gridUv + 0.5) - 0.5));
    float grid = max(gridLine.x, gridLine.y);
    color = mix(color, vec3(0.82, 0.68, 0.34), grid * 0.45);
    vec2 lightPosition = vec2(0.24 * sin(time * 0.45), 0.13 * cos(time * 0.62));
    float lightShape = 1.0 - smoothstep(0.06, 0.075, length(centered - lightPosition));
    color += vec3(1.8, 0.72, 0.24) * lightShape;
    float bar = 1.0 - smoothstep(0.018, 0.028, abs(centered.x + 0.31));
    color = mix(color, vec3(0.08, 0.8, 0.72), bar * smoothstep(-0.4, 0.3, centered.y));
    return color;
}
Buffer A
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = fragCoord / iResolution.xy;
    fragColor = vec4(backgroundScene(uv, iTime), 1.0);
}
Buffer B1
1 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2 vec3 rayDirection = cameraRay(fragCoord);
3 vec3 center = vec3(0.0, -0.02, -3.1);
4 float hit = sphereHit(rayDirection, center, 0.92);
5 if (hit >= GLASS_FAR) {
6 fragColor = vec4(0.5, 0.5, 1.0, 0.0);
7 return;
8 }
9 vec3 position = rayDirection * hit;
10 vec3 normal = normalize(position - center);
11 float depth = -position.z;
12 fragColor = vec4(normal.xy * 0.5 + 0.5, depth / GLASS_FAR, 1.0);
13 }
14
Main Image5
1 uniform float uIOR; // @param 1.0..2.2 = 1.45 "Index of refraction"
2 uniform float uThickness; // @param 0.0..0.20 = 0.075 "Glass thickness"
3 uniform float uDispersion; // @param 0.0..0.12 = 0.025 "Colour dispersion"
4 uniform float uTint; // @param 0.0..1.0 = 0.22 "Tint strength"
5
6 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
7 vec2 uv = fragCoord / iResolution.xy;
8 vec3 scene = texture(iChannel0, uv).rgb;
9 vec4 glass = texture(iChannel1, uv);
10 float mask = glass.a;
11 if (mask < 0.5) {
12 fragColor = vec4(scene / (scene + 1.0), 1.0);
13 return;
14 }
15 vec2 normalXY = glass.rg * 2.0 - 1.0;
16 float normalZ = sqrt(max(0.0, 1.0 - dot(normalXY, normalXY)));
17 vec3 normal = normalize(vec3(normalXY, normalZ));
18 float depth = glass.b * GLASS_FAR;
19 float bend = 1.0 - 1.0 / max(uIOR, 1.001);
20 float depthScale = 3.0 / max(depth, 0.5);
21 vec2 offset = normal.xy * bend * uThickness * depthScale;
22 float redScale = 1.0 + uDispersion;
23 float blueScale = 1.0 - uDispersion;
24 float maximumScale = max(redScale, blueScale);
25 float frameDistance = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y));
26 float maximumDisplacement = length(offset) * maximumScale;
27 float boundaryFade = smoothstep(0.0, max(maximumDisplacement * 1.5, 1.0 / min(iResolution.x, iResolution.y)), frameDistance);
28 offset *= boundaryFade;
29 vec2 furthestUv = uv + offset * maximumScale;
30 if (any(lessThan(furthestUv, vec2(0.0))) || any(greaterThan(furthestUv, vec2(1.0)))) offset = vec2(0.0);
31 vec3 refracted = vec3(texture(iChannel0, uv + offset * redScale).r,
32 texture(iChannel0, uv + offset).g,
33 texture(iChannel0, uv + offset * blueScale).b);
34 float edge = pow(1.0 - normalZ, 2.0);
35 vec3 absorption = mix(vec3(1.0), vec3(0.58, 0.88, 0.92), uTint);
36 vec3 reflectedTint = vec3(0.45, 0.72, 0.88) * edge;
37 vec3 color = mix(refracted * absorption, reflectedTint, edge * 0.45);
38 color = color / (color + 1.0);
39 fragColor = vec4(mix(scene / (scene + 1.0), color, mask), 1.0);
40 }
41

Inputs for this pass

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

Learn from this shader

How it works

Inside the mask, two stored normal components reconstruct the sphere’s front-facing normal. The index of refraction determines a bend amount, depth scales the apparent displacement, and thickness controls how far the background lookup travels. Separate red, green, and blue offsets add optional dispersion. Displacement fades near the frame boundary, and any remaining out-of-range footprint is rejected before sampling, preventing clamped edge texels from smearing across the glass. A normal-based edge response contributes a restrained reflected tint.

Try changing

Raise IOR from nearly air-like values toward dense glass and watch straight background lines bend. Thickness magnifies the offset but eventually reveals the screen-space approximation at the silhouette. Increase Dispersion to separate colours, then return it near zero for ordinary clear glass. Tint strength controls absorption independently of bending.

Using it in a game

This is an opaque full-screen composite, not a transparent material pass; output alpha is one. Replace channel 0 with scene colour captured before transparent objects and channel 1 with view-space refractor normals, linear depth, and a coverage mask from the same camera. Unity, Unreal, Godot, and raw WebGL differ in normal packing, depth direction, and transparent ordering. Adapt those conventions, and expect to add thickness data and an environment fallback for production water or glass.

Explore the techniques

Continue with curated explanations and progressively related examples.

LicenseMIT
Views0
Forks0

Discussion

Loading comments...