# AI 꾸러미 — 침식 지형 (물방울 침식) — Hydraulic erosion (particle / droplet based)
> 물방울 수만 개를 높이 지도 위에 굴려 비탈에서는 흙을 깎고 느려지면 쌓게 해서, 골짜기 · 능선 · 아래쪽 모래 부채꼴이 저절로 생긴 3D 지형을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i253

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

## 주문서

### 만들어 줘: 침식 지형 (물방울 침식) — Hydraulic erosion (particle / droplet based)

#### 1. 목표
잡음으로 만든 산 지형을 물방울 침식으로 다듬어 줘 — 물방울이 비탈을 굴러 흙을 깎고 느려지면 쌓아서 골짜기와 부채꼴 퇴적이 생기게. 보이는 모습은 깎인 곳은 짙게 · 쌓인 곳은 모래색인 설명.

#### 2. 핵심 기술 용어
- **Hydraulic erosion (particle / droplet based)** — 물방울 침식 — 물방울 하나씩 굴리며 깎고 쌓기
- **Sediment capacity** — 물방울이 실을 수 있는 흙 양 = 기울기 × 속도 × 물
- **Bilinear height · gradient sampling** — 네 칸 사이를 섞어 높이 · 기울기 읽기
- **three.js PlaneGeometry · computeVertexNormals** — 높이 지도를 판 메시로, 법선 다시 계산

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

#### 4. 조건
- 높이 · 기울기는 네 칸 겹선형 보간으로 — 칸 정수 자리만 읽으면 계단 무늬가 생긴다
- 깎는 양은 높이 차(−dh)를 넘지 않게 — 넘으면 구멍이 파이고 뾰족한 가시가 생긴다
- 한 프레임에 물방울 수를 제한(150)하고 메시 갱신은 두 프레임에 한 번 — 끝나면 멈춘다
- 깎인 곳 · 쌓인 곳 색을 원래 높이와의 차이로 칠해 변화가 보이게

#### 5. 완성 기준 (이게 보이면 성공)
- 물방울 자취가 비탈을 따라 흐르고, 시간이 지나며 골짜기가 파여 능선이 날카로워진다
- 깎인 골은 짙은 흙색, 아래 평지엔 모래색 부채꼴이 생긴다
- 「한 번에 떨어뜨릴 물방울」을 올리면 빨리 깎이고, 「새 지형」으로 다시 시작된다
- 물방울 2만 4천 개 뒤에는 멈추고 프레임이 다시 가벼워진다

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

## 원리
- 물방울은 무작위 칸에서 태어나 최대 40걸음 굴러간다. 방향 = 이전 방향 × 0.05 − 기울기 × 0.95 (관성이 아주 조금).
- 실을 수 있는 흙 = max(−높이 변화, 0.01) × 속도 × 물 × 4. 실은 흙이 그보다 적으면 깎고, 많거나 오르막이면 내려놓는다.
- 깎을 때는 반지름 2 칸 붓으로 가까울수록 많이, 쌓을 때는 지금 칸 네 귀퉁이에 거리 비율로 나눠 쌓는다.
- 내려가면 빨라지고(속도² − 높이 변화 × 4), 물은 걸음마다 0.98 배로 마른다.
- 수천 개가 같은 골을 지나며 골은 더 깊어지고, 느려지는 아래쪽에 흙이 부채꼴로 쌓인다.

## 핵심 코드 — 물방울 하나 굴리기 (깎기 · 쌓기)
(발췌: demos/demosMapA.ts i253 sample · erodeAt · drop 을 정리)
```ts
// hm: N×N 높이, sample(x,y) → [기울기x, 기울기y, 높이] (네 칸 겹선형)
function drop(hm: Float32Array, N: number, sample: (x: number, y: number) => [number, number, number],
              erodeAt: (x: number, y: number, amt: number) => void) {
  let x = 2 + Math.random() * (N - 5), y = 2 + Math.random() * (N - 5);
  let dx = 0, dy = 0, sp = 1, wv = 1, sed = 0;
  for (let life = 0; life < 40; life++) {
    const ix = Math.floor(x), iy = Math.floor(y), fx = x - ix, fy = y - iy;
    const [gx, gy, hh] = sample(x, y);
    dx = dx * 0.05 - gx * 0.95;                       // 관성 조금 + 내리막
    dy = dy * 0.05 - gy * 0.95;
    const len = Math.hypot(dx, dy);
    if (len < 1e-9) break;
    dx /= len; dy /= len;
    x += dx; y += dy;
    if (x < 1 || y < 1 || x >= N - 2 || y >= N - 2) break;
    const dh = sample(x, y)[2] - hh;
    const cap = Math.max(-dh, 0.01) * sp * wv * 4;     // 실을 수 있는 흙
    const i = iy * N + ix;
    if (sed > cap || dh > 0) {                          // 내려놓기 — 네 귀퉁이에 나눠서
      const dep = dh > 0 ? Math.min(dh, sed) : (sed - cap) * 0.3;
      sed -= dep;
      hm[i] += dep * (1 - fx) * (1 - fy); hm[i + 1] += dep * fx * (1 - fy);
      hm[i + N] += dep * (1 - fx) * fy;   hm[i + N + 1] += dep * fx * fy;
    } else {                                            // 깎기 — 반지름 2 붓
      const er = Math.min((cap - sed) * 0.3, -dh);
      erodeAt(x - dx, y - dy, er);
      sed += er;
    }
    sp = Math.sqrt(Math.max(0, sp * sp - dh * 4));      // 내려가면 빨라짐
    wv *= 0.98;                                          // 물이 마름
  }
}
```

## 흔한 실수 · 확인 목록
- [ ] **깎는 양을 높이 차보다 크게 두면 바늘 같은 구멍 · 가시가 생긴다** — er = min((cap − sed) × 0.3, −dh) 처럼 그 걸음의 높이 차를 넘지 않게 묶는다.
- [ ] **한 칸에서만 깎으면 골이 한 줄짜리 홈처럼 보인다** — 반지름 2 붓으로 거리만큼 무게를 나눠 깎는다 (erodeAt).
- [ ] **물방울마다 법선을 다시 계산하면 프레임이 멈춘다** — 물방울은 한 프레임에 150개씩, 메시 높이 · 법선 · 색 갱신은 두 프레임에 한 번.
- [ ] **지형 가장자리에서 물방울이 판 밖을 읽는다** — 1 ~ N−2 범위를 벗어나면 그 물방울을 끝낸다 — 네 칸 보간이 i + N + 1 을 읽기 때문.

## 완성 기준 체크리스트
- [ ] 물방울 자취가 비탈을 따라 흐르고, 시간이 지나며 골짜기가 파여 능선이 날카로워진다
- [ ] 깎인 골은 짙은 흙색, 아래 평지엔 모래색 부채꼴이 생긴다
- [ ] 「한 번에 떨어뜨릴 물방울」을 올리면 빨리 깎이고, 「새 지형」으로 다시 시작된다
- [ ] 물방울 2만 4천 개 뒤에는 멈추고 프레임이 다시 가벼워진다

## 이 기술 정보
- id: `i253` · 분류: 지도 · 길찾기 › 지도 생성 (절차) · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 112² = 1만 2천 꼭짓점 판. 한 프레임에 물방울 150개 × 40걸음 + 두 프레임마다 법선 · 색 다시 계산. 총 2만 4천 개면 끝난다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i253
- 쓰면 좋을 때: 잡음 지형이 너무 매끈하고 가짜 같을 때 — 골짜기 결을 더하고 싶을 때 / 「물이 산을 깎는다」를 보여 주는 과학 체험
- 쓰지 말 때: 폰에서 매 판마다 새로 깎기 — 미리 깎아 둔 높이 지도를 저장해 쓴다 / 평평한 퍼즐 판 — 의미가 없다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i253 견본 항목 — `src/demos/demosMapA.ts:2413`
```ts
  i253: {
    kind: '3d',
    caption: '물방울 수만 개가 비탈을 굴러 흙을 깎고 낮은 곳에 쌓아요 — 골짜기가 파이고 아래엔 모래 부채꼴',
    make() {
      const N = 112;
      const scene = new THREE.Scene();
      scene.background = new THREE.Color(0x9fc7e8);
      scene.fog = new THREE.Fog(0x9fc7e8, 13, 27);
      const cam = new THREE.PerspectiveCamera(40, 1.6, 0.1, 100);
      scene.add(new THREE.HemisphereLight(0xdbeeff, 0x4a3a2a, 1.1));
      const sun = new THREE.DirectionalLight(0xfff0d8, 2.4);
      sun.position.set(-6, 5, 3);
      scene.add(sun);
      const geo = new THREE.PlaneGeometry(10, 10, N - 1, N - 1);
      geo.rotateX(-Math.PI / 2);
      const col = new Float32Array(N * N * 3);
      geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
      const mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.92, metalness: 0 });
      const mesh = new THREE.Mesh(geo, mat);
      scene.add(mesh);
      const water = new THREE.Mesh(new THREE.CircleGeometry(60, 48), new THREE.MeshStandardMaterial({ color: 0x2f7fb8, roughness: 0.25, transparent: true, opacity: 0.82 }));
      water.rotation.x = -Math.PI / 2;
      water.position.y = 0.32;
      scene.add(water);
      // 물방울 자취
      const TR = 600;
      const tpos = new Float32Array(TR * 3);
      const tgeo = new THREE.BufferGeometry();
      tgeo.setAttribute('position', new THREE.BufferAttribute(tpos, 3));
      const tmat = new THREE.PointsMaterial({ color: 0x9fe4ff, size: 0.07, transparent: true, opacity: 0.9, depthWrite: false });
      const trail = new THREE.Points(tgeo, tmat);
      scene.add(trail);
      let ti = 0;
      const HS = 3.4;
      let hm = new Float32Array(N * N);
      let orig = new Float32Array(N * N);
      let drops = 0;
      const TOTAL = 24000;
      let perFrame = 150;
      let showDrops = true;
      let holdT = 0;
      let seed = 1;
      let frame = 0;
      const build = (): void => {
        seed = 1 + Math.floor(Math.random() * 9999);
        for (let y = 0; y < N; y++)
          for (let x = 0; x < N; x++) {
            const nx = x / (N - 1) - 0.5;
            const ny = y / (N - 1) - 0.5;
            const d = Math.sqrt(nx * nx + ny * ny) * 2;
            let v = fbm(x * 0.045 + seed, y * 0.045, seed, 5);
            const ridge = 1 - Math.abs(fbm(x * 0.03, y * 0.03 + seed, seed + 3, 3) * 2 - 1);
            v = v * 0.6 + ridge * 0.55;
            v = v * (1 - smooth(0.55, 1.05, d)) - 0.05;
            hm[y * N + x] = Math.max(0, v);
          }
        orig = hm.slice();
        drops = 0;
        holdT = 0;
        tpos.fill(0);
        tgeo.setDrawRange(0, 0);
        ti = 0;
        refresh();
      };
      const sample = (x: number, y: number): [number, number, number] => {
        const ix = Math.floor(x);
        const iy = Math.floor(y);
        const fx = x - ix;
        const fy = y - iy;
        const i = iy * N + ix;
        const a = hm[i]!;
        const b = hm[i + 1]!;
        const c = hm[i + N]!;
        const d = hm[i + N + 1]!;
        return [(b - a) * (1 - fy) + (d - c) * fy, (c - a) * (1 - fx) + (d - b) * fx, a * (1 - fx) * (1 - fy) + b * fx * (1 - fy) + c * (1 - fx) * fy + d * fx * fy];
      };
      const erodeAt = (x: number, y: number, amt: number): void => {
        const cx = Math.floor(x);
        const cy = Math.floor(y);
        let ws = 0;
        const R = 2;
        for (let j = -R; j <= R; j++)
          for (let i = -R; i <= R; i++) {
            const d = Math.hypot(i + cx - x, j + cy - y);
            if (d < R) ws += R - d;
          }
        for (let j = -R; j <= R; j++)
          for (let i = -R; i <= R; i++) {
            const px = cx + i;
            const py = cy + j;
            if (px < 0 || py < 0 || px >= N || py >= N) continue;
            const d = Math.hypot(px - x, py - y);
            if (d >= R) continue;
            const k = py * N + px;
            const take = Math.min(hm[k]!, (amt * (R - d)) / ws);
            hm[k] = hm[k]! - take;
          }
      };
      const drop = (record: boolean): void => {
        let x = 2 + Math.random() * (N - 5);
        let y = 2 + Math.random() * (N - 5);
        let dx = 0;
        let dy = 0;
        let sp = 1;
        let wv = 1;
        let sed = 0;
        for (let life = 0; life < 40; life++) {
          const ix = Math.floor(x);
          const iy = Math.floor(y);
          const fx = x - ix;
          const fy = y - iy;
          const [gx, gy, hh] = sample(x, y);
          dx = dx * 0.05 - gx * 0.95;
          dy = dy * 0.05 - gy * 0.95;
          const len = Math.hypot(dx, dy);
          if (len < 1e-9) break;
          dx /= len;
          dy /= len;
          x += dx;
          y += dy;
          if (x < 1 || y < 1 || x >= N - 2 || y >= N - 2) break;
          const nh = sample(x, y)[2];
          const dh = nh - hh;
          if (record && life % 2 === 0) {
            const o = (ti % TR) * 3;
            tpos[o] = (x / (N - 1) - 0.5) * 10;
            tpos[o + 1] = nh * HS + 0.05;
            tpos[o + 2] = (y / (N - 1) - 0.5) * 10;
            ti++;
          }
          const cap = Math.max(-dh, 0.01) * sp * wv * 4;
          const i = iy * N + ix;
          if (sed > cap || dh > 0) {
            const dep = dh > 0 ? Math.min(dh, sed) : (sed - cap) * 0.3;
            sed -= dep;
            hm[i] = hm[i]! + dep * (1 - fx) * (1 - fy);
            hm[i + 1] = hm[i + 1]! + dep * fx * (1 - fy);
            hm[i + N] = hm[i + N]! + dep * (1 - fx) * fy;
            hm[i + N + 1] = hm[i + N + 1]! + dep * fx * fy;
          } else {
            const er = Math.min((cap - sed) * 0.3, -dh);
            erodeAt(x - dx, y - dy, er);
            sed += er;
          }
          sp = Math.sqrt(Math.max(0, sp * sp - dh * 4));
          wv *= 0.98;
        }
      };
      const pos = geo.attributes.position as THREE.BufferAttribute;
      const C_G: V3 = [0.36, 0.6, 0.27];
      const C_R: V3 = [0.5, 0.44, 0.38];
      const C_S: V3 = [0.86, 0.76, 0.52];
      const C_D: V3 = [0.32, 0.24, 0.18];
      const C_W: V3 = [0.95, 0.96, 0.98];
      const refresh = (): void => {
        for (let i = 0; i < N * N; i++) pos.setY(i, hm[i]! * HS);
        pos.needsUpdate = true;
        geo.computeVertexNormals();
        const nor = geo.attributes.normal as THREE.BufferAttribute;
        for (let i = 0; i < N * N; i++) {
          const ny = nor.getY(i);
          const hh = hm[i]!;
          const diff = hh - orig[i]!;
          let c = mix(C_R, C_G, smooth(0.7, 0.86, ny));
          c = mix(c, C_W, smooth(0.62, 0.72, hh) * smooth(0.75, 0.9, ny));
          c = mix(c, C_S, clamp01(diff * 60));
          c = mix(c, C_D, clamp01(-diff * 25) * 0.6);
          if (hh * HS < 0.4) c = mix(c, C_S, 0.85);
          col[i * 3] = c[0];
          col[i * 3 + 1] = c[1];
          col[i * 3 + 2] = c[2];
        }
        (geo.attributes.color as THREE.BufferAttribute).needsUpdate = true;
      };
      build();
      // 숫자 표지
      const lab = document.createElement('canvas');
      lab.width = 512;
      lab.height = 96;
      const ltex = new THREE.CanvasTexture(lab);
      ltex.colorSpace = THREE.SRGBColorSpace;
      const lspr = new THREE.Sprite(new THREE.SpriteMaterial({ map: ltex, depthTest: false, transparent: true }));
      lspr.scale.set(4.2, 0.79, 1);
      lspr.renderOrder = 10;
      scene.add(lspr);
      let lastLab = '';
      const drawLab = (s: string): void => {
        if (s === lastLab) return;
        lastLab = s;
        const g = c2(lab);
        g.clearRect(0, 0, 512, 96);
        rr(g, 8, 14, 496, 68, 34);
        g.fillStyle = 'rgba(12,28,48,0.78)';
        g.fill();
        txt(g, s, 256, 50, 36, '#fff', 'center', 800);
        ltex.needsUpdate = true;
      };
      return {
        scene,
        camera: cam,
        update(t, dt) {
          frame++;
          const ang = t * 0.12;
          cam.position.set(Math.sin(ang) * 11.5, 7.2, Math.cos(ang) * 11.5);
          cam.lookAt(0, 0.6, 0);
          lspr.position.copy(cam.position).multiplyScalar(0.45).add(new THREE.Vector3(0, 2.2, 0));
          if (drops < TOTAL) {
            const n = Math.min(perFrame, TOTAL - drops);
            for (let k = 0; k < n; k++) drop(showDrops && k % 25 === 0);
            drops += n;
            if (frame % 2 === 0 || drops >= TOTAL) refresh();
            tgeo.setDrawRange(0, Math.min(ti, TR));
            (tgeo.attributes.position as THREE.BufferAttribute).needsUpdate = true;
          } else {
            holdT += dt;
            tmat.opacity = Math.max(0, 0.9 - holdT);
            if (holdT > 2.5) {
              build();
              tmat.opacity = 0.9;
            }
          }
          trail.visible = showDrops;
          drawLab(drops >= TOTAL ? `침식 끝 · 물방울 ${TOTAL.toLocaleString()}개` : `물방울 ${drops.toLocaleString()}개`);
        },
        controls: [
          { type: 'button', label: '새 지형', on: () => build() },
          { type: 'range', label: '한 번에 떨어뜨릴 물방울', min: 50, max: 800, step: 50, value: 150, on: (v) => { perFrame = v; } },
          { type: 'toggle', label: '물방울 자취', value: true, on: (v) => { showDrops = v; } },
        ],
        dispose() {
          geo.dispose();
          mat.dispose();
          water.geometry.dispose();
          (water.material as THREE.Material).dispose();
          tgeo.dispose();
          tmat.dispose();
          ltex.dispose();
          lspr.material.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [강 흘려보내기 (내리막 따라)](https://ai-techstudio.web.app/ai/t/i251.md) `i251` · [잡음 섬 지도 (높이 → 생물군)](https://ai-techstudio.web.app/ai/t/i245.md) `i245`
- 다음에 해 볼 기술: [시드 지도 (같은 수 = 같은 지도)](https://ai-techstudio.web.app/ai/t/i255.md) `i255`
