# AI 꾸러미 — 수채화 (번짐 · 가장자리 짙게) — Watercolor simulation (layered polygon deformation)
> 면 둘레를 여러 번 잘게 흔든 반투명 다각형을 수십 겹 쌓고 가장자리만 짙게 해, 물감이 종이에 스며 경계가 짙어지고 겹치면 진해지는 수채화를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i327

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

## 주문서

### 만들어 줘: 수채화 (번짐 · 가장자리 짙게) — Watercolor simulation (layered polygon deformation)

#### 1. 목표
그림책 배경을 수채화로 칠해 줘 — 흔들린 반투명 다각형을 여러 겹 쌓아 번지게, 가장자리는 짙게, 겹친 곳은 진하게. 분위기는 맑은 수채 그림책.

#### 2. 핵심 기술 용어
- **Watercolor simulation (layered polygon deformation)** — 여러 겹 흔들린 다각형 수채 — Tyler Hobbs 방식
- **Recursive midpoint displacement** — 변 가운데 점을 가우스로 밀어 쪼개기를 되풀이
- **Edge darkening (blur subtract)** — 가장자리 짙게 — 흐린 겹을 빼 테두리 고리
- **Multiply blending** — 곱하기 — 겹친 곳이 진해짐

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- 흔들기는 가우스 난수(균등 난수 넷 더하기)로 — 균등 난수는 가장자리가 톱니 같다
- 기본 모양을 먼저 흔들고, 겹마다 그것을 다시 다르게 흔들기 (두 단계)
- 겹 투명도는 아주 낮게(0.05 ~ 0.08), 겹 수는 20 ~ 40
- 가장자리 짙게는 흐린 겹 빼기로 안쪽 고리만
- 한 번 굽고 캐시 — 조절 값이 바뀔 때만 다시

#### 5. 완성 기준 (이게 보이면 성공)
- 면 가장자리가 들쭉날쭉 번지고, 테두리 안쪽이 살짝 짙은 고리가 보인다
- 두 색 면이 겹친 곳은 더 진하고 섞인 색이 된다
- 번짐 슬라이더를 올리면 가장자리가 더 크게 흔들리고, 겹 수를 줄이면 옅고 거칠어진다
- 「가장자리 짙게」 끔/켬 차이가 보인다

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

## 원리
- 면의 점을 16개쯤으로 줄이고 중심 쪽으로 0.965 배 당겨 종이 흰 틈이 남게 한다.
- 흔들기(deform): 변마다 가운데 점을 끼우고, 변 길이 × 번짐 × 가우스 난수만큼 옆으로(앞뒤로는 0.3 배) 민다. 이걸 2 ~ 3번 되풀이하면 점 수가 4 ~ 8 배로 늘며 번진 가장자리가 된다.
- 기본 모양을 한 번 흔든 뒤, 그것을 다시 매번 다르게 3번 흔든 다각형을 26겹 — 겹마다 투명도 0.055 ~ 0.075 로 채운다. 많이 겹친 가운데는 진하고 가장자리는 들쭉날쭉 옅다.
- 가장자리 짙게: 칠한 겹에서 흐리게 한 같은 겹을 destination-out 으로 빼면 안쪽 테두리 고리만 남는다. 그 고리를 더 짙은 색으로 칠해 두 번 얹는다.
- 면마다 종이 결로 조금 지우고 multiply 로 종이에 얹어 겹친 면이 진해진다.

## 핵심 코드 — 가우스 가운데 점 밀기 · 여러 겹 쌓기
(발췌: demos/demosHandA.ts deform() · bakeWater() 를 정리)
```ts
type P = [number, number];
const gauss = (r: () => number) => (r() + r() + r() + r() - 2) * 0.866; // 대략 정규분포

/** 변마다 가운데 점을 끼워 옆으로 민다 — depth 번 되풀이 */
function deform(pts: P[], depth: number, vr: number, r: () => number): P[] {
  let cur = pts;
  for (let d = 0; d < depth; d++) {
    const nx: P[] = [];
    for (let i = 0; i < cur.length; i++) {
      const a = cur[i], b = cur[(i + 1) % cur.length];
      const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
      const ux = (b[0] - a[0]) / len, uy = (b[1] - a[1]) / len;
      const gn = gauss(r) * len * vr, ga = gauss(r) * len * vr * 0.3;
      nx.push(a, [(a[0] + b[0]) / 2 - uy * gn + ux * ga, (a[1] + b[1]) / 2 + ux * gn + uy * ga]);
    }
    cur = nx;
  }
  return cur;
}
/** 면 하나를 수채로 (lg = 그 면 전용 겹) */
function waterFill(lg: CanvasRenderingContext2D, base0: P[], color: string, r: () => number, spread = 1, layers = 26) {
  const base = deform(base0, 2, 0.16 * spread, r);         // 1단계: 기본 모양
  lg.fillStyle = color;
  for (let i = 0; i < layers; i++) {
    const poly = deform(base, 3, 0.13 * spread, r);         // 2단계: 겹마다 다르게
    lg.globalAlpha = 0.055 + 0.02 * r();
    lg.beginPath();
    poly.forEach(([x, y], k) => (k ? lg.lineTo(x, y) : lg.moveTo(x, y)));
    lg.closePath(); lg.fill();
  }
  lg.globalAlpha = 1;
}
// 가장자리 짙게: 같은 겹을 복사 → filter blur 로 흐린 겹을 destination-out → 남은 고리를
// source-in 으로 짙은 색 → 원래 겹에 두 번 얹기. 마지막에 종이에 multiply.
```

## 흔한 실수 · 확인 목록
- [ ] **한 겹만 진하게 칠하면 수채가 아니라 색종이 같다** — 아주 옅은 겹(0.05 ~ 0.08)을 26장쯤 — 겹이 많은 가운데가 진하고 가장자리가 들쭉날쭉.
- [ ] **겹마다 처음 모양부터 크게 흔들면 면이 퍼져 모양을 잃는다** — 기본 모양을 한 번 흔든 것에서 겹마다 작게 다시 흔든다 (0.16 → 0.13).
- [ ] **매 프레임 굽으면 폰이 멈춘다** — 한 번 구워 캐시하고 drawImage 만. 마르는 연출은 구운 그림에 흐림 · 투명도만 바꾼다.
- [ ] **ctx.filter 가 안 되는 브라우저에선 가장자리 고리가 사라진다** — 흐림 없이도 겹 쌓기만으로 괜찮게 보이는지 확인해 둔다.

## 완성 기준 체크리스트
- [ ] 면 가장자리가 들쭉날쭉 번지고, 테두리 안쪽이 살짝 짙은 고리가 보인다
- [ ] 두 색 면이 겹친 곳은 더 진하고 섞인 색이 된다
- [ ] 번짐 슬라이더를 올리면 가장자리가 더 크게 흔들리고, 겹 수를 줄이면 옅고 거칠어진다
- [ ] 「가장자리 짙게」 끔/켬 차이가 보인다

## 이 기술 정보
- id: `i327` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 면 하나 = 다각형 26겹 × 점 수백 개 + 흐림 필터 2번. 한 번 구우면 drawImage 뿐 — 구울 때 폰에서 수십 ms.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i327
- 쓰면 좋을 때: 그림책 · 동화 배경, 지도 색칠 / 정지 화면 — 한 번 구워 두고 오래 보여 주는 그림
- 쓰지 말 때: 매 프레임 모양이 바뀌는 것 — 겹 26장 × 수백 점이라 실시간은 무겁다. 대신 한 번 구운 그림을 움직이기 / 작고 또렷해야 하는 UI

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### bakeClean — `src/demos/demosHandA.ts:637`
```ts
function bakeClean(b: BK, s: Subject, mode: 'color' | 'gray' | 'line'): void {
  const g = b.g;
  if (mode !== 'line') {
    const [lc, lg] = layer(b, 0);
    for (const f of s.fills) {
      if (mode === 'color') lg.fillStyle = f.color;
      else {
        const v = Math.round(255 * (1 - f.tone * 0.86));
        lg.fillStyle = `rgb(${v},${v},${v})`;
      }
      polyPath(lg, mapPts(b, f.pts));
      lg.fill();
    }
    if (mode === 'gray') {
      lg.globalCompositeOperation = 'source-atop';
      const gr = lg.createLinearGradient(b.m, b.m, b.m + b.S, b.m + b.S);
      gr.addColorStop(0, 'rgba(255,255,255,0.25)');
      gr.addColorStop(1, 'rgba(0,0,0,0.22)');
      lg.fillStyle = gr;
      lg.fillRect(0, 0, b.m * 2 + b.S, b.m * 2 + b.S);
      lg.globalCompositeOperation = 'source-over';
    }
    blit(g, lc);
  }
  g.strokeStyle = INK;
  g.lineWidth = b.S * 0.016;
  for (const l of s.lines) {
    linePath(g, mapPts(b, l.pts), l.closed);
    g.stroke();
  }
  g.fillStyle = INK;
  for (const dt of s.dots) {
    const c = X(b, dt.c);
    g.beginPath();
    g.arc(c[0], c[1], dt.r * b.S, 0, TAU);
    g.fill();
  }
}
```

### i327 견본 항목 — `src/demos/demosHandA.ts:1538`
```ts
  i327: {
    kind: '2d',
    caption: '왼쪽 단색 칠 · 오른쪽 수채 — 젖은 물감이 번지다 마르면서 가장자리가 짙어지고 겹친 곳이 진해져요',
    make() {
      const bk = new Bakery();
      let spread = 1;
      let layers = 26;
      let edge = true;
      let seed = 1;
      let pick = 0;
      let split = true;
      let ver = 0;
      return {
        draw(g, w, h, t) {
          const { i, tc, a } = cyc(t, 5, pick);
          const s = SUBJ[i]!;
          const st = stage(g, w, h, split, '반듯한 원본', '수채', '#1c7ed6');
          if (st.L) put(g, bk.get(`c${i}`, st.L.S, st.d, (b) => bakeClean(b, s, 'color')), st.L, a);
          const R = st.R;
          const c = bk.get(`w${i}-${seed}-${ver}`, R.S, st.d, (b) => bakeWater(b, s, seed, spread, layers, edge));
          // 젖음 → 마름: 흐림이 걷히고 색이 자리 잡음
          const wet = 1 - smooth(0.3, 1.9, tc);
          g.save();
          if (wet > 0.01) g.filter = `blur(${wet * 3.5 * st.u}px)`;
          put(g, c, R, a * (0.55 + 0.45 * (1 - wet)), 'multiply');
          g.restore();
          if (wet > 0.02) tag(g, '마르는 중…', R.x + R.w - 6 * st.u, R.y + 12 * st.u, st.u, `rgba(28,126,214,${0.9 * wet + 0.1})`);
        },
        controls: [
          { type: 'range', label: '번짐 (가장자리 흔들림)', min: 0.3, max: 2.2, step: 0.05, value: 1, on: (v) => { spread = v; ver++; } },
          { type: 'range', label: '물감 겹 수', min: 8, max: 44, step: 1, value: 26, on: (v) => { layers = v; ver++; } },
          { type: 'toggle', label: '가장자리 짙게', value: true, on: (v) => { edge = v; ver++; } },
          { type: 'button', label: '다시 칠하기', on: () => { seed++; } },
          pickCtl((v) => (pick = v)),
          splitCtl((v) => (split = v)),
        ],
        dispose() {
          bk.clear();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [도장 붓 (크레파스 · 수채 · 분필)](https://ai-techstudio.web.app/ai/t/i313.md) `i313`
- 다음에 해 볼 기술: [마커 · 형광펜 (겹치면 진하게)](https://ai-techstudio.web.app/ai/t/i329.md) `i329` · [색연필 · 크레파스 결 (종이 이)](https://ai-techstudio.web.app/ai/t/i330.md) `i330`
- 참고 문서: [Tyler Hobbs — A Guide to Simulating Watercolor Paint with Generative Art](https://www.tylerxhobbs.com/words/a-guide-to-simulating-watercolor-paint-with-generative-art)
