# AI 꾸러미 — 물감 · 잉크 튀김 (철퍽 데칼 · 흘러내림)
> 날아온 물감 덩이가 벽에 철퍽 — 캔버스 데칼에 가운데 덩이 + 가시 + 위성 방울을 그리고, 아래로 흘러내리는 줄, 튄 방울은 바닥에 작은 자국  
> 견본: https://ai-techstudio.web.app/#t/i506

이 문서를 코드 도우미(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: `i506` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i506
- 쓰면 좋을 때: 쓰면 좋을 곳 — 색칠 대결 · 물감 놀이 · 칸 차지 게임

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i506 견본 항목 — `src/demos/demosVfx3.ts:1675`
```ts
  i506: {
    kind: '3d',
    caption: '날아온 물감 덩이가 벽에 철퍽 — 가운데 덩이 · 가시 · 위성 방울 자국이 남고, 아래로 흘러내리며 튄 방울은 바닥에 작은 자국',
    make(): Scene3D {
      const S = makeStage({ bg: 0xd6dbe2, cam: [-0.6, 1.7, 5.6], look: [0, 1.45, -1.2], fov: 44 });
      const lab = S.hud.label('tl');
      S.scene.add(new THREE.HemisphereLight(0xffffff, 0xb8b0a4, 1.3));
      const sunL = new THREE.DirectionalLight(0xffffff, 1.9);
      sunL.position.set(3, 5, 5);
      S.scene.add(sunL);
      const WW = 7;
      const WH = 3.6;
      const CW = 700;
      const CHh = 360;
      const mk = (w: number, h: number): [HTMLCanvasElement, CanvasRenderingContext2D] => {
        const c = document.createElement('canvas');
        c.width = w;
        c.height = h;
        return [c, c.getContext('2d')!];
      };
      // 벽: 색 · 높이(볼록) · 거칠기(물감은 반들) 세 장
      const [wc, wg] = mk(CW, CHh);
      const [hc, hg] = mk(CW, CHh);
      const [rc, rgc] = mk(CW, CHh);
      const [bc, bg] = mk(CW, CHh);
      {
        const r = rng(4);
        bg.fillStyle = '#e9e6e0';
        bg.fillRect(0, 0, CW, CHh);
        for (let i = 0; i < 4000; i++) {
          bg.fillStyle = r() < 0.5 ? 'rgba(0,0,0,0.035)' : 'rgba(255,255,255,0.05)';
          bg.fillRect(r() * CW, r() * CHh, 2, 2);
        }
        bg.strokeStyle = 'rgba(0,0,0,0.08)';
        bg.lineWidth = 2;
        for (let x = 0; x <= CW; x += CW / 7) {
          bg.beginPath();
          bg.moveTo(x, 0);
          bg.lineTo(x, CHh);
          bg.stroke();
        }
      }
      const resetWall = (): void => {
        wg.globalAlpha = 1;
        wg.drawImage(bc, 0, 0);
        hg.fillStyle = '#000';
        hg.fillRect(0, 0, CW, CHh);
        rgc.fillStyle = '#e0e0e0';
        rgc.fillRect(0, 0, CW, CHh);
      };
      resetWall();
      const wTex = new THREE.CanvasTexture(wc);
      wTex.colorSpace = THREE.SRGBColorSpace;
      const hTex = new THREE.CanvasTexture(hc);
      const rTex = new THREE.CanvasTexture(rc);
      const wall = new THREE.Mesh(new THREE.PlaneGeometry(WW, WH), new THREE.MeshStandardMaterial({ map: wTex, bumpMap: hTex, bumpScale: 4, roughnessMap: rTex, roughness: 1, metalness: 0 }));
      wall.position.set(0, WH / 2, -1.5);
      S.scene.add(wall);
      const FW = 9;
      const FD = 6;
      const [fc, fg] = mk(540, 360);
      const [fhc, fhg] = mk(540, 360);
      const [fbc, fbg] = mk(540, 360);
      {
        fbg.fillStyle = '#cfcac2';
        fbg.fillRect(0, 0, 540, 360);
        fbg.strokeStyle = 'rgba(0,0,0,0.12)';
        fbg.lineWidth = 2;
        for (let x = 0; x <= 540; x += 60) {
          fbg.beginPath();
          fbg.moveTo(x, 0);
          fbg.lineTo(x, 360);
          fbg.stroke();
        }
        for (let y = 0; y <= 360; y += 60) {
          fbg.beginPath();
          fbg.moveTo(0, y);
          fbg.lineTo(540, y);
          fbg.stroke();
        }
      }
      const resetFloor = (): void => {
        fg.globalAlpha = 1;
        fg.drawImage(fbc, 0, 0);
        fhg.fillStyle = '#000';
        fhg.fillRect(0, 0, 540, 360);
      };
      resetFloor();
      const fTex = new THREE.CanvasTexture(fc);
      fTex.colorSpace = THREE.SRGBColorSpace;
      const fhTex = new THREE.CanvasTexture(fhc);
      const floor = new THREE.Mesh(new THREE.PlaneGeometry(FW, FD), new THREE.MeshStandardMaterial({ map: fTex, bumpMap: fhTex, bumpScale: 3, roughness: 0.75 }));
      floor.rotation.x = -Math.PI / 2;
      floor.position.set(0, 0, -1.5 + FD / 2);
      S.scene.add(floor);
      const COLS = ['#ff7a1a', '#14c4e6', '#e82a8c', '#8ee020', '#7a44ff'];
      const splat = (g: CanvasRenderingContext2D, x: number, y: number, r: number, col: string, dirA: number, seed: number, blur: number): void => {
        const q = rng(seed);
        g.save();
        g.fillStyle = col;
        g.strokeStyle = col;
        if (blur) g.filter = `blur(${blur}px)`;
        const el = (a: number): number => 1 + 0.45 * Math.max(0, Math.cos(a - dirA));
        g.beginPath();
        const p1 = q() * 6;
        const p2 = q() * 6;
        for (let k = 0; k <= 56; k++) {
          const a = (k / 56) * TAU;
          const rr = r * (0.86 + 0.16 * Math.sin(a * 3 + p1) + 0.09 * Math.sin(a * 7 + p2) + 0.05 * Math.sin(a * 13)) * el(a);
          const px = x + Math.cos(a) * rr;
          const py = y + Math.sin(a) * rr;
          if (k) g.lineTo(px, py);
          else g.moveTo(px, py);
        }
        g.fill();
        const ns = 11 + Math.floor(q() * 6);
        for (let i = 0; i < ns; i++) {
          const a = q() < 0.5 ? dirA + (q() - 0.5) * 2.2 : q() * TAU;
          const L = r * (1.15 + q() * 1.3) * el(a);
          const w0 = r * (0.14 + q() * 0.16);
          const ca = Math.cos(a);
          const sa = Math.sin(a);
          g.beginPath();
          g.moveTo(x - sa * w0, y + ca * w0);
          g.quadraticCurveTo(x + ca * L * 0.6 - sa * w0 * 0.25, y + sa * L * 0.6 + ca * w0 * 0.25, x + ca * L, y + sa * L);
          g.quadraticCurveTo(x + ca * L * 0.6 + sa * w0 * 0.25, y + sa * L * 0.6 - ca * w0 * 0.25, x + sa * w0, y - ca * w0);
          g.fill();
          g.beginPath();
          g.arc(x + ca * L, y + sa * L, w0 * 0.55, 0, TAU);
          g.fill();
        }
        const nd = 22 + Math.floor(q() * 10);
        for (let i = 0; i < nd; i++) {
          const a = q() < 0.6 ? dirA + (q() - 0.5) * 2.0 : q() * TAU;
          const dd = r * (1.4 + Math.pow(q(), 0.8) * 2.2) * el(a);
          const rr = r * (0.16 - (dd / (r * 4)) * 0.11) * (0.5 + q());
          g.beginPath();
          g.ellipse(x + Math.cos(a) * dd, y + Math.sin(a) * dd, Math.max(0.8, rr * 1.5), Math.max(0.8, rr), a, 0, TAU);
          g.fill();
        }
        g.restore();
      };
      interface Drip {
        x: number;
        y: number;
        len: number;
        max: number;
        w: number;
        col: string;
        sp: number;
      }
      const drips: Drip[] = [];
      interface Blob {
        p: THREE.Vector3;
        v: THREE.Vector3;
        r: number;
        col: number;
        main: boolean;
        alive: boolean;
      }
      const blobs: Blob[] = [];
      const MAXB = 80;
      const im = new THREE.InstancedMesh(new THREE.SphereGeometry(1, 20, 14), new THREE.MeshStandardMaterial({ roughness: 0.2, metalness: 0 }), MAXB);
      im.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
      im.frustumCulled = false;
      for (let i = 0; i < MAXB; i++) im.setColorAt(i, new THREE.Color(1, 1, 1));
      S.scene.add(im);
      const START = new THREE.Vector3(-4.8, 1.0, 1.6);
      let ci = 0;
      let size = 1;
      let dripOn = true;
      let count = 0;
      let next = 0.3;
      let fade = 0;
      const throwBlob = (): void => {
        const tx = R(-2.6, 2.6);
        const ty = R(0.9, 2.9);
        const T = 0.55;
        const v = new THREE.Vector3((tx - START.x) / T, (ty - START.y) / T + 0.5 * 9.8 * T, (-1.5 - START.z) / T);
        blobs.push({ p: START.clone(), v, r: 0.13 * size, col: ci, main: true, alive: true });
        ci = (ci + 1) % COLS.length;
      };
      let dirty = 0;
      const hit = (b: Blob): void => {
        const u = (b.p.x + WW / 2) / WW;
        const vv = 1 - b.p.y / WH;
        const px = u * CW;
        const py = vv * CHh;
        const dirA = Math.atan2(-b.v.y, b.v.x);
        const rpx = (b.r / WW) * CW * 2.1;
        const seed = Math.floor(Math.random() * 1e6);
        const col = COLS[b.col]!;
        splat(wg, px, py, rpx, col, dirA, seed, 0);
        splat(hg, px, py, rpx, '#ffffff', dirA, seed, 2);
        splat(rgc, px, py, rpx, '#303030', dirA, seed, 0);
        if (dripOn)
          for (let k = 0; k < 3 + Math.floor(Math.random() * 3); k++) drips.push({ x: px + R(-0.7, 0.7) * rpx, y: py + rpx * 0.5, len: 0, max: R(25, 95) * size, w: R(2.5, 5) * size, col, sp: R(30, 60) });
        // 되튀는 방울
        for (let k = 0; k < 12; k++) {
          const a = R(0, TAU);
          blobs.push({ p: new THREE.Vector3(b.p.x, b.p.y, -1.42), v: new THREE.Vector3(Math.cos(a) * R(0.5, 2.2), Math.sin(a) * R(0.5, 2.2) + 1, R(0.6, 2.4)), r: R(0.018, 0.04) * size, col: b.col, main: false, alive: true });
        }
        S.shake = Math.max(S.shake, 0.025);
        dirty = 1;
        lab.set('철퍽! — 덩이 · 가시 · 위성 방울 자국이 벽에 남음', COLS[b.col]!);
      };
      const floorHit = (b: Blob): void => {
        const px = ((b.p.x + FW / 2) / FW) * 540;
        const py = ((b.p.z - (-1.5)) / FD) * 360;
        const seed = Math.floor(Math.random() * 1e6);
        const rr = (b.r / FW) * 540 * 2.2;
        const a = Math.atan2(b.v.z, b.v.x);
        splat(fg, px, py, rr, COLS[b.col]!, a, seed, 0);
        splat(fhg, px, py, rr, '#ffffff', a, seed, 1.5);
        dirty = 1;
      };
      const M = new THREE.Matrix4();
      const Q = new THREE.Quaternion();
      const SC = new THREE.Vector3();
      const Z = new THREE.Vector3(0, 1, 0);
      const tmpC = new THREE.Color();
      const controls: Control[] = [
        ctrlRange('덩이 크기', 0.6, 1.8, 0.05, 1, (v) => (size = v)),
        ctrlTog('흘러내림', true, (v) => (dripOn = v)),
        ctrlBtn('벽 닦기', () => (fade = 1)),
      ];
      let dripAcc = 0;
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          next -= dt;
          if (next <= 0 && fade <= 0) {
            throwBlob();
            count++;
            next = count % 7 === 0 ? 2.2 : 0.75;
            if (count % 7 === 0) fade = 2.0;
            lab.set('물감 덩이 날아감', COLS[(ci + COLS.length - 1) % COLS.length]!);
          }
          for (const b of blobs) {
            if (!b.alive) continue;
            b.v.y -= 9.8 * dt;
            b.p.addScaledVector(b.v, dt);
            if (b.main && b.p.z <= -1.5) {
              b.p.z = -1.5;
              b.alive = false;
              hit(b);
            } else if (!b.main && b.p.y <= 0.02) {
              b.alive = false;
              floorHit(b);
            }
          }
          for (let i = blobs.length - 1; i >= 0; i--) if (!blobs[i]!.alive) blobs.splice(i, 1);
          let n = 0;
          for (const b of blobs) {
            if (n >= MAXB) break;
            const sp = b.v.length();
            const st = b.main ? 1 + Math.min(0.3, sp * 0.02) : 1 + Math.min(1.5, sp * 0.25);
            const wob = b.main ? 1 + Math.sin(t * 30) * 0.08 : 1;
            Q.setFromUnitVectors(Z, SC.copy(b.v).normalize());
            M.compose(b.p, Q, SC.set(b.r * wob / Math.sqrt(st), b.r * st, b.r * wob / Math.sqrt(st)));
            im.setMatrixAt(n, M);
            im.setColorAt(n, tmpC.set(COLS[b.col]!).convertSRGBToLinear());
            n++;
          }
          im.count = n;
          im.instanceMatrix.needsUpdate = true;
          if (im.instanceColor) im.instanceColor.needsUpdate = true;
          // 흘러내림
          dripAcc += dt;
          if (dripAcc > 1 / 30) {
            const st = dripAcc;
            dripAcc = 0;
            for (const d of drips) {
              if (d.len >= d.max) continue;
              const k = d.len / d.max;
              d.len = Math.min(d.max, d.len + d.sp * st * (1 - k * 0.8));
              const w = d.w * (1 - k * 0.35);
              const y = d.y + d.len;
              wg.fillStyle = d.col;
              wg.beginPath();
              wg.arc(d.x, y, w, 0, TAU);
              wg.fill();
              hg.fillStyle = '#ffffff';
              hg.beginPath();
              hg.arc(d.x, y, w * 0.9, 0, TAU);
              hg.fill();
              rgc.fillStyle = '#303030';
              rgc.beginPath();
              rgc.arc(d.x, y, w, 0, TAU);
              rgc.fill();
              dirty = 1;
            }
            for (let i = drips.length - 1; i >= 0; i--) if (drips[i]!.len >= drips[i]!.max) drips.splice(i, 1);
            if (fade > 0) {
              wg.globalAlpha = 0.12;
              wg.drawImage(bc, 0, 0);
              wg.globalAlpha = 1;
              hg.fillStyle = 'rgba(0,0,0,0.12)';
              hg.fillRect(0, 0, CW, CHh);
              rgc.fillStyle = 'rgba(224,224,224,0.12)';
              rgc.fillRect(0, 0, CW, CHh);
              fg.globalAlpha = 0.12;
              fg.drawImage(fbc, 0, 0);
              fg.globalAlpha = 1;
              fhg.fillStyle = 'rgba(0,0,0,0.12)';
              fhg.fillRect(0, 0, 540, 360);
              dirty = 1;
            }
            if (dirty) {
              wTex.needsUpdate = hTex.needsUpdate = rTex.needsUpdate = true;
              fTex.needsUpdate = fhTex.needsUpdate = true;
              dirty = 0;
            }
          }
          if (fade > 0) {
            fade -= dt;
            if (fade <= 0) {
              resetWall();
              resetFloor();
              drips.length = 0;
              dirty = 1;
            }
            if (fade < 1.8) lab.set('벽 닦는 중 — 다시 던지기', '#8a96a8');
          } else if (drips.length) lab.set('흘러내림 — 물감이 아래로 줄을 그으며 흐름', '#e8a040');
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => {
          S.dispose();
          wTex.dispose();
          hTex.dispose();
          rTex.dispose();
          fTex.dispose();
          fhTex.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [데칼 (글자 · 경고 표시 · 총알 자국)](https://ai-techstudio.web.app/ai/t/i479.md) `i479`
