Heat distortion (haze)

아지랑이(열기 굴절)

Capturing the screen to a texture, then redrawing it with noise-warped UVs to fake the shimmer of light bending through hot air.

Also known as: Screen-space refractionHeat haze shaderTwo-pass distortion
···
js
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);

const rt = new THREE.WebGLRenderTarget(2, 2);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a14);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 6);
camera.lookAt(0, 0, 0);

const group = new THREE.Group();
for (let i = 0; i < 7; i++) {
  const m = new THREE.Mesh(new THREE.BoxGeometry(0.5, 3, 0.5), new THREE.MeshBasicMaterial({ color: new THREE.Color().setHSL(i / 7, 0.8, 0.55) }));
  m.position.x = (i - 3) * 0.9;
  group.add(m);
}
scene.add(group);
const grid = new THREE.GridHelper(10, 10, 0x334466, 0x223344);
grid.position.y = -1.6;
scene.add(grid);

const quadScene = new THREE.Scene();
const quadCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
quadCamera.position.set(0, 0, 1);
quadCamera.lookAt(0, 0, 0);
const distortFrag = [
  'uniform sampler2D tScene;',
  'uniform float uTime;',
  'varying vec2 vUv;',
  '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);',
  '  float a = hash(i), b = hash(i + vec2(1.0,0.0)), c = hash(i + vec2(0.0,1.0)), d = hash(i + vec2(1.0,1.0));',
  '  vec2 u = f * f * (3.0 - 2.0 * f);',
  '  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);',
  '}',
  'void main() {',
  '  float rise = 1.0 - vUv.y;',
  '  float n1 = noise(vec2(vUv.x * 6.0, vUv.y * 3.0 - uTime * 1.6));',
  '  float n2 = noise(vec2(vUv.x * 11.0 + 4.0, vUv.y * 6.0 - uTime * 2.3));',
  '  float d = (n1 - 0.5) * 0.03 + (n2 - 0.5) * 0.015;',
  '  d *= rise * rise;',
  '  vec2 uv = vUv + vec2(d, d * 0.4);',
  '  gl_FragColor = texture2D(tScene, uv);',
  '}',
].join('\n');
const quadMat = new THREE.ShaderMaterial({
  uniforms: { tScene: { value: rt.texture }, uTime: { value: 0 } },
  vertexShader: 'varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 1.0); }',
  fragmentShader: distortFrag,
});
quadScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), quadMat));

function resize() {
  const w = innerWidth, h = innerHeight;
  const ratio = Math.min(devicePixelRatio, 2);
  renderer.setSize(w, h);
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  rt.setSize(w * ratio, h * ratio);
}
addEventListener('resize', resize); resize();
renderer.setAnimationLoop((t) => {
  const time = t * 0.001;
  group.rotation.y = Math.sin(time * 0.3) * 0.15;
  group.children.forEach((m, i) => { m.position.y = Math.sin(time + i) * 0.1; });
  quadMat.uniforms.uTime.value = time;
  renderer.setRenderTarget(rt);
  renderer.render(scene, camera);
  renderer.setRenderTarget(null);
  renderer.render(quadScene, quadCamera);
});

Heat haze is really light refracting through air of varying density, but real-time rendering fakes it with "screen-space refraction" instead of physics: 1) render the scene into a WebGLRenderTarget as usual, 2) draw a screen-covering plane wearing that texture, offsetting its UV coordinates by noise that flows over time. The net effect: pixels get sampled from "the wrong spot," so the image warps like it’s rippling.

This demo makes the distortion strongest near the bottom of the screen (rise = 1 - uv.y) and fades it toward the top, mimicking how real heat haze rises off the ground and thins out with height. Blending two noise layers at different scales and speeds also reads far more "shimmery" than a single layer.

Like post-processing/bloom, heat-distortion is a classic two-pass pipeline: one RenderPass (draw the scene) plus one custom pass (warp it). This demo builds it by hand with a WebGLRenderTarget and OrthographicCamera instead of EffectComposer, but in production, when combined with other post effects, wrapping it as a ShaderPass and dropping it into an EffectComposer chain is easier to manage.

When to use

Air above hot surfaces — fire, engine exhaust, desert roads — or screen-warp moments like teleporting or entering a portal.