Heat Distortion / Haze Shader — full GLSL walkthrough

47 views 0 replies
Live Shader
Loading versions...

Been working on a heat haze effect for a desert scene and finally got it to a point where I'm happy with it. Sharing the full fragment shader here because I couldn't find a clean, well-commented example anywhere when I was starting out.

The core idea: generate scrolling noise and vertical wave ripples, use them to offset UVs when sampling the scene. The distortion is masked by a gradient so it's strongest just above the horizon line and fades out above and below. I built the whole desert scene procedurally (sky, clouds, mountains, dunes, road, cacti) so you can actually see the shimmer warping things.

precision mediump float;

uniform vec2 iResolution;
uniform float iTime;

float hash(vec2 p) {
    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}

float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    f = f * f * (3.0 - 2.0 * f);
    float a = hash(i);
    float b = hash(i + vec2(1.0, 0.0));
    float c = hash(i + vec2(0.0, 1.0));
    float d = hash(i + vec2(1.0, 1.0));
    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

float fbm(vec2 p) {
    float v = 0.0;
    float amp = 0.5;
    for (int i = 0; i < 4; i++) {
        v += amp * noise(p);
        p *= 2.1;
        amp *= 0.5;
    }
    return v;
}

// draw the full scene at given UV
vec3 drawScene(vec2 uv) {
    float horizon = 0.38;

    // sky - blue gradient with wispy clouds
    vec3 sky = mix(vec3(0.9, 0.7, 0.45), vec3(0.25, 0.55, 0.95), pow(uv.y, 0.5));
    // clouds
    float cloud = fbm(vec2(uv.x * 3.0 + iTime * 0.02, uv.y * 2.0));
    cloud = smoothstep(0.3, 0.65, cloud);
    sky = mix(sky, vec3(1.0, 0.98, 0.95), cloud * smoothstep(horizon, 0.8, uv.y) * 0.7);

    // sun glow
    vec2 sunPos = vec2(0.65, 0.78);
    float sunDist = length(uv - sunPos);
    sky += vec3(1.0, 0.85, 0.4) * 0.5 / (sunDist + 0.06);
    sky += vec3(1.0, 0.95, 0.7) * max(0.0, 0.03 / (sunDist * sunDist + 0.001));

    // mountains silhouette
    float mt = 0.0;
    mt += 0.08 * sin(uv.x * 5.0 + 1.0);
    mt += 0.04 * sin(uv.x * 11.0 + 3.0);
    mt += 0.02 * sin(uv.x * 23.0 + 0.5);
    float mountainLine = horizon + 0.08 + mt;
    vec3 mountain = mix(vec3(0.35, 0.3, 0.4), vec3(0.55, 0.45, 0.55), (uv.y - horizon) / 0.2);

    // ground - sand with texture
    vec3 sand = vec3(0.82, 0.68, 0.42);
    sand *= 0.85 + 0.15 * noise(uv * vec2(40.0, 10.0));
    // dune ridges
    float dunes = sin(uv.x * 25.0 + noise(uv * 8.0) * 4.0) * 0.5 + 0.5;
    sand *= 0.9 + 0.1 * dunes;
    // darken with distance from horizon
    sand *= 0.7 + 0.3 * smoothstep(0.0, horizon, uv.y);

    // road/path through desert
    float road = smoothstep(0.02, 0.0, abs(uv.x - 0.5 - sin(uv.y * 3.0) * 0.05));
    sand = mix(sand, vec3(0.45, 0.4, 0.35), road * 0.6);
    // road markings
    float marks = step(0.5, fract(uv.y * 15.0)) * road;
    sand = mix(sand, vec3(0.9, 0.85, 0.5), marks * 0.3);

    // cactus silhouettes
    for (int k = 0; k < 3; k++) {
        float cx = 0.2 + float(k) * 0.3 + 0.05 * sin(float(k) * 7.0);
        float cw = 0.008;
        float cBase = horizon * (0.6 + 0.2 * hash(vec2(float(k), 3.0)));
        float cTop = cBase + 0.08 + 0.04 * hash(vec2(float(k), 5.0));
        float cactus = smoothstep(cw, cw * 0.3, abs(uv.x - cx))
                     * step(cBase, uv.y) * step(uv.y, cTop);
        // arm
        float armY = cBase + (cTop - cBase) * 0.6;
        float armDir = hash(vec2(float(k), 9.0)) > 0.5 ? 1.0 : -1.0;
        float armH = smoothstep(0.012, 0.0, abs(uv.y - armY))
                   * smoothstep(0.0, 0.025, (uv.x - cx) * armDir)
                   * smoothstep(0.035, 0.0, (uv.x - cx) * armDir);
        float armV = smoothstep(cw, cw * 0.3, abs(uv.x - cx - armDir * 0.03))
                   * step(armY, uv.y) * step(uv.y, armY + 0.04);
        cactus = max(cactus, max(armH, armV));
        sand = mix(sand, vec3(0.15, 0.25, 0.12), cactus);
    }

    // composite layers
    vec3 col = sand;
    if (uv.y > horizon) col = (uv.y < mountainLine) ? mountain : sky;

    return col;
}

void main() {
    vec2 uv = gl_FragCoord.xy / iResolution.xy;

    // heat distortion mask - strongest just above the horizon
    float horizon = 0.38;
    float heatMask = smoothstep(horizon + 0.35, horizon - 0.08, uv.y);
    heatMask *= smoothstep(horizon - 0.15, horizon + 0.05, uv.y);
    heatMask = pow(heatMask, 1.5);

    // scrolling noise for the shimmer
    vec2 n1uv = uv * vec2(6.0, 15.0) + vec2(iTime * 0.12, iTime * 0.25);
    vec2 n2uv = uv * vec2(8.0, 10.0) + vec2(-iTime * 0.08, iTime * 0.18);
    float n1 = fbm(n1uv) * 2.0 - 1.0;
    float n2 = fbm(n2uv) * 2.0 - 1.0;

    // rising heat waves - vertical ripple
    float wave = sin(uv.y * 60.0 - iTime * 4.0) * 0.5 + 0.5;
    wave *= sin(uv.y * 35.0 - iTime * 2.5 + uv.x * 10.0) * 0.5 + 0.5;

    // combine noise and wave for distortion offset
    float strength = 0.04;
    vec2 distortion = vec2(n1 + wave * 0.5, n2 * 0.3) * strength * heatMask;

    // sample scene at original and distorted UVs
    vec3 clean = drawScene(uv);
    vec2 dUv = clamp(uv + distortion, 0.0, 1.0);
    vec3 warped = drawScene(dUv);

    // blend
    vec3 col = mix(clean, warped, heatMask);

    // slight brightness shimmer in distorted areas
    col += vec3(0.03, 0.02, 0.0) * heatMask * wave;

    gl_FragColor = vec4(col, 1.0);
}

A few things worth noting: the distortion has two components working together. The fbm noise gives organic randomness, while the sin() wave adds that characteristic vertical ripple you see in real heat shimmer. Without the wave component it looks more like underwater distortion than heat.

The drawScene() function gets called twice per pixel (once clean, once at distorted UVs), which is where all the cost is. In a real engine you'd just sample your framebuffer texture twice instead, which is way cheaper. I'm rebuilding everything procedurally here so it works standalone.

If anyone has ideas on adding chromatic aberration on top I'd be interested. I tried splitting the RGB channels slightly but it looked more like a lens flare than heat.