# AI 꾸러미 — SPH · 입자 물 — SPH-style particle fluid (double density relaxation)
> 물을 입자 500개로 두고, 이웃 입자와의 거리로 밀도를 재서 너무 몰리면 서로 밀어내게 해 — 수조를 기울이면 물처럼 출렁이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i47

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

## 주문서

### 만들어 줘: SPH · 입자 물 — SPH-style particle fluid (double density relaxation)

#### 1. 목표
기울어지는 수조를 입자 물로 만들어 줘 — 입자들이 밀도로 서로 밀어내 출렁이고, 기울이면 한쪽으로 쏠리게. 분위기는 밝은 파란 물방울.

#### 2. 핵심 기술 용어
- **SPH-style particle fluid (double density relaxation)** — 입자 물 — 밀도 두 가지로 밀어내기
- **Position-based integration (Verlet-like)** — 위치를 먼저 옮기고 고친 뒤 속도를 다시 구하기
- **Spatial hash grid (neighbor search)** — 격자 칸으로 이웃 입자 빨리 찾기
- **Kernel radius h** — 서로 영향을 주는 거리 (견본 9)

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

#### 4. 조건
- 이웃 찾기는 격자 칸(칸 = 영향 거리)으로 — 모든 쌍 비교 금지
- 밀어내기는 두 입자에 반씩 반대로 (운동량이 새지 않게)
- 속도는 위치 차이로 다시 구하고 상한을 둔다 (폭발 막기)
- 그리기는 큰 흐린 점 위에 작은 점 — 입자가 뭉쳐 물 덩어리처럼 보이게

#### 5. 완성 기준 (이게 보이면 성공)
- 수조가 기울면 물이 한쪽으로 쏠렸다 출렁이며 돌아온다
- 빠른 입자는 밝게 보여 흐름이 보인다
- 「압력 세기」를 낮추면 물이 눌려 납작해지고, 높이면 탱탱해진다
- 「물 다시 붓기」로 처음 배치로 돌아간다

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

## 원리
- 한 걸음: 중력을 속도에 더하고, 이전 위치를 기억한 뒤 위치를 속도만큼 옮긴다.
- 이웃 찾기: 입자를 칸 크기 = 영향 거리 h(9)인 격자에 넣고, 둘레 9칸만 본다.
- 밀도 둘: q = 1 − r/h 일 때 밀도 d = Σq², 가까운 밀도 dn = Σq³. 압력 P = 굳기 × (d − 쉬는 밀도 3.2), 가까운 압력 Pn = 1.2 × dn.
- 이웃마다 (P·q + Pn·q²)/2 만큼 서로 반대로 밀어낸다 — 몰리면 퍼지고, 가까운 압력이 입자끼리 겹치지 않게 한다.
- 벽 안으로 위치를 묶고, 속도 = (새 위치 − 이전 위치) / dt 로 다시 구한다 (±4 로 제한).

## 핵심 코드 — 밀도 두 가지로 밀어내기 (한 입자 i)
(발췌: demos/demosSim.ts i47 step() 의 가운데를 정리 (이웃 격자 순회는 forNeighbors 로 줄임))
```ts
const HR = 9;          // 영향 거리
const REST = 3.2;     // 쉬는 밀도
const KN = 1.2;       // 가까운 압력 세기
// forNeighbors(i, fn): 격자 둘레 9칸의 다른 입자 j 마다 fn(j)

function relax(i: number, x: Float32Array, y: Float32Array, stiff: number, dt = 1) {
  const xi = x[i], yi = y[i];
  let d = 0, dn = 0;
  forNeighbors(i, (j) => {
    const r = Math.hypot(x[j] - xi, y[j] - yi);
    if (r < HR) { const q = 1 - r / HR; d += q * q; dn += q * q * q; }
  });
  const P = stiff * (d - REST);       // 몰리면 + (밀어냄), 성기면 - (당김)
  const Pn = KN * dn;                 // 아주 가까우면 늘 밀어냄 — 겹침 막기
  let dxi = 0, dyi = 0;
  forNeighbors(i, (j) => {
    const rx = x[j] - xi, ry = y[j] - yi;
    const r = Math.hypot(rx, ry);
    if (r < HR && r > 1e-4) {
      const q = 1 - r / HR;
      const D = (dt * dt * (P * q + Pn * q * q)) / 2;
      const ux = (rx / r) * D, uy = (ry / r) * D;
      x[j] += ux; y[j] += uy;         // 이웃은 바깥으로
      dxi -= ux; dyi -= uy;           // 나는 반대로
    }
  });
  x[i] = xi + dxi;
  y[i] = yi + dyi;
}
// 걸음 끝: 벽 안으로 묶고 v = clamp((x - 이전 x) / dt, -4, 4)
```

## 흔한 실수 · 확인 목록
- [ ] **모든 입자 쌍을 비교하면 500개만 돼도 느리다** — 영향 거리만 한 격자 칸에 넣고 둘레 9칸만 본다.
- [ ] **밀도 하나(압력)만 쓰면 입자가 서로 겹쳐 뭉친다** — 가까운 밀도(q³)로 늘 밀어내는 압력을 하나 더 둔다.
- [ ] **속도를 따로 적분하면 벽에서 튀어 나간다** — 위치를 먼저 고치고, 속도는 위치 차이로 다시 구한 뒤 상한을 건다.
- [ ] **입자를 작은 점으로만 그리면 물이 아니라 모래처럼 보인다** — 큰 반투명 점을 먼저 깔고 작은 점을 위에 — 덩어리가 이어져 보인다.

## 완성 기준 체크리스트
- [ ] 수조가 기울면 물이 한쪽으로 쏠렸다 출렁이며 돌아온다
- [ ] 빠른 입자는 밝게 보여 흐름이 보인다
- [ ] 「압력 세기」를 낮추면 물이 눌려 납작해지고, 높이면 탱탱해진다
- [ ] 「물 다시 붓기」로 처음 배치로 돌아간다

## 이 기술 정보
- id: `i47` · 분류: 물리 · 수학 원리 › 물리 · 시뮬레이션 · 공통 · 난이도 어려움 · 폰 부담 무거움 — 입자 500개 × 이웃 수십 개 × 프레임당 2걸음. 이웃 격자 없이 전부 비교하면 25만 쌍이라 폰이 못 버틴다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i47
- 쓰면 좋을 때: 출렁임 · 쏟아짐처럼 물 모양이 바뀌는 장면 / 「밀면 퍼진다」를 보여 주는 압력 · 부력 체험
- 쓰지 말 때: 넓은 바다 · 강 표면 — 셰이더 물이나 높이 지도 물결(i48)이 훨씬 싸다 / 입자 수만 개를 CPU 로 — 몇천 개까지만

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i47 견본 항목 — `src/demos/demosSim.ts:3552`
```ts
  i47: {
    kind: '2d',
    caption: '입자 500개가 서로 밀어내며(밀도 → 압력) 물처럼 출렁 — 수조를 기울였다 세웠다 해요',
    make() {
      const N = 500;
      const TW = 200;
      const TH = 120;
      const HR = 9;
      const x = new Float32Array(N);
      const y = new Float32Array(N);
      const px = new Float32Array(N);
      const py = new Float32Array(N);
      const vx = new Float32Array(N);
      const vy = new Float32Array(N);
      const reset = (): void => {
        for (let i = 0; i < N; i++) {
          x[i] = 6 + (i % 22) * 4.4;
          y[i] = TH - 6 - Math.floor(i / 22) * 4.4;
          vx[i] = vy[i] = 0;
        }
      };
      reset();
      const CS = HR;
      const GX = Math.ceil(TW / CS) + 1;
      const GY = Math.ceil(TH / CS) + 1;
      const head = new Int32Array(GX * GY);
      const nxt = new Int32Array(N);
      let tiltK = 1;
      let stiff = 0.5;
      const REST = 3.2;
      const KN = 1.2;
      const step = (gx: number, gy: number): void => {
        const dt = 1;
        for (let i = 0; i < N; i++) {
          vx[i] = vx[i]! + gx * dt;
          vy[i] = vy[i]! + gy * dt;
          px[i] = x[i]!;
          py[i] = y[i]!;
          x[i] = x[i]! + vx[i]! * dt;
          y[i] = y[i]! + vy[i]! * dt;
        }
        head.fill(-1);
        for (let i = 0; i < N; i++) {
          const cx = clamp(Math.floor(x[i]! / CS), 0, GX - 1);
          const cy = clamp(Math.floor(y[i]! / CS), 0, GY - 1);
          const c = cx + cy * GX;
          nxt[i] = head[c]!;
          head[c] = i;
        }
        for (let i = 0; i < N; i++) {
          const xi = x[i]!;
          const yi = y[i]!;
          const cx = clamp(Math.floor(xi / CS), 0, GX - 1);
          const cy = clamp(Math.floor(yi / CS), 0, GY - 1);
          let d = 0;
          let dn = 0;
          for (let oy = -1; oy <= 1; oy++)
            for (let ox = -1; ox <= 1; ox++) {
              const ax = cx + ox;
              const ay = cy + oy;
              if (ax < 0 || ay < 0 || ax >= GX || ay >= GY) continue;
              for (let j = head[ax + ay * GX]!; j >= 0; j = nxt[j]!) {
                if (j === i) continue;
                const r = Math.hypot(x[j]! - xi, y[j]! - yi);
                if (r < HR) {
                  const q = 1 - r / HR;
                  d += q * q;
                  dn += q * q * q;
                }
              }
            }
          const P = stiff * (d - REST);
          const Pn = KN * dn;
          let dxi = 0;
          let dyi = 0;
          for (let oy = -1; oy <= 1; oy++)
            for (let ox = -1; ox <= 1; ox++) {
              const ax = cx + ox;
              const ay = cy + oy;
              if (ax < 0 || ay < 0 || ax >= GX || ay >= GY) continue;
              for (let j = head[ax + ay * GX]!; j >= 0; j = nxt[j]!) {
                if (j === i) continue;
                const rx = x[j]! - xi;
                const ry = y[j]! - yi;
                const r = Math.hypot(rx, ry);
                if (r < HR && r > 1e-4) {
                  const q = 1 - r / HR;
                  const D = (dt * dt * (P * q + Pn * q * q)) / 2;
                  const ux = (rx / r) * D;
                  const uy = (ry / r) * D;
                  x[j] = x[j]! + ux;
                  y[j] = y[j]! + uy;
                  dxi -= ux;
                  dyi -= uy;
                }
              }
            }
          x[i] = xi + dxi;
          y[i] = yi + dyi;
        }
        for (let i = 0; i < N; i++) {
          x[i] = clamp(x[i]!, 1.5, TW - 1.5);
          y[i] = clamp(y[i]!, 1.5, TH - 1.5);
          vx[i] = clamp((x[i]! - px[i]!) / dt, -4, 4);
          vy[i] = clamp((y[i]! - py[i]!) / dt, -4, 4);
        }
      };
      return {
        draw(g, w, h, t) {
          const ang = Math.sin(t * 0.7) * 0.32 * tiltK;
          const G = 0.12;
          // 수조 안 좌표에서 본 중력 (아래가 +y)
          const gx = -Math.sin(ang) * G;
          const gy = Math.cos(ang) * G;
          step(gx, gy);
          step(gx, gy);
          bg(g, w, h, '#fff4dc', '#ffe0b8');
          const s = Math.min((w * 0.86) / TW, (h * 0.8) / TH);
          g.save();
          g.translate(w / 2, h * 0.53);
          g.rotate(-ang);
          g.translate((-TW / 2) * s, (-TH / 2) * s);
          g.fillStyle = 'rgba(255,255,255,0.6)';
          g.fillRect(0, 0, TW * s, TH * s);
          // 물 — 큰 흐린 점 위에 작은 점
          g.fillStyle = 'rgba(60,160,255,0.35)';
          for (let i = 0; i < N; i++) {
            g.beginPath();
            g.arc(x[i]! * s, y[i]! * s, 4.6 * s, 0, TAU);
            g.fill();
          }
          for (let i = 0; i < N; i++) {
            const sp = Math.min(1, Math.hypot(vx[i]!, vy[i]!) / 1.6);
            g.fillStyle = `rgb(${Math.round(40 + sp * 200)},${Math.round(130 + sp * 115)},255)`;
            g.beginPath();
            g.arc(x[i]! * s, y[i]! * s, 2 * s, 0, TAU);
            g.fill();
          }
          g.strokeStyle = '#30406a';
          g.lineWidth = Math.max(2, s * 2);
          g.beginPath();
          g.moveTo(0, 0);
          g.lineTo(0, TH * s);
          g.lineTo(TW * s, TH * s);
          g.lineTo(TW * s, 0);
          g.stroke();
          g.restore();
          const fs = clamp(h * 0.05, 9, 18);
          pill(g, `입자 ${N} · 이웃 찾기 격자`, 8, fs * 1.3, fs, 'rgba(48,64,106,0.8)');
        },
        controls: [
          { type: 'range', label: '기울이기', min: 0, max: 2, step: 0.1, value: 1, on: (v) => (tiltK = v) },
          { type: 'range', label: '압력 세기 (안 눌림)', min: 0.1, max: 1.2, step: 0.05, value: 0.5, on: (v) => (stiff = v) },
          { type: 'button', label: '물 다시 붓기', on: reset },
        ],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [GPU 유체 (stable fluids — 셰이더 핑퐁)](https://ai-techstudio.web.app/ai/t/i46.md) `i46`
- 다음에 해 볼 기술: [물결 파동 (높이 지도)](https://ai-techstudio.web.app/ai/t/i48.md) `i48`
- 참고 문서: [Wikipedia — Smoothed-particle hydrodynamics](https://en.wikipedia.org/wiki/Smoothed-particle_hydrodynamics)
