# AI 꾸러미 — 물결 파동 (높이 지도) — Height-field water (2D wave equation)
> 물 높이를 격자(80×80)에 두고 「이웃 평균 × 2 − 이전 높이」로 매 프레임 갱신해, 물방울이 떨어지면 동심원이 퍼지고 서로 겹치며 바닥에 빛무늬가 생기게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i48

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

## 주문서

### 만들어 줘: 물결 파동 (높이 지도) — Height-field water (2D wave equation)

#### 1. 목표
수영장 · 연못 물 표면에 높이 지도 물결을 넣어 줘 — 물방울이 떨어지면 동심원이 퍼지고, 둘이 만나면 간섭하고, 바닥에 빛무늬가 일렁이게. 분위기는 맑은 하늘색 타일 수영장.

#### 2. 핵심 기술 용어
- **Height-field water (2D wave equation)** — 높이 지도 물결 — 파동 방정식
- **Two-buffer ripple (current / previous)** — 지금 · 이전 높이 두 장을 번갈아
- **Interference** — 간섭 — 두 물결이 만나 더해지고 상쇄
- **Caustics from Laplacian** — 물결이 오목한 곳(라플라시안)으로 바닥 빛무늬

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

#### 4. 조건
- 높이 두 장(지금 · 이전)을 번갈아 — 한 장으로 하면 파동이 안 된다
- 감쇠는 1 보다 아주 조금 작게 (0.95 ~ 0.998) — 1 이상이면 끝없이 커진다
- 가장자리 한 줄은 계산하지 않아 벽처럼 반사
- 높이를 바꾼 뒤 position · color 의 needsUpdate 와 computeVertexNormals

#### 5. 완성 기준 (이게 보이면 성공)
- 물방울이 떨어진 자리에서 동심원이 퍼지고 벽에서 되돌아온다
- 두 방울이 동시에 떨어지면 물결이 겹쳐 무늬가 생긴다 (간섭)
- 바닥 타일 위에 물결을 따라 밝은 빛무늬가 일렁인다
- 「물결 남는 정도」로 오래 남거나 금방 잔잔해진다

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

## 원리
- 칸마다 높이 둘(지금 · 이전). 새 높이 = (위 + 아래 + 왼 + 오른) / 2 − 이전 높이, 그리고 × 감쇠(0.988).
- 이 한 줄이 파동 방정식을 흉내 낸다 — 볼록한 곳은 꺼지고 둘레가 솟아 동심원이 퍼진다. 두 동심원은 그냥 더해져 간섭이 저절로 생긴다.
- 계산 뒤 두 장을 바꾼다. 물방울은 한 점 둘레를 가우스 모양(exp(−r²/3))으로 눌러 넣는다.
- 화면: 평면 메시(80×80 정점)의 y 를 높이로, 색도 높이로 칠하고 법선을 다시 구한다.
- 빛무늬: 이웃 합 − 4×가운데(라플라시안)가 양수인 오목한 곳을 밝게 — 그 캔버스를 더하기 섞기로 바닥에 깐다.

## 핵심 코드 — 높이 지도 파동 한 걸음 · 물방울 · 빛무늬
(발췌: demos/demosSim.ts i48 update() · disturb() 를 정리)
```ts
const NG = 80;
let cur = new Float32Array(NG * NG);
let prv = new Float32Array(NG * NG);

function drop(ci: number, cj: number, amt: number) {
  for (let j = -3; j <= 3; j++)
    for (let i = -3; i <= 3; i++) {
      const a = ci + i, b = cj + j;
      if (a < 1 || b < 1 || a >= NG - 1 || b >= NG - 1) continue;
      cur[a + b * NG] -= amt * Math.exp(-(i * i + j * j) / 3);   // 가우스 모양으로 눌러 넣기
    }
}

function waveStep(damp = 0.988) {
  for (let j = 1; j < NG - 1; j++)
    for (let i = 1; i < NG - 1; i++) {
      const k = i + j * NG;
      const nv = (cur[k - 1] + cur[k + 1] + cur[k - NG] + cur[k + NG]) / 2 - prv[k];
      prv[k] = nv * damp;                 // 이전 칸 자리에 새 높이를 쓴다
    }
  [cur, prv] = [prv, cur];                // 새 높이가 지금, 지금이 이전
}

// 화면: 정점 y = 높이, 바닥 빛무늬 = 라플라시안이 양수인 곳
function apply(pos: THREE.BufferAttribute, caus: ImageData) {
  for (let j = 0; j < NG; j++)
    for (let i = 0; i < NG; i++) {
      const k = i + j * NG;
      pos.setY(k, cur[k] * 0.5);
      const lap = i > 0 && j > 0 && i < NG - 1 && j < NG - 1 ? cur[k - 1] + cur[k + 1] + cur[k - NG] + cur[k + NG] - 4 * cur[k] : 0;
      caus.data[k * 4 + 3] = Math.max(0, Math.min(220, lap * 500));
    }
  pos.needsUpdate = true;
}
```

## 흔한 실수 · 확인 목록
- [ ] **높이를 한 장에서 바로 고치면 물결이 한쪽으로 쏠린다** — 지금 · 이전 두 장을 두고, 새 값은 다른 장에 쓴 뒤 바꾼다.
- [ ] **감쇠를 1 로 두면 물결이 점점 커져 터진다** — 0.95 ~ 0.998 사이로 둔다.
- [ ] **높이만 바꾸고 법선을 안 구하면 물결이 빛을 안 받는다** — 매 프레임 computeVertexNormals — 무거우면 셰이더에서 높이 차로 법선을 구한다.
- [ ] **빛무늬를 보통 섞기로 깔면 바닥이 하얗게 덮인다** — 더하기 섞기(AdditiveBlending) + depthWrite: false 로 바닥 위에 빛만 얹는다.

## 완성 기준 체크리스트
- [ ] 물방울이 떨어진 자리에서 동심원이 퍼지고 벽에서 되돌아온다
- [ ] 두 방울이 동시에 떨어지면 물결이 겹쳐 무늬가 생긴다 (간섭)
- [ ] 바닥 타일 위에 물결을 따라 밝은 빛무늬가 일렁인다
- [ ] 「물결 남는 정도」로 오래 남거나 금방 잔잔해진다

## 이 기술 정보
- id: `i48` · 분류: 물리 · 수학 원리 › 물리 · 시뮬레이션 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 80×80 = 6,400칸 계산 + 정점 6,400개 · 법선 다시 구하기 + 빛무늬 캔버스 올리기. 폰은 격자 64 정도, 더 크면 GPU 로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i48
- 쓰면 좋을 때: 연못 · 수영장처럼 표면만 움직이는 물 / 파동 · 간섭을 눈으로 보여 줄 때
- 쓰지 말 때: 물이 쏟아지거나 출렁여 모양이 바뀌는 장면 — 입자 물(i47) / 끝없는 바다 — 셰이더 파도(사인 · 게르스트너)가 싸다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i48 견본 항목 — `src/demos/demosSim.ts:3712`
```ts
  i48: {
    kind: '3d',
    caption: '물방울이 떨어지면 동심원이 퍼지고, 둘이 만나면 겹쳐(간섭) — 바닥엔 물결이 모은 빛무늬',
    make() {
      const scene = new THREE.Scene();
      scene.background = skyTexture('#bfe6ff', '#f0f9ff');
      scene.add(new THREE.HemisphereLight(0xffffff, 0x88aacc, 1.1));
      const sun = new THREE.DirectionalLight(0xffffff, 2.4);
      sun.position.set(-3, 6, 2);
      scene.add(sun);
      const glint = new THREE.DirectionalLight(0xfff6e0, 2.2);
      glint.position.set(1, 3, -6);
      scene.add(glint);
      const cam = new THREE.PerspectiveCamera(42, 1.6, 0.1, 60);
      const NG = 80;
      const SIZE = 6;
      const cur = new Float32Array(NG * NG);
      const prv = new Float32Array(NG * NG);
      // 바닥 타일
      const tc = document.createElement('canvas');
      tc.width = tc.height = 256;
      const tg = tc.getContext('2d')!;
      tg.fillStyle = '#e8f6ff';
      tg.fillRect(0, 0, 256, 256);
      for (let j = 0; j < 8; j++)
        for (let i = 0; i < 8; i++) {
          tg.fillStyle = (i + j) % 2 ? '#7ac8f0' : '#a8dcf6';
          tg.fillRect(i * 32 + 1.5, j * 32 + 1.5, 29, 29);
        }
      const tileTex = new THREE.CanvasTexture(tc);
      tileTex.colorSpace = THREE.SRGBColorSpace;
      const floor = new THREE.Mesh(new THREE.PlaneGeometry(SIZE, SIZE), new THREE.MeshLambertMaterial({ map: tileTex }));
      floor.rotation.x = -Math.PI / 2;
      floor.position.y = -0.7;
      scene.add(floor);
      // 빛무늬 (물결의 휜 정도 → 밝기)
      const cc = document.createElement('canvas');
      cc.width = cc.height = NG;
      const cg = cc.getContext('2d')!;
      const cimg = cg.createImageData(NG, NG);
      const cTex = new THREE.CanvasTexture(cc);
      const caus = new THREE.Mesh(new THREE.PlaneGeometry(SIZE, SIZE), new THREE.MeshBasicMaterial({ map: cTex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
      caus.rotation.x = -Math.PI / 2;
      caus.position.y = -0.69;
      scene.add(caus);
      const wallMat = new THREE.MeshLambertMaterial({ color: 0xf4f8ff });
      for (const [px, pz, sx, sz] of [
        [0, -SIZE / 2 - 0.15, SIZE + 0.6, 0.3],
        [0, SIZE / 2 + 0.15, SIZE + 0.6, 0.3],
        [-SIZE / 2 - 0.15, 0, 0.3, SIZE],
        [SIZE / 2 + 0.15, 0, 0.3, SIZE],
      ] as const) {
        const wl = new THREE.Mesh(new THREE.BoxGeometry(sx, 1, sz), wallMat);
        wl.position.set(px, -0.25, pz);
        scene.add(wl);
      }
      const geo = new THREE.PlaneGeometry(SIZE, SIZE, NG - 1, NG - 1);
      geo.rotateX(-Math.PI / 2);
      const water = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({ color: 0xffffff, vertexColors: true, specular: 0xffffff, shininess: 90, transparent: true, opacity: 0.72 }));
      scene.add(water);
      const wcol = new Float32Array(NG * NG * 3);
      geo.setAttribute('color', new THREE.BufferAttribute(wcol, 3));
      const drop = new THREE.Mesh(new THREE.SphereGeometry(0.07, 12, 8), new THREE.MeshStandardMaterial({ color: 0x9adcff, roughness: 0.1 }));
      scene.add(drop);
      const pos = geo.attributes['position'] as THREE.BufferAttribute;
      const r = rng(4);
      let next = 0.3;
      let pending: { i: number; j: number; at: number }[] = [];
      let damp = 0.988;
      let strength = 1;
      const disturb = (ci: number, cj: number, amt: number): void => {
        for (let j = -3; j <= 3; j++)
          for (let i = -3; i <= 3; i++) {
            const a = ci + i;
            const b = cj + j;
            if (a < 1 || b < 1 || a >= NG - 1 || b >= NG - 1) continue;
            const w = Math.exp(-(i * i + j * j) / 3);
            cur[a + b * NG] = cur[a + b * NG]! - amt * w;
          }
      };
      return {
        scene,
        camera: cam,
        update(t) {
          cam.position.set(Math.sin(t * 0.15) * 2.2, 6.6, 7.6);
          cam.lookAt(0, -0.4, 0);
          if (t > next) {
            const two = r() < 0.4;
            const at = t + 0.35;
            pending.push({ i: 12 + Math.floor(r() * (NG - 24)), j: 12 + Math.floor(r() * (NG - 24)), at });
            if (two) pending.push({ i: 12 + Math.floor(r() * (NG - 24)), j: 12 + Math.floor(r() * (NG - 24)), at });
            next = t + 1.1;
          }
          drop.visible = false;
          pending = pending.filter((p) => {
            if (t >= p.at) {
              disturb(p.i, p.j, 1.4 * strength);
              return false;
            }
            const k = 1 - (p.at - t) / 0.35;
            drop.visible = true;
            drop.position.set((p.i / (NG - 1) - 0.5) * SIZE, 2.2 - k * 2.2, (p.j / (NG - 1) - 0.5) * SIZE);
            return true;
          });
          for (let it = 0; it < 1; it++) {
            for (let j = 1; j < NG - 1; j++)
              for (let i = 1; i < NG - 1; i++) {
                const k = i + j * NG;
                const nv = (cur[k - 1]! + cur[k + 1]! + cur[k - NG]! + cur[k + NG]!) / 2 - prv[k]!;
                prv[k] = nv * damp;
              }
            const tmp = prv.slice();
            prv.set(cur);
            cur.set(tmp);
          }
          const D = cimg.data;
          for (let j = 0; j < NG; j++)
            for (let i = 0; i < NG; i++) {
              const k = i + j * NG;
              pos.setY(k, cur[k]! * 0.5);
              const hv = clamp(cur[k]! * 1.6, -1, 1);
              wcol[k * 3] = clamp(0.16 + hv * 0.7, 0, 1);
              wcol[k * 3 + 1] = clamp(0.6 + hv * 0.45, 0, 1);
              wcol[k * 3 + 2] = clamp(0.9 + hv * 0.2, 0, 1);
              const lap = i > 0 && j > 0 && i < NG - 1 && j < NG - 1 ? cur[k - 1]! + cur[k + 1]! + cur[k - NG]! + cur[k + NG]! - 4 * cur[k]! : 0;
              const b = clamp(lap * 500, 0, 220);
              const o = k * 4;
              D[o] = 255;
              D[o + 1] = 255;
              D[o + 2] = 240;
              D[o + 3] = b;
            }
          cg.putImageData(cimg, 0, 0);
          cTex.needsUpdate = true;
          pos.needsUpdate = true;
          geo.attributes['color']!.needsUpdate = true;
          geo.computeVertexNormals();
        },
        controls: [
          { type: 'range', label: '물결 남는 정도', min: 0.95, max: 0.998, step: 0.001, value: 0.988, on: (v) => (damp = v) },
          { type: 'range', label: '물방울 세기', min: 0.2, max: 3, step: 0.1, value: 1, on: (v) => (strength = v) },
          { type: 'button', label: '가운데 톡', on: () => disturb(NG >> 1, NG >> 1, 2 * strength) },
        ],
        dispose() {
          disposeScene(scene);
          (scene.background as THREE.Texture).dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [GPU 유체 (stable fluids — 셰이더 핑퐁)](https://ai-techstudio.web.app/ai/t/i46.md) `i46`
- 다음에 해 볼 기술: [SPH · 입자 물](https://ai-techstudio.web.app/ai/t/i47.md) `i47`
- 참고 문서: [Evan Wallace — WebGL Water](https://madebyevan.com/webgl-water/)
