# AI 꾸러미 — 스프라이트 글씨 · 표시 — Billboard sprite (THREE.Sprite)
> 글씨 · 하트 · Z 를 캔버스에 그려 Sprite 로 띄워서, 카메라가 어디로 돌아도 늘 정면으로 보이는 점수 · 표시를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/u35

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

## 주문서

### 만들어 줘: 스프라이트 글씨 · 표시 — Billboard sprite (THREE.Sprite)

#### 1. 목표
점수 +10 · 하트 · 잠자는 Z을(를) 늘 카메라 정면을 보는 스프라이트로 띄워 줘 — 캔버스로 글씨를 그리고, 떠오르며 나타났다 사라지게. 분위기는 맑은 하늘 동화.

#### 2. 핵심 기술 용어
- **Billboard sprite (THREE.Sprite)** — 늘 카메라를 보는 판
- **SpriteMaterial + CanvasTexture** — 캔버스로 그린 글씨를 스프라이트 그림으로
- **World-space label** — 3D 자리에 붙는 글씨 표시
- **Floating text (rise & fade)** — 떠오르며 사라지는 글씨

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 글씨 캔버스 크기는 measureText 로 재서 정하고, 스프라이트 가로 · 세로 비율을 캔버스와 같게 (찌그러짐 금지)
- CanvasTexture.colorSpace = SRGBColorSpace — 안 하면 색이 바랜다
- SpriteMaterial 은 transparent · depthWrite: false
- 글씨를 바꿀 때 예전 텍스처 · 재질은 dispose
- 굵은 테두리(strokeText, lineJoin round)를 글씨 아래에 — 배경 위에서도 읽히게

#### 5. 완성 기준 (이게 보이면 성공)
- 카메라가 빙 돌 때 왼쪽 평면 판은 옆에서 얇아지고, 오른쪽 스프라이트는 늘 정면으로 보인다
- 잠자는 친구 위로 Z, 뛰는 친구 위로 ♥ · +10 이 떠오르며 나타났다 사라진다
- 글씨가 늘어나거나 찌그러지지 않는다
- 폰에서도 글씨가 또렷하다

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

## 원리
- THREE.Sprite 는 자리만 3D 이고 늘 화면 쪽을 본다. 평면 판(PlaneGeometry)은 옆으로 돌면 얇아지지만 스프라이트는 그대로다.
- 글씨는 캔버스에 그린다: 글씨 폭을 measureText 로 재서 캔버스 크기를 정하고, 굵은 테두리(strokeText) → 글씨(fillText) 순서.
- CanvasTexture 는 SRGBColorSpace, 재질은 transparent · depthWrite: false. 크기는 높이 h 를 정하고 가로는 캔버스 비율로 (h × 폭 / 높이).
- 떠오르는 표시는 2.4초 주기로 1.6 만큼 올라가며, 처음 15% 동안 나타나고 60% 뒤부터 사라진다. 크기도 0.7 → 1 로 톡 커진다.

## 핵심 코드 — 캔버스 글씨 → 스프라이트 (비율 그대로)
(발췌: demos/demosSim.ts textSprite() · u35 update() 를 정리)
```ts
function textSprite(s: string, o: { size?: number; color?: string; stroke?: string; bg?: string; h?: number } = {}): THREE.Sprite {
  const size = o.size ?? 64;
  const c = document.createElement('canvas');
  const g = c.getContext('2d')!;
  const font = '800 ' + size + 'px Pretendard, system-ui, sans-serif';
  g.font = font;
  const pad = Math.round(size * 0.45);
  c.width = Math.ceil(g.measureText(s).width) + pad * 2;   // 글씨 폭에 맞춘 캔버스
  c.height = Math.round(size * 1.6);
  g.font = font;                                           // 크기를 바꾸면 글꼴이 풀리니 다시
  g.textAlign = 'center';
  g.textBaseline = 'middle';
  if (o.bg) { g.fillStyle = o.bg; g.beginPath(); g.roundRect(2, 2, c.width - 4, c.height - 4, c.height / 2 - 2); g.fill(); }
  if (o.stroke) { g.lineWidth = size * 0.16; g.lineJoin = 'round'; g.strokeStyle = o.stroke; g.strokeText(s, c.width / 2, c.height / 2 + size * 0.04); }
  g.fillStyle = o.color ?? '#fff';
  g.fillText(s, c.width / 2, c.height / 2 + size * 0.04);
  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }));
  const hh = o.h ?? 0.5;
  sp.scale.set((hh * c.width) / c.height, hh, 1);          // 캔버스 비율 그대로
  return sp;
}

// 떠오르며 나타났다 사라지기 (2.4초 주기, t0 = 하나마다 다른 시작)
const k = ((t + t0) % 2.4) / 2.4;
spr.position.set(base.x + Math.sin(k * 6 + t0) * 0.25, 1.1 + k * 1.6, base.z);
spr.material.opacity = k < 0.15 ? k / 0.15 : 1 - Math.max(0, (k - 0.6) / 0.4);
const sc = 0.7 + Math.min(1, k * 4) * 0.3;
spr.scale.set(w0 * sc, h0 * sc, 1); // w0 · h0 = 처음 크기
```

## 흔한 실수 · 확인 목록
- [ ] **캔버스 크기를 정한 뒤 글꼴을 다시 안 넣으면 기본 글꼴로 그려진다** — canvas.width 를 바꾸면 그리기 상태가 초기화된다. 크기를 정한 뒤 g.font 를 다시 넣는다.
- [ ] **스프라이트 크기를 정사각형으로 두면 글씨가 찌그러진다** — 가로 = 높이 × 캔버스 폭 / 캔버스 높이.
- [ ] **글씨를 자주 바꾸며 새 텍스처만 만들면 GPU 메모리가 샌다** — 바꿀 때 예전 map · material 을 dispose 한다.

## 완성 기준 체크리스트
- [ ] 카메라가 빙 돌 때 왼쪽 평면 판은 옆에서 얇아지고, 오른쪽 스프라이트는 늘 정면으로 보인다
- [ ] 잠자는 친구 위로 Z, 뛰는 친구 위로 ♥ · +10 이 떠오르며 나타났다 사라진다
- [ ] 글씨가 늘어나거나 찌그러지지 않는다
- [ ] 폰에서도 글씨가 또렷하다

## 이 기술 정보
- id: `u35` · 분류: 이펙트 · 연출 › 입자 · 연출 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 스프라이트 하나 = 판 하나. 글씨가 바뀔 때만 캔버스를 다시 그린다. 수십 개는 폰에서도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u35
- 쓰면 좋을 때: 점수 · 하트 · 상태(잠, 화남)처럼 3D 물체에 붙는 작은 표시 / 이름표 · 칸 숫자처럼 어느 각도에서도 읽혀야 하는 글씨
- 쓰지 말 때: 바닥에 눕혀 써야 하는 글씨(칸 위 무늬) — 평면 판 + 캔버스 텍스처가 맞다 / 많이 바뀌는 긴 문장 — 바뀔 때마다 캔버스를 새로 그려 올려야 한다. 그땐 HTML 층이 낫다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u35 견본 항목 — `src/demos/demosSim.ts:1970`
```ts
  u35: {
    kind: '3d',
    caption: '카메라가 빙 돌아도 스프라이트(오른쪽 · Z · 하트 · +10)는 늘 정면 — 왼쪽 평면 판은 옆으로 돌면 얇아져요',
    make() {
      const scene = new THREE.Scene();
      scene.background = skyTexture('#7cc8f4', '#d8f0ff');
      scene.add(new THREE.HemisphereLight(0xffffff, 0x6a9a4a, 1.3));
      const sun = new THREE.DirectionalLight(0xfff2d8, 1.5);
      sun.position.set(3, 6, 4);
      scene.add(sun);
      const cam = new THREE.PerspectiveCamera(40, 1.6, 0.1, 80);
      const ground = new THREE.Mesh(new THREE.CylinderGeometry(4.6, 4.8, 0.4, 48), new THREE.MeshLambertMaterial({ color: 0x8ad06a }));
      ground.position.y = -0.2;
      scene.add(ground);
      const post = (x: number, col: number): void => {
        const p = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 1.6, 10), new THREE.MeshLambertMaterial({ color: col }));
        p.position.set(x, 0.8, 0);
        scene.add(p);
      };
      post(-1.9, 0x8a6a4a);
      post(1.9, 0x8a6a4a);
      // 왼쪽: 평면 판 (글씨를 그린 PlaneGeometry)
      const plate = textSprite('평면 판', { size: 64, color: '#20304a', bg: '#ffffff' });
      const planeMat = new THREE.MeshBasicMaterial({ map: (plate.material as THREE.SpriteMaterial).map, transparent: true, side: THREE.DoubleSide });
      const plane = new THREE.Mesh(new THREE.PlaneGeometry(plate.scale.x * 1.4, plate.scale.y * 1.4), planeMat);
      plane.position.set(-1.9, 1.9, 0);
      scene.add(plane);
      plate.material.dispose();
      // 오른쪽: 스프라이트
      const spr = textSprite('스프라이트', { size: 64, color: '#5a2a00', bg: '#ffd23a', h: 0.7 });
      spr.position.set(1.9, 1.9, 0);
      scene.add(spr);
      // 잠자는 친구 + Z
      const blobMat = new THREE.MeshStandardMaterial({ color: 0xa48aff, roughness: 0.6 });
      const blob = new THREE.Mesh(new THREE.SphereGeometry(0.55, 32, 20), blobMat);
      blob.scale.y = 0.8;
      blob.position.set(0, 0.44, -1.4);
      scene.add(blob);
      const happy = new THREE.Mesh(new THREE.SphereGeometry(0.5, 32, 20), new THREE.MeshStandardMaterial({ color: 0xff8aa0, roughness: 0.6 }));
      happy.position.set(0, 0.5, 1.5);
      scene.add(happy);
      const floaters: { s: THREE.Sprite; t0: number; kind: number }[] = [];
      const mk = (k: number): THREE.Sprite =>
        k === 0
          ? textSprite('Z', { size: 80, color: '#ffffff', stroke: '#4a3aa0', font: 'title', h: 0.45 })
          : k === 1
            ? textSprite('♥', { size: 80, color: '#ff4a6a', stroke: '#ffffff', h: 0.5 })
            : textSprite('+10', { size: 72, color: '#ffe23a', stroke: '#5a2a00', font: 'title', h: 0.5 });
      for (let k = 0; k < 9; k++) {
        const s = mk(k % 3);
        scene.add(s);
        floaters.push({ s, t0: (k / 9) * 2.4 + (k % 3) * 0.2, kind: k % 3 });
      }
      return {
        scene,
        camera: cam,
        update(t) {
          const a = t * 0.55;
          cam.position.set(Math.sin(a) * 7.2, 3.2, Math.cos(a) * 7.2);
          cam.lookAt(0, 1.1, 0);
          blob.scale.y = 0.8 + Math.sin(t * 2) * 0.04;
          happy.position.y = 0.5 + Math.abs(Math.sin(t * 3)) * 0.4;
          for (const f of floaters) {
            const k = ((t + f.t0) % 2.4) / 2.4;
            const base = f.kind === 0 ? blob.position : happy.position;
            const side = f.kind === 2 ? -0.8 : f.kind === 1 ? -0.2 : 0.3;
            f.s.position.set(base.x + side + Math.sin(k * 6 + f.t0) * 0.25, (f.kind === 0 ? 0.9 : 1.1) + k * 1.6, base.z);
            (f.s.material as THREE.SpriteMaterial).opacity = k < 0.15 ? k / 0.15 : 1 - Math.max(0, (k - 0.6) / 0.4);
            const sc = 0.7 + Math.min(1, k * 4) * 0.3;
            f.s.scale.set((f.s.userData['w0'] ??= f.s.scale.x) * sc, (f.s.userData['h0'] ??= f.s.scale.y) * sc, 1);
          }
        },
        dispose() {
          disposeScene(scene);
          (scene.background as THREE.Texture).dispose();
        },
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [튀어 오르는 숫자](https://ai-techstudio.web.app/ai/t/i202.md) `i202` · [인스턴싱 (InstancedMesh)](https://ai-techstudio.web.app/ai/t/u36.md) `u36`
- 참고 문서: [three.js 문서 — Sprite](https://threejs.org/docs/#api/en/objects/Sprite)
