Heat haze
The air over hot ground shimmers, made by reading the finished frame at coordinates nudged by rising ripples.
- 01GoalDistort
- 02TechniqueUV warping
- 03ExecutionHeat haze
Parameters
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.
sinSinsinsinsin UVTimeTextureScreen01 Rising ripples
smoothstepSmoothStepsmoothstepsmoothstepsmoothstep In02 Near the ground
+MultiplyAdd+++ In03 Shift the lookup
textureTexture2Dtexturetexturetexture In04 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 targetA texture a draw writes into instead of the screen, so a later pass can read the result. Blur, bloom and every multi-pass effect use them. Render target A texture a draw writes into instead of the screen, so a later pass can read the result. Blur, bloom and every multi-pass effect use them. Post-process chain Full-screen passes run in order over the finished frame, each reading the image the previous one wrote. Bloom, tone mapping and color grading live here.t_screen, at a coordinate it has nudged. It is one link of the
post-process chainFull-screen passes run in order over the finished frame, each reading the image the previous one wrote. Bloom, tone mapping and color grading live here.
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.
- 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 textureLINEARfiltering andCLAMP_TO_EDGEwrapping. - Each frame, bind the framebuffer and draw the scene into it.
- Bind the default framebuffer (
null), bind the scene texture to a texture unit and pointt_screenat that unit. - Set
u_timeandu_resolution, then draw a triangle that covers the screen, with a vertex shader that passes the UV, 0..1 from the bottom-left, asv_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.
- Add a
MeshInstance3Das a child of theCamera3D, and give it aQuadMeshof size 2 by 2. - Set its Material Override to a new
ShaderMaterial, and its Shader to a newShader. - Paste the copied code into the shader.
- Under Geometry, set Extra Cull Margin to the maximum (16384), so Godot never culls the quad.
- Tune the params under Shader Parameters: the same names as on this card, without the
p_prefix. Raiseheightto 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
#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);}// 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.