Skip to main content
GameDev.net gamedev.net

Heat Haze Distortion

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

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.

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
// 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
1 uniform float uStrength; // @param 0.0..0.030 = 0.016 "Distortion strength"
2 uniform float uSpeed; // @param 0.0..3.0 = 1.0 "Rise speed"
3 uniform float uHeight; // @param 0.05..1.0 = 0.45 "Haze height"
4 uniform float uScale; // @param 4.0..40.0 = 16.0 "Ripple scale"
5
6 float hash21(vec2 cell) {
7 return fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453);
8 }
9
10 float valueNoise(vec2 p) {
11 vec2 cell = floor(p);
12 vec2 local = fract(p);
13 vec2 weight = local * local * (3.0 - 2.0 * local);
14 float bottom = mix(hash21(cell), hash21(cell + vec2(1.0, 0.0)), weight.x);
15 float top = mix(hash21(cell + vec2(0.0, 1.0)), hash21(cell + vec2(1.0, 1.0)), weight.x);
16 return mix(bottom, top, weight.y);
17 }
18
19 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
20 vec2 uv = fragCoord / iResolution.xy;
21
22 // Haze rises, so the field scrolls downward through the coordinate and the
23 // two layers move at different rates to avoid a single sliding texture.
24 float rise = iTime * uSpeed;
25 vec2 p = vec2(uv.x, uv.y) * uScale;
26 float wobbleX = valueNoise(p + vec2(0.0, -rise * 1.6)) - 0.5;
27 float wobbleY = valueNoise(p * 1.7 + vec2(5.0, -rise * 2.3)) - 0.5;
28
29 // Only the lower band shimmers, fading out with height: heat comes off the
30 // ground, and distorting the whole frame reads as a broken lens instead.
31 float mask = 1.0 - smoothstep(0.0, uHeight, uv.y);
32 vec2 offset = vec2(wobbleX, wobbleY) * uStrength * mask;
33
34 // Sampling the *scene* with a displaced coordinate. Nothing about the scene
35 // changes; the effect is entirely in where each fragment looks.
36 vec3 color = texture(iChannel0, uv + offset).rgb;
37 color = color / (color + 1.0);
38 fragColor = vec4(color, 1.0);
39 }
40

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.

LicenseMIT
Views0
Forks0

Discussion

Loading comments...