Screen-Space Refraction
by GameDev.net · GLSL ES 3.00 (WebGL2) · 30 Aug 2026
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.
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 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
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec3 rayDirection = cameraRay(fragCoord);
vec3 center = vec3(0.0, -0.02, -3.1);
float hit = sphereHit(rayDirection, center, 0.92);
if (hit >= GLASS_FAR) {
fragColor = vec4(0.5, 0.5, 1.0, 0.0);
return;
}
vec3 position = rayDirection * hit;
vec3 normal = normalize(position - center);
float depth = -position.z;
fragColor = vec4(normal.xy * 0.5 + 0.5, depth / GLASS_FAR, 1.0);
}
Main Image5
uniform float uIOR; // @param 1.0..2.2 = 1.45 "Index of refraction"
uniform float uThickness; // @param 0.0..0.20 = 0.075 "Glass thickness"
uniform float uDispersion; // @param 0.0..0.12 = 0.025 "Colour dispersion"
uniform float uTint; // @param 0.0..1.0 = 0.22 "Tint strength"
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec3 scene = texture(iChannel0, uv).rgb;
vec4 glass = texture(iChannel1, uv);
float mask = glass.a;
if (mask < 0.5) {
fragColor = vec4(scene / (scene + 1.0), 1.0);
return;
}
vec2 normalXY = glass.rg * 2.0 - 1.0;
float normalZ = sqrt(max(0.0, 1.0 - dot(normalXY, normalXY)));
vec3 normal = normalize(vec3(normalXY, normalZ));
float depth = glass.b * GLASS_FAR;
float bend = 1.0 - 1.0 / max(uIOR, 1.001);
float depthScale = 3.0 / max(depth, 0.5);
vec2 offset = normal.xy * bend * uThickness * depthScale;
float redScale = 1.0 + uDispersion;
float blueScale = 1.0 - uDispersion;
float maximumScale = max(redScale, blueScale);
float frameDistance = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y));
float maximumDisplacement = length(offset) * maximumScale;
float boundaryFade = smoothstep(0.0, max(maximumDisplacement * 1.5, 1.0 / min(iResolution.x, iResolution.y)), frameDistance);
offset *= boundaryFade;
vec2 furthestUv = uv + offset * maximumScale;
if (any(lessThan(furthestUv, vec2(0.0))) || any(greaterThan(furthestUv, vec2(1.0)))) offset = vec2(0.0);
vec3 refracted = vec3(texture(iChannel0, uv + offset * redScale).r,
texture(iChannel0, uv + offset).g,
texture(iChannel0, uv + offset * blueScale).b);
float edge = pow(1.0 - normalZ, 2.0);
vec3 absorption = mix(vec3(1.0), vec3(0.58, 0.88, 0.92), uTint);
vec3 reflectedTint = vec3(0.45, 0.72, 0.88) * edge;
vec3 color = mix(refracted * absorption, reflectedTint, edge * 0.45);
color = color / (color + 1.0);
fragColor = vec4(mix(scene / (scene + 1.0), color, mask), 1.0);
}
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.
Discussion