Heat Haze Distortion
by GameDev.net · GLSL ES 3.00 (WebGL2) · 25 Aug 2026
Run the shader to adjust these controls.
What it demonstrates
Heat shimmer is refraction, and refraction in screen space is just sampling the frame at a slightly wrong place. This effect never touches the scene's colour: it perturbs the coordinate used to read the already-rendered frame, with a noise field scrolling upward and a mask that keeps the distortion near the ground.
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
// Shared by every pass, because the Common pass is prepended to each of them
// before compilation. The scene lives here rather than in a committed image: a
// post effect in a game runs on a frame the renderer just produced, and a
// rendered source also means the highlights are genuinely brighter than white,
// which is what a bright-pass needs in order to find anything at all.
//
// This file is identical across the post-processing seeds on purpose, so the
// effects are comparable: the only thing that differs between them is the
// processing, not the picture.
float hash11(float n) {
return fract(sin(n * 91.7) * 43758.5453);
}
vec3 renderScene(vec2 uv, float time) {
vec3 color = mix(vec3(0.04, 0.05, 0.10), vec3(0.30, 0.17, 0.26), uv.y + 0.5);
// Sun: small, hard edged, and far above the displayable range so it blooms
// hard. Deliberately no painted-on glow around it — every halo in the final
// image has to come from the effect, or the effect demonstrates nothing.
vec2 sunPos = vec2(0.44, 0.19);
float sunDist = length(uv - sunPos);
color += vec3(3.4, 2.5, 1.4) * (1.0 - smoothstep(0.052, 0.062, sunDist));
// Distant ridge, a flat silhouette to separate sky from city.
float ridge = -0.13 + 0.045 * sin(uv.x * 4.1 + 1.3) + 0.022 * sin(uv.x * 9.7);
if (uv.y < ridge) {
color = mix(color, vec3(0.09, 0.08, 0.15), 0.92);
}
// Near skyline with lit windows: hard edges and fine bright detail, which
// is exactly what outline, pixelate and bloom effects need to chew on.
float cell = 0.20;
float column = floor((uv.x + 2.0) / cell);
float height = -0.34 + 0.20 * hash11(column);
if (uv.y < height) {
color = vec3(0.045, 0.045, 0.075);
float row = floor((uv.y + 0.5) * 26.0);
vec2 local = fract(vec2((uv.x + 2.0) / cell * 3.0, (uv.y + 0.5) * 26.0));
float lit = hash11(column * 13.7 + row * 7.13);
float blink = 0.75 + 0.25 * sin(time * 1.7 + column);
if (local.x > 0.28 && local.x < 0.72 && local.y > 0.30 && local.y < 0.72
&& lit > 0.42) {
color = vec3(1.75, 1.35, 0.72) * blink;
}
}
// A single beacon so the frame is never completely static.
vec2 beacon = vec2(-0.62, -0.14);
float pulse = 0.5 + 0.5 * sin(time * 2.4);
color += vec3(2.2, 0.5, 0.4) * pulse
* (1.0 - smoothstep(0.006, 0.016, length(uv - beacon)));
return color;
}
vec2 sceneUv(vec2 fragCoord) {
return (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
}
Buffer A
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
fragColor = vec4(renderScene(sceneUv(fragCoord), iTime), 1.0);
}
Main Image4
uniform float uStrength; // @param 0.0..0.030 = 0.016 "Distortion strength"
uniform float uSpeed; // @param 0.0..3.0 = 1.0 "Rise speed"
uniform float uHeight; // @param 0.05..1.0 = 0.45 "Haze height"
uniform float uScale; // @param 4.0..40.0 = 16.0 "Ripple scale"
float hash21(vec2 cell) {
return fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453);
}
float valueNoise(vec2 p) {
vec2 cell = floor(p);
vec2 local = fract(p);
vec2 weight = local * local * (3.0 - 2.0 * local);
float bottom = mix(hash21(cell), hash21(cell + vec2(1.0, 0.0)), weight.x);
float top = mix(hash21(cell + vec2(0.0, 1.0)), hash21(cell + vec2(1.0, 1.0)), weight.x);
return mix(bottom, top, weight.y);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
// Haze rises, so the field scrolls downward through the coordinate and the
// two layers move at different rates to avoid a single sliding texture.
float rise = iTime * uSpeed;
vec2 p = vec2(uv.x, uv.y) * uScale;
float wobbleX = valueNoise(p + vec2(0.0, -rise * 1.6)) - 0.5;
float wobbleY = valueNoise(p * 1.7 + vec2(5.0, -rise * 2.3)) - 0.5;
// Only the lower band shimmers, fading out with height: heat comes off the
// ground, and distorting the whole frame reads as a broken lens instead.
float mask = 1.0 - smoothstep(0.0, uHeight, uv.y);
vec2 offset = vec2(wobbleX, wobbleY) * uStrength * mask;
// Sampling the *scene* with a displaced coordinate. Nothing about the scene
// changes; the effect is entirely in where each fragment looks.
vec3 color = texture(iChannel0, uv + offset).rgb;
color = color / (color + 1.0);
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
Two layers of value noise, recentred so they push both ways, supply a horizontal and a vertical offset. They use different scales and speeds, because one field driving both axes displaces everything diagonally and reads as a sliding texture rather than rising air. The field scrolls downward through the coordinate, which makes the shimmer rise. The offset is scaled by a mask that fades with height, since heat leaves the ground and distorting the whole frame looks like a broken lens. Strength stays small on purpose: a few thousandths is convincing, more reads as underwater. Because the scene is a buffer pass, the distortion has real detail to bend.
Try changing
Take Strength to zero for the clean frame, then raise it slowly and watch the point where shimmer becomes wobble. Raise Haze height until the sky distorts and the illusion breaks. Increase Ripple scale for fine turbulence, and drop Rise speed to nearly zero to see the noise field itself.
Using it in a game
Sample the frame buffer with an offset for exhaust, fire, portals, force fields and underwater. In a real pipeline this is a mesh drawn over the scene with a grab pass rather than a full screen effect, so the distortion is confined to the geometry that should be causing it, and the mask becomes the mesh's own alpha.
Explore the techniques
Continue with curated explanations and progressively related examples.
Discussion