# AI 꾸러미 — 2D 조명 흉내 (Light2D) — 2D lighting (light map multiply)
> 어두운 바탕빛에 손전등 · 전등 빛을 더한 「빛 지도」를 따로 그려 그림에 곱해, 2D 장면에 어둠 속 빛을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/u19

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

## 주문서

### 만들어 줘: 2D 조명 흉내 (Light2D) — 2D lighting (light map multiply)

#### 1. 목표
어두운 방 · 동굴 장면에 2D 조명을 넣어 줘 — 화면 밖 캔버스에 어두운 바탕빛을 깔고 손전등 · 전등 빛을 더한 뒤, 그 빛 지도를 그림에 곱한다. 분위기는 따뜻한 전등 빛.

#### 2. 핵심 기술 용어
- **2D lighting (light map multiply)** — 빛 지도를 그림에 곱하는 2D 조명
- **Ambient light** — 어디나 깔리는 어두운 바탕빛
- **globalCompositeOperation 'lighter' · 'multiply'** — 빛끼리는 더하고, 그림에는 곱하기
- **Light2D (Unity URP)** — 유니티 2D 조명 — 이 방식의 원본

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

#### 4. 조건
- 빛 지도는 따로 캔버스(반 해상도), 빛끼리는 lighter 로 더하기
- 그림에는 multiply 로 곱하기 — 빛이 원래 색보다 밝게 만들지는 않는다
- 바탕빛은 0 이 아니게 (완전 검정이면 아무것도 안 보인다), 조절 가능하게
- 그리고 나면 globalCompositeOperation 을 source-over 로 되돌리기

#### 5. 완성 기준 (이게 보이면 성공)
- 「그림 × 빛 지도 = 결과」 세 장을 나란히 보여 주면 결과가 빛 자리만 밝다
- 손전등이 움직이면 그 자리만 밝아지고, 전등은 살짝 일렁인다
- 바탕빛 슬라이더를 올리면 어둠이 옅어진다
- 조명 끔 / 켬 비교가 된다

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

## 원리
- 그림(방)은 밝게 그대로 그려 둔다. 빛은 반 해상도 캔버스(빛 지도)에 따로 그린다.
- 빛 지도를 먼저 어두운 바탕빛(amb 0.18, 파랑을 1.9배 — 푸른 어둠)으로 채운다.
- 그 위에 'lighter'(더하기)로 빛마다 방사 그러데이션을 그린다 — 밝기는 (1 − 거리)^1.6 로 줄어든다. 전등은 0.85 + 0.15·sin(11t)·sin(5.3t) 으로 일렁인다.
- 결과 = 그림을 그리고 그 위에 빛 지도를 'multiply'(곱하기)로 덮기. 빛 없는 곳은 바탕빛만큼 어둡고, 빛 아래만 원래 색이 산다.

## 핵심 코드 — 빛 지도 그리기 → 그림에 곱하기
(발췌: demos/demosLight.ts u19 draw() 를 정리)
```ts
// room: 밝게 그린 그림 (RW × RH) · light: 반 해상도 빛 지도 · out: 결과
const light = document.createElement('canvas');
light.width = RW / 2;
light.height = RH / 2;

function renderLit(o: CanvasRenderingContext2D, t: number, amb = 0.18) {
  const l = light.getContext('2d')!;
  const s = light.width / RW;
  // 1) 어두운 바탕빛 (파랑을 더 — 푸른 어둠)
  const a = Math.round(amb * 255);
  l.globalCompositeOperation = 'source-over';
  l.fillStyle = 'rgb(' + a + ',' + Math.round(a * 1.1) + ',' + Math.min(255, Math.round(a * 1.9)) + ')';
  l.fillRect(0, 0, light.width, light.height);
  // 2) 빛은 더하기
  l.globalCompositeOperation = 'lighter';
  const spot = (x: number, y: number, rad: number, c: [number, number, number], k: number) => {
    const gr = l.createRadialGradient(x * s, y * s, 0, x * s, y * s, rad * s);
    for (let i = 0; i <= 6; i++) {
      const q = i / 6;
      const v = Math.pow(1 - q, 1.6) * k;
      gr.addColorStop(q, 'rgb(' + Math.round(c[0] * v) + ',' + Math.round(c[1] * v) + ',' + Math.round(c[2] * v) + ')');
    }
    l.fillStyle = gr;
    l.beginPath();
    l.arc(x * s, y * s, rad * s, 0, Math.PI * 2);
    l.fill();
  };
  spot(RW / 2 + Math.sin(t * 0.7) * 200, 250 + Math.sin(t * 1.3) * 80, 125, [255, 250, 230], 1.1); // 손전등
  spot(380, 95, 170, [255, 210, 120], 0.85 + 0.15 * Math.sin(t * 11) * Math.sin(t * 5.3)); // 일렁이는 전등
  // 3) 그림 위에 빛 지도를 곱하기
  o.globalCompositeOperation = 'source-over';
  o.drawImage(room, 0, 0);
  o.globalCompositeOperation = 'multiply';
  o.drawImage(light, 0, 0, RW, RH);
  o.globalCompositeOperation = 'source-over';
}
```

## 흔한 실수 · 확인 목록
- [ ] **빛을 그림 위에 lighter 로 바로 더하면 어둠이 안 생기고 하얗게 뜬다** — 빛은 따로 빛 지도에 모아 두고, 그림에는 multiply 로 곱한다.
- [ ] **바탕빛을 0 으로 두면 빛 밖이 새까매 아무것도 안 보인다** — 0.15~0.25 정도로, 파랑을 조금 더해 밤 느낌을 낸다.
- [ ] **빛 지도를 전체 해상도로 그리면 폰에서 무겁다** — 반 해상도로 그리고 늘려 곱한다 — 빛은 부드러워서 티가 안 난다.
- [ ] **글자 · 단추까지 곱하기 안에 넣으면 같이 어두워진다** — UI 는 곱하기를 끝낸 뒤 그 위에 그린다.

## 완성 기준 체크리스트
- [ ] 「그림 × 빛 지도 = 결과」 세 장을 나란히 보여 주면 결과가 빛 자리만 밝다
- [ ] 손전등이 움직이면 그 자리만 밝아지고, 전등은 살짝 일렁인다
- [ ] 바탕빛 슬라이더를 올리면 어둠이 옅어진다
- [ ] 조명 끔 / 켬 비교가 된다

## 이 기술 정보
- id: `u19` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 빛 지도는 반 해상도라 칠할 화소가 1/4. 빛 몇 개 + 곱하기 한 번이면 폰도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u19
- 쓰면 좋을 때: 2D 장면에 어둠 · 손전등 · 등불 분위기가 필요할 때 / 유니티 2D 조명을 쓰던 게임을 웹으로 옮길 때 (SEVEN)
- 쓰지 말 때: 입체 음영(요철)까지 필요할 때 — 곱하기 빛은 평평하다. 대신 노멀 맵 스프라이트 조명(i234) / 벽 뒤가 가려져야 할 때 — 빛이 벽을 뚫는다. 대신 시야 다각형(i235)으로 빛을 자르기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### paintRoom — `src/demos/demosLight.ts:591`
```ts
function paintRoom(g: CanvasRenderingContext2D): void {
  const wall = g.createLinearGradient(0, 0, 0, RH);
  wall.addColorStop(0, '#7fb7ff');
  wall.addColorStop(1, '#b9d8ff');
  g.fillStyle = wall;
  g.fillRect(0, 0, RW, RH);
  g.fillStyle = 'rgba(255,255,255,0.35)';
  for (let y = 20; y < 320; y += 36) for (let x = (y / 36) % 2 ? 18 : 36; x < RW; x += 36) g.fillRect(x, y, 6, 6);
  // 바닥
  g.fillStyle = '#c98a4b';
  g.fillRect(0, 320, RW, 100);
  g.strokeStyle = '#a86c33';
  g.lineWidth = 3;
  for (let x = 0; x < RW; x += 70) {
    g.beginPath();
    g.moveTo(x, 320);
    g.lineTo(x - 30, RH);
    g.stroke();
  }
  // 창문
  g.fillStyle = '#ffffff';
  g.fillRect(48, 60, 120, 100);
  g.fillStyle = '#2b3f8f';
  g.fillRect(56, 68, 104, 84);
  g.fillStyle = '#fff3a0';
  g.beginPath();
  g.arc(128, 96, 16, 0, Math.PI * 2);
  g.fill();
  g.fillStyle = '#ffffff';
  g.fillRect(105, 60, 6, 100);
  // 선반과 책
  g.fillStyle = '#8a5a2b';
  g.fillRect(230, 150, 160, 12);
  const books = ['#ff5d73', '#ffd23f', '#3ec46d', '#4aa3ff', '#b06cff', '#ff9f43'];
  books.forEach((c, i) => {
    g.fillStyle = c;
    g.fillRect(240 + i * 22, 100 + (i % 2) * 8, 18, 50 - (i % 2) * 8);
  });
  // 문
  g.fillStyle = '#e07a3f';
  g.fillRect(450, 150, 80, 170);
  g.fillStyle = '#ffd23f';
  g.beginPath();
  g.arc(515, 240, 7, 0, Math.PI * 2);
  g.fill();
  // 숫자 블록
  const blocks: [number, number, string, string][] = [
    [70, 250, '#ff5d73', '7'],
    [150, 270, '#3ec46d', '3'],
    [300, 260, '#ffb02e', '4'],
  ];
  for (const [x, y, c, d] of blocks) {
    g.fillStyle = c;
    g.beginPath();
    g.roundRect(x, y, 66, 66, 10);
    g.fill();
    g.fillStyle = 'rgba(255,255,255,0.9)';
    g.font = `900 44px "Black Han Sans", ${FONT}`;
    g.textAlign = 'center';
    g.textBaseline = 'middle';
    g.fillText(d, x + 33, y + 36);
  }
  // 고양이
  g.fillStyle = '#ffffff';
  g.beginPath();
  g.ellipse(400, 310, 34, 26, 0, 0, Math.PI * 2);
  g.fill();
  g.beginPath();
  g.arc(400, 272, 22, 0, Math.PI * 2);
  g.fill();
  g.beginPath();
  g.moveTo(382, 262);
  g.lineTo(386, 240);
  g.lineTo(396, 256);
  g.moveTo(418, 262);
  g.lineTo(414, 240);
  g.lineTo(404, 256);
  g.fill();
  g.fillStyle = '#222';
  g.beginPath();
  g.arc(392, 272, 3, 0, Math.PI * 2);
  g.arc(408, 272, 3, 0, Math.PI * 2);
  g.fill();
  // 천장 등
  g.strokeStyle = '#333';
  g.lineWidth = 2;
  g.beginPath();
  g.moveTo(380, 0);
  g.lineTo(380, 60);
  g.stroke();
  g.fillStyle = '#ffe066';
  g.beginPath();
  g.moveTo(356, 82);
  g.lineTo(404, 82);
  g.lineTo(392, 60);
  g.lineTo(368, 60);
  g.fill();
}
```

### u19 견본 항목 — `src/demos/demosLight.ts:1254`
```ts
  u19: {
    kind: '2d',
    caption: '그림 × 빛 지도 = 결과 — 어두운 바탕 빛에 손전등 · 전등 빛을 더한 지도를 그림에 곱해요 (SEVEN 방식)',
    make() {
      const room = document.createElement('canvas');
      room.width = RW;
      room.height = RH;
      let painted = false;
      const light = document.createElement('canvas');
      light.width = RW / 2;
      light.height = RH / 2;
      const out = document.createElement('canvas');
      out.width = RW;
      out.height = RH;
      let on = true;
      let amb = 0.18;
      const coverRect = (x: number, y: number, w: number, h: number): [number, number, number, number] => {
        const s = Math.max(w / RW, h / RH);
        const dw = RW * s;
        const dh = RH * s;
        return [x + (w - dw) / 2, y + (h - dh) / 2, dw, dh];
      };
      const tag = (g: CanvasRenderingContext2D, s: string, x: number, y: number, size: number): void => {
        g.font = `700 ${size}px ${FONT}`;
        const tw = g.measureText(s).width;
        g.fillStyle = 'rgba(14,20,44,0.8)';
        g.beginPath();
        g.roundRect(x - tw / 2 - size * 0.5, y - size * 0.75, tw + size, size * 1.5, size * 0.75);
        g.fill();
        g.fillStyle = '#fff';
        g.textAlign = 'center';
        g.textBaseline = 'middle';
        g.fillText(s, x, y + 1);
      };
      return {
        draw(g, w, h, t) {
          if (!painted) {
            paintRoom(room.getContext('2d')!);
            painted = true;
            document.fonts?.ready.then(() => paintRoom(room.getContext('2d')!));
          }
          // 빛 지도
          const l = light.getContext('2d')!;
          const lw = light.width;
          const lh = light.height;
          const s = lw / RW;
          l.globalCompositeOperation = 'source-over';
          const a = Math.round(amb * 255);
          l.fillStyle = `rgb(${a},${Math.round(a * 1.1)},${Math.min(255, Math.round(a * 1.9))})`;
          l.fillRect(0, 0, lw, lh);
          l.globalCompositeOperation = 'lighter';
          const spot = (x: number, y: number, rad: number, c: [number, number, number], k: number): void => {
            const gr = l.createRadialGradient(x * s, y * s, 0, x * s, y * s, rad * s);
            for (let i = 0; i <= 6; i++) {
              const q = i / 6;
              const v = Math.pow(1 - q, 1.6) * k;
              gr.addColorStop(q, `rgb(${Math.round(c[0] * v)},${Math.round(c[1] * v)},${Math.round(c[2] * v)})`);
            }
            l.fillStyle = gr;
            l.beginPath();
            l.arc(x * s, y * s, rad * s, 0, Math.PI * 2);
            l.fill();
          };
          const fx = RW / 2 + Math.sin(t * 0.7) * 200;
          const fy = 250 + Math.sin(t * 1.3) * 80;
          spot(fx, fy, 125, [255, 250, 230], 1.1);
          const fl = 0.85 + 0.15 * Math.sin(t * 11) * Math.sin(t * 5.3);
          spot(380, 95, 170, [255, 210, 120], fl);
          spot(103, 283, 70, [255, 120, 150], 0.55 + 0.25 * Math.sin(t * 2));
          // 결과
          const o = out.getContext('2d')!;
          o.globalCompositeOperation = 'source-over';
          o.drawImage(room, 0, 0);
          if (on) {
            o.globalCompositeOperation = 'multiply';
            o.drawImage(light, 0, 0, RW, RH);
            o.globalCompositeOperation = 'source-over';
          }
          // 배치
          g.fillStyle = '#0c1230';
          g.fillRect(0, 0, w, h);
          const fsz = Math.max(10, Math.min(18, h * 0.065));
          if (w >= h * 1.15) {
            const pad = Math.max(4, h * 0.025);
            const cw = Math.min(w * 0.3, (h - pad * 3) / 2 / 0.75);
            const th = cw * 0.75;
            const y1 = (h - th * 2 - pad) / 2;
            g.drawImage(room, pad, y1, cw, th);
            g.drawImage(light, pad, y1 + th + pad, cw, th);
            tag(g, '그림', pad + cw / 2, y1 + fsz, fsz * 0.9);
            tag(g, '빛 지도', pad + cw / 2, y1 + th + pad + fsz, fsz * 0.9);
            g.fillStyle = '#ffd23f';
            g.font = `900 ${fsz * 1.6}px ${FONT}`;
            g.textAlign = 'center';
            g.textBaseline = 'middle';
            g.fillText('×', pad + cw / 2, y1 + th + pad / 2);
            const rx = pad * 2 + cw + fsz * 1.4;
            g.fillText('=', pad + cw + (rx - pad - cw) / 2, h / 2);
            g.save();
            g.beginPath();
            g.rect(rx, 0, w - rx, h);
            g.clip();
            const [dx, dy, dw, dh] = coverRect(rx, 0, w - rx, h);
            g.drawImage(out, dx, dy, dw, dh);
            g.restore();
            tag(g, on ? '결과 (곱하기)' : '빛 끔', rx + (w - rx) / 2, h - fsz * 1.2, fsz);
          } else {
            const pad = Math.max(4, w * 0.02);
            const tw = (w - pad * 3) / 2;
            const th = Math.min(h * 0.3, tw * 0.75);
            const tw2 = th / 0.75;
            const x1 = (w - tw2 * 2 - pad) / 2;
            g.drawImage(room, x1, pad, tw2, th);
            g.drawImage(light, x1 + tw2 + pad, pad, tw2, th);
            tag(g, '그림', x1 + tw2 / 2, pad + fsz, fsz * 0.9);
            tag(g, '빛 지도', x1 + tw2 * 1.5 + pad, pad + fsz, fsz * 0.9);
            const ry = pad * 2 + th;
            g.save();
            g.beginPath();
            g.rect(0, ry, w, h - ry);
            g.clip();
            const [dx, dy, dw, dh] = coverRect(0, ry, w, h - ry);
            g.drawImage(out, dx, dy, dw, dh);
            g.restore();
            tag(g, on ? '결과 (곱하기)' : '빛 끔', w / 2, h - fsz * 1.2, fsz);
          }
        },
        controls: [
          { type: 'toggle', label: '빛 지도 곱하기', value: true, on: (v) => (on = v) },
          { type: 'range', label: '바탕 빛 (어둠 정도)', min: 0, max: 0.8, step: 0.02, value: 0.18, on: (v) => (amb = v) },
        ],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [캔버스 그러데이션 · 흐림 · 빛 섞기](https://ai-techstudio.web.app/ai/t/u52.md) `u52`
- 다음에 해 볼 기술: [노멀 맵 스프라이트 조명](https://ai-techstudio.web.app/ai/t/i234.md) `i234` · [2D 시야 다각형 (빛 그림자)](https://ai-techstudio.web.app/ai/t/i235.md) `i235`
- 참고 문서: [MDN — globalCompositeOperation (multiply · lighter)](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation)
