# AI 꾸러미 — 기울여도 평평한 물 — World-space plane clipping (discard)
> 통 안 물 상자에서 세계 높이 y = h 위쪽 픽셀을 버리고 뒷면을 수면으로 칠해, 통이 기울어도 물 표면은 늘 수평으로 남게 한다.  
> 견본: https://ai-techstudio.web.app/#t/u25

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

## 주문서

### 만들어 줘: 기울여도 평평한 물 — World-space plane clipping (discard)

#### 1. 목표
기울어지는 유리 수조 안의 물을 셰이더로 잘라 줘 — 그릇이 기울어도 수면은 늘 수평, 느낌은 맑은 과학 실험.

#### 2. 핵심 기술 용어
- **World-space plane clipping (discard)** — 세계 좌표 평면 위를 잘라 버리기
- **Back-face cap (gl_FrontFacing)** — 잘린 구멍으로 보이는 뒷면을 뚜껑(수면)으로 칠하기
- **Liquid in tilted container** — 기울어진 그릇 속 액체

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

#### 4. 조건
- 물 재질은 side: DoubleSide — 뒷면이 그려져야 수면 뚜껑이 생긴다
- 자르기는 세계 좌표(vW.y - uLevel)로 — 물체 좌표로 하면 통과 같이 기운다
- 뒷면(gl_FrontFacing 거짓)은 수면 색, 잔물결 잡음 좌표도 세계 xz
- 물 상자는 유리보다 조금 작게(-0.04), 유리는 투명 · depthWrite false · renderOrder 2
- 수면 높이 uLevel 은 매 프레임 통 위치로 갱신 (통이 움직이면)

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽(물체 좌표로 자름)은 물이 통과 같이 기울고, 오른쪽(세계 좌표)은 수면이 늘 수평이다
- 「기울기」를 0 ~ 1.4 로 올려도 오른쪽 수면은 바닥과 평행하고 잔물결이 일렁인다
- 수면 바로 아래에 밝은 물 테두리 띠가 보인다
- 폰에서도 60fps

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

## 원리
- 물은 통 안에 딱 맞는 상자 하나다. 통과 같이 돌지만, 조각 셰이더에서 세계 좌표 높이가 수면 h 보다 크면 그 픽셀을 버린다.
- 그러면 위가 잘린 상자가 되는데, 잘린 구멍으로 상자 안쪽 뒷면이 보인다.
- 그 뒷면(gl_FrontFacing 이 거짓)을 수면 색 + 잔물결 잡음으로 칠하면 뚜껑처럼 평평한 수면이 생긴다.
- 앞면은 수면에서 깊을수록 짙은 파랑으로, 수면 바로 아래 0.035 띠는 밝게 해 물 테두리를 낸다.

## 핵심 코드 — 세계 높이로 자르고 뒷면을 수면으로
(발췌: demos/demosShader.ts flatWaterDemo() mkWater() 조각 셰이더 (켬 모드))
```glsl
// 정점: vL = position; vW = 세계 위치; vN = 세계 법선. noise() 는 이 위에
uniform float uLevel; uniform float uTime; uniform vec3 uDeep; uniform vec3 uTop;
varying vec3 vW; varying vec3 vL; varying vec3 vN;
void main(){
  float hgt = vW.y - uLevel;        // 수면 = 세계 평면 y = uLevel
  if (hgt > 0.0) discard;           // 그 위는 버린다
  vec3 col;
  if (gl_FrontFacing) {
    vec3 V = normalize(cameraPosition - vW);
    float F = pow(1.0 - abs(dot(normalize(vN), V)), 2.0);
    col = mix(uDeep, uTop, clamp(1.0 + hgt * 1.3, 0.0, 1.0)) + vec3(0.25, 0.35, 0.4) * F;
    col += vec3(0.85, 0.97, 1.0) * smoothstep(-0.035, 0.0, hgt) * 0.8; // 수면 바로 아래 밝은 띠
  } else {
    // 잘린 구멍으로 보이는 뒷면 = 수면 뚜껑
    vec2 q = vW.xz;
    float rip = noise(q * 7.0 + uTime * 0.7) * 0.5 + noise(q * 15.0 - uTime * 1.1) * 0.5;
    col = uTop * (0.95 + rip * 0.35) + vec3(0.08);
  }
  gl_FragColor = vec4(col, 1.0);
  #include <colorspace_fragment>
}
// 재질: side: THREE.DoubleSide · uLevel 은 매 프레임 tank.position.y (통 가운데 = 물 반)
```

## 흔한 실수 · 확인 목록
- [ ] **물체 좌표 y 로 자르면 수면이 통과 같이 기운다** — 자르기는 세계 좌표로. 견본 왼쪽 칸이 바로 그 잘못된 모습이다.
- [ ] **FrontSide 재질이면 수면 뚜껑이 안 생기고 속이 뚫려 보인다** — side: DoubleSide 로 뒷면까지 그리고 gl_FrontFacing 으로 나눠 칠한다.
- [ ] **유리를 물보다 먼저 그리면 물이 가려진다** — 유리는 투명 · depthWrite false · renderOrder 를 물보다 크게.

## 완성 기준 체크리스트
- [ ] 왼쪽(물체 좌표로 자름)은 물이 통과 같이 기울고, 오른쪽(세계 좌표)은 수면이 늘 수평이다
- [ ] 「기울기」를 0 ~ 1.4 로 올려도 오른쪽 수면은 바닥과 평행하고 잔물결이 일렁인다
- [ ] 수면 바로 아래에 밝은 물 테두리 띠가 보인다
- [ ] 폰에서도 60fps

## 이 기술 정보
- id: `u25` · 분류: 그래픽 · 셰이더 › 셰이더 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 물 상자 하나에 픽셀마다 비교 1번 + 잡음 2번(수면만). 폰도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u25
- 쓰면 좋을 때: 그릇이 기울거나 도는 실험 · 체험 (피타고라스 물 실험 등) / 컵 · 병 속 물을 흔들 때 / 물 양(h)을 바꾸는 장면
- 쓰지 말 때: 물이 출렁이며 쏟아져야 할 때 — 대신 입자 · 유체 시뮬레이션 / 모양이 오목한 그릇 — 뒷면 뚜껑이 겹쳐 보일 수 있다 (볼록한 그릇에 맞음)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### flatWaterDemo — `src/demos/demosShader.ts:597`
```ts
function flatWaterDemo(): Scene3D {
  const scene = new THREE.Scene();
  const bg = gradTex([
    [0, '#8fcff4'],
    [1, '#eef9ff'],
  ]);
  scene.background = bg;
  lights(scene, 0xffffff, 0x99aabb, 1.1, 1.5);
  const time = { value: 0 };
  const mkWater = (mode: number): THREE.ShaderMaterial =>
    new THREE.ShaderMaterial({
      uniforms: { uMode: { value: mode }, uLevel: { value: 0 }, uTime: time, uDeep: { value: new THREE.Color(0x1f6fd0) }, uTop: { value: new THREE.Color(0x6fd8f4) } },
      side: THREE.DoubleSide,
      vertexShader: /* glsl */ `varying vec3 vW; varying vec3 vL; varying vec3 vN;
        void main(){ vL = position; vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; vN = normalize(mat3(modelMatrix) * normal); gl_Position = projectionMatrix * viewMatrix * w; }`,
      fragmentShader: /* glsl */ `uniform float uMode; uniform float uLevel; uniform float uTime; uniform vec3 uDeep; uniform vec3 uTop;
        varying vec3 vW; varying vec3 vL; varying vec3 vN;
        ${NOISE2}
        void main(){
          // 수면 = 평면 y = uLevel (세상 좌표) — 그 위는 버린다. 끔이면 통 좌표 y = 0 (통과 같이 기운다)
          float hgt = uMode > 0.5 ? vW.y - uLevel : vL.y;
          if (hgt > 0.0) discard;
          vec3 col;
          if (gl_FrontFacing) {
            vec3 V = normalize(cameraPosition - vW);
            float F = pow(1.0 - abs(dot(normalize(vN), V)), 2.0);
            col = mix(uDeep, uTop, clamp(1.0 + hgt * 1.3, 0.0, 1.0)) + vec3(0.25, 0.35, 0.4) * F;
            col += vec3(0.85, 0.97, 1.0) * smoothstep(-0.035, 0.0, hgt) * 0.8;
          } else {
            // 앞면이 잘린 곳으로 보이는 뒷면 = 수면 뚜껑
            vec2 q = uMode > 0.5 ? vW.xz : vL.xz;
            float rip = noise(q * 7.0 + uTime * 0.7) * 0.5 + noise(q * 15.0 - uTime * 1.1) * 0.5;
            col = uTop * (0.95 + rip * 0.35) + vec3(0.08);
          }
          gl_FragColor = vec4(col, 1.0);
          #include <colorspace_fragment>
        }`,
    });
  const S = { x: 1.0, y: 1.4, z: 0.7 };
  const mats: THREE.ShaderMaterial[] = [];
  const tanks: THREE.Group[] = [];
  const world = new THREE.Group();
  for (const [i, mode] of [
    [0, 0],
    [1, 1],
  ] as const) {
    const tank = new THREE.Group();
    const wm = mkWater(mode);
    mats.push(wm);
    const water = new THREE.Mesh(new THREE.BoxGeometry(S.x - 0.04, S.y - 0.04, S.z - 0.04), wm);
    const glassG = new THREE.BoxGeometry(S.x, S.y, S.z);
    const glass = new THREE.Mesh(glassG, new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.05, transparent: true, opacity: 0.16, depthWrite: false }));
    glass.renderOrder = 2;
    const edges = new THREE.LineSegments(new THREE.EdgesGeometry(glassG), new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.9 }));
    const cap = new THREE.Mesh(new THREE.BoxGeometry(S.x + 0.06, 0.06, S.z + 0.06), new THREE.MeshStandardMaterial({ color: i ? 0x4fc06a : 0xf07a5a, roughness: 0.5 }));
    cap.position.y = -S.y / 2 - 0.03;
    tank.add(water, glass, edges, cap);
    tank.position.set(i ? 1.25 : -1.25, 0.15, 0);
    world.add(tank);
    tanks.push(tank);
  }
  const table = new THREE.Mesh(new THREE.BoxGeometry(6, 0.2, 2.4), new THREE.MeshStandardMaterial({ color: 0xd8a868, roughness: 0.8 }));
  table.position.y = -1.25;
  world.add(table);
  scene.add(world);
  const cam = new THREE.PerspectiveCamera(40, 1, 0.1, 50);
  cam.position.set(0, 1.5, 5.6);
  cam.lookAt(0, 0, 0);
  const tags = new Tags(['통과 같이 기울어요 ✗', '수면은 늘 수평 ✓'], ['rgba(170,60,50,0.75)', 'rgba(30,120,60,0.75)']);
  let amp = 0.7;
  return {
    scene,
    camera: cam,
    update(t) {
      time.value = t;
      for (const [i, tk] of tanks.entries()) {
        tk.rotation.set(0, 0.35 + Math.sin(t * 0.4) * 0.25, Math.sin(t * 0.9) * amp);
        tk.updateMatrixWorld();
        mats[i]!.uniforms['uLevel']!.value = tk.position.y; // 통 가운데를 지나는 평면 → 물은 늘 반
      }
    },
    render(r, w, h) {
      r.render(scene, cam);
      tags.draw(r, w, h, [toScreen(new THREE.Vector3(-1.25, -1.15, 1.2), cam), toScreen(new THREE.Vector3(1.25, -1.15, 1.2), cam)]);
    },
    controls: [{ type: 'range', label: '기울기', min: 0, max: 1.4, step: 0.05, value: 0.7, on: (v) => (amp = v) }],
    dispose() {
      freeAll(world);
      bg.dispose();
      tags.dispose();
    },
  };
}
```

### u25 견본 항목 — `src/demos/demosShader.ts:2382`
```ts
  u25: { kind: '3d', caption: '왼쪽은 물을 통에 그냥 붙임 · 오른쪽은 평면 y = h 위를 잘라 내 — 통이 기울어도 수면은 수평', make: () => flatWaterDemo() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [재질에 셰이더 끼워 넣기](https://ai-techstudio.web.app/ai/t/u08.md) `u08`
- 다음에 해 볼 기술: [흐르는 강물 셰이더](https://ai-techstudio.web.app/ai/t/u22.md) `u22` · [물 바닥 빛무늬 (커스틱)](https://ai-techstudio.web.app/ai/t/u23.md) `u23`
