아지랑이(열기 굴절)

Heat distortion (haze)

화면을 한 번 텍스처로 찍어두고, 그 텍스처를 노이즈로 흔들며 다시 그려서 뜨거운 공기에 비친 상이 일렁이는 걸 흉내 내는 기법.

다른 이름: 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);
});

열기 굴절은 실제로 빛이 밀도가 다른 공기층을 지나며 굴절하는 현상이지만, 실시간 렌더링에서는 물리 계산 대신 "화면 공간 굴절"로 흉내 냅니다: 1) 평소처럼 장면을 WebGLRenderTarget에 그리고, 2) 화면을 덮는 평면 하나에 그 텍스처를 입힌 뒤, UV 좌표를 시간에 따라 흐르는 노이즈만큼 어긋나게 해서 다시 그립니다. 결과적으로 원래 있던 픽셀이 "엉뚱한 자리"에서 샘플링되며 이미지가 물결치듯 비틀립니다.

이 데모는 화면 아래쪽일수록(rise = 1 - uv.y) 왜곡을 강하게 주고 위로 갈수록 약해지게 해서, 열기가 바닥에서 피어올라 위로 갈수록 옅어지는 실제 아지랑이의 결을 흉내 냅니다. 노이즈 두 겹을 다른 스케일·속도로 섞은 것도 한 겹만 쓸 때보다 훨씬 "지글거리는" 느낌을 줍니다.

heat-distortion은 post-processing/bloom과 마찬가지로 RenderPass 하나(장면 렌더) + 커스텀 패스 하나(왜곡)로 이루어진 전형적인 2-패스 파이프라인입니다. EffectComposer 없이 WebGLRenderTarget과 OrthographicCamera로 직접 구현했지만, 실무에서 다른 후처리 효과와 함께 쓸 땐 ShaderPass로 만들어 EffectComposer 체인에 끼워 넣는 편이 관리하기 쉽습니다.

언제 쓰나

불·엔진 배기구·사막 도로처럼 뜨거운 표면 위 공기, 순간이동·차원문 진입 같은 화면 왜곡 연출에.