# AI 꾸러미 — 비눗방울 박막 무지개
> 두께가 흐르며 무지갯빛 소용돌이 — 박막 간섭 색  
> 견본: https://ai-techstudio.web.app/#t/i227

이 문서를 코드 도우미(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: `i227` · 분류: 그래픽 · 셰이더 › 재질 · 그림체 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i227
- 쓰면 좋을 때: 쓰면 좋을 곳 — 방울 터뜨려 수 세기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeBubble — `src/demos/demosLook2.ts:1362`
```ts
function makeBubble(): Scene3D {
  const scene = new THREE.Scene();
  scene.background = gradBg([
    [0, '#0b1230'],
    [0.6, '#1a1f4a'],
    [1, '#2b2350'],
  ]);
  const cam = new THREE.PerspectiveCamera(40, 1.6, 0.1, 50);
  cam.position.set(0, 0, 6);
  const root = new THREE.Group();
  scene.add(root);
  // 흐린 빛 방울 (보케)
  const bokehGeo = new THREE.PlaneGeometry(1, 1);
  const bokehMat = new THREE.ShaderMaterial({
    uniforms: { uC: { value: new THREE.Color() } },
    vertexShader: /* glsl */ `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
    fragmentShader: /* glsl */ `varying vec2 vUv; uniform vec3 uC; void main(){ float d = length(vUv - 0.5) * 2.0; float a = smoothstep(1.0, 0.85, d) * (0.5 + 0.5 * d); gl_FragColor = vec4(uC * a * 0.35, 1.0); }`,
    transparent: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
  });
  const r0 = rng(17);
  for (let i = 0; i < 18; i++) {
    const m = new THREE.Mesh(bokehGeo, bokehMat.clone());
    ((m.material as THREE.ShaderMaterial).uniforms.uC as THREE.IUniform).value = new THREE.Color().setHSL(0.55 + r0() * 0.4, 0.7, 0.5);
    m.position.set((r0() - 0.5) * 14, (r0() - 0.5) * 8, -6 - r0() * 4);
    m.scale.setScalar(0.6 + r0() * 1.6);
    root.add(m);
  }
  const uni = { uTime: { value: 0 }, uThick: { value: 420 }, uFlow: { value: 1 }, uFilm: { value: 1 } };
  const vert = /* glsl */ `
    varying vec3 vN; varying vec3 vW; varying vec3 vO;
    void main(){ vO = position; vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; vN = normalize(mat3(modelMatrix) * normal);
      gl_Position = projectionMatrix * viewMatrix * w; }`;
  const frag = (back: boolean) => /* glsl */ `
    uniform float uTime, uThick, uFlow, uFilm, uSeed, uFade;
    varying vec3 vN; varying vec3 vW; varying vec3 vO;
    ${NOISE}
    void main(){
      vec3 V = normalize(cameraPosition - vW);
      vec3 N = normalize(vN); ${back ? 'N = -N;' : ''}
      float c = clamp(abs(dot(N, V)), 0.0, 1.0);
      vec3 o = normalize(vO);
      float t = uTime * uFlow;
      vec3 q = o * 1.6 + vec3(uSeed, t * 0.18, -t * 0.1);
      q.xz += vec2(sin(o.y * 3.0 + t * 0.7), cos(o.y * 2.0 - t * 0.5)) * 0.5;
      float n = fbm3(q) * 1.2 + fbm3(q * 2.3 + 4.0) * 0.4;
      float grav = mix(1.45, 0.45, o.y * 0.5 + 0.5);
      float d = uThick * grav * (0.45 + n);
      float st = sqrt(1.0 - c * c) / 1.33; float ct = sqrt(1.0 - st * st);
      vec3 lam = vec3(650.0, 532.0, 450.0);
      vec3 film = 0.5 - 0.5 * cos(6.2831853 * 2.0 * 1.33 * d * ct / lam);
      film = mix(vec3(0.6), film, uFilm);
      float fr = 0.06 + 0.94 * pow(1.0 - c, 3.0);
      vec3 R = reflect(-V, N);
      float win = smoothstep(0.55, 0.62, R.y) * smoothstep(0.75, 0.0, abs(R.x - 0.25)) * step(abs(fract(R.x * 3.0) - 0.5), 0.44);
      float win2 = smoothstep(0.85, 0.95, dot(R, normalize(vec3(-0.7, -0.2, 0.7))));
      vec3 col = film * (0.22 + fr * 1.3) + vec3(1.0) * (win * 0.9 + win2 * 0.5);
      gl_FragColor = vec4(col * ${back ? '0.35' : '1.0'} * uFade, 1.0);
    }`;
  const bubbles: { g: THREE.Group; mats: THREE.ShaderMaterial[]; x: number; ph: number; size: number; speed: number }[] = [];
  const sgeo = new THREE.SphereGeometry(1, 64, 48);
  for (let i = 0; i < 4; i++) {
    const g = new THREE.Group();
    const mats = [true, false].map((back) => {
      const m = new THREE.ShaderMaterial({ uniforms: { ...uni, uSeed: { value: i * 7.3 }, uFade: { value: 1 } }, vertexShader: vert, fragmentShader: frag(back), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: back ? THREE.BackSide : THREE.FrontSide });
      g.add(new THREE.Mesh(sgeo, m));
      return m;
    });
    root.add(g);
    bubbles.push({ g, mats, x: [-1.6, 1.4, 0.1, 2.9][i]!, ph: i * 0.27, size: [1.25, 0.8, 0.55, 0.5][i]!, speed: [0.12, 0.17, 0.2, 0.15][i]! });
  }
  // 터질 때 물방울
  const DN = 40;
  const dp = new Float32Array(DN * 3);
  const dv: THREE.Vector3[] = [];
  for (let i = 0; i < DN; i++) dv.push(new THREE.Vector3());
  const dGeo = new THREE.BufferGeometry();
  dGeo.setAttribute('position', new THREE.BufferAttribute(dp, 3));
  const dMat = new THREE.PointsMaterial({ color: 0xcff4ff, size: 0.06, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false });
  const drops = new THREE.Points(dGeo, dMat);
  root.add(drops);
  let popAge = 9;
  const popAt = (p: THREE.Vector3, s: number) => {
    popAge = 0;
    for (let i = 0; i < DN; i++) {
      const v = dv[i]!.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).normalize();
      dp[i * 3] = p.x + v.x * s;
      dp[i * 3 + 1] = p.y + v.y * s;
      dp[i * 3 + 2] = p.z + v.z * s;
      v.multiplyScalar(1.5 + Math.random() * 2);
    }
  };
  let lastPop = 0;
  return {
    scene,
    camera: cam,
    update(t, dt) {
      uni.uTime.value = t;
      bubbles.forEach((b, i) => {
        const life = ((t * b.speed + b.ph) % 1 + 1) % 1;
        const y = -3.6 + life * 7.2;
        b.g.position.set(b.x + Math.sin(t * 0.7 + i) * 0.35, y, Math.cos(t * 0.5 + i * 2) * 0.4);
        const wob = 1 + Math.sin(t * 3.1 + i) * 0.025;
        b.g.scale.set(b.size * wob, b.size / wob, b.size * wob);
        b.g.rotation.y = t * 0.2 + i;
        const fade = smooth(0, 0.06, life) * (1 - smooth(0.93, 0.97, life));
        for (const m of b.mats) (m.uniforms.uFade as THREE.IUniform).value = fade;
      });
      // 큰 방울은 가운데쯤에서 2.8초마다 톡
      if (t - lastPop > 2.8) {
        lastPop = t;
        const b = bubbles[2]!;
        popAt(b.g.position, b.size);
        b.ph = (b.ph + 0.37) % 1;
      }
      popAge += dt;
      dMat.opacity = Math.max(0, 1 - popAge * 1.6);
      for (let i = 0; i < DN; i++) {
        const v = dv[i]!;
        v.y -= dt * 4;
        dp[i * 3] = dp[i * 3]! + v.x * dt;
        dp[i * 3 + 1] = dp[i * 3 + 1]! + v.y * dt;
        dp[i * 3 + 2] = dp[i * 3 + 2]! + v.z * dt;
      }
      (dGeo.attributes.position as THREE.BufferAttribute).needsUpdate = true;
    },
    controls: [
      { type: 'range', label: '막 두께 (nm)', min: 120, max: 1000, step: 10, value: 420, on: (v) => (uni.uThick.value = v) },
      { type: 'range', label: '흐름 빠르기', min: 0, max: 3, step: 0.1, value: 1, on: (v) => (uni.uFlow.value = v) },
      { type: 'toggle', label: '박막 간섭 색 (끄면 그냥 유리)', value: true, on: (v) => (uni.uFilm.value = v ? 1 : 0) },
      { type: 'button', label: '톡 터뜨리기', on: () => (lastPop = -9) },
    ],
    dispose() {
      for (const b of bubbles) for (const m of b.mats) m.dispose();
      sgeo.dispose();
      root.traverse((o) => {
        const m = o as THREE.Mesh;
        if (m.material) (m.material as THREE.Material).dispose();
      });
      bokehGeo.dispose();
      bokehMat.dispose();
      dGeo.dispose();
      dMat.dispose();
      (scene.background as THREE.Texture).dispose();
    },
  };
}
```

### i227 견본 항목 — `src/demos/demosLook2.ts:4511`
```ts
  i227: { kind: '3d', caption: '막 두께가 흐르며 빛 세 파장이 간섭 → 무지갯빛 소용돌이, 위는 얇고 아래는 두꺼움 · 톡 터짐', make: () => makeBubble() }
```

## 관련 기술
- 이 효과를 만든 기술: [PBR + 클리어코트](https://ai-techstudio.web.app/ai/t/u04.md) `u04`
