# AI 꾸러미 — 아지랑이 (heat haze) — Heat haze (heat distortion)
> 장면을 텍스처로 그린 뒤 불 위 기둥 · 지평선 띠 안에서만 잡음만큼 읽는 자리를 흔들어, 뜨거운 공기가 일렁이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i61

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 아지랑이 (heat haze) — Heat haze (heat distortion)

#### 1. 목표
모닥불 위 공기 위로 뜨거운 공기가 일렁이는 아지랑이 후처리를 넣어 줘. 분위기는 한낮의 뜨거운 사막.

#### 2. 핵심 기술 용어
- **Heat haze (heat distortion)** — 뜨거운 공기 때문에 뒤가 일렁여 보임
- **Screen-space UV distortion** — 화면 텍스처를 읽는 좌표를 조금씩 흔들기
- **Mirage band** — 먼 땅 위 지평선 띠의 신기루

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 흔드는 곳은 마스크(불 위 기둥 · 지평선 띠)로 제한 — 나머지 화면은 그대로
- 불 · 지평선의 3D 위치를 매 장면 화면 좌표로 바꿔 uniform 으로
- 잡음은 위로 흐르게, 흔드는 양은 화면의 3% 이하
- 「반씩 비교」(왼쪽 끔 · 오른쪽 켬)와 세기 슬라이더

#### 5. 완성 기준 (이게 보이면 성공)
- 모닥불 위 공기만 위로 흐르며 일렁이고, 바닥 · 하늘의 먼 곳은 흔들리지 않는다
- 먼 지평선 띠의 선인장 · 바위가 신기루처럼 물결친다
- 반씩 비교에서 왼쪽은 또렷하고 오른쪽만 일렁인다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 장면을 텍스처에 그린 뒤, 화면 판이 그 텍스처를 읽는 UV 를 잡음 두 겹(가로 · 세로 따로)만큼 흔든다.
- 흔드는 곳은 마스크로 정한다: 불 위로 올라가는 가우스 기둥 exp(−(Δx/0.11)²) + 지평선 띠 exp(−(Δy/0.07)²) × 0.55.
- 잡음은 y 에서 시간 × 3 을 빼 위로 흘러가게 — 열기가 올라가는 느낌.
- 흔드는 양은 0.028 × 마스크 × 세기, 일렁이는 곳은 살짝 따뜻한 색(빨강 1.06 · 파랑 0.94)을 섞는다.

## 핵심 코드 — 마스크 안에서만 UV 흔들기 (화면 판 조각 셰이더)
(발췌: demos/demosShader.ts hazeDemo() 의 후처리 셰이더를 정리)
```glsl
// uF = 불 화면 좌표, uHor = 지평선 화면 높이 (매 장면 3D 위치를 화면 좌표로 바꿔 넣는다)
uniform sampler2D tScene; uniform vec2 uRes; uniform float uTime;
uniform vec2 uF; uniform float uHor; uniform float uAmt;
varying vec2 vUv;
void main(){
  float asp = uRes.x / uRes.y;
  // 불 위로 올라가는 기둥 + 땅 위 신기루 띠
  float colm = exp(-pow((vUv.x - uF.x) * asp / 0.11, 2.0))
             * smoothstep(uF.y - 0.04, uF.y + 0.04, vUv.y) * smoothstep(1.0, uF.y, vUv.y);
  float band = exp(-pow((vUv.y - uHor) / 0.07, 2.0)) * 0.55;
  float m = clamp(colm + band, 0.0, 1.0);
  // 위로 흐르는 잡음 두 겹 (가로 · 세로 흔들기)
  vec2 n = vec2(noise(vec2(vUv.x * 14.0 * asp, vUv.y * 20.0 - uTime * 3.0)),
                noise(vec2(vUv.x * 28.0 * asp + 3.0, vUv.y * 34.0 - uTime * 4.5))) - 0.5;
  vec2 uv = vUv + n * 0.028 * m * uAmt;
  vec3 col = texture2D(tScene, uv).rgb;
  col = mix(col, col * vec3(1.06, 1.0, 0.94), m * 0.4);       // 뜨거운 곳은 살짝 따뜻하게
  gl_FragColor = vec4(col, 1.0);
}
```

## 흔한 실수 · 확인 목록
- [ ] **마스크 없이 화면 전체를 흔들면 물속처럼 보인다** — 열이 나는 곳 위쪽 기둥과 먼 지평선 띠만 흔든다.
- [ ] **흔드는 양이 크면 물체가 찢어져 보인다** — 화면의 2~3% 이내(견본 0.028). 세기는 슬라이더로 0~3 배.
- [ ] **잡음이 제자리에서 떨리면 아지랑이가 아니라 화면 고장처럼 보인다** — 잡음 y 에서 시간을 빼 위로 흘러가게 한다.

## 완성 기준 체크리스트
- [ ] 모닥불 위 공기만 위로 흐르며 일렁이고, 바닥 · 하늘의 먼 곳은 흔들리지 않는다
- [ ] 먼 지평선 띠의 선인장 · 바위가 신기루처럼 물결친다
- [ ] 반씩 비교에서 왼쪽은 또렷하고 오른쪽만 일렁인다

## 이 기술 정보
- id: `i61` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 장면을 텍스처로 한 번 더 그리고, 화면 판에서 잡음 2번 + 텍스처 1번. 화면 크기에 비례.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i61
- 쓰면 좋을 때: 불 · 엔진 · 사막처럼 「뜨겁다」를 그림만으로 보여 줄 때 / 굴절 · 신기루 같은 빛의 원리 설명
- 쓰지 말 때: 화면 전체를 계속 흔들기 — 어지럽다. 마스크로 좁은 곳만 / 후처리 없는 가벼운 장면 — 불 위에 화면 텍스처를 읽는 작은 판 하나로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### hazeDemo — `src/demos/demosShader.ts:1540`
```ts
function hazeDemo(): Scene3D {
  const scene = new THREE.Scene();
  const bg = gradTex([
    [0, '#ffb35a'],
    [0.55, '#ffe0a0'],
    [1, '#fff4d8'],
  ]);
  scene.background = bg;
  lights(scene, 0xfff0d0, 0xc08850, 1.0, 1.8);
  const world = new THREE.Group();
  const sand = new THREE.Mesh(new THREE.PlaneGeometry(40, 30), new THREE.MeshStandardMaterial({ color: 0xe8c07a, roughness: 1 }));
  sand.rotation.x = -Math.PI / 2;
  world.add(sand);
  const road = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 30), new THREE.MeshStandardMaterial({ color: 0x5a5a66, roughness: 0.9 }));
  road.rotation.x = -Math.PI / 2;
  road.position.set(0, 0.01, -8);
  world.add(road);
  const dashM = new THREE.MeshBasicMaterial({ color: 0xffffff });
  for (let i = 0; i < 12; i++) {
    const d = new THREE.Mesh(new THREE.PlaneGeometry(0.1, 0.6), dashM);
    d.rotation.x = -Math.PI / 2;
    d.position.set(0, 0.02, 2 - i * 2);
    world.add(d);
  }
  const cactM = new THREE.MeshStandardMaterial({ color: 0x4caf5a, roughness: 0.8 });
  const rockM = new THREE.MeshStandardMaterial({ color: 0xc87a4a, roughness: 0.9, flatShading: true });
  for (let i = 0; i < 10; i++) {
    const s = i % 2 ? 1 : -1;
    const x = s * (1.6 + (i % 3) * 1.4);
    const z = -2 - i * 1.8;
    if (i % 3 === 0) {
      const m = new THREE.Mesh(new THREE.ConeGeometry(1.2, 2.2, 5), rockM);
      m.position.set(x * 1.5, 1.1, z - 4);
      world.add(m);
    } else {
      const c = new THREE.Mesh(new THREE.CapsuleGeometry(0.16, 0.9, 6, 10), cactM);
      c.position.set(x, 0.6, z);
      const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.1, 0.35, 6, 8), cactM);
      arm.position.set(0.22, 0.1, 0);
      arm.rotation.z = -0.6;
      c.add(arm);
      world.add(c);
    }
  }
  // 모닥불 둘 (왼쪽 · 오른쪽 똑같이)
  const fires: THREE.Group[] = [];
  const flameMs: THREE.Mesh[] = [];
  for (const x of [-1.5, 1.5]) {
    const f = new THREE.Group();
    const logM = new THREE.MeshStandardMaterial({ color: 0x7a4a26 });
    for (const a of [0.5, -0.5]) {
      const l = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.6, 8), logM);
      l.rotation.set(Math.PI / 2, a, 0);
      l.position.y = 0.06;
      f.add(l);
    }
    for (const [c, s, y] of [
      [0xff5a1a, 1, 0.25],
      [0xffb02a, 0.7, 0.22],
      [0xfff2a0, 0.4, 0.18],
    ] as const) {
      const fl = new THREE.Mesh(new THREE.ConeGeometry(0.2 * s, 0.6 * s, 12), new THREE.MeshBasicMaterial({ color: c, transparent: true, opacity: 0.9, toneMapped: false }));
      fl.position.y = y + 0.05;
      f.add(fl);
      flameMs.push(fl);
    }
    f.position.set(x * 0.8, 0, 0.6);
    world.add(f);
    fires.push(f);
  }
  scene.add(world);
  const cam = new THREE.PerspectiveCamera(42, 1, 0.1, 80);
  cam.position.set(0, 1.4, 5.4);
  cam.lookAt(0, 0.8, -2);
  const rt = makeRT();
  const pu = { tScene: { value: null as THREE.Texture | null }, uRes: { value: new THREE.Vector2(1, 1) }, uTime: { value: 0 }, uF: { value: new THREE.Vector2(0.75, 0.3) }, uHor: { value: 0.5 }, uAmt: { value: 1 }, uSplit: { value: 1 } };
  const post = fsq(
    /* glsl */ `${POST_HEAD} uniform vec2 uF; uniform float uHor; uniform float uAmt; uniform float uSplit;
    ${NOISE2}
    void main(){
      bool on = uSplit < 0.5 || vUv.x > 0.5;
      float asp = uRes.x / uRes.y;
      // 불 위 기둥 + 땅 위 신기루 띠
      float colm = exp(-pow((vUv.x - uF.x) * asp / 0.11, 2.0)) * smoothstep(uF.y - 0.04, uF.y + 0.04, vUv.y) * smoothstep(1.0, uF.y, vUv.y);
      float band = exp(-pow((vUv.y - uHor) / 0.07, 2.0)) * 0.55;
      float m = on ? clamp(colm + band, 0.0, 1.0) : 0.0;
      vec2 n = vec2(noise(vec2(vUv.x * 14.0 * asp, vUv.y * 20.0 - uTime * 3.0)), noise(vec2(vUv.x * 28.0 * asp + 3.0, vUv.y * 34.0 - uTime * 4.5))) - 0.5;
      vec2 uv = vUv + n * 0.028 * m * uAmt;
      vec3 col = texture2D(tScene, uv).rgb;
      col = mix(col, col * vec3(1.06, 1.0, 0.94), m * 0.4);
      if (uSplit > 0.5 && abs(vUv.x - 0.5) * uRes.x < 1.0) col = vec3(4.0);
      gl_FragColor = vec4(col, 1.0);
      ${POST_TAIL}
    }`,
    pu,
  );
  const tags = new Tags(['아지랑이 없음', '아지랑이 켬']);
  return {
    scene,
    camera: cam,
    update(t) {
      pu.uTime.value = t;
      flameMs.forEach((f, i) => {
        f.scale.set(1 + Math.sin(t * 13 + i) * 0.08, 1 + Math.sin(t * 9 + i * 2) * 0.15, 1);
      });
    },
    render(r, w, h) {
      const target = rt.get(w, h);
      const prev = r.getRenderTarget();
      r.setRenderTarget(target);
      r.clear();
      r.render(scene, cam);
      r.setRenderTarget(prev);
      const f = toScreen(new THREE.Vector3(1.2, 0.25, 0.6), cam);
      pu.uF.value.set(f[0], 1 - f[1]);
      const hz = toScreen(new THREE.Vector3(0, 0, -14), cam);
      pu.uHor.value = 1 - hz[1];
      pu.tScene.value = target.texture;
      pu.uRes.value.set(w, h);
      r.render(post.scene, post.camera);
      if (pu.uSplit.value > 0.5)
        tags.draw(r, w, h, [
          [0.25, 0.08],
          [0.75, 0.08],
        ]);
    },
    controls: [
      { type: 'range', label: '일렁임 세기', min: 0, max: 3, step: 0.05, value: 1, on: (v) => (pu.uAmt.value = v) },
      { type: 'toggle', label: '반씩 비교', value: true, on: (v) => (pu.uSplit.value = v ? 1 : 0) },
    ],
    dispose() {
      freeAll(world);
      bg.dispose();
      rt.dispose();
      post.dispose();
      tags.dispose();
    },
  };
}
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [잡음 일렁임 (displacement)](https://ai-techstudio.web.app/ai/t/i109.md) `i109`
- 먼저 알면 좋은 기술: [충격파 화면 왜곡](https://ai-techstudio.web.app/ai/t/i60.md) `i60`
- 다음에 해 볼 기술: [투과 · 굴절 (유리 · 얼음)](https://ai-techstudio.web.app/ai/t/u07.md) `u07` · [렌즈 · 유리 (굴절 · 반사 코팅)](https://ai-techstudio.web.app/ai/t/i473.md) `i473`
