# AI 꾸러미 — 잡음 일렁임 (displacement)
> 열기 · 물결처럼 휘어짐 — feTurbulence + feDisplacementMap  
> 견본: https://ai-techstudio.web.app/#t/i109

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

## 주문서

### 만들어 줘: 잡음 일렁임 (displacement)

#### 1. 목표
수면에 「잡음 일렁임 (displacement)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 열기 · 물결처럼 휘어짐 — feTurbulence + feDisplacementMap

#### 2. 핵심 기술 용어
- **displacement**
- **feTurbulence**
- **feDisplacementMap**

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- devicePixelRatio 를 맞춰 또렷하게
- 매 프레임 다시 그릴 필요 없는 그림은 화면 밖 캔버스에 미리 구워 두기
- 한 프레임 16ms 안 (폰 포함)

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「열기 · 물결처럼 휘어짐 — feTurbulence + feDisplacementMap」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 열기 · 물결처럼 휘어짐 — feTurbulence + feDisplacementMap


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「열기 · 물결처럼 휘어짐 — feTurbulence + feDisplacementMap」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i109` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i109
- 쓰면 좋을 때: 쓰면 좋을 곳 — 수면 · 마법 · 꿈 장면

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i109 견본 항목 — `src/demos/demosMotionB.ts:289`
```ts
  i109: {
    kind: 'dom',
    caption: '왼쪽 그대로 · 오른쪽 잡음 지도로 픽셀을 밀어냄 — 글자와 물결이 열기처럼 일렁여요',
    make(box) {
      const id = `mbw${++uid}`;
      const scene = (ox: number): string => `
        <g transform="translate(${ox} 0)">
          <rect width="160" height="200" fill="url(#${id}sky)"/>
          <circle cx="118" cy="46" r="20" fill="#ffe27a"/><circle cx="118" cy="46" r="28" fill="#ffe27a" opacity=".25"/>
          ${[0, 1, 2, 3, 4, 5, 6].map((i) => `<line x1="${20 + i * 20}" y1="20" x2="${20 + i * 20}" y2="118" stroke="#fff" stroke-opacity=".14" stroke-width="1"/>`).join('')}
          ${[0, 1, 2, 3, 4].map((i) => `<line x1="0" y1="${30 + i * 20}" x2="160" y2="${30 + i * 20}" stroke="#fff" stroke-opacity=".14" stroke-width="1"/>`).join('')}
          <text x="80" y="106" text-anchor="middle" font-size="64" font-family='${TF}' fill="#ff7ab6" stroke="#fff" stroke-width="3" paint-order="stroke">π</text>
          <rect y="120" width="160" height="80" fill="url(#${id}sea)"/>
          ${[0, 1, 2, 3, 4, 5].map((i) => `<rect x="${(i * 37) % 120 + 8}" y="${130 + i * 11}" width="${40 - i * 3}" height="3" rx="1.5" fill="#bff3ff" opacity="${0.75 - i * 0.08}"/>`).join('')}
          <text x="80" y="176" text-anchor="middle" font-size="22" font-family='${TF}' fill="#ffd23f" opacity=".85">3.14</text>
        </g>`;
      const root = domRoot(
        box,
        'mb-warp',
        `.mb-warp{background:#16205a}
         .mb-warp svg{width:100%;height:100%;display:block}
         .mb-warp .lb{position:absolute;top:4cqmin;padding:.5em 1em;border-radius:99px;font-weight:800;font-size:max(9px,4.6cqmin);background:rgba(10,14,40,.62)}
         .mb-warp .lb.r{background:rgba(255,90,150,.9)}`,
        `<svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice">
          <defs>
            <linearGradient id="${id}sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3b2d8f"/><stop offset="1" stop-color="#ff8fb4"/></linearGradient>
            <linearGradient id="${id}sea" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2f8fd8"/><stop offset="1" stop-color="#173d8a"/></linearGradient>
            <filter id="${id}f" x="-10%" y="-10%" width="120%" height="120%">
              <feTurbulence class="tb" type="fractalNoise" baseFrequency="0.02 0.06" numOctaves="2" seed="7" result="n"/>
              <feDisplacementMap class="dm" in="SourceGraphic" in2="n" scale="14" xChannelSelector="R" yChannelSelector="G"/>
            </filter>
            <clipPath id="${id}L"><rect width="160" height="200"/></clipPath>
            <clipPath id="${id}R"><rect x="160" width="160" height="200"/></clipPath>
          </defs>
          <g clip-path="url(#${id}L)">${scene(0)}</g>
          <g clip-path="url(#${id}R)"><g filter="url(#${id}f)">${scene(160)}</g></g>
          <rect x="159" width="2" height="200" fill="#fff" opacity=".7"/>
        </svg>
        <div class="lb" style="left:25%;transform:translateX(-50%)">그대로</div>
        <div class="lb r" style="left:75%;transform:translateX(-50%)">잡음 일렁임</div>`,
      );
      const tb = q<SVGElement>(root, '.tb');
      const dm = q<SVGElement>(root, '.dm');
      let power = 14;
      let size = 1;
      return {
        update(t) {
          const fx = (0.016 + 0.005 * Math.sin(t * 0.9)) * size;
          const fy = (0.055 + 0.018 * Math.sin(t * 1.3 + 1)) * size;
          tb.setAttribute('baseFrequency', `${fx.toFixed(4)} ${fy.toFixed(4)}`);
          dm.setAttribute('scale', (power * (0.75 + 0.25 * Math.sin(t * 2.1))).toFixed(2));
        },
        controls: [
          { type: 'range', label: '밀어내는 세기', min: 0, max: 40, step: 1, value: 14, on: (v) => (power = v) },
          { type: 'range', label: '물결 촘촘함', min: 0.4, max: 3, step: 0.1, value: 1, on: (v) => (size = v) },
        ] as Control[],
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [아지랑이 (heat haze)](https://ai-techstudio.web.app/ai/t/i61.md) `i61`
