# AI 꾸러미 — 미리 그린 3D 스프라이트 (pre-rendered) — Pre-rendered sprites
> 구 · 캡슐로 짠 3D 인물에 화소마다 광선을 쏘아 2:1 등각 2D 그림으로 굽고, 몇 단계 명암 · 디더 · 테두리를 넣어 그림 없이 8방향 걷기 스프라이트를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i73

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

## 주문서

### 만들어 줘: 미리 그린 3D 스프라이트 (pre-rendered) — Pre-rendered sprites

#### 1. 목표
모자 쓴 아이 캐릭터를 3D 기본 도형(타원체 · 캡슐)으로 짜고 2:1 등각 카메라로 화소마다 광선을 쏘아 2D 스프라이트로 구워 줘 — 4단계 명암 · 4×4 디더 · 바깥 테두리 · 안쪽 깊이 선, 8방향 × 걷기 4장. 그림체는 따뜻한 도트 그림.

#### 2. 핵심 기술 용어
- **Pre-rendered sprites** — 3D 를 미리 2D 그림으로 구워 쓰기
- **Sphere tracing (SDF ray marching)** — 거리 함수로 광선을 성큼성큼 보내 표면 찾기
- **Dimetric (2:1 isometric) projection** — 고도 30° 정사영 — 땅 네모가 2:1 마름모
- **Ordered (Bayer) dithering · toon ramp** — 4×4 바이어 디더 · 몇 단계 명암

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

#### 4. 조건
- 카메라는 정사영 2:1 등각(고도 30°) — 원근 금지
- 명암은 몇 단계(기본 4) + 4×4 바이어 디더, 색은 도형 재질마다 4칸 색 사다리
- 바깥 테두리는 검정이 아닌 진한 색(자두색), 안쪽 깊이 선도 넣기
- 서쪽 3방향은 동쪽을 좌우 뒤집어 재사용, 굽기는 프레임당 한 장 (멈춤 없게)
- 그릴 때 imageSmoothingEnabled = false 로 정수 배 확대

#### 5. 완성 기준 (이게 보이면 성공)
- 같은 인물을 「3D 도형(매끈한 명암)」과 「구운 그림(4단계 · 디더 · 테두리)」으로 나란히 보여 준다
- 8방향 바퀴에서 인물이 걷기 4장으로 걷고, 서쪽은 뒤집힌 표시(⇋)가 있다
- 명암 단계 2~6 슬라이더 · 테두리 켬/끔으로 다시 구워진다
- 굽는 동안에도 화면이 멈추지 않는다 (굽는 중 n/20 표시)

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

## 원리
- 인물을 타원체 · 캡슐 14개로 짠다. 각 도형은 「점에서 표면까지 거리」 함수(SDF)를 가진다.
- 화소마다 2:1 등각 방향(고도 30°)으로 광선을 쏘고, 가장 가까운 도형 거리만큼 성큼 나아가다(최대 64걸음) 0.05 안쪽이면 맞은 것.
- 맞은 곳의 법선 · 빛(N·L)으로 명암 값을 내고, 4×4 바이어 디더를 더해 4단계로 끊어 도형별 색 사다리에서 고른다.
- 빈 칸인데 이웃이 몸이면 진한 자두색 테두리, 옆 화소가 3.2 넘게 앞에 있으면 안쪽 깊이 선. 눈 · 볼은 3D 점을 투영해 화소 하나로 찍는다.
- 8방향 중 5방향(남 ~ 북)만 굽고 서쪽 셋은 좌우 뒤집기. 굽기는 한 프레임에 한 장씩 (20장).

## 핵심 코드 — 맞은 화소를 4단계 명암 + 디더 + 테두리로 칠하기
(발췌: demos/demosEpic.ts bake73() 를 정리 (광선 쏘기는 castAll))
```ts
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
const bayer = (x: number, y: number) => (BAYER[(y & 3) * 4 + (x & 3)]! + 0.5) / 16;
const OUT = [58, 36, 48]; // 테두리: 검정 대신 진한 자두색

// H = castAll(prims, W, H, ...) — 화소마다 id(맞은 도형, 없으면 -1) · depth · 법선 nx ny nz
for (let k = 0; k < W * Ht; k++) {
  const x = k % W, y = (k / W) | 0;
  const id = H.id[k]!;
  const inside = (xx: number, yy: number) => xx >= 0 && yy >= 0 && xx < W && yy < Ht && H.id[yy * W + xx]! >= 0;
  if (id < 0) {
    // 빈 칸인데 이웃이 몸이면 바깥 테두리
    if (inside(x - 1, y) || inside(x + 1, y) || inside(x, y - 1) || inside(x, y + 1)) put(k, OUT);
    continue;
  }
  const lam = Math.max(0, H.nx[k]! * L[0] + H.ny[k]! * L[1] + H.nz[k]! * L[2]);
  const shade = Math.min(1, 0.26 + 0.74 * lam);
  // 몇 단계 + 디더: 값에 바이어 문턱을 더해 반올림
  const v = shade * (steps - 1) + (bayer(x, y) - 0.5) * 0.95;
  const lvl = Math.max(0, Math.min(steps - 1, Math.round(v)));
  let col = rampAt(RAMPS[prims[id]!.m]!, lvl / (steps - 1));
  // 안쪽 깊이 선: 옆 화소가 3.2 넘게 앞이면 (나는 뒤) 어둡게
  const me = H.depth[k]!;
  const front = (xx: number, yy: number) => xx >= 0 && yy >= 0 && xx < W && yy < Ht && H.depth[yy * W + xx]! < me - 3.2;
  if (front(x - 1, y) || front(x + 1, y) || front(x, y - 1) || front(x, y + 1)) {
    const d0 = RAMPS[prims[id]!.m]![0]!;
    col = [(d0[0] + OUT[0]) / 2, (d0[1] + OUT[1]) / 2, (d0[2] + OUT[2]) / 2];
  }
  put(k, col);
}
// 8방향: dir 0~4 만 굽고, 5~7 은 8 - dir 을 좌우 뒤집어 그린다 (g.scale(-1, 1))
```

## 흔한 실수 · 확인 목록
- [ ] **테두리를 검정으로 하면 그림이 딱딱하고 어둡다** — 진한 자두색(58,36,48)처럼 어두운 따뜻한 색으로.
- [ ] **디더 없이 단계만 끊으면 넓은 면에 줄무늬 띠가 생긴다** — 4×4 바이어 문턱을 더한 뒤 반올림한다.
- [ ] **20장을 한 번에 구우면 시작할 때 멈춘다** — 한 프레임에 한 장씩 큐에서 꺼내 굽고 「굽는 중」 표시. 조절 값이 바뀌면 큐를 다시 채운다.
- [ ] **눈 · 볼을 도형으로 만들면 너무 작아 사라지거나 뭉개진다** — 3D 점을 투영해 화소 하나로 찍고, 그 화소가 앞을 보고 깊이가 맞을 때만 그린다.

## 완성 기준 체크리스트
- [ ] 같은 인물을 「3D 도형(매끈한 명암)」과 「구운 그림(4단계 · 디더 · 테두리)」으로 나란히 보여 준다
- [ ] 8방향 바퀴에서 인물이 걷기 4장으로 걷고, 서쪽은 뒤집힌 표시(⇋)가 있다
- [ ] 명암 단계 2~6 슬라이더 · 테두리 켬/끔으로 다시 구워진다
- [ ] 굽는 동안에도 화면이 멈추지 않는다 (굽는 중 n/20 표시)

## 이 기술 정보
- id: `i73` · 분류: 2D · 화면 › 2D 그리기 · 2D · 난이도 어려움 · 폰 부담 가벼움 (폰 OK) — 굽기는 처음 한 번: 40×52 화소 × 광선 최대 64걸음 × 도형 14개. 한 프레임에 한 장씩 나눠 구워 멈춤 없음. 게임 중엔 drawImage 만.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i73
- 쓰면 좋을 때: 그림 그릴 사람 없이 등각 2D 게임 캐릭터 · 소품이 많이 필요할 때 / 방향 · 동작별 그림이 많은 스프라이트를 같은 그림체로 맞출 때
- 쓰지 말 때: 실시간 3D 로 바로 그려도 되는 게임 — 굽는 단계가 필요 없다 / 아주 큰 그림(수백 화소 넘게) — 화소마다 광선 64걸음이라 굽기가 느리다. 대신 three.js 로 찍어 줄이기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeI73 — `src/demos/demosEpic.ts:409`
```ts
function makeI73() {
  let steps = 4;
  let lines = true;
  let ringRaw = false;
  let cache = new Map<string, Baked73>();
  const queue: [number, number][] = [];
  const fill = (): void => {
    queue.length = 0;
    for (let f = 0; f < 4; f++) for (let d = 0; d <= 4; d++) queue.push([d, f]);
  };
  fill();
  const rebake = (): void => {
    cache = new Map();
    fill();
  };
  // 동 · 남동 · 북동만 굽고 서쪽 셋은 좌우 뒤집기
  const get = (dir: number, frame: number): { s: Baked73; flip: boolean } | null => {
    const src = dir > 4 ? 8 - dir : dir;
    const s = cache.get(src + '_' + frame);
    return s ? { s, flip: dir > 4 } : null;
  };
  const blit = (g: G, img: HTMLCanvasElement, cx: number, footY: number, sc: number, flip: boolean): void => {
    g.save();
    g.imageSmoothingEnabled = false;
    g.translate(Math.round(cx), Math.round(footY));
    if (flip) g.scale(-1, 1);
    g.drawImage(img, -AX73 * sc, -AY73 * sc, SW73 * sc, SH73 * sc);
    g.restore();
  };
  const shadow = (g: G, x: number, y: number, rx: number): void => {
    g.fillStyle = 'rgba(70,60,90,.22)';
    g.beginPath();
    g.ellipse(x, y, rx, rx * 0.45, 0, 0, Math.PI * 2);
    g.fill();
  };
  const NAMES = ['남', '남동', '동', '북동', '북', '북서', '서', '남서'];
  const controls: Control[] = [
    { type: 'range', label: '명암 단계', min: 2, max: 6, step: 1, value: steps, on: (v) => ((steps = v), rebake()) },
    { type: 'toggle', label: '테두리 · 깊이 선', value: lines, on: (v) => ((lines = v), rebake()) },
    { type: 'toggle', label: '바퀴도 3D 도형으로', value: ringRaw, on: (v) => (ringRaw = v) },
  ];
  return {
    controls,
    draw(g: G, w: number, h: number, t: number) {
      reset(g);
      const u = scaleOf(w, h);
      bg(g, w, h, '#cfe8ff', '#fdf3e2');
      // 한 프레임에 한 장씩 굽는다 (굽는 모습도 보이게)
      const job = queue.shift();
      if (job) cache.set(job[0] + '_' + job[1], bake73(job[0], job[1], steps, lines));
      const frame = Math.floor(t * 6) % 4;
      // ── 왼쪽: 8방향 바퀴
      const cx = w * 0.27;
      const cy = h * 0.5;
      const rx = Math.min(w * 0.205, h * 0.6);
      const ry = Math.min(rx * 0.62, h * 0.3);
      g.fillStyle = '#e9d9b4';
      g.strokeStyle = '#c9b68a';
      g.lineWidth = 1.5 * u;
      g.beginPath();
      g.moveTo(cx, cy - ry * 1.15);
      g.lineTo(cx + rx * 1.15, cy);
      g.lineTo(cx, cy + ry * 1.15);
      g.lineTo(cx - rx * 1.15, cy);
      g.closePath();
      g.fill();
      g.stroke();
      const sr = Math.max(0.6, (h * 0.22) / SH73);
      const order = [4, 3, 5, 2, 6, 1, 7, 0]; // 뒤(위)부터 앞(아래)으로
      for (const d of order) {
        const a = (d * Math.PI) / 4;
        const x = cx + Math.sin(a) * rx;
        const y = cy + Math.cos(a) * ry;
        shadow(g, x, y, 7 * sr);
        const sp = get(d, frame);
        if (sp) blit(g, ringRaw ? sp.s.raw : sp.s.baked, x, y, sr, sp.flip);
        else txt(g, '…', x, y - 20 * sr, 10 * u, '#8a7a9a');
      }
      for (let d = 0; d < 8; d++) {
        const a = (d * Math.PI) / 4;
        pill(g, NAMES[d]! + (d > 4 ? ' ⇋' : ''), cx + Math.sin(a) * rx, cy + Math.cos(a) * ry + 7 * u, 6 * u, 'rgba(255,255,255,.8)', d > 4 ? '#d0457a' : '#7a6450');
      }
      // ── 오른쪽: 같은 인물, 3D 도형 ↔ 구운 그림
      const dir = Math.floor(t / 1.3) % 8;
      const sb = Math.max(1, (h * 0.58) / SH73);
      const fy = h * 0.83;
      const xr = w * 0.64;
      const xb = w * 0.87;
      shadow(g, xr, fy, 9 * sb);
      shadow(g, xb, fy, 9 * sb);
      const sp = get(dir, frame);
      if (sp) {
        blit(g, sp.s.raw, xr, fy, sb, sp.flip);
        blit(g, sp.s.baked, xb, fy, sb, sp.flip);
      }
      g.fillStyle = 'rgba(80,70,110,.25)';
      g.fillRect(w * 0.755, h * 0.2, 1.2 * u, h * 0.7);
      pill(g, '3D 도형', xr, 13 * u, 8.5 * u, 'rgba(60,50,100,.7)', '#e6e0ff');
      pill(g, '구운 그림', xb, 13 * u, 8.5 * u, '#ff6fa8');
      pill(g, `걷기 ${frame + 1}/4 · ${NAMES[dir]}`, (xr + xb) / 2, h - 9 * u, 7.5 * u, 'rgba(255,255,255,.75)', '#5a4a70');
      if (queue.length) pill(g, `굽는 중 ${20 - queue.length}/20`, 8 * u, h - 10 * u, 7.5 * u, '#ffb43a', '#3a2a10', 'left');
      else pill(g, `${steps}단계 명암 + 디더`, 8 * u, h - 10 * u, 7.5 * u, 'rgba(60,50,100,.7)', '#fff', 'left');
    },
  };
}
```

### i73 견본 항목 — `src/demos/demosEpic.ts:2091`
```ts
  i73: {
    kind: '2d',
    caption: '구 · 캡슐로 짠 인물을 화소마다 광선으로 구움 — 왼쪽 8방향 걷기(⇋ = 좌우 뒤집어 재사용), 오른쪽 3D 도형 ↔ 4단계 명암 · 디더 · 테두리 그림',
    make: makeI73,
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [SDF 조각 모델링 (부드럽게 합치기)](https://ai-techstudio.web.app/ai/t/i446.md) `i446`
- 다음에 해 볼 기술: [픽셀 아트 확대 (계단 그대로 · 서브픽셀)](https://ai-techstudio.web.app/ai/t/i304.md) `i304` · [색 바꾸기 · 팔레트 교체 (2D)](https://ai-techstudio.web.app/ai/t/i296.md) `i296`
- 참고 문서: [Wikipedia — Ordered dithering](https://en.wikipedia.org/wiki/Ordered_dithering) · [Inigo Quilez — distance functions](https://iquilezles.org/articles/distfunctions/)
