# AI 꾸러미 — 스프라이트 묶기 · 텍스처 아틀라스 — Texture atlas (sprite sheet packing)
> 흩어진 작은 그림 수십 장을 큰 것부터 「가장 낮은 빈자리」에 꾸려 넣어 한 장으로 합쳐, 그리기 한 번 · 텍스처 바꾸기 0번으로 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i303

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

## 주문서

### 만들어 줘: 스프라이트 묶기 · 텍스처 아틀라스 — Texture atlas (sprite sheet packing)

#### 1. 목표
아이콘 · 스티커 그림 수십 장을 텍스처 아틀라스로 꾸려 줘 — 높이가 큰 것부터 스카이라인 방식으로 가장 낮은 빈자리에 넣고, 그림마다 아틀라스 안 자리(x · y · w · h)를 적어 한 장으로. 꾸려 넣는 과정을 밤하늘 판 위 색 카드(으)로 한 장씩 보여 줘.

#### 2. 핵심 기술 용어
- **Texture atlas (sprite sheet packing)** — 텍스처 아틀라스 — 그림 여러 장을 한 장에
- **Skyline bin packing** — 스카이라인 꾸리기 — 칸마다 쌓인 높이를 보고 가장 낮은 곳에
- **Draw call batching** — 그리기 호출 묶기
- **UV rect (source rectangle)** — 아틀라스 안 그림 자리 (x · y · w · h)

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

#### 4. 조건
- 정렬은 높이 큰 순, 같으면 폭 큰 순
- 자리마다 걸친 칸의 최대 높이를 보고 가장 낮은 곳 — 같으면 왼쪽
- 판을 넘치면 실패로 알리고 판을 키우거나 장 수를 줄여 다시
- 그림마다 아틀라스 안 자리를 저장하고, 밉맵을 쓰면 그림 사이에 1~2px 여백(번짐 막기)
- 쓴 넓이 ÷ 판 넓이(채움률)를 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 흩어진 카드가 한 장씩 날아와 판 아래쪽부터 빈틈 적게 쌓인다
- 스카이라인 선(칸마다 쌓인 높이)이 놓을 때마다 올라간다
- 「새 그림 묶음」을 누르면 다른 크기 묶음으로 다시 꾸리고 채움률이 바뀐다

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

## 원리
- 판 폭만큼 칸마다 「지금 쌓인 높이」 배열(스카이라인)을 둔다. 처음은 모두 0.
- 그림을 높이 → 폭 큰 순으로 정렬한다 (큰 것부터 넣어야 빈틈이 적다).
- 그림 폭만큼 걸치는 모든 자리를 훑어, 걸친 칸들의 최대 높이가 가장 낮은 x 에 놓는다.
- 놓은 칸들의 높이를 「그 높이 + 그림 높이」로 올린다. 판을 넘치면 장 수를 줄여 다시 꾸린다.

## 핵심 코드 — 스카이라인 꾸리기 — 가장 낮은 빈자리에
(발췌: demos/demos2dLook.ts mkI303() build() 를 정리)
```ts
interface It { w: number; h: number; x: number; y: number }

/** AW × AW 판(칸 단위)에 꾸려 넣기 — 성공하면 true, 넘치면 false */
function pack(items: It[], AW: number): boolean {
  items.sort((a, b) => b.h - a.h || b.w - a.w); // 큰 것부터
  const hs = new Array<number>(AW).fill(0);      // 칸마다 쌓인 높이 (스카이라인)
  for (const it of items) {
    let best = 1e9;
    let bx = 0;
    for (let x = 0; x + it.w <= AW; x++) {
      let m = 0;
      for (let k = x; k < x + it.w; k++) m = Math.max(m, hs[k]); // 걸친 칸 중 가장 높은 곳에 얹힌다
      if (m < best) {
        best = m;
        bx = x;
      }
    }
    it.x = bx;
    it.y = best;
    for (let k = bx; k < bx + it.w; k++) hs[k] = best + it.h;
  }
  return Math.max(...hs) <= AW;
}

// 그리기: drawImage(atlas, it.x * cell, it.y * cell, it.w * cell, it.h * cell, 화면 x, y, w, h)
// three 면 UV = (it.x / AW, it.y / AW) ~ ((it.x + it.w) / AW, (it.y + it.h) / AW)
```

## 흔한 실수 · 확인 목록
- [ ] **그림 사이 여백 없이 붙이면 축소될 때 이웃 그림 색이 번진다** — 밉맵 · 선형 필터를 쓰면 1~2px 여백을 두고, 가장자리 픽셀을 한 칸 늘려 둔다.
- [ ] **들어온 순서대로 넣으면 큰 그림이 들어갈 자리가 없어진다** — 높이 · 폭 큰 순으로 정렬한 뒤 넣는다.
- [ ] **판보다 높이 쌓여도 그대로 쓰면 그림이 잘린다** — 최대 높이가 판을 넘으면 실패 — 견본은 장 수를 2장씩 줄여 다시 꾸린다.

## 완성 기준 체크리스트
- [ ] 흩어진 카드가 한 장씩 날아와 판 아래쪽부터 빈틈 적게 쌓인다
- [ ] 스카이라인 선(칸마다 쌓인 높이)이 놓을 때마다 올라간다
- [ ] 「새 그림 묶음」을 누르면 다른 크기 묶음으로 다시 꾸리고 채움률이 바뀐다

## 이 기술 정보
- id: `i303` · 분류: 게임 시스템 · AI › 속도 기법 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 꾸리기는 그림 수 × 판 폭 번 비교 — 40장 · 폭 32 면 순식간. 빌드 때 미리 해 두면 실행 중 비용 0.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i303
- 쓰면 좋을 때: 작은 그림이 수십 장 넘어 그릴 때마다 텍스처를 바꾸는 장면 / 스프라이트를 한 번에 그리고 싶을 때 (인스턴싱 · 한 메시)
- 쓰지 말 때: 그림이 몇 장뿐이거나 아주 큰 그림 — 한 장 최대 크기(폰 4096)를 넘는다 / 실행 중 계속 그림이 바뀌는 것 — 대신 그림마다 텍스처

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI303 — `src/demos/demos2dLook.ts:1974`
```ts
function mkI303() {
  const AW = 32;
  const COLS = ['#ffd84d', '#ff7aa8', '#6fd3ff', '#8be07a', '#c69bff', '#ffa45c'];
  interface It {
    w: number;
    h: number;
    x: number;
    y: number;
    sx: number;
    sy: number;
    rot: number;
    kind: number;
    col: string;
  }
  let items: It[] = [];
  let used = 0;
  let seed = 1;
  let stepT = 0.07;
  let showSky = true;
  let sky: number[] = [];
  const build = (): void => {
    const r = rng(seed++);
    // 판(AW × AW)을 넘치지 않을 때까지 장 수를 줄여 다시 꾸린다
    for (let n = 40; n >= 16; n -= 2) {
    items = [];
    for (let i = 0; i < n; i++) {
      const big = r() < 0.18;
      const w = big ? 7 + Math.floor(r() * 5) : 3 + Math.floor(r() * 5);
      const h = big ? 6 + Math.floor(r() * 5) : 3 + Math.floor(r() * 5);
      items.push({ w, h, x: 0, y: 0, sx: 8 + r() * 104, sy: 32 + r() * 120, rot: (r() - 0.5) * 0.6, kind: Math.floor(r() * 6), col: COLS[Math.floor(r() * COLS.length)]! });
    }
    items.sort((a, b) => b.h - a.h || b.w - a.w);
    const hs = new Array<number>(AW).fill(0);
    used = 0;
    for (const it of items) {
      let best = 1e9;
      let bx = 0;
      for (let x = 0; x + it.w <= AW; x++) {
        let m = 0;
        for (let k = x; k < x + it.w; k++) m = Math.max(m, hs[k]!);
        if (m < best) {
          best = m;
          bx = x;
        }
      }
      it.x = bx;
      it.y = best;
      for (let k = bx; k < bx + it.w; k++) hs[k] = best + it.h;
      used += it.w * it.h;
    }
    sky = hs;
    if (Math.max(...hs) <= AW) break;
    }
  };
  build();
  let cycleStart = 0;
  const controls: Control[] = [
    { type: 'range', label: '한 장 꾸리는 시간 (초)', min: 0.02, max: 0.2, step: 0.01, value: stepT, on: (v) => (stepT = v) },
    { type: 'toggle', label: '스카이라인(빈자리 높이) 선', value: showSky, on: (v) => (showSky = v) },
    {
      type: 'button',
      label: '새 그림 묶음',
      on: () => {
        build();
        cycleStart = -1;
      },
    },
  ];
  const icon = (g: G, kind: number, cx: number, cy: number, s: number): void => {
    g.fillStyle = 'rgba(40,30,70,.75)';
    g.strokeStyle = 'rgba(40,30,70,.75)';
    g.lineWidth = s * 0.12;
    g.beginPath();
    if (kind === 0) g.arc(cx, cy, s * 0.35, 0, TAU);
    else if (kind === 1) {
      for (let i = 0; i < 10; i++) {
        const a = -Math.PI / 2 + (i * Math.PI) / 5;
        const r = i % 2 ? s * 0.17 : s * 0.4;
        g.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
      }
    } else if (kind === 2) {
      g.moveTo(cx, cy + s * 0.32);
      g.bezierCurveTo(cx - s * 0.55, cy - s * 0.05, cx - s * 0.2, cy - s * 0.45, cx, cy - s * 0.15);
      g.bezierCurveTo(cx + s * 0.2, cy - s * 0.45, cx + s * 0.55, cy - s * 0.05, cx, cy + s * 0.32);
    } else if (kind === 3) {
      g.moveTo(cx, cy - s * 0.38);
      g.lineTo(cx + s * 0.32, cy);
      g.lineTo(cx, cy + s * 0.38);
      g.lineTo(cx - s * 0.32, cy);
    } else if (kind === 4) g.ellipse(cx, cy, s * 0.38, s * 0.2, -0.7, 0, TAU);
    else {
      g.rect(cx - s * 0.25, cy - s * 0.25, s * 0.5, s * 0.5);
    }
    g.closePath();
    g.fill();
  };
  return {
    controls,
    draw(g: G, w: number, h: number, t: number) {
      reset(g);
      const f = frame(g, w, h);
      vbg(g, f, '#1d2142', '#0d1024');
      if (cycleStart < 0) cycleStart = t;
      const n = items.length;
      const total = n * stepT + 0.4 + 2.2;
      let lt = t - cycleStart;
      if (lt > total) {
        build();
        cycleStart = t;
        lt = 0;
      }
      const AX = 146;
      const AY = 30;
      const AS = 124;
      const cu = AS / AW;
      // 아틀라스 판
      rr(g, AX - 3, AY - 3, AS + 6, AS + 6, 4);
      g.fillStyle = '#272c55';
      g.fill();
      g.strokeStyle = 'rgba(255,255,255,.08)';
      g.lineWidth = 0.4;
      for (let i = 0; i <= AW; i += 4) {
        g.beginPath();
        g.moveTo(AX + i * cu, AY);
        g.lineTo(AX + i * cu, AY + AS);
        g.moveTo(AX, AY + i * cu);
        g.lineTo(AX + AS, AY + i * cu);
        g.stroke();
      }
      txt(g, '흩어진 그림 ' + n + '장', 64, 24, 8, '#aeb6e0');
      txt(g, '아틀라스 1장', AX + AS / 2, 21, 8, '#aeb6e0');
      let placed = 0;
      let area = 0;
      for (let i = 0; i < n; i++) {
        const it = items[i]!;
        const k = clamp01((lt - i * stepT) / 0.4);
        const e = 1 - Math.pow(1 - k, 3);
        const tx = AX + it.x * cu;
        const ty = AY + it.y * cu;
        const ww = it.w * cu;
        const hh = it.h * cu;
        const x = lerp(it.sx, tx, e);
        const y = lerp(it.sy, ty, e);
        const rot = it.rot * (1 - e);
        if (k >= 1) {
          placed++;
          area += it.w * it.h;
        }
        g.save();
        g.translate(x + ww / 2, y + hh / 2);
        g.rotate(rot);
        if (k > 0 && k < 1) {
          g.shadowColor = 'rgba(0,0,0,.5)';
          g.shadowBlur = 6;
        }
        rr(g, -ww / 2 + 0.4, -hh / 2 + 0.4, ww - 0.8, hh - 0.8, Math.min(ww, hh) * 0.18);
        g.fillStyle = it.col;
        g.fill();
        g.shadowBlur = 0;
        g.shadowColor = 'transparent';
        icon(g, it.kind, 0, 0, Math.min(ww, hh));
        if (k > 0 && k < 1) {
          g.strokeStyle = '#fff';
          g.lineWidth = 1;
          g.stroke();
        }
        g.restore();
      }
      if (showSky && placed > 0) {
        const hs = new Array<number>(AW).fill(0);
        for (let i = 0; i < placed; i++) {
          const it = items[i]!;
          for (let k = it.x; k < it.x + it.w; k++) hs[k] = Math.max(hs[k]!, it.y + it.h);
        }
        g.strokeStyle = '#ff5a8a';
        g.lineWidth = 1.2;
        g.beginPath();
        for (let k = 0; k < AW; k++) {
          const y = AY + hs[k]! * cu;
          if (k === 0) g.moveTo(AX, y);
          else g.lineTo(AX + k * cu, y);
          g.lineTo(AX + (k + 1) * cu, y);
        }
        g.stroke();
      }
      void sky;
      const fill = Math.round((area / (AW * AW)) * 100);
      rr(g, 12, 148, 120, 18, 9);
      g.fillStyle = 'rgba(255,255,255,.08)';
      g.fill();
      txt(g, `그리기 호출 ${n - placed + (placed ? 1 : 0)}번 · 채움 ${fill}%`, 72, 157, 7.5, placed === n ? '#8bffb0' : '#e7eaff');
      void used;
      g.restore();
    },
  };
}
```

### i303 견본 항목 — `src/demos/demos2dLook.ts:3786`
```ts
  i303: { kind: '2d', caption: '흩어진 그림 수십 장을 큰 것부터 가장 낮은 빈자리(스카이라인)에 꾸려 넣어 한 장으로 — 그리기 한 번', make: mkI303 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [고정 물체 합치기 (그리기 호출 줄이기)](https://ai-techstudio.web.app/ai/t/i391.md) `i391`
- 다음에 해 볼 기술: [인스턴싱 (InstancedMesh)](https://ai-techstudio.web.app/ai/t/u36.md) `u36`
- 참고 문서: [Wikipedia — Texture atlas](https://en.wikipedia.org/wiki/Texture_atlas)
