# AI 꾸러미 — 캔버스로 그린 무늬 (CanvasTexture)
> 2D 캔버스에 얼굴 · 숫자 · 무늬를 그려 텍스처로 붙여, 그림 파일 없이 3D 물체에 글씨와 무늬를 입히고 바로 바꾼다.  
> 견본: https://ai-techstudio.web.app/#t/u09

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

## 주문서

### 만들어 줘: 캔버스로 그린 무늬 (CanvasTexture)

#### 1. 목표
얼굴 · 숫자가 바뀌는 상자에 2D 캔버스로 그린 무늬(CanvasTexture)를 붙여 줘 — 그림이 바뀌면 3D 도 함께. 느낌은 귀엽고 아기자기.

#### 2. 핵심 기술 용어
- **CanvasTexture** — 2D 캔버스를 그대로 3D 텍스처로
- **texture.needsUpdate** — 다시 그렸으니 GPU 로 다시 올리라는 표시
- **colorSpace = SRGBColorSpace** — 색 그림은 sRGB 로 — 색이 바래지 않게
- **Procedural texture** — 코드로 만든 무늬

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

#### 4. 조건
- 색이 든 그림은 texture.colorSpace = THREE.SRGBColorSpace (높이 · 거칠기용은 그대로)
- 다시 그린 뒤 needsUpdate = true, 다시 그리기는 바뀔 때만 (견본 0.08초 간격)
- 캔버스 크기는 2의 거듭제곱(256 · 512) 정도로 작게
- 같은 캔버스를 왼쪽 2D 판 · 오른쪽 3D 상자에 함께 붙여 바로 바뀌는 것을 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽 평면에 보이는 그림과 오른쪽 상자 면의 그림이 똑같고 동시에 바뀐다 (눈 깜박임 · 숫자)
- 「바탕 색」 슬라이더를 움직이면 양쪽 색이 함께 바뀐다
- 글씨가 뭉개지지 않고 또렷하다 (anisotropy 4)

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

## 원리
- HTML 캔버스에 fillRect · arc · fillText 로 그림을 그린다.
- new THREE.CanvasTexture(캔버스) 로 감싸면 그 그림이 그대로 재질의 map 이 된다.
- 다시 그린 뒤 texture.needsUpdate = true 하면 다음 프레임에 3D 에도 바뀐다.
- 다시 그리기는 비싸니 매 프레임이 아니라 바뀔 때만(견본은 0.08초마다) 한다.

## 핵심 코드 — 캔버스 그림 → 텍스처 → 바뀔 때만 다시 그리기
(발췌: demos/demosMaterial.ts u09 make() 의 draw() 를 줄여 정리)
```ts
const c = document.createElement('canvas');
c.width = c.height = 256;
const g = c.getContext('2d')!;
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;   // 색 그림은 sRGB
tex.anisotropy = 4;

let hue = 200;
function draw(t: number) {
  g.fillStyle = 'hsl(' + hue + ',70%,62%)';
  g.fillRect(0, 0, 256, 256);
  // 얼굴 — 2.6초마다 잠깐 눈 감기
  const blink = t % 2.6 < 0.16;
  g.fillStyle = '#1e1a2e';
  for (const ex of [92, 164]) {
    if (blink) g.fillRect(ex - 14, 118, 28, 6);
    else { g.beginPath(); g.ellipse(ex, 120, 11, 15, 0, 0, Math.PI * 2); g.fill(); }
  }
  // 숫자
  g.fillStyle = '#fff';
  g.font = '900 54px system-ui';
  g.textAlign = 'center';
  g.textBaseline = 'middle';
  g.fillText(String(Math.floor(t) % 10), 128, 58);
  tex.needsUpdate = true;                 // GPU 로 다시 올리기
}

const box = new THREE.Mesh(new RoundedBoxGeometry(1.05, 1.05, 1.05, 4, 0.1), new THREE.MeshStandardMaterial({ map: tex, roughness: 0.55 }));
scene.add(box);

let last = -1;
function update(t: number) {
  if (t - last > 0.08) { draw(t); last = t; } // 매 프레임이 아니라 가끔만
}
```

## 흔한 실수 · 확인 목록
- [ ] **colorSpace 를 안 주면 색이 허옇게 바랜다** — 색 그림은 SRGBColorSpace. 반대로 요철 · 거칠기 같은 데이터 그림에는 주지 않는다.
- [ ] **매 프레임 큰 캔버스를 다시 올리면 폰에서 끊긴다** — 바뀔 때만 needsUpdate. 크게 바뀌지 않는 무늬는 한 번만 그린다.
- [ ] **같은 무늬 캔버스를 물체마다 새로 만들면 시작이 느리다** — 얼음 별 게임은 무늬 캔버스를 열쇠(cacheKey)로 돌려 써서 시작 4초를 1초대로 줄였다.
- [ ] **글꼴이 늦게 내려오면 첫 그림이 기본 글꼴로 그려진다** — document.fonts.ready 를 기다린 뒤 한 번 더 그린다.

## 완성 기준 체크리스트
- [ ] 왼쪽 평면에 보이는 그림과 오른쪽 상자 면의 그림이 똑같고 동시에 바뀐다 (눈 깜박임 · 숫자)
- [ ] 「바탕 색」 슬라이더를 움직이면 양쪽 색이 함께 바뀐다
- [ ] 글씨가 뭉개지지 않고 또렷하다 (anisotropy 4)

## 이 기술 정보
- id: `u09` · 분류: 그래픽 · 셰이더 › 재질 · 그림체 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 그리기 자체는 가볍지만 needsUpdate 마다 GPU 로 다시 올린다 (256² 는 가볍고 2048² 를 자주 올리면 끊긴다).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u09
- 쓰면 좋을 때: 글씨 · 숫자 · 얼굴처럼 번역되거나 바뀌는 그림 / 나뭇결 · 펠트 같은 무늬를 그림 파일 없이
- 쓰지 말 때: 사진 같은 세밀한 질감 — 대신 그림 파일 / 매 프레임 큰 캔버스를 통째로 다시 그리기 — 대신 바뀐 때만, 작은 캔버스로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u09 견본 항목 — `src/demos/demosMaterial.ts:733`
```ts
  u09: {
    kind: '3d',
    caption: '왼쪽 2D 캔버스에 얼굴 · 숫자를 그리면 → 오른쪽 3D 상자에 그대로 붙어 함께 바뀌어요',
    make() {
      const B = base({ bg: gradBg('#ffe9c8', '#fff7ea'), labels: ['2D 캔버스에 그림', '3D 물체에 붙임'], fit: [2.5, 0.9], target: [0, 0, 0] });
      lights(B.scene, 0xffffff, 0xc0a080, 1.0, 1.6);
      const [c, g] = cv(256, 256);
      const t2 = tex(c);
      let hue = 200;
      let last = -1;
      const draw = (t: number): void => {
        g.fillStyle = `hsl(${hue},70%,62%)`;
        g.fillRect(0, 0, 256, 256);
        // 펠트 줄무늬 (흐름)
        g.strokeStyle = 'rgba(255,255,255,0.18)';
        g.lineWidth = 10;
        for (let k = -4; k < 12; k++) {
          const o = ((t * 30) % 40) + k * 40;
          g.beginPath();
          g.moveTo(o, 0);
          g.lineTo(o - 256, 256);
          g.stroke();
        }
        g.strokeStyle = 'rgba(255,255,255,0.9)';
        g.lineWidth = 6;
        g.setLineDash([12, 10]);
        g.strokeRect(14, 14, 228, 228);
        g.setLineDash([]);
        // 얼굴
        const blink = t % 2.6 < 0.16;
        g.fillStyle = '#1e1a2e';
        for (const ex of [92, 164]) {
          if (blink) g.fillRect(ex - 14, 118, 28, 6);
          else {
            g.beginPath();
            g.ellipse(ex, 120, 11, 15, 0, 0, Math.PI * 2);
            g.fill();
            g.fillStyle = '#fff';
            g.beginPath();
            g.arc(ex + 4, 114, 4, 0, Math.PI * 2);
            g.fill();
            g.fillStyle = '#1e1a2e';
          }
        }
        g.fillStyle = 'rgba(255,120,150,0.7)';
        g.beginPath();
        g.ellipse(70, 152, 16, 9, 0, 0, Math.PI * 2);
        g.ellipse(186, 152, 16, 9, 0, 0, Math.PI * 2);
        g.fill();
        g.strokeStyle = '#1e1a2e';
        g.lineWidth = 6;
        g.lineCap = 'round';
        g.beginPath();
        g.arc(128, 146, 18, 0.15 * Math.PI, 0.85 * Math.PI);
        g.stroke();
        // 숫자
        g.fillStyle = '#fff';
        g.font = `900 54px ${FONT}`;
        g.textAlign = 'center';
        g.textBaseline = 'middle';
        g.fillText(String(Math.floor(t) % 10), 128, 58);
        t2.needsUpdate = true;
      };
      draw(0);
      const paper = new THREE.Mesh(new THREE.PlaneGeometry(1.5, 1.5), new THREE.MeshBasicMaterial({ map: t2, toneMapped: false }));
      const frame = new THREE.Mesh(new THREE.PlaneGeometry(1.62, 1.62), new THREE.MeshBasicMaterial({ color: 0xffffff }));
      frame.position.z = -0.01;
      const left = new THREE.Group();
      left.add(frame, paper);
      left.position.x = -1.35;
      const box = new THREE.Mesh(new RoundedBoxGeometry(1.05, 1.05, 1.05, 4, 0.1), new THREE.MeshStandardMaterial({ map: t2, roughness: 0.55 }));
      box.position.x = 1.4;
      const am = new THREE.MeshStandardMaterial({ color: 0xff8a3c, roughness: 0.5 });
      const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.42, 16), am);
      shaft.rotation.z = Math.PI / 2;
      shaft.position.x = -0.06;
      const head = new THREE.Mesh(new THREE.ConeGeometry(0.13, 0.24, 20), am);
      head.rotation.z = -Math.PI / 2;
      head.position.x = 0.25;
      const arrow = new THREE.Group();
      arrow.add(shaft, head);
      B.scene.add(left, box, arrow);
      return B.done({
        update(t) {
          if (t - last > 0.08) {
            draw(t);
            last = t;
          }
          left.rotation.y = 0.25 + Math.sin(t * 0.8) * 0.12;
          box.rotation.set(Math.sin(t * 0.5) * 0.4, t * 0.6, 0);
          arrow.position.x = Math.sin(t * 3) * 0.06;
        },
        controls: [{ type: 'range', label: '바탕 색 (색상)', min: 0, max: 360, step: 1, value: 200, on: (v) => { hue = v; } }],
      });
    },
  }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [코드로 그린 도트 무늬](https://ai-techstudio.web.app/ai/t/u53.md) `u53`
- 다음에 해 볼 기술: [그린 요철 · 노멀 맵](https://ai-techstudio.web.app/ai/t/u10.md) `u10` · [매트캡 (MatCap)](https://ai-techstudio.web.app/ai/t/i09.md) `i09`
- 참고 문서: [three.js 문서 — CanvasTexture](https://threejs.org/docs/#api/en/textures/CanvasTexture) · [MDN — Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API)
