# AI 꾸러미 — 캔버스 그러데이션 · 흐림 · 빛 섞기 — Canvas radial gradient (createRadialGradient)
> 단색 원 대신 방사 그러데이션 · 흐림 필터 · 빛 번짐 그림자 · 더하기 혼합을 겹쳐, 겹친 곳이 환하게 빛나는 빛 덩어리를 캔버스로 그린다.  
> 견본: https://ai-techstudio.web.app/#t/u52

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

## 주문서

### 만들어 줘: 캔버스 그러데이션 · 흐림 · 빛 섞기 — Canvas radial gradient (createRadialGradient)

#### 1. 목표
떠다니는 빛 구슬을 캔버스 2D 로 빛나게 그려 줘 — 방사 그러데이션 · 흐린 배경 덩어리 · shadowBlur 빛 번짐 · lighter 더하기 혼합을 겹친다. 분위기는 짙은 남색 밤하늘.

#### 2. 핵심 기술 용어
- **Canvas radial gradient (createRadialGradient)** — 가운데서 바깥으로 색이 바뀌는 칠
- **globalCompositeOperation = 'lighter'** — 더하기 혼합 — 겹치면 밝아진다
- **shadowBlur · shadowColor (glow)** — 그림 둘레로 번지는 빛
- **ctx.filter = blur()** — 그리는 것을 통째로 흐리게

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

#### 4. 조건
- 그러데이션 마지막 색은 같은 색의 알파 0 (검정 투명 rgba(0,0,0,0) 이 아니라) — 아니면 가장자리가 탁해진다
- globalCompositeOperation · filter · shadowBlur · globalAlpha 는 쓰고 나서 바로 원래대로 (source-over · none · 0 · 1)
- 더하기 혼합은 어두운 바탕 위에서만
- 흐림 · 빛 번짐 세기를 슬라이더로

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽 단색 원 · 오른쪽 빛 구슬을 나란히 보면, 오른쪽은 가장자리가 부드럽고 겹친 곳이 하얗게 빛난다
- 「겹치면 더하기」를 끄면 겹친 곳이 그냥 덮인다
- 빛 번짐(shadowBlur) 0~40 슬라이더로 빛무리 크기가 바뀐다
- 혼합 설정이 다른 그림(글자 · 단추)에 새어 나가지 않는다

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

## 원리
- 방사 그러데이션: 가운데 흰빛(#ffffffcc) → 제 색 → 바깥은 투명(c00)으로 끝내면 가장자리가 부드럽게 사라진다.
- 더하기 혼합('lighter'): 겹친 화소의 색을 더하므로 겹친 곳이 하얗게 빛난다. 보통 혼합(source-over)은 위에 덮을 뿐이다.
- shadowBlur 는 그린 모양 둘레로 shadowColor 빛을 번지게 한다 — 같은 색으로 주면 빛무리.
- 뒤 배경 덩어리는 ctx.filter = blur(10px) 로 그리고 바로 none 으로 돌려 놓는다.

## 핵심 코드 — 흐린 배경 덩어리 + 빛 구슬 (그러데이션 · 빛 번짐 · 더하기)
(발췌: demos/demosDraw2d.ts u52 orbs() 의 fancy 쪽을 정리)
```ts
function glowOrbs(g: CanvasRenderingContext2D, orbs: { x: number; y: number; r: number; c: string }[], blur = 16) {
  // 1) 뒤 배경 덩어리 — 통째로 흐리게
  g.filter = 'blur(10px)';
  for (const [i, c] of ['#3b2a8f', '#16608f', '#7a2a7f'].entries()) {
    g.fillStyle = c;
    g.beginPath();
    g.arc(80 + i * 90, 70 + (i % 2) * 40, 34, 0, Math.PI * 2);
    g.fill();
  }
  g.filter = 'none';
  // 2) 빛 구슬 — 겹치면 더하기
  g.globalCompositeOperation = 'lighter';
  for (const o of orbs) {
    const gr = g.createRadialGradient(o.x - o.r * 0.3, o.y - o.r * 0.3, 0, o.x, o.y, o.r * 1.7);
    gr.addColorStop(0, '#ffffffcc');
    gr.addColorStop(0.18, o.c + 'cc');
    gr.addColorStop(0.55, o.c + '55');
    gr.addColorStop(1, o.c + '00'); // 같은 색의 투명으로 끝내기
    g.shadowColor = o.c;
    g.shadowBlur = blur;
    g.globalAlpha = 0.85;
    g.fillStyle = gr;
    g.beginPath();
    g.arc(o.x, o.y, o.r * 1.7, 0, Math.PI * 2);
    g.fill();
  }
  // 3) 꼭 되돌리기
  g.globalAlpha = 1;
  g.shadowBlur = 0;
  g.globalCompositeOperation = 'source-over';
}
// 색은 '#ff5fa2' 처럼 6자리 hex — 뒤에 알파 2자리를 붙인다
```

## 흔한 실수 · 확인 목록
- [ ] **그러데이션 끝을 검정 투명으로 하면 가장자리에 회색 테가 생긴다** — 같은 색에 알파만 0 (예: '#ff5fa2' + '00') 으로 끝낸다.
- [ ] **lighter 를 되돌리지 않으면 뒤에 그린 글자 · 단추까지 빛난다** — 그린 뒤 바로 source-over 로. save()/restore() 로 감싸도 된다.
- [ ] **shadowBlur 를 켠 채로 많이 그리면 폰에서 느려진다** — 빛 구슬 하나를 작은 캔버스에 구워 두고 drawImage 로 찍는다.
- [ ] **3자리 hex(#f5a) 뒤에 알파를 붙이면 엉뚱한 색이 된다** — 색은 6자리 hex 로 맞춘 뒤 알파 2자리를 붙인다.

## 완성 기준 체크리스트
- [ ] 왼쪽 단색 원 · 오른쪽 빛 구슬을 나란히 보면, 오른쪽은 가장자리가 부드럽고 겹친 곳이 하얗게 빛난다
- [ ] 「겹치면 더하기」를 끄면 겹친 곳이 그냥 덮인다
- [ ] 빛 번짐(shadowBlur) 0~40 슬라이더로 빛무리 크기가 바뀐다
- [ ] 혼합 설정이 다른 그림(글자 · 단추)에 새어 나가지 않는다

## 이 기술 정보
- id: `u52` · 분류: 2D · 화면 › 2D 그리기 · 2D · 난이도 쉬움 · 폰 부담 보통 (폰 주의) — shadowBlur · filter blur 는 그릴 때마다 흐림 계산을 한다. 구슬 5개는 가볍지만 수백 개면 폰에서 느려진다 — 구워 두고 붙이기.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u52
- 쓰면 좋을 때: 어두운 바탕에 빛 · 마법 · 보상을 그릴 때 / 3D 없이 2D 로 은은한 배경 분위기를 낼 때
- 쓰지 말 때: 밝은 흰 바탕 — 더하기 혼합은 바탕이 밝으면 하얗게 날아간다. 대신 보통 혼합 + 그러데이션 / 수백 개를 매 프레임 — shadowBlur · filter 는 비싸다. 대신 빛 그림을 한 번 구워 drawImage

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u52 견본 항목 — `src/demos/demosDraw2d.ts:257`
```ts
  u52: {
    kind: '2d',
    caption: '왼쪽 단색 원 · 오른쪽 그러데이션 + 흐림 + 빛 섞기(lighter) — 겹친 곳이 환하게 빛나요',
    make() {
      let blur = 16;
      let lighter = true;
      const cols = ['#ff5fa2', '#ffd23f', '#3ee0ff', '#8b6bff', '#5dff9b'];
      const orbs = (g: G, ox: number, hw: number, h: number, t: number, u: number, fancy: boolean): void => {
        if (fancy) {
          // 흐린 배경 덩어리 (filter blur)
          g.filter = `blur(${10 * u}px)`;
          for (let i = 0; i < 3; i++) {
            g.fillStyle = ['#3b2a8f', '#16608f', '#7a2a7f'][i]!;
            g.beginPath();
            g.arc(ox + hw * (0.2 + 0.3 * i) + Math.sin(t * 0.4 + i) * 10 * u, h * (0.35 + 0.2 * (i % 2)), 34 * u, 0, Math.PI * 2);
            g.fill();
          }
          g.filter = 'none';
          g.globalCompositeOperation = lighter ? 'lighter' : 'source-over';
        }
        for (let i = 0; i < cols.length; i++) {
          const a = t * 0.7 + i * 1.257;
          const x = ox + hw / 2 + Math.cos(a) * hw * 0.24 * (1 + 0.25 * Math.sin(t * 0.5 + i));
          const y = h * 0.56 + Math.sin(a * 1.3) * h * 0.24;
          const r = 20 * u;
          const c = cols[i]!;
          if (!fancy) {
            g.fillStyle = c;
            g.beginPath();
            g.arc(x, y, r, 0, Math.PI * 2);
            g.fill();
          } else {
            const gr = g.createRadialGradient(x - r * 0.3, y - r * 0.3, 0, x, y, r * 1.7);
            gr.addColorStop(0, '#ffffffcc');
            gr.addColorStop(0.18, c + 'cc');
            gr.addColorStop(0.55, c + '55');
            gr.addColorStop(1, c + '00');
            g.shadowColor = c;
            g.shadowBlur = blur * u;
            g.globalAlpha = 0.85;
            g.fillStyle = gr;
            g.beginPath();
            g.arc(x, y, r * 1.7, 0, Math.PI * 2);
            g.fill();
            g.globalAlpha = 1;
            g.shadowBlur = 0;
          }
        }
        g.globalCompositeOperation = 'source-over';
      };
      return {
        draw(g, w, h, t) {
          const u = scaleOf(w, h);
          g.save();
          g.beginPath();
          g.rect(0, 0, w / 2, h);
          g.clip();
          g.fillStyle = '#161b3d';
          g.fillRect(0, 0, w / 2, h);
          orbs(g, 0, w / 2, h, t, u, false);
          g.restore();
          g.save();
          g.beginPath();
          g.rect(w / 2, 0, w / 2, h);
          g.clip();
          const lg = g.createLinearGradient(0, 0, 0, h);
          lg.addColorStop(0, '#2b1d66');
          lg.addColorStop(1, '#070a20');
          g.fillStyle = lg;
          g.fillRect(w / 2, 0, w / 2, h);
          orbs(g, w / 2, w / 2, h, t, u, true);
          const vg = g.createRadialGradient(w * 0.75, h * 0.5, h * 0.2, w * 0.75, h * 0.5, h * 0.9);
          vg.addColorStop(0, 'rgba(0,0,0,0)');
          vg.addColorStop(1, 'rgba(0,0,0,.45)');
          g.fillStyle = vg;
          g.fillRect(w / 2, 0, w / 2, h);
          g.restore();
          splitLabels(g, w, h, u, '단색', '빛 섞기');
        },
        controls: [
          { type: 'range', label: '빛 번짐 (shadowBlur)', min: 0, max: 40, step: 1, value: 16, on: (v) => (blur = v) },
          { type: 'toggle', label: "겹치면 더하기 ('lighter')", value: true, on: (v) => (lighter = v) },
        ] as Control[],
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [잡음으로 그린 성운 (fbm)](https://ai-techstudio.web.app/ai/t/u54.md) `u54` · [캔버스 입자 엔진 (2D)](https://ai-techstudio.web.app/ai/t/i302.md) `i302` · [2D 조명 흉내 (Light2D)](https://ai-techstudio.web.app/ai/t/u19.md) `u19`
- 참고 문서: [MDN — CanvasRenderingContext2D.createRadialGradient()](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createRadialGradient) · [MDN — globalCompositeOperation](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation) · [MDN — CanvasRenderingContext2D.filter](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/filter)
