# AI 꾸러미 — 흐르는 강물 셰이더 — Flowing water shader
> 겹친 잡음 높이를 흐름 방향으로 흘리고 그 기울기로 물결 면을 만들어, 프레넬 하늘 반사 · 햇빛 반짝 · 기슭 거품이 있는 강물을 그린다.  
> 견본: https://ai-techstudio.web.app/#t/u22

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

## 주문서

### 만들어 줘: 흐르는 강물 셰이더 — Flowing water shader

#### 1. 목표
강 건너기 게임의 강에 흐르는 강물 셰이더를 넣어 줘 — 잔물결 · 하늘 반사 · 햇빛 반짝 · 기슭 거품, 느낌은 맑고 밝은 그림책.

#### 2. 핵심 기술 용어
- **Flowing water shader** — 흐르는 강물 셰이더
- **Normal from height gradient (finite difference)** — 높이 차로 물결 면 방향 만들기
- **Fresnel (Schlick)** — 비스듬히 볼수록 하늘이 많이 비침
- **Shore foam mask** — 기슭 · 배 둘레 흰 거품

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

#### 4. 조건
- 물결 높이는 흐름 방향으로만 흘린다 (vec2(0.0, -uTime * 빠르기)) — 둥둥 떠 보이지 않게
- 법선은 높이 차로: N = normalize(vec3(-hx/e × 0.09, 1, -hz/e × 0.09)), e = 0.02
- 기슭 얕음 · 가운데 깊음은 강 폭 좌표(x / 반폭)로, 투명도도 0.45 ~ 0.86 으로 깊이 따라
- transparent · depthWrite false · renderOrder 2 (바닥보다 나중에)
- 조각 셰이더 끝에 tonemapping_fragment · colorspace_fragment 를 넣어 다른 재질과 색을 맞춘다

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽 일반 재질 · 오른쪽 강물 셰이더를 나란히 두면, 오른쪽 물만 흐르는 잔물결 · 하늘 반사 · 반짝이가 보인다
- 물결이 한 방향으로 흘러가고, 기슭 쪽은 밝은 청록 · 가운데는 짙은 파랑이다
- 양쪽 기슭과 배 둘레에 흰 거품이 흐르며 생긴다
- 폰에서 화면 절반이 물이어도 30fps 이상

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

## 원리
- 물결 높이 = 잡음 세 겹을 서로 다른 빠르기로 흐름 방향(-z)으로 민 것 (fbm 0.55 · fbm 1.05 · 잡음 2.0).
- 옆(0.02)으로 조금 옮긴 높이와의 차로 면 기울기를 구해 법선을 만든다 — 진짜로 면을 휘지 않아도 빛이 물결친다.
- 프레넬: F = 0.03 + 0.97·(1 - N·V)⁵ — 비스듬히 볼수록 하늘색 반사가 세진다. 반사 방향 R 의 높이로 하늘 위 · 아래 색을 섞는다.
- 햇빛 반짝은 R·L 의 140제곱(날카로운 점) + 18제곱(옅은 번짐). 기슭은 얕아 밝고 투명하게, 가운데는 깊고 짙게, 가장자리에는 흐르는 거품.

## 핵심 코드 — 흐르는 물결 → 법선 → 프레넬 · 반짝 · 거품
(발췌: demos/snap/riverWater.ts createWater() 조각 셰이더를 정리 (원본 rivercross/water.ts))
```glsl
// noise() · fbm(4겹) 은 이 위에. vXZ = 물 판 좌표, uHalfW = 강 반폭
float height(vec2 p){
  float a = fbm(p * 1.5 + vec2(0.0, -uTime * 0.55));
  float b = fbm(p * 3.4 + vec2(2.0, -uTime * 1.05));
  float c = noise(p * 9.0 + vec2(0.0, -uTime * 2.0));
  return a * 0.6 + b * 0.32 + c * 0.08;
}
void main(){
  vec2 p = vXZ; float e = 0.02;
  float h = height(p);
  float hx = height(p + vec2(e, 0.0)) - h, hz = height(p + vec2(0.0, e)) - h;
  vec3 N = normalize(vec3(-hx / e * 0.09, 1.0, -hz / e * 0.09));
  vec3 V = normalize(cameraPosition - vWorld);
  float F = 0.03 + 0.97 * pow(1.0 - max(dot(N, V), 0.0), 5.0);
  float edge = 1.0 - abs(p.x) / uHalfW;             // 기슭 0 · 가운데 1
  float deep = smoothstep(0.0, 0.7, edge) * 0.9;
  vec3 base = mix(lin(vec3(0.52, 0.86, 0.84)), lin(vec3(0.16, 0.55, 0.74)), deep);
  vec3 R = reflect(-V, N);
  vec3 sky = mix(lin(vec3(0.86, 0.93, 0.98)), lin(vec3(0.45, 0.68, 0.95)), clamp(R.y * 1.2, 0.0, 1.0));
  vec3 col = mix(base, sky, clamp(F * 1.1, 0.0, 0.85));
  vec3 L = normalize(vec3(-0.45, 0.8, 0.4));
  col += vec3(1.0, 0.97, 0.9) * (pow(max(dot(R, L), 0.0), 140.0) * 2.2 + pow(max(dot(R, L), 0.0), 18.0) * 0.12);
  float shore = 1.0 - smoothstep(0.0, 0.1, edge);
  float foam = shore * smoothstep(0.38, 0.62, fbm(p * 7.0 + vec2(0.0, -uTime * 1.6)) + shore * 0.25);
  col = mix(col, lin(vec3(0.97, 0.99, 1.0)), foam);
  gl_FragColor = vec4(col, clamp(mix(0.45, 0.86, deep) + F * 0.3 + foam * 0.4, 0.0, 1.0));
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}
```

## 흔한 실수 · 확인 목록
- [ ] **잡음 반복문을 여러 번 부르는 셰이더가 첫 컴파일에 오래 멈춘다** — 윈도 D3D 에서 반복문 잡음 셰이더가 17~20초 걸린 일이 있다. 무거우면 잡음을 미리 구운 텍스처로 읽는다.
- [ ] **색을 sRGB 값 그대로 섞으면 물빛이 뿌옇게 바랜다** — 견본은 lin() = pow(c, 2.2) 로 선형으로 바꿔 섞고, 끝에 colorspace_fragment 로 되돌린다.
- [ ] **물에 depthWrite 를 켜면 물속 바닥 · 배 아랫부분이 사라진다** — 반투명 물은 depthWrite false, 바닥을 먼저 그리게 renderOrder 를 크게.

## 완성 기준 체크리스트
- [ ] 왼쪽 일반 재질 · 오른쪽 강물 셰이더를 나란히 두면, 오른쪽 물만 흐르는 잔물결 · 하늘 반사 · 반짝이가 보인다
- [ ] 물결이 한 방향으로 흘러가고, 기슭 쪽은 밝은 청록 · 가운데는 짙은 파랑이다
- [ ] 양쪽 기슭과 배 둘레에 흰 거품이 흐르며 생긴다
- [ ] 폰에서 화면 절반이 물이어도 30fps 이상

## 이 기술 정보
- id: `u22` · 분류: 그래픽 · 셰이더 › 셰이더 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 픽셀마다 높이를 3번 (기울기 계산) — 높이 하나에 fbm 2번 + 잡음 1번, 거품 fbm 4번. 화면 대부분이 물이면 폰에서 무겁다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u22
- 쓰면 좋을 때: 강 · 개울처럼 한 방향으로 흐르는 물 / 위에서 비스듬히 내려다보는 판 게임의 물 / 배 · 통나무가 떠 가는 장면
- 쓰지 말 때: 고인 연못 · 수조 — 대신 수면 셰이더(흐름 없음) / 넓은 바다 — 대신 파도 높이를 정점에서 움직이는 바다(u26 계열) / 툰 그림체 — 대신 툰 물(i222)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### riverDemo — `src/demos/demosShader.ts:245`
```ts
function riverDemo(): Scene3D {
  const scene = new THREE.Scene();
  const bg = gradTex([
    [0, '#5eaee8'],
    [1, '#d6efff'],
  ]);
  scene.background = bg;
  lights(scene);
  const peb = new THREE.CanvasTexture(pebbleCanvas());
  peb.wrapS = peb.wrapT = THREE.RepeatWrapping;
  const W = createWater(1.3, 3.4, 0, -0.3, peb);
  scene.add(W.surface, W.bed);
  const shaderMat = W.surface.material as THREE.Material;
  const plainMat = new THREE.MeshStandardMaterial({ color: 0x3f8fd2, roughness: 0.35, transparent: true, opacity: 0.88 });
  const grass = new THREE.MeshStandardMaterial({ color: 0x7cc456, roughness: 0.9 });
  const sand = new THREE.MeshStandardMaterial({ color: 0xd9bd84, roughness: 1 });
  const rock = new THREE.MeshStandardMaterial({ color: 0x9a9aa6, roughness: 0.8, flatShading: true });
  const leaf = new THREE.MeshStandardMaterial({ color: 0x3f9d4a, roughness: 0.8, flatShading: true });
  const wood = new THREE.MeshStandardMaterial({ color: 0xa0683a, roughness: 0.8 });
  const world = new THREE.Group();
  for (const s of [-1, 1]) {
    const bank = new THREE.Mesh(new THREE.BoxGeometry(3, 0.6, 7), grass);
    bank.position.set(s * (1.3 + 1.5), -0.17, 0);
    const sl = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.12, 7), sand);
    sl.position.set(s * 1.32, -0.06, 0);
    sl.rotation.z = s * 0.38;
    world.add(bank, sl);
    for (let k = 0; k < 3; k++) {
      const r = new THREE.Mesh(new THREE.DodecahedronGeometry(0.14 + k * 0.04), rock);
      r.position.set(s * (1.7 + k * 0.35), 0.15, -1.6 + k * 1.4 + (s > 0 ? 0.6 : 0));
      world.add(r);
      const tr = new THREE.Group();
      const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.07, 0.35, 8), wood);
      trunk.position.y = 0.3;
      const top = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.7, 7), leaf);
      top.position.y = 0.75;
      tr.add(trunk, top);
      tr.position.set(s * (2.3 + (k % 2) * 0.5), 0.1, -2.5 + k * 1.7);
      world.add(tr);
    }
  }
  const boat = new THREE.Group();
  const hull = new THREE.Mesh(new THREE.BoxGeometry(0.48, 0.16, 1.0), wood);
  const seat = new THREE.Mesh(new THREE.BoxGeometry(0.44, 0.04, 0.16), new THREE.MeshStandardMaterial({ color: 0xf2d39a }));
  seat.position.y = 0.08;
  const kid = new THREE.Mesh(new THREE.SphereGeometry(0.12, 16, 12), new THREE.MeshStandardMaterial({ color: 0xff7a6a }));
  kid.position.set(0, 0.2, 0.15);
  boat.add(hull, seat, kid);
  world.add(boat);
  scene.add(world);
  const cam = new THREE.PerspectiveCamera(38, 1, 0.1, 60);
  cam.position.set(2.6, 3.6, 4.6);
  cam.lookAt(0, -0.2, 0.2);
  const tags = new Tags(['일반 재질', '물 셰이더']);
  let split = true;
  return {
    scene,
    camera: cam,
    update(t) {
      const bx = Math.sin(t * 0.45) * 0.6;
      boat.position.set(bx, 0.03 + Math.sin(t * 2.2) * 0.015, 0.4);
      boat.rotation.z = Math.sin(t * 1.7) * 0.05;
      W.update(t, bx);
    },
    render(r, w, h) {
      if (!split) {
        W.surface.material = shaderMat;
        r.render(scene, cam);
        return;
      }
      splitRender(
        r,
        w,
        h,
        cam,
        () => {
          W.surface.material = plainMat;
          r.render(scene, cam);
        },
        () => {
          W.surface.material = shaderMat;
          r.render(scene, cam);
        },
      );
      tags.draw(r, w, h, [
        [0.25, 0.08],
        [0.75, 0.08],
      ]);
    },
    controls: [{ type: 'toggle', label: '반씩 비교', value: true, on: (v) => (split = v) }],
    dispose() {
      W.surface.material = shaderMat;
      W.dispose();
      peb.dispose();
      plainMat.dispose();
      bg.dispose();
      freeAll(world);
      tags.dispose();
    },
  };
}
```

### u22 견본 항목 — `src/demos/demosShader.ts:2379`
```ts
  u22: { kind: '3d', caption: '왼쪽 일반 재질 · 오른쪽 강물 셰이더 — 잔물결 · 하늘 반사 · 햇빛 반짝 · 기슭과 배 둘레 거품', make: () => riverDemo() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [재질에 셰이더 끼워 넣기](https://ai-techstudio.web.app/ai/t/u08.md) `u08` · [테두리 빛 (rim light, 프레넬)](https://ai-techstudio.web.app/ai/t/i10.md) `i10`
- 다음에 해 볼 기술: [물 바닥 빛무늬 (커스틱)](https://ai-techstudio.web.app/ai/t/u23.md) `u23` · [툰 물 (기슭 거품 테)](https://ai-techstudio.web.app/ai/t/i222.md) `i222`
- 참고 문서: [Wikipedia — Schlick's approximation](https://en.wikipedia.org/wiki/Schlick%27s_approximation)
