# AI 꾸러미 — 물 튀김 (왕관 스플래시 · 물기둥 · 파문)
> 떨어진 방울 → 위가 벌어지며 톱니로 찢어지는 얇은 물벽(원통 메시 셰이더) + 톱니 끝 방울 → 되튀는 물기둥과 끊어지는 방울 → 수면 셰이더의 퍼지는 파문(방울마다 고리)  
> 견본: https://ai-techstudio.web.app/#t/i502

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

## 주문서

### 만들어 줘: 물 튀김 (왕관 스플래시 · 물기둥 · 파문)

#### 1. 목표
물에 빠지기에 「물 튀김 (왕관 스플래시 · 물기둥 · 파문)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 떨어진 방울 → 위가 벌어지며 톱니로 찢어지는 얇은 물벽(원통 메시 셰이더) + 톱니 끝 방울 → 되튀는 물기둥과 끊어지는 방울 → 수면 셰이더의 퍼지는 파문(방울마다 고리)

#### 2. 핵심 기술 용어
- 물 튀김 (왕관 스플래시 · 물기둥 · 파문) — 떨어진 방울 → 위가 벌어지며 톱니로 찢어지는 얇은 물벽(원통 메시 셰이더) + 톱니 끝 방울 → 되튀는 물기둥과 끊어지는 방울 → 수면 셰이더의 퍼지는 파문(방울마다 고리)

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

#### 4. 조건
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 화면 가장자리 흐림 · 비네트로 얼버무리지 않기
- 외부 라이브러리 추가 없이 three.js 와 three/examples 만

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「떨어진 방울 → 위가 벌어지며 톱니로 찢어지는 얇은 물벽(원통 메시 셰이더) + 톱니 끝 방울 → 되튀는 물기둥과 끊어지는 방울 → 수면 셰이더의 퍼지는 파문(방울마다 고리)」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 떨어진 방울 → 위가 벌어지며 톱니로 찢어지는 얇은 물벽(원통 메시 셰이더) + 톱니 끝 방울 → 되튀는 물기둥과 끊어지는 방울 → 수면 셰이더의 퍼지는 파문(방울마다 고리)


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「떨어진 방울 → 위가 벌어지며 톱니로 찢어지는 얇은 물벽(원통 메시 셰이더) + 톱니 끝 방울 → 되튀는 물기둥과 끊어지는 방울 → 수면 셰이더의 퍼지는 파문(방울마다 고리)」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i502` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i502
- 쓰면 좋을 때: 쓰면 좋을 곳 — 물에 빠지기 · 물통 붓기 · 정답 「퐁당」

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i502 견본 항목 — `src/demos/demosVfx3.ts:882`
```ts
  i502: {
    kind: '3d',
    caption: '물방울이 떨어지면 얇은 물벽 왕관 + 튀는 방울 → 되튀는 물기둥 → 방울이 다시 떨어지며 작은 파문',
    make(): Scene3D {
      const S = makeStage({ bg: 0x9fc4dc, cam: [0, 0.95, 2.7], look: [0, 0.32, 0], fov: 40 });
      S.scene.add(skyDome(0x4f86c0, 0xd8e8f0, 0x5a7d90, 0, { dir: [0.75, 0.3, -0.6], col: 0xfff2d8, pow: 24, I: 0.9 }));
      const lab = S.hud.label('tl');
      const SUN = new THREE.Vector3(0.75, 0.3, -0.6).normalize();
      const MAXR = 10;
      const drops = Array.from({ length: MAXR }, () => new THREE.Vector4(0, 0, -99, 0));
      let rSlot = 1;
      const ripple = (x: number, z: number, amp: number, now: number, slot?: number): void => {
        const i = slot ?? rSlot;
        if (slot === undefined) rSlot = 1 + (rSlot % (MAXR - 1));
        drops[i]!.set(x, z, now, amp);
      };
      const waterMat = new THREE.ShaderMaterial({
        uniforms: {
          uTime: { value: 0 },
          uDrops: { value: drops },
          uSun: { value: SUN },
          uAmp: { value: 1 },
        },
        vertexShader: WORLD_VS,
        fragmentShader: /* glsl */ `uniform float uTime, uAmp; uniform vec4 uDrops[${MAXR}]; uniform vec3 uSun; varying vec3 vW;
          void main(){
            vec2 p = vW.xz; vec2 g = vec2(0.0); float foam = 0.0;
            g += vec2(0.6, 0.3) * 0.010 * cos(dot(p, vec2(2.1, 1.0)) + uTime * 1.3);
            g += vec2(-0.3, 0.8) * 0.008 * cos(dot(p, vec2(-1.2, 3.1)) + uTime * 1.7);
            for (int i = 0; i < ${MAXR}; i++) {
              vec4 d = uDrops[i]; float age = uTime - d.z;
              if (age < 0.0 || age > 4.0) continue;
              vec2 q = p - d.xy; float r = length(q) + 1e-4;
              float r0 = age * 1.05;
              float env = exp(-pow((r - r0) * (2.6 / (0.4 + age * 0.6)), 2.0)) * d.w * exp(-age * 0.8) / (1.0 + r * 2.0);
              float k = 15.0 / (0.6 + age * 0.5);
              g += (q / r) * env * cos((r - r0) * k) * 0.09 * k * uAmp;
              foam += exp(-pow((r - r0 * 0.75) * 9.0, 2.0)) * exp(-age * 2.2) * d.w;
            }
            vec3 N = normalize(vec3(-g.x, 1.0, -g.y));
            vec3 V = normalize(cameraPosition - vW);
            vec3 Rf = reflect(-V, N);
            float ry = clamp(Rf.y, 0.0, 1.0);
            vec3 sky = mix(vec3(0.85, 0.91, 0.94), vec3(0.3, 0.52, 0.78), pow(ry, 0.6));
            float fr = 0.03 + 0.97 * pow(1.0 - max(dot(N, V), 0.0), 5.0);
            vec3 body = vec3(0.04, 0.2, 0.26) * (0.7 + 0.6 * N.y);
            body += vec3(0.05, 0.22, 0.2) * smoothstep(0.98, 0.9, N.y);
            vec3 col = mix(body, sky, fr);
            col += vec3(1.0, 0.95, 0.85) * pow(max(dot(Rf, uSun), 0.0), 900.0) * 3.0;
            col += vec3(0.9, 0.97, 1.0) * clamp(foam, 0.0, 1.0) * 0.55;
            float far = smoothstep(9.0, 26.0, length(vW.xz));
            col = mix(col, vec3(0.78, 0.86, 0.9), far);
            gl_FragColor = vec4(col, 1.0); ${TONE} }`,
      });
      const water = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), waterMat);
      water.rotation.x = -Math.PI / 2;
      S.scene.add(water);
      // 물 셰이더 (방울 · 왕관 · 기둥)
      const liquid = (): THREE.ShaderMaterial =>
        new THREE.ShaderMaterial({
          uniforms: { uSun: { value: SUN }, uA: { value: 1 } },
          vertexShader: WORLD_VS,
          fragmentShader: /* glsl */ `uniform vec3 uSun; uniform float uA; varying vec3 vW; varying vec3 vN;
            void main(){ vec3 N = normalize(vN); vec3 V = normalize(cameraPosition - vW); if (dot(N, V) < 0.0) N = -N;
              float ndv = max(dot(N, V), 0.0); float fr = 0.06 + 0.94 * pow(1.0 - ndv, 4.0);
              vec3 Rf = reflect(-V, N);
              vec3 sky = mix(vec3(0.82, 0.9, 0.95), vec3(0.32, 0.55, 0.8), pow(clamp(Rf.y, 0.0, 1.0), 0.6));
              sky = Rf.y < 0.0 ? vec3(0.08, 0.26, 0.3) : sky;
              vec3 inner = vec3(0.1, 0.32, 0.36);
              vec3 col = mix(inner, sky, fr) + vec3(1.0, 0.96, 0.88) * pow(max(dot(Rf, uSun), 0.0), 90.0) * 6.0;
              gl_FragColor = vec4(col, 1.0); ${TONE}
              gl_FragColor.a = clamp((0.45 + 0.55 * fr) * uA, 0.0, 1.0); }`,
          transparent: true,
          depthWrite: false,
          side: THREE.DoubleSide,
        });
      const dropMat = liquid();
      const drop = new THREE.Mesh(new THREE.SphereGeometry(0.085, 24, 16), dropMat);
      S.scene.add(drop);
      // 왕관: 위가 벌어지고 톱니 모양으로 찢어진 얇은 물벽
      const crownMat = liquid();
      crownMat.uniforms.uK = { value: 0 };
      crownMat.vertexShader = /* glsl */ `uniform float uK; varying vec3 vW; varying vec3 vN; varying vec2 vUv;
        void main(){ vec3 p = position; float h = uv.y; float ang = atan(p.z, p.x);
          float flare = 1.0 + h * h * (0.55 + 0.35 * uK);
          float lip = 1.0 + 0.05 * sin(ang * 9.0) * h;
          p.xz *= flare * lip; vUv = uv;
          vec4 w = modelMatrix * vec4(p, 1.0); vW = w.xyz;
          vec3 n = normal; n.y -= h * 0.9; vN = normalize(mat3(modelMatrix) * n);
          gl_Position = projectionMatrix * viewMatrix * w; }`;
      crownMat.fragmentShader = crownMat.fragmentShader
        .replace('varying vec3 vN;', 'varying vec3 vN; varying vec2 vUv; uniform float uK;')
        .replace(
          'void main(){',
          `void main(){ float ang = atan(vW.z, vW.x);
            float teeth = 0.7 + 0.22 * pow(abs(sin(ang * 8.0 + 0.6)), 3.0) + 0.08 * sin(ang * 23.0);
            if (vUv.y > teeth * (1.0 - uK * 0.35)) discard;`,
        );
      const crown = new THREE.Mesh(new THREE.CylinderGeometry(1, 1, 1, 64, 6, true).translate(0, 0.5, 0), crownMat);
      crown.visible = false;
      S.scene.add(crown);
      const jetMat = liquid();
      const jet = new THREE.Mesh(new THREE.CylinderGeometry(0.028, 0.07, 1, 20, 4, true).translate(0, 0.5, 0), jetMat);
      const jetTop = new THREE.Mesh(new THREE.SphereGeometry(0.06, 16, 12), jetMat);
      jet.visible = jetTop.visible = false;
      S.scene.add(jet, jetTop);
      const P = S.pool(900, 4);
      const MIST = ramp([[0, 0xffffff, 1.3, 0, 0.4], [0.15, 0xf0f8ff, 1.2, 0.16, 0.4], [1, 0xd8ecf4, 1, 0, 0.3]]);
      const DROPR = ramp([[0, 0xffffff, 2.2, 0.95, 0.5], [0.8, 0xe8f6ff, 1.6, 0.85, 0.45], [1, 0xc8e8f8, 1.2, 0.6, 0.4]]);
      const SPRAY = ramp([[0, 0xffffff, 2.6, 1, 0.7], [1, 0xd8f0ff, 1.4, 0, 0.6]]);
      P.floorY = -10;
      P.onStep = (p, i) => {
        if (p.y[i]! < 0 && p.vy[i]! < 0) {
          const big = p.tag[i] === 2;
          ripple(p.x[i]!, p.z[i]!, big ? 0.55 : 0.16 + p.s0[i]! * 2, time);
          const n = big ? 7 : 2;
          for (let j = 0; j < n; j++) {
            const a = R(0, TAU);
            const sp = R(0.4, big ? 1.2 : 0.7);
            P.spawn({ x: p.x[i]!, y: 0.01, z: p.z[i]!, vx: Math.cos(a) * sp, vy: R(0.8, big ? 2.2 : 1.3), vz: Math.sin(a) * sp, life: 0.35, s0: big ? 0.035 : 0.022, ramp: DROPR, shape: 5, st: 0.03, grav: 9.8 });
          }
          p.end(i);
        }
      };
      let time = 0;
      let cyc = 0;
      let slow = true;
      let size = 1;
      const CYC = 3.4;
      const T_HIT = 0.82;
      let hit = false;
      let pinched = false;
      const controls: Control[] = [
        ctrlTog('느리게 보기 (고속 촬영)', true, (v) => (slow = v)),
        ctrlRange('물방울 크기', 0.6, 1.6, 0.05, 1, (v) => (size = v)),
        ctrlRange('파문 세기', 0, 2, 0.05, 1, (v) => (waterMat.uniforms.uAmp!.value = v)),
        ctrlBtn('다시 떨어뜨리기', () => (cyc = 0)),
      ];
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(_t, dt0) {
          const dt = Math.min(dt0, 0.05) * (slow ? 0.35 : 1);
          time += dt;
          const prev = cyc;
          cyc += dt;
          if (cyc >= CYC) {
            cyc = 0;
            hit = false;
            pinched = false;
          }
          if (prev > cyc) {
            hit = false;
            pinched = false;
          }
          waterMat.uniforms.uTime!.value = time;
          // 떨어지는 방울
          if (cyc < T_HIT) {
            const k = cyc / T_HIT;
            const y = 3.2 * (1 - k * k) + 0.02;
            drop.visible = true;
            drop.position.set(0, y, 0);
            drop.scale.set(size, size * (1 + k * 0.45), size);
            lab.set('물방울 낙하', '#7ac8ff');
          } else drop.visible = false;
          const a = cyc - T_HIT;
          if (a >= 0 && !hit) {
            hit = true;
            ripple(0, 0, 1.3 * size, time, 0);
            // 바깥으로 낮게 튀는 물보라
            for (let i = 0; i < 46 * size; i++) {
              const an = R(0, TAU);
              const sp = R(1.4, 3.4) * size;
              P.spawn({ x: Math.cos(an) * 0.08, y: 0.03, z: Math.sin(an) * 0.08, vx: Math.cos(an) * sp, vy: R(0.6, 1.8), vz: Math.sin(an) * sp, life: 1.2, s0: R(0.012, 0.026), ramp: SPRAY, shape: 5, st: 0.04, grav: 9.8, tag: 1 });
            }
            // 왕관 끝에서 떨어져 나가는 방울 (톱니 끝마다)
            for (let i = 0; i < 16; i++) {
              const an = (i / 16) * TAU + 0.2 + R(-0.05, 0.05);
              const sp = R(0.9, 1.3) * size;
              P.spawn({ x: Math.cos(an) * 0.3 * size, y: 0.32 * size, z: Math.sin(an) * 0.3 * size, vx: Math.cos(an) * sp, vy: R(2.0, 2.6) * Math.sqrt(size), vz: Math.sin(an) * sp, life: 2.0, s0: R(0.03, 0.045) * size, ramp: DROPR, shape: 5, st: 0.02, grav: 9.8, tag: 1, delay: 0.13 });
            }
            for (let i = 0; i < 16; i++) {
              const an = R(0, TAU);
              P.spawn({ x: Math.cos(an) * 0.2, y: 0.1, z: Math.sin(an) * 0.2, vx: Math.cos(an) * 0.9, vy: R(0.3, 0.8), vz: Math.sin(an) * 0.9, life: R(0.9, 1.4), s0: 0.15, s1: 0.6, ramp: MIST, shape: 1, rot: R(0, TAU), rv: R(-0.5, 0.5), drag: 2 });
            }
          }
          // 왕관
          if (a >= 0 && a < 0.55) {
            const k = a / 0.55;
            crown.visible = true;
            const rr = (0.1 + 0.26 * easeOut(k * 1.3)) * size;
            const hh = 0.42 * Math.sin(Math.PI * Math.pow(k, 0.6)) * size;
            crown.scale.set(rr, Math.max(0.001, hh), rr);
            crownMat.uniforms.uK!.value = k;
            crownMat.uniforms.uA!.value = 1 - smooth((k - 0.6) / 0.4);
            lab.set('왕관 — 얇은 물벽이 솟고 톱니 끝에서 방울이 떨어져 나감', '#7ac8ff');
          } else crown.visible = false;
          // 되튀는 물기둥 (워딩턴 제트)
          const ja = a - 0.32;
          if (ja >= 0 && ja < 0.75) {
            const k = ja / 0.75;
            const h = 0.95 * Math.sin(Math.PI * Math.pow(k, 0.7)) * size;
            jet.visible = jetTop.visible = true;
            jet.scale.set(size, Math.max(0.001, h), size);
            jetTop.scale.setScalar(size * (1 + (pinched ? 0 : 0.4 * k)));
            jetTop.position.set(0, h, 0);
            jetTop.visible = !pinched;
            if (!pinched && k > 0.38) {
              pinched = true;
              P.spawn({ x: 0, y: h, z: 0, vx: R(-0.05, 0.05), vy: 1.6 * size, vz: R(-0.05, 0.05), life: 3, s0: 0.11 * size, ramp: DROPR, shape: 5, st: 0.015, grav: 9.8, tag: 2 });
            }
            lab.set('물기둥 — 가운데가 되튀어 오르고 꼭대기 방울이 끊어짐', '#7ac8ff');
          } else {
            jet.visible = jetTop.visible = false;
            if (a > 1.1) lab.set('파문 — 다시 떨어진 방울마다 작은 고리 물결', '#7ac8ff');
          }
          P.tick(dt);
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => S.dispose(),
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [층 쌓기 (핵 · 빛무리 · 불티 · 연기)](https://ai-techstudio.web.app/ai/t/i171.md) `i171`
