# AI 꾸러미 — 화면 밖 캔버스 (OffscreenCanvas)
> 시작할 때 큰 캔버스 무늬를 굽는 일을 일꾼(Worker) 안의 화면 밖 캔버스로 넘겨, 굽는 동안에도 게임 화면이 먼저 열리게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i32

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

## 주문서

### 만들어 줘: 화면 밖 캔버스 (OffscreenCanvas)

#### 1. 목표
캔버스 무늬를 많이 굽는 게임을 시작할 때 굽는 큰 무늬를 Web Worker 안의 OffscreenCanvas 에서 그리게 바꿔 줘 — 게임은 먼저 열리고 무늬는 다 되면 갈아 끼운다. 기다리는 동안은 빈 자리에 옅은 바탕색.

#### 2. 핵심 기술 용어
- **OffscreenCanvas** — 화면에 붙지 않은 캔버스 — 일꾼 안에서도 그릴 수 있다
- **Web Worker** — 화면 줄(메인 스레드)과 따로 도는 일꾼
- **transferToImageBitmap · Transferable** — 다 그린 그림을 복사 없이 화면 줄로 넘기기
- **Main-thread blocking** — 화면 줄이 막혀 하얀 화면 · 멈춤이 생기는 것

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 그림을 넘길 때 postMessage(bmp, [bmp]) 처럼 옮기기 목록에 넣어 복사하지 않기
- 일꾼 안에서는 document · window · Image 를 쓰지 않기 (OffscreenCanvas · fetch · createImageBitmap 만)
- OffscreenCanvas 가 없는 브라우저면 화면 줄에서 같은 함수로 굽는 대비 길을 두기 (그리기 함수는 캔버스 종류와 상관없게)
- 그림이 오기 전에도 게임이 바로 열리고, 오면 그 자리만 갈아 끼우기 (전체 다시 그리기 금지)

#### 5. 완성 기준 (이게 보이면 성공)
- 켬/끔 비교: 끔이면 시작할 때 하얀 화면이 굽는 시간만큼 이어지고, 켬이면 바로 게임 화면이 뜬 뒤 무늬가 들어온다
- 성능 패널(Performance)에서 굽는 동안 화면 줄에 50ms 넘는 긴 작업이 없다
- OffscreenCanvas 를 끈 대비 길에서도 같은 무늬가 나온다
- 폰에서도 첫 화면이 굽는 시간과 상관없이 뜬다

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

## 원리
- 보통 캔버스는 화면 줄에서만 그릴 수 있어서, 큰 무늬를 굽는 동안 화면이 멈춘다 (견본 위 줄: 굽는 2초 동안 하얀 화면).
- OffscreenCanvas 는 화면에 붙지 않은 캔버스라 일꾼 안에서 만들고 그릴 수 있다.
- 일꾼이 다 그리면 transferToImageBitmap() 으로 그림을 꺼내 postMessage 로 넘긴다 — 두 번째 인자에 넣으면 복사 없이 옮겨진다.
- 화면 줄은 그동안 게임을 먼저 열고(견본 아래 줄: 0.6초), 그림이 오면 drawImage 나 텍스처로 갈아 끼운다.

## 핵심 코드 — 일꾼 안에서 무늬 굽기 → 그림만 넘겨 받기
(발췌: 새로 씀 (견본 demos/demosSystem.ts i32 는 개념 그림 — 6×6 네 가지 색 무늬는 견본 pat() 과 같게))
```ts
// bake.worker.ts — 일꾼: 화면 밖 캔버스에 무늬를 굽고 그림만 넘긴다
self.onmessage = (e: MessageEvent<{ size: number }>) => {
  const s = e.data.size;
  const cv = new OffscreenCanvas(s, s);
  const g = cv.getContext('2d')!;
  const cols = ['#ff9ab0', '#ffd23f', '#7ad0ff', '#9af0b8'];
  const n = 6;
  const c = s / n;
  for (let i = 0; i < n * n; i++) {
    const cx = i % n;
    const cy = Math.floor(i / n);
    g.fillStyle = cols[(cx + cy * 2) % 4]!;
    g.fillRect(cx * c, cy * c, c, c);
  }
  const bmp = cv.transferToImageBitmap();
  (self as unknown as Worker).postMessage(bmp, [bmp]); // 복사 없이 옮기기
};

// main.ts — 게임은 먼저 열고, 그림이 오면 그 자리만 갈아 끼운다
let pattern: ImageBitmap | null = null;
const worker = new Worker(new URL('./bake.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (e: MessageEvent<ImageBitmap>) => {
  pattern = e.data;
  worker.terminate();
};
worker.postMessage({ size: 1024 });

function drawBoard(g: CanvasRenderingContext2D) {
  if (pattern) g.drawImage(pattern, 0, 0, 240, 240);
  else { g.fillStyle = '#e8ecf8'; g.fillRect(0, 0, 240, 240); } // 오기 전엔 옅은 바탕
}
```

## 흔한 실수 · 확인 목록
- [ ] **그림을 옮기기 목록 없이 보내면 큰 그림이 복사된다** — postMessage(bmp, [bmp]) 로 넘겨야 복사 없이 옮겨진다. 넘긴 뒤 일꾼 쪽 bmp 는 못 쓴다.
- [ ] **일꾼 안에서 document · new Image() 를 쓰면 바로 오류가 난다** — 그림 파일은 fetch → createImageBitmap 으로 읽고, 글꼴은 FontFace 를 self.fonts 에 넣어 쓴다.
- [ ] **OffscreenCanvas 가 없는 옛 브라우저에서 무늬가 안 나온다** — typeof OffscreenCanvas === 'undefined' 이면 화면 줄에서 같은 그리기 함수를 부르는 대비 길을 둔다.
- [ ] **그림 하나 받을 때마다 일꾼을 새로 띄우면 오히려 느리다** — 일꾼 하나에 굽기 일을 여러 개 맡기고, 다 끝나면 terminate 한다.

## 완성 기준 체크리스트
- [ ] 켬/끔 비교: 끔이면 시작할 때 하얀 화면이 굽는 시간만큼 이어지고, 켬이면 바로 게임 화면이 뜬 뒤 무늬가 들어온다
- [ ] 성능 패널(Performance)에서 굽는 동안 화면 줄에 50ms 넘는 긴 작업이 없다
- [ ] OffscreenCanvas 를 끈 대비 길에서도 같은 무늬가 나온다
- [ ] 폰에서도 첫 화면이 굽는 시간과 상관없이 뜬다

## 이 기술 정보
- id: `i32` · 분류: 게임 시스템 · AI › 플랫폼 · 성능 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 굽는 비용은 그대로지만 화면 줄 밖에서 돈다. 일꾼 하나를 띄우는 데 수십 ms, 그림 넘기기는 복사가 없어 거의 0.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i32
- 쓰면 좋을 때: 시작할 때 잡음 · 무늬 캔버스를 여러 장 굽느라 첫 화면이 늦을 때 / 판을 바꿀 때마다 큰 배경을 새로 그려야 할 때
- 쓰지 말 때: 그림이 작아 몇 ms 안에 끝날 때 — 일꾼을 띄우는 비용이 더 크다 / DOM · 글꼴 측정이 꼭 필요한 그림 — 일꾼에는 document 가 없다. 대신 화면 줄에서 프레임마다 조금씩 나눠 굽는다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i32 견본 항목 — `src/demos/demosSystem.ts:2154`
```ts
  i32: {
    kind: '2d',
    caption: '위: 화면 줄에서 무늬를 구우면 시작이 늦음 · 아래: 일꾼의 화면 밖 캔버스가 굽는 동안 게임이 먼저 열려요',
    make() {
      const P = 5;
      const pat = (g: G, x: number, y: number, s: number, prog: number): void => {
        const n = 6;
        const c = s / n;
        for (let i = 0; i < n * n; i++) {
          if (i / (n * n) > prog) break;
          const cx = i % n;
          const cy = Math.floor(i / n);
          g.fillStyle = ['#ff9ab0', '#ffd23f', '#7ad0ff', '#9af0b8'][(cx + cy * 2) % 4]!;
          g.fillRect(x + cx * c, y + cy * c, c, c);
        }
      };
      return {
        draw(g, w, h, t) {
          stage(g, w, h, NAVY);
          const x = t % P;
          const tx = (s: number): number => 70 + (s / 4) * 150;
          const row = (oy: number, off: boolean): void => {
            txt(g, off ? 'OffscreenCanvas' : '보통 캔버스', 10, oy + 6, 10, off ? '#7af0c8' : '#ffb2a8', 'left', 800, TF);
            txt(g, '화면 줄', 12, oy + 28, 8, '#cdd6ff', 'left', 700);
            box(g, 70, oy + 20, 150, 16, 4, 'rgba(255,255,255,0.08)');
            if (off) {
              txt(g, '일꾼 줄', 12, oy + 52, 8, '#cdd6ff', 'left', 700);
              box(g, 70, oy + 44, 150, 16, 4, 'rgba(255,255,255,0.08)');
              box(g, tx(0.2), oy + 45, tx(2.2) - tx(0.2), 14, 3, '#a87aff');
              txt(g, '무늬 굽기', (tx(0.2) + tx(2.2)) / 2, oy + 52.5, 8, '#fff', 'center', 800);
              for (let s = 0; s < 4; s += 0.2) box(g, tx(s) + 0.5, oy + 22, 4, 12, 1.5, s < 0.6 ? '#9ab0ff' : '#3ccf7a');
              arrow(g, tx(2.2), oy + 44, tx(2.2), oy + 36, '#ffd23f', 1.4);
            } else {
              box(g, tx(0.2), oy + 21, tx(2.2) - tx(0.2), 14, 3, '#ff7a6b');
              txt(g, '무늬 굽기 (멈춤)', (tx(0.2) + tx(2.2)) / 2, oy + 28.5, 8, '#fff', 'center', 800);
              for (let s = 2.2; s < 4; s += 0.2) box(g, tx(s) + 0.5, oy + 22, 4, 12, 1.5, '#3ccf7a');
            }
            line(g, tx(Math.min(x, 4)), oy + 16, tx(Math.min(x, 4)), oy + (off ? 62 : 38), '#fff', 1.5);
            // 화면
            box(g, 236, oy, 74, 62, 8, '#fdfbff');
            const prog = clamp((x - 0.2) / 2);
            const ready = off ? x > 0.6 : x > 2.2;
            if (!ready) {
              if (off || x < 0.2) {
                for (let i = 0; i < 6; i++) {
                  const a = t * 5 + i;
                  circle(g, 273 + Math.cos(a) * 10, oy + 31 + Math.sin(a) * 10, 2, '#9ab0ff');
                }
              } else txt(g, '하얀 화면…', 273, oy + 31, 8.5, '#a0a8c8', 'center', 700);
            } else {
              if (off && prog < 1) {
                g.fillStyle = '#e8ecf8';
                g.fillRect(244, oy + 8, 46, 46);
              } else pat(g, 244, oy + 8, 46, 1);
              circle(g, 297, oy + 48, 9, '#ff7a6b');
              face(g, 297, oy + 48, 6);
            }
          };
          row(10, false);
          row(96, true);
          // 일꾼 안에서 구워지는 무늬
          box(g, 236, 168, 74, 26, 6, 'rgba(168,122,255,0.25)');
          pat(g, 240, 171, 20, clamp((x - 0.2) / 2));
          txt(g, '일꾼이 굽는 중', 284, 181, 7.5, '#d8c8ff', 'center', 700);
          txt(g, '시작이 빨라져요 →', 120, 182, 8.5, '#7af0c8', 'center', 800);
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [화면 안 멈추는 계산 (Web Worker)](https://ai-techstudio.web.app/ai/t/i359.md) `i359`
- 다음에 해 볼 기술: [지형 조각 캐시 (청크 · LRU)](https://ai-techstudio.web.app/ai/t/i78.md) `i78` · [잡음으로 그린 성운 (fbm)](https://ai-techstudio.web.app/ai/t/u54.md) `u54`
- 참고 문서: [MDN — OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) · [MDN — OffscreenCanvas.transferToImageBitmap()](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas/transferToImageBitmap)
