Skip to content
Distort · UV warpingBeginner

Heat haze

The air over hot ground shimmers, made by reading the finished frame at coordinates nudged by rising ripples.

UVTimeTextureScreenScreen / post
  1. 01GoalDistort
  2. 02TechniqueUV warping
  3. 03ExecutionHeat haze
Test scene

Parameters

0.0060
6.0
3.0
0.60

Copy for your platform

  • GLSLCopy
  • GodotCopy

Node chain

Built step by step, in GLSL style, with its node names.

Built step by step, in Godot style, with its node names.

No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.

No Unity version of this card yet, so the nodes show the GLSL names.

No Blender version of this card yet, so the nodes show the GLSL names.

  1. sinSinsinsinsin
    UV
    Time
    Texture
    Screen

    01 Rising ripples

  2. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    02 Near the ground

  3. +MultiplyAdd+++

    03 Shift the lookup

  4. textureTexture2Dtexturetexturetexture

    04 Read the screen

How it works

Hot air bends light a little, because its density, and with it its refractive index, changes from one pocket to the next. Those pockets rise and churn, so what you see through them wobbles. A post pass fakes exactly that: it reads the finished frame, but not quite where each pixel is.

The scene is drawn first into a render target. This pass then runs once per screen pixel and samples that target, t_screen, at a coordinate it has nudged. It is one link of the post-process chain.

1. Rising ripples. Two sines make the offset, one for x and one for y. Each mixes the pixel’s position with time, and subtracting time moves the x ripple’s crests up the screen, as hot air rises, and slides the y ripple’s crests sideways. The x ripple’s phase is bent by a third, slower sine, so the bands aren’t straight lines. The frequencies, 7, 5.3 and 3.1, share no simple ratio, so the pattern doesn’t line up into a visible repeat. The coordinate is scaled by the aspect ratio first, so a ripple is as wide as it is tall on any screen. The preview shows the offset as red and green, black where it is negative.

2. Near the ground. 1.0 - smoothstep(0.0, height, uv.y) is 1 at the bottom of the screen and fades to 0 at height. Hot air lies over the ground, and the camera looks at the ground across the bottom of the frame, so that is where the haze goes. A game would use a better mask: the distance to a fire, or a hot area painted into a texture.

3. Shift the lookup. The offset, times the mask, times the strength, is added to the UV. The strength is tiny, 0.006 by default: a fraction of a percent of the screen, a handful of pixels. Any more and the haze reads as a broken lens rather than air.

4. Read the screen. texture(t_screen, warped) reads the frame at the moved coordinate. Neighbouring pixels read neighbouring, slightly shuffled points, and the scene appears to ripple. The target is sampled with a linear filter, so a shift of half a pixel blends two texels instead of snapping.

What it costs and where it breaks

About 30 math operations and one texture read per pixel, in one full-screen pass. The read is the real cost: the pass touches every pixel of the screen whether there is haze there or not. Masking with an if doesn’t save much on a GPU, where neighbouring pixels run together; scissoring the pass to the area under the haze does.

  • The edges of the screen. Near the border, the warped coordinate leaves 0..1. The target is clamped, so those pixels repeat the edge row; with a strength this small that is a line or two of pixels and doesn’t show.
  • Things in front of the heat move too. A post pass doesn’t know what is near or far: a character standing in front of hot asphalt wobbles with it. Scale the offset by sceneDepth() to keep near objects still, or draw the haze as a material on a surface placed in the hot area, which the depth test hides behind closer objects.
  • Order in the chain. Put the haze before bloom and other blurs, so they smooth its edges, and after anything that should wobble with the scene.

Make it in GLSL

The copied shader is a complete WebGL2 fragment shader for a full-screen pass: it declares the inputs, one uniform per param, u_time, u_resolution and t_screen, the finished scene. Your code draws the scene into a texture first, then draws one full-screen triangle with this shader.

  1. Create a texture the size of the canvas and a framebuffer with it as COLOR_ATTACHMENT0, plus a depth renderbuffer for the scene. Give the texture LINEAR filtering and CLAMP_TO_EDGE wrapping.
  2. Each frame, bind the framebuffer and draw the scene into it.
  3. Bind the default framebuffer (null), bind the scene texture to a texture unit and point t_screen at that unit.
  4. Set u_time and u_resolution, then draw a triangle that covers the screen, with a vertex shader that passes the UV, 0..1 from the bottom-left, as v_uv. The other inputs aren’t used.

The shader reads t_screen as linear color and encodes its output to sRGB. If your scene texture already holds sRGB values (an RGBA8 target written by shaders that encoded their own output), decode it after the read, or the haze pass encodes twice and the image comes out washed out. An SRGB8_ALPHA8 texture is decoded by the GPU for you.

Make it in Godot

The shader is a spatial shader for a full-screen quad. It reads the screen through hint_screen_texture, which works in the Forward+, Mobile and Compatibility renderers.

The exported shader draws the effect over the whole screen from a quad you place in the 3D scene. Its vertex() function writes the quad’s corners straight to the screen’s corners, so it covers the view wherever the quad and the camera are.

  1. Add a MeshInstance3D as a child of the Camera3D, and give it a QuadMesh of size 2 by 2.
  2. Set its Material Override to a new ShaderMaterial, and its Shader to a new Shader.
  3. Paste the copied code into the shader.
  4. Under Geometry, set Extra Cull Margin to the maximum (16384), so Godot never culls the quad.
  5. Tune the params under Shader Parameters: the same names as on this card, without the p_ prefix. Raise height to cover more of the screen, or replace the mask with your own.

To build it as a VisualShader (a spatial VisualShader, unshaded), the chain is ScreenUV, the two Sin ripples built from it and Time, a SmoothStep mask on the y of ScreenUV, a MultiplyAdd into the UV, and a Texture2D node with Source set to Screen, read at the result and connected to Albedo. The vertex part, which moves the quad onto the screen, needs an Expression node or the text shader.

For haze around one source instead of over the whole screen, keep the same shader on a smaller quad placed over the fire, without the vertex function: then the screen read happens only where the quad is drawn.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

heat-haze.fragGLSL
#version 300 es// UV warping → Heat haze, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_strength; // Strength: 0.006 (0.0 to 0.03)uniform float p_scale; // Ripple scale: 6.0 (1.0 to 20.0)uniform float p_speed; // Rise speed: 3.0 (0.0 to 10.0)uniform float p_height; // Height: 0.6 (0.05 to 1.0)uniform sampler2D t_screen; // the scene's color: linear HDR (RGBA16F), linear filter, clamp to edgein vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}vec4 effect(Inputs i) {	// Scaled by the aspect ratio so the ripples stay round on a wide screen	vec2 p = i.uv * vec2(i.resolution.x / i.resolution.y, 1.0) * p_scale;	float rise = i.time * p_speed;	// Two sines at unrelated frequencies, the first bent by a third, so the pattern never visibly repeats	vec2 wobble = vec2(sin(p.y * 7.0 - rise * 1.3 + sin(p.x * 3.1)), sin(p.x * 5.3 + p.y * 2.0 - rise));	// Hot air hugs the ground: full strength at the bottom of the screen, none above p_height	float mask = 1.0 - smoothstep(0.0, p_height, i.uv.y);	vec2 warped = i.uv + wobble * mask * p_strength;	vec3 scene = texture(t_screen, warped).rgb;	return vec4(scene, 1.0);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	vec4 frag_out = effect(i);	// The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}
heat-haze.gdshaderGodot shader
// UV warping → Heat haze, Godot 4. From Creative Center, MIT license.shader_type spatial;// A full-screen quad over the finished frame: no lighting, no culling, never hidden by depthrender_mode unshaded, cull_disabled, depth_test_disabled;uniform float strength : hint_range(0.0, 0.03, 0.0005) = 0.006;uniform float scale : hint_range(1.0, 20.0, 0.5) = 6.0;uniform float speed : hint_range(0.0, 10.0, 0.1) = 3.0;uniform float height : hint_range(0.05, 1.0, 0.01) = 0.6;uniform sampler2D t_screen : hint_screen_texture, filter_linear, repeat_disable;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// Godot's textures start at the top-left and the effect's uv at the bottom-left: each sample flips v backvec2 u_flipUv(vec2 uv) {	return vec2(uv.x, 1.0 - uv.y);}vec4 effect(Inputs i) {	// Scaled by the aspect ratio so the ripples stay round on a wide screen	vec2 p = i.uv * vec2(i.resolution.x / i.resolution.y, 1.0) * scale;	float rise = i.time * speed;	// Two sines at unrelated frequencies, the first bent by a third, so the pattern never visibly repeats	vec2 wobble = vec2(sin(p.y * 7.0 - rise * 1.3 + sin(p.x * 3.1)), sin(p.x * 5.3 + p.y * 2.0 - rise));	// Hot air hugs the ground: full strength at the bottom of the screen, none above height	float mask = 1.0 - smoothstep(0.0, height, i.uv.y);	vec2 warped = i.uv + wobble * mask * strength;	vec3 scene = texture(t_screen, u_flipUv(warped)).rgb;	return vec4(scene, 1.0);}void vertex() {	// Put the shader on a MeshInstance3D with a 2x2 QuadMesh in front of the camera; it covers the screen wherever it is	POSITION = vec4(VERTEX.xy, 1.0, 1.0);}void fragment() {	Inputs i;	// SCREEN_UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(SCREEN_UV.x, 1.0 - SCREEN_UV.y);	// A flat quad facing the viewer, the same as the site's 2D preview	i.position = vec3(i.uv * 2.0 - 1.0, 0.0);	i.normal = vec3(0.0, 0.0, 1.0);	i.view = vec3(0.0, 0.0, 1.0);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = VIEWPORT_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	ALBEDO = frag_out.rgb;}

No Unreal Engine 5 code for this card yet.

No Unity code for this card yet.

No Blender code for this card yet.

Further reading