# AI 꾸러미 — GPU 유체 (stable fluids — 셰이더 핑퐁) — Stable Fluids on GPU (Jos Stam · GPU Gems ch.38)
> 속도 · 압력 · 잉크를 렌더 타깃 두 장씩(핑퐁)에 담고 이류 → 발산 → 압력 야코비 → 기울기 빼기를 셰이더 패스로 돌려, 손으로 저으면 잉크가 소용돌이치는 유체를 GPU 로 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i46

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

## 주문서

### 만들어 줘: GPU 유체 (stable fluids — 셰이더 핑퐁) — Stable Fluids on GPU (Jos Stam · GPU Gems ch.38)

#### 1. 목표
손으로 젓는 잉크 · 연기 화면에 GPU stable fluids 유체를 넣어 줘 — 속도 · 압력 · 잉크를 HalfFloat 렌더 타깃 핑퐁으로 두고, 이류 · 발산 · 압력 야코비 · 기울기 빼기 · 소용돌이 살리기를 셰이더 패스로. 끌면 그 방향으로 저어지게, 분위기는 어두운 물에 번지는 무지개 잉크.

#### 2. 핵심 기술 용어
- **Stable Fluids on GPU (Jos Stam · GPU Gems ch.38)** — 큰 시간 간격에도 터지지 않는 유체를 셰이더로
- **Render target ping-pong (WebGLRenderTarget, HalfFloatType)** — 읽는 판 · 쓰는 판을 번갈아 — 16비트 실수 텍스처
- **Pressure projection (Jacobi iterations)** — 압력 풀이 — 흐름을 안 눌리게(발산 0) 맞추기, 패스를 N번
- **Vorticity confinement** — 계산하며 뭉개지는 잔 소용돌이 되살리기

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표
- WebGLRenderTarget(type: HalfFloatType) 두 장을 바꿔 가며, 화면 크기 판(PlaneGeometry 2×2) 하나에 ShaderMaterial 을 갈아 끼워 그린다. GPUComputationRenderer 로도 같은 틀을 짤 수 있다.

#### 4. 조건
- 렌더 타깃은 HalfFloatType · LinearFilter · ClampToEdge, 깊이 버퍼 끔 — 8비트로는 속도가 뭉개진다
- 셰이더 안에 반복문 금지(윈도 D3D 컴파일이 느려진다) — 야코비 되풀이는 패스를 N번 그린다
- render() 안에서 렌더 타깃에 다 그린 뒤 setRenderTarget(null) 로 화면에, 끝나면 autoClear · 렌더 타깃을 원래대로
- 순서: 힘 · 잉크 뿌리기 → curl → 소용돌이 → 발산 → 압력(지난 값 × 0.8 에서 시작) → 기울기 빼기 → 속도 이류 → 잉크 이류
- 실제 지난 시간(0.25초까지)을 1/60초 조각으로 나눠 걸음 — 느린 기기에서도 흐름 빠르기가 같게

#### 5. 완성 기준 (이게 보이면 성공)
- 카드에서는 저절로 도는 붓 셋이 무지개 잉크를 풀고, 잉크가 소용돌이치며 섞인다
- 크게 보기에서 끌면 그 방향으로 잉크가 밀려가며 버섯 모양 소용돌이가 생긴다
- 압력 반복 수를 1 로 내리면 잉크가 한쪽에 몰리거나 퍼져 나가는 차이가 보이고, 소용돌이 세기 0 ↔ 60 으로 잔 소용돌이가 사라졌다 살아난다
- 「속도장 보기」로 칸마다 흰 화살표 + 방향 색이 보이고, 해상도 128 ↔ 256 을 바꾸면 결이 고와진다

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

## 원리
- 격자(견본 세로 128 또는 256칸) 텍셀마다 속도 (u, v), 잉크는 더 고운 판(세로 256 · 512)에 색 (r, g, b).
- 이류: 각 텍셀에서 속도를 거꾸로 따라가 「한 걸음 전 그 자리」 값을 선형 보간으로 가져온다 — 셰이더 한 줄 texture(src, uv − dt·v·texel).
- 발산을 구하고, 압력을 「이웃 넷 평균 − 발산」으로 20~30번 되풀이(패스 N번)해 구한 뒤, 그 기울기만큼 속도에서 빼면 안 눌리는 흐름이 된다.
- 소용돌이 살리기: 회전량(curl)이 큰 쪽으로 작은 힘 — 격자가 성겨서 잃는 잔 소용돌이를 되살린다.
- 셰이더에 반복문이 없다 — 반복은 패스를 여러 번 그리는 것으로. 한 걸음 ≈ 패스 33번이지만 판이 작아 GPU 에는 가볍다.

## 핵심 코드 — 핑퐁 렌더 타깃 + 압력 야코비 패스 (반복은 패스 N번)
(발췌: demos/demosSim.ts makeFluidDemo() · FL_FRAG.pressure 를 정리)
```ts
const RT = (w: number, h: number) => new THREE.WebGLRenderTarget(w, h, {
  type: THREE.HalfFloatType, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter,
  wrapS: THREE.ClampToEdgeWrapping, wrapT: THREE.ClampToEdgeWrapping, depthBuffer: false,
});
const pair = (w: number, h: number) => {
  const o = { read: RT(w, h), write: RT(w, h), swap() { [o.read, o.write] = [o.write, o.read]; } };
  return o;
};
// 화면 크기 판 하나에 재질만 갈아 끼워 그린다
function pass(mat: THREE.ShaderMaterial, target: THREE.WebGLRenderTarget | null) {
  quad.material = mat;
  renderer.setRenderTarget(target);
  renderer.render(scene, cam);
}
// 압력: 이웃 넷 평균에서 발산을 뺀다 — 셰이더엔 반복문이 없다
const pressureFrag = 'uniform sampler2D uPressure; uniform sampler2D uDivergence;' +
  'varying vec2 vUv, vL, vR, vT, vB;' +
  'void main() {' +
  '  float L = texture2D(uPressure, vL).x, R = texture2D(uPressure, vR).x;' +
  '  float T = texture2D(uPressure, vT).x, B = texture2D(uPressure, vB).x;' +
  '  gl_FragColor = vec4((L + R + B + T - texture2D(uDivergence, vUv).x) * 0.25, 0., 0., 1.);' +
  '}';
for (let i = 0; i < iters; i++) {          // 20 ~ 30번
  pressureMat.uniforms.uPressure.value = prs.read.texture;
  pressureMat.uniforms.uDivergence.value = div.texture;
  pass(pressureMat, prs.write);
  prs.swap();
}
// 그다음 기울기 빼기: vel −= 0.5·(R − L, T − B) → 속도 · 잉크 이류 → setRenderTarget(null) 로 화면
```

## 흔한 실수 · 확인 목록
- [ ] **셰이더 안에서 야코비를 for 문으로 돌리면 윈도에서 컴파일이 수 초 멈춘다** — 반복은 패스를 여러 번 그리는 것으로. 셰이더는 짧고 한 종류씩.
- [ ] **8비트 렌더 타깃을 쓰면 속도가 −1 ~ 1 밖에서 잘려 흐름이 멈춘다** — HalfFloatType 렌더 타깃. 속도는 「칸/초」 단위 수백 ~ 수천 값이 그대로 담긴다.
- [ ] **화면에 그린 뒤 렌더 타깃을 되돌리지 않으면 같은 renderer 를 쓰는 다른 장면이 엉뚱한 판에 그려진다** — render() 끝에서 setRenderTarget(이전 값) · autoClear 를 원래대로.
- [ ] **압력을 매 걸음 0 에서 시작하면 반복 수가 모자라 흐름이 눌린다** — 지난 압력에 0.8 을 곱해 첫 값으로 — 같은 반복 수로 훨씬 잘 맞는다.
- [ ] **느린 기기에서 dt 를 잘라 쓰면 유체가 슬로모션이 된다** — 실제 지난 시간(0.25초까지)을 1/60초 조각으로 나눠 걸음을 여러 번.

## 완성 기준 체크리스트
- [ ] 카드에서는 저절로 도는 붓 셋이 무지개 잉크를 풀고, 잉크가 소용돌이치며 섞인다
- [ ] 크게 보기에서 끌면 그 방향으로 잉크가 밀려가며 버섯 모양 소용돌이가 생긴다
- [ ] 압력 반복 수를 1 로 내리면 잉크가 한쪽에 몰리거나 퍼져 나가는 차이가 보이고, 소용돌이 세기 0 ↔ 60 으로 잔 소용돌이가 사라졌다 살아난다
- [ ] 「속도장 보기」로 칸마다 흰 화살표 + 방향 색이 보이고, 해상도 128 ↔ 256 을 바꾸면 결이 고와진다

## 이 기술 정보
- id: `i46` · 분류: 물리 · 수학 원리 › 물리 · 시뮬레이션 · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 256 × 455 격자 ≈ 11만 텍셀 × 한 걸음 패스 33번(압력 25번 포함) + 잉크 512 × 910 판 이류. PC 는 1ms 안쪽, 폰(작은 화면)은 격자 128 · 잉크 256 으로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i46
- 쓰면 좋을 때: 손가락으로 저어 보는 체험 · 감탄 화면 (큰 화면도 매끄럽게) / 바람 · 연기 · 대류처럼 흐름 자체를 보여 줄 때 / CPU 유체(격자 수천 칸)로는 화면이 거칠 때
- 쓰지 말 때: 물이 출렁이는 수조 · 물보라 — 입자 물(i47)이 맞다 / 물결 · 파문 — 높이 지도 파동(i48)이 훨씬 싸다 / 렌더 타깃 실수 텍스처를 못 쓰는 아주 오래된 기기 — CPU 작은 격자로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### FL_FRAG — `src/demos/demosSim.ts:1332`
```ts
const FL_FRAG = {
  /** 가우스 모양으로 값 더하기 (잉크 색 또는 힘) */
  splat: `
uniform sampler2D uTarget;
uniform float aspect;
uniform vec3 color;
uniform vec2 point;
uniform float radius;
void main() {
  vec2 p = vUv - point;
  p.x *= aspect;
  vec3 s = exp(-dot(p, p) / radius) * color;
  gl_FragColor = vec4(texture2D(uTarget, vUv).xyz + s, 1.0);
}`,
  /** 이류 — 속도를 거꾸로 따라가 한 걸음 전 자리의 값을 가져온다 (semi-Lagrangian) */
  advect: `
uniform sampler2D uVelocity;
uniform sampler2D uSource;
uniform vec2 simTexel;
uniform float dt;
uniform float dissipation;
void main() {
  vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * simTexel;
  gl_FragColor = texture2D(uSource, coord) / (1.0 + dissipation * dt);
}`,
  /** 발산 — 벽에서는 벽에 수직인 속도를 뒤집어 물이 새지 않게 */
  divergence: `
uniform sampler2D uVelocity;
void main() {
  float L = texture2D(uVelocity, vL).x;
  float R = texture2D(uVelocity, vR).x;
  float T = texture2D(uVelocity, vT).y;
  float B = texture2D(uVelocity, vB).y;
  vec2 C = texture2D(uVelocity, vUv).xy;
  if (vL.x < 0.0) L = -C.x;
  if (vR.x > 1.0) R = -C.x;
  if (vT.y > 1.0) T = -C.y;
  if (vB.y < 0.0) B = -C.y;
  gl_FragColor = vec4(0.5 * (R - L + T - B), 0.0, 0.0, 1.0);
}`,
  /** 회전량 (2D 와도) */
  curl: `
uniform sampler2D uVelocity;
void main() {
  float L = texture2D(uVelocity, vL).y;
  float R = texture2D(uVelocity, vR).y;
  float T = texture2D(uVelocity, vT).x;
  float B = texture2D(uVelocity, vB).x;
  gl_FragColor = vec4(0.5 * (R - L - T + B), 0.0, 0.0, 1.0);
}`,
  /** 소용돌이 살리기 — 회전량이 큰 쪽으로 작은 힘 (vorticity confinement) */
  vorticity: `
uniform sampler2D uVelocity;
uniform sampler2D uCurl;
uniform float curl;
uniform float dt;
void main() {
  float L = texture2D(uCurl, vL).x;
  float R = texture2D(uCurl, vR).x;
  float T = texture2D(uCurl, vT).x;
  float B = texture2D(uCurl, vB).x;
  float C = texture2D(uCurl, vUv).x;
  vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));
  force /= length(force) + 0.0001;
  force *= curl * C;
  force.y *= -1.0;
  vec2 vel = texture2D(uVelocity, vUv).xy + force * dt;
  gl_FragColor = vec4(clamp(vel, -2000.0, 2000.0), 0.0, 1.0);
}`,
  /** 압력 야코비 한 번 — 이웃 넷의 평균에서 발산을 뺀다 */
  pressure: `
uniform sampler2D uPressure;
uniform sampler2D uDivergence;
void main() {
  float L = texture2D(uPressure, vL).x;
  float R = texture2D(uPressure, vR).x;
  float T = texture2D(uPressure, vT).x;
  float B = texture2D(uPressure, vB).x;
  float div = texture2D(uDivergence, vUv).x;
  gl_FragColor = vec4((L + R + B + T - div) * 0.25, 0.0, 0.0, 1.0);
}`,
  /** 기울기 빼기 — 압력이 높은 곳에서 낮은 곳으로 밀어 안 눌리는 흐름으로 */
  gradient: `
uniform sampler2D uPressure;
uniform sampler2D uVelocity;
void main() {
  float L = texture2D(uPressure, vL).x;
  float R = texture2D(uPressure, vR).x;
  float T = texture2D(uPressure, vT).x;
  float B = texture2D(uPressure, vB).x;
  vec2 vel = texture2D(uVelocity, vUv).xy - 0.5 * vec2(R - L, T - B);
  gl_FragColor = vec4(vel, 0.0, 1.0);
}`,
  /** 값에 곱하기 (압력을 다음 걸음 첫 값으로 조금 남김) */
  scale: `
uniform sampler2D uTexture;
uniform float value;
void main() {
  gl_FragColor = value * texture2D(uTexture, vUv);
}`,
  /** 화면 — 잉크 + 결 그늘, 「속도장 보기」면 방향 색 + 칸마다 화살표 (반복문 없음) */
  display: `
uniform sampler2D uTexture;
uniform sampler2D uVelocity;
uniform vec2 dyeTexel;
uniform float showVel;
uniform float aspect;
uniform vec3 bgTop;
uniform vec3 bgBot;
void main() {
  vec3 c = texture2D(uTexture, vUv).rgb;
  vec3 lc = texture2D(uTexture, vUv - vec2(dyeTexel.x, 0.0)).rgb;
  vec3 rc = texture2D(uTexture, vUv + vec2(dyeTexel.x, 0.0)).rgb;
  vec3 tc = texture2D(uTexture, vUv + vec2(0.0, dyeTexel.y)).rgb;
  vec3 bc = texture2D(uTexture, vUv - vec2(0.0, dyeTexel.y)).rgb;
  vec3 n = normalize(vec3(length(rc) - length(lc), length(tc) - length(bc), length(dyeTexel)));
  c *= clamp(n.z + 0.7, 0.7, 1.0);
  c = vec3(1.0) - exp(-c * 1.6);
  float a = clamp(max(c.r, max(c.g, c.b)) * 1.2, 0.0, 1.0);
  vec3 col = c + mix(bgBot, bgTop, vUv.y) * (1.0 - a);
  if (showVel > 0.5) {
    vec2 v = texture2D(uVelocity, vUv).xy;
    float sp = length(v);
    vec2 dir = v / (sp + 0.0001);
    vec3 vc = vec3(0.5 + 0.5 * dir.x, 0.5 + 0.5 * dir.y, 0.75 - 0.35 * (dir.x + dir.y) * 0.5) * clamp(sp / 120.0, 0.0, 1.0);
    col = col * 0.3 + vc * 0.8;
    vec2 grid = vec2(26.0 * aspect, 26.0);
    vec2 cc = (floor(vUv * grid) + 0.5) / grid;
    vec2 cv = texture2D(uVelocity, cc).xy;
    float cl = length(cv);
    vec2 cd = cv / (cl + 0.0001);
    float len = clamp(cl / 140.0, 0.0, 1.0) * 0.9;
    vec2 q = (vUv - cc) * grid;
    vec2 a0 = -cd * len * 0.5;
    vec2 ba = cd * len;
    float hh = clamp(dot(q - a0, ba) / max(dot(ba, ba), 1e-5), 0.0, 1.0);
    float line = smoothstep(0.075, 0.03, length(q - a0 - ba * hh));
    float head = smoothstep(0.15, 0.09, length(q - a0 - ba));
    col = mix(col, vec3(1.0), max(line, head * step(0.15, len)) * step(0.06, len));
  }
  gl_FragColor = vec4(col, 1.0);
}`,
};
```

### makeFluidDemo — `src/demos/demosSim.ts:1544`
```ts
function makeFluidDemo(): Scene3D {
  const scene = new THREE.Scene();
  const cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2));
  quad.frustumCulled = false;
  scene.add(quad);
  const U = (v: unknown): { value: unknown } => ({ value: v });
  const mats = {} as Record<FlPass, THREE.ShaderMaterial>;
  for (const k of Object.keys(FL_FRAG) as FlPass[]) {
    mats[k] = new THREE.ShaderMaterial({
      vertexShader: FL_VERT,
      fragmentShader: FL_HEAD + FL_FRAG[k],
      uniforms: {
        texel: U(new THREE.Vector2()),
        uTarget: U(null),
        uVelocity: U(null),
        uSource: U(null),
        uCurl: U(null),
        uPressure: U(null),
        uDivergence: U(null),
        uTexture: U(null),
        aspect: U(1),
        color: U(new THREE.Vector3()),
        point: U(new THREE.Vector2()),
        radius: U(0.002),
        simTexel: U(new THREE.Vector2()),
        dt: U(0.016),
        dissipation: U(0),
        curl: U(30),
        value: U(0.8),
        dyeTexel: U(new THREE.Vector2()),
        showVel: U(0),
        bgTop: U(new THREE.Vector3(0.03, 0.04, 0.09)),
        bgBot: U(new THREE.Vector3(0.0, 0.0, 0.02)),
      },
      depthTest: false,
      depthWrite: false,
    });
  }
  mats.display.toneMapped = false;

  // 조절 값
  let simRes = 256;
  let iters = 25;
  let curlK = 30;
  let showVel = false;
  let big = false;

  let simW = 0;
  let simH = 0;
  let dyeW = 0;
  let dyeH = 0;
  let vel!: FlDouble;
  let dye!: FlDouble;
  let prs!: FlDouble;
  let div!: THREE.WebGLRenderTarget;
  let crl!: THREE.WebGLRenderTarget;
  const RT = (w: number, h: number): THREE.WebGLRenderTarget =>
    new THREE.WebGLRenderTarget(w, h, {
      type: THREE.HalfFloatType,
      format: THREE.RGBAFormat,
      minFilter: THREE.LinearFilter,
      magFilter: THREE.LinearFilter,
      wrapS: THREE.ClampToEdgeWrapping,
      wrapT: THREE.ClampToEdgeWrapping,
      depthBuffer: false,
      stencilBuffer: false,
    });
  const dbl = (w: number, h: number): FlDouble => {
    const o = {
      read: RT(w, h),
      write: RT(w, h),
      swap() {
        const t = o.read;
        o.read = o.write;
        o.write = t;
      },
    };
    return o;
  };
  const freeTargets = (): void => {
    if (!vel) return;
    for (const d of [vel, dye, prs]) {
      d.read.dispose();
      d.write.dispose();
    }
    div.dispose();
    crl.dispose();
  };
  let fresh = true;
  /** 화면 비율 · 해상도에 맞춰 판 만들기 (바뀔 때만) */
  const ensure = (aspect: number): void => {
    const res = big ? simRes : 128;
    const sh = res;
    const sw = Math.max(16, Math.round(res * aspect));
    const dh = big ? 512 : 256;
    const dw = Math.max(16, Math.round(dh * aspect));
    if (sw === simW && sh === simH && dw === dyeW && dh === dyeH) return;
    freeTargets();
    simW = sw;
    simH = sh;
    dyeW = dw;
    dyeH = dh;
    vel = dbl(sw, sh);
    prs = dbl(sw, sh);
    div = RT(sw, sh);
    crl = RT(sw, sh);
    dye = dbl(dw, dh);
    fresh = true;
  };

  let R!: THREE.WebGLRenderer;
  const pass = (k: FlPass, target: THREE.WebGLRenderTarget | null, set: Record<string, unknown>, tw: number, th: number): void => {
    const m = mats[k];
    for (const [n, v] of Object.entries(set)) m.uniforms[n]!.value = v;
    (m.uniforms.texel!.value as THREE.Vector2).set(1 / tw, 1 / th);
    quad.material = m;
    R.setRenderTarget(target);
    R.render(scene, cam);
  };
  const tmpC = new THREE.Vector3();
  const tmpP = new THREE.Vector2();
  /** 한 점에 잉크 · 힘 (x, y 는 0~1, fx fy 는 칸/초) */
  const splat = (x: number, y: number, fx: number, fy: number, col: THREE.Color, rad: number): void => {
    const aspect = simW / simH;
    const r = (rad / 100) * (aspect > 1 ? aspect : 1);
    tmpP.set(x, y);
    pass('splat', vel.write, { uTarget: vel.read.texture, aspect, point: tmpP, color: tmpC.set(fx, fy, 0), radius: r }, simW, simH);
    vel.swap();
    pass('splat', dye.write, { uTarget: dye.read.texture, aspect, point: tmpP, color: tmpC.set(col.r, col.g, col.b), radius: r }, dyeW, dyeH);
    dye.swap();
  };
  const simTexel = new THREE.Vector2();
  const step = (dt: number): void => {
    const sw = simW;
    const sh = simH;
    simTexel.set(1 / sw, 1 / sh);
    pass('curl', crl, { uVelocity: vel.read.texture }, sw, sh);
    pass('vorticity', vel.write, { uVelocity: vel.read.texture, uCurl: crl.texture, curl: curlK, dt }, sw, sh);
    vel.swap();
    pass('divergence', div, { uVelocity: vel.read.texture }, sw, sh);
    pass('scale', prs.write, { uTexture: prs.read.texture, value: 0.8 }, sw, sh);
    prs.swap();
    for (let i = 0; i < iters; i++) {
      pass('pressure', prs.write, { uPressure: prs.read.texture, uDivergence: div.texture }, sw, sh);
      prs.swap();
    }
    pass('gradient', vel.write, { uPressure: prs.read.texture, uVelocity: vel.read.texture }, sw, sh);
    vel.swap();
    pass('advect', vel.write, { uVelocity: vel.read.texture, uSource: vel.read.texture, simTexel, dt, dissipation: 0.25 }, sw, sh);
    vel.swap();
    pass('advect', dye.write, { uVelocity: vel.read.texture, uSource: dye.read.texture, simTexel, dt, dissipation: 0.9 }, dyeW, dyeH);
    dye.swap();
  };
  const clearAll = (): void => {
    for (const d of [vel, dye, prs]) {
      pass('scale', d.write, { uTexture: d.read.texture, value: 0 }, simW, simH);
      d.swap();
    }
  };

  const ptr = new FluidPointer();
  const col = new THREE.Color();
  const dyeTexel = new THREE.Vector2();
  const overlay = new Overlay();
  let tags: Tag[] = [];
  let last = -1;
  let time = 0;
  let wantClear = false;
  return {
    scene,
    camera: cam,
    tone: THREE.NoToneMapping,
    update(t) {
      time = t;
    },
    render(r, w, h) {
      R = r;
      big = w >= 700;
      ptr.attach(w, h);
      const now = performance.now();
      // 실제 지난 시간 (0.25초까지) — 1/60초보다 길면 걸음을 나눈다
      const rdt = last < 0 ? 1 / 60 : clamp((now - last) / 1000, 0.001, 0.25);
      last = now;
      ptr.idle += rdt;
      const prevRT = r.getRenderTarget();
      const prevAuto = r.autoClear;
      r.autoClear = false;
      ensure(w / h);
      const k = simRes / 128;
      if (wantClear) {
        wantClear = false;
        clearAll();
      }
      if (fresh) {
        fresh = false;
        clearAll();
        for (let i = 0; i < 6; i++) {
          col.setHSL(Math.random(), 1, 0.5).multiplyScalar(0.9);
          const a = Math.random() * TAU;
          splat(0.15 + Math.random() * 0.7, 0.15 + Math.random() * 0.7, Math.cos(a) * 900 * (big ? k : 1), Math.sin(a) * 900 * (big ? k : 1), col, 0.35);
        }
      }
      // 손으로 젓기 (크게 보기) — 끈 만큼 힘, 누른 자리에 잉크
      const fk = (big ? k : 1) * 5200;
      if (ptr.pressed) {
        ptr.pressed = false;
        col.setHSL((time * 0.13) % 1, 1, 0.5).multiplyScalar(1.1);
        splat(ptr.x, ptr.y, 0, 0, col, 0.4);
      }
      if (ptr.moved) {
        ptr.moved = false;
        col.setHSL((time * 0.13) % 1, 1, 0.5).multiplyScalar(0.7);
        splat(ptr.x, ptr.y, ptr.dx * fk * (w / h), ptr.dy * fk, col, 0.25);
        ptr.dx = ptr.dy = 0;
      }
      // 저절로 젓는 붓 셋 (카드 · 손을 뗀 지 3초 뒤)
      if (!big || ptr.idle > 3) {
        for (let i = 0; i < 3; i++) {
          const a = time * 0.55 + (i * TAU) / 3;
          const x = 0.5 + Math.cos(a) * 0.3;
          const y = 0.5 + Math.sin(a * 1.37 + i) * 0.28;
          const vx = -Math.sin(a) * 0.3 * 0.55;
          const vy = Math.cos(a * 1.37 + i) * 0.28 * 0.55 * 1.37;
          col.setHSL((time * 0.05 + i / 3) % 1, 1, 0.5).multiplyScalar(0.12 * rdt * 60);
          splat(x, y, vx * 700 * (big ? k : 1), vy * 700 * (big ? k : 1), col, 0.18);
        }
      }
      const n = Math.min(4, Math.ceil(rdt / (1 / 60) - 0.01));
      for (let i = 0; i < n; i++) step(rdt / n);
      // 화면
      r.setRenderTarget(null);
      dyeTexel.set(1 / dyeW, 1 / dyeH);
      pass('display', null, { uTexture: dye.read.texture, uVelocity: vel.read.texture, dyeTexel, showVel: showVel ? 1 : 0, aspect: w / h }, w, h);
      tags = [
        { text: `GPU 격자 ${simW}×${simH} · 잉크 ${dyeW}×${dyeH} · 압력 야코비 ${iters}번 · 걸음 ${n}`, x: 0.015, y: 0.03, ax: 0, ay: 0 },
      ];
      if (big && ptr.idle > 3) tags.push({ text: '끌어서 저어 보세요', x: 0.5, y: 0.96, ax: 0.5, ay: 1, big: true });
      overlay.draw(r, w, h, tags);
      r.autoClear = prevAuto;
      r.setRenderTarget(prevRT);
    },
    controls: [
      { type: 'range', label: '해상도 (속도 격자 세로 칸)', min: 128, max: 256, step: 128, value: 256, on: (v) => (simRes = v) },
      { type: 'range', label: '압력 반복 수 (야코비)', min: 1, max: 40, step: 1, value: 25, on: (v) => (iters = v) },
      { type: 'range', label: '소용돌이 세기 (vorticity)', min: 0, max: 60, step: 1, value: 30, on: (v) => (curlK = v) },
      { type: 'toggle', label: '속도장 보기', value: false, on: (v) => (showVel = v) },
      { type: 'button', label: '맑은 물로', on: () => (wantClear = true) },
    ],
    dispose() {
      ptr.dispose();
      overlay.dispose();
      freeTargets();
      for (const m of Object.values(mats)) m.dispose();
      quad.geometry.dispose();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [GPU 입자 (수만 개)](https://ai-techstudio.web.app/ai/t/i22.md) `i22`
- 다음에 해 볼 기술: [SPH · 입자 물](https://ai-techstudio.web.app/ai/t/i47.md) `i47` · [물결 파동 (높이 지도)](https://ai-techstudio.web.app/ai/t/i48.md) `i48`
- 참고 문서: [GPU Gems — Chapter 38. Fast Fluid Dynamics Simulation on the GPU](https://developer.nvidia.com/gpugems/gpugems/part-vi-beyond-triangles/chapter-38-fast-fluid-dynamics-simulation-gpu) · [Jos Stam — Real-Time Fluid Dynamics for Games (PDF)](https://www.dgp.toronto.edu/public_user/stam/reality/Research/pdf/GDC03.pdf) · [WebGL Fluid Simulation (Pavel Dobryakov)](https://paveldogreat.github.io/WebGL-Fluid-Simulation/)
