# AI 꾸러미 — 툰 셰이딩 (3단 명암) — Toon shading (cel shading)
> 빛 받는 정도를 2~3단 계단으로 끊어 칠해, 3D 물체를 셀 애니메이션처럼 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/u01

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

## 주문서

### 만들어 줘: 툰 셰이딩 (3단 명암) — Toon shading (cel shading)

#### 1. 목표
공 · 도넛 같은 기본 도형을 밝은 애니메이션 느낌의 툰 셰이딩(3단 명암 + 검은 외곽선)으로 그려 줘.

#### 2. 핵심 기술 용어
- **Toon shading (cel shading)** — 만화 그림체 명암
- **MeshToonMaterial + gradientMap** — three.js 툰 재질 + 계단 텍스처
- **Inverted hull outline** — 뒤집은 껍데기 외곽선 (뒷면만 그린 조금 큰 복제)
- **NoToneMapping** — 톤 매핑 끄기 — 계단 색이 뭉개지지 않게

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

#### 4. 조건
- renderer.toneMapping = THREE.NoToneMapping (켜 두면 계단 색이 뭉개진다)
- 계단 텍스처는 DataTexture + NearestFilter, 단계 값을 바꿀 수 있게
- 외곽선은 뒤집은 껍데기(BackSide) 방식 — 두께는 물체 크기와 상관없이 같게 보이도록 크기에 반비례해 키우기
- 얇은 판 · 투명한 것 · 아주 작은 조각에는 외곽선을 붙이지 않기
- 후처리(외곽선 패스 등) 없이 재질만으로

#### 5. 완성 기준 (이게 보이면 성공)
- 같은 물체를 왼쪽 일반 재질 · 오른쪽 툰으로 나란히 보여 주면, 오른쪽 명암이 3단 계단으로 끊겨 보인다
- 물체 가장자리에 고른 두께의 검은 외곽선이 보이고, 물체가 돌아도 끊기지 않는다
- 「그늘 밝기」 · 「외곽선 굵기」 슬라이더와 「툰 켜기」 단추로 바로 바뀐다
- 폰에서도 60fps (물체 수십 개 기준)

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

## 원리
- 보통 재질은 빛을 받는 정도(N·L)를 0~1 로 매끈하게 칠한다.
- 툰 재질은 그 값을 작은 계단 텍스처(gradientMap)로 바꿔, 밝음 · 중간 · 그늘 같은 몇 단계로 끊는다.
- 계단 텍스처는 NearestFilter 로 읽어야 단계 사이가 섞이지 않는다.
- 외곽선은 같은 모양을 조금 키워 뒷면만 검게 그리면(BackSide) 물체 가장자리에 테두리가 생긴다.
- 톤 매핑은 계단 색을 다시 휘게 하므로 끄고, 밝기는 빛 세기로 맞춘다.

## 핵심 코드 — 계단 텍스처 + 툰 재질 + 뒤집은 껍데기 외곽선
(발췌: src/game/core/toon.ts (makeToonKit) · demos/demosRef.ts u01 을 짧게 정리)
```ts
import * as THREE from 'three';

// 1) 3단 명암 계단 — NearestFilter 가 핵심 (보간하면 계단이 섞여 뭉개진다)
const steps = new Uint8Array([75, 168, 255]); // 그늘 · 중간 · 밝음 (0~255)
const grad = new THREE.DataTexture(steps, steps.length, 1, THREE.RedFormat);
grad.minFilter = grad.magFilter = THREE.NearestFilter;
grad.needsUpdate = true;

// 2) 툰 재질
const ball = new THREE.Mesh(
  new THREE.SphereGeometry(0.9, 48, 32),
  new THREE.MeshToonMaterial({ color: 0xe8453c, gradientMap: grad }),
);
scene.add(ball);

// 3) 외곽선 — 같은 모양을 조금 키워 뒷면만 검게. 두께 thick 이 크기와 상관없이 같도록
const ink = new THREE.MeshBasicMaterial({ color: 0x1c1a2e, side: THREE.BackSide });
const thick = 0.035;
const size = new THREE.Vector3();
ball.geometry.computeBoundingBox();
ball.geometry.boundingBox!.getSize(size);
const hull = new THREE.Mesh(ball.geometry, ink);
hull.scale.set(1 + (2 * thick) / size.x, 1 + (2 * thick) / size.y, 1 + (2 * thick) / size.z);
hull.raycast = () => {}; // 클릭 판정에서 빼기
ball.add(hull);

// 4) 톤 매핑은 끄고 빛 세기로 밝기를 맞춘다
renderer.toneMapping = THREE.NoToneMapping;
scene.add(new THREE.HemisphereLight(0xffffff, 0x8aa86a, 0.9));
const sun = new THREE.DirectionalLight(0xfff0d6, 1.6);
sun.position.set(-3, 5, 4);
scene.add(sun);

// 단계 바꾸기: grad.image.data.set([v, 168, 255]); grad.needsUpdate = true;
```

## 흔한 실수 · 확인 목록
- [ ] **톤 매핑을 켜 둔 채 쓰면 계단이 흐려진다** — ACES · Neutral 톤 매핑이 색을 다시 휘게 만든다. 툰 장면은 NoToneMapping 으로 두고 빛 세기를 낮춰 맞춘다.
- [ ] **계단 텍스처를 기본 필터로 두면 그러데이션이 된다** — DataTexture 의 minFilter · magFilter 를 NearestFilter 로.
- [ ] **얇은 판 · 투명한 것에 외곽선을 붙이면 지저분하다** — 바닥 판 · 유리 · 아주 작은 조각(0.1 미만)은 건너뛴다. 이 사이트의 toonify 도구는 크기 · 투명 여부로 거른다.
- [ ] **모든 껍데기를 같은 비율로 키우면 큰 물체만 외곽선이 두껍다** — 물체 크기에 반비례하게 키워 화면 두께를 맞춘다 (1 + 2·두께 / 크기).
- [ ] **위에서 내려다보는 구도에선 외곽선이 가늘어 보인다** — 빵빵 주차장은 0.045 로 굵게, 그림자는 PCFShadowMap 으로 또렷하게 해서 맞췄다.

## 완성 기준 체크리스트
- [ ] 같은 물체를 왼쪽 일반 재질 · 오른쪽 툰으로 나란히 보여 주면, 오른쪽 명암이 3단 계단으로 끊겨 보인다
- [ ] 물체 가장자리에 고른 두께의 검은 외곽선이 보이고, 물체가 돌아도 끊기지 않는다
- [ ] 「그늘 밝기」 · 「외곽선 굵기」 슬라이더와 「툰 켜기」 단추로 바로 바뀐다
- [ ] 폰에서도 60fps (물체 수십 개 기준)

## 이 기술 정보
- id: `u01` · 분류: 그래픽 · 셰이더 › 재질 · 그림체 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 재질 자체는 표준 재질보다 가볍다. 외곽선 껍데기는 그리기 횟수를 2배로 늘리니 물체가 수백 개면 합치거나 작은 것은 건너뛴다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u01
- 쓰면 좋을 때: 아이들 · 캐주얼 게임에서 밝고 또렷한 그림체가 필요할 때 / 저사양 기기에서 PBR 대신 가벼운 재질이 필요할 때 / 위에서 내려다보는 판 게임 — 평면이 넓어 계단 명암이 잘 드러남
- 쓰지 말 때: 실사 질감(금속 반사 · 유리 투과)이 중요한 장면 / 얇은 판 · 투명한 물체 — 외곽선 껍데기가 지저분하게 보인다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u01 견본 항목 — `src/demos/demosRef.ts:9`
```ts
  u01: {
    kind: '3d',
    caption: '왼쪽 일반 재질 · 오른쪽 툰 — 명암이 3단 계단으로 끊겨요',
    make(T) {
      const mk = (): { scene: InstanceType<typeof T.Scene>; root: InstanceType<typeof T.Group> } => {
        const scene = new T.Scene();
        scene.background = new T.Color(0x9ad0f0);
        scene.add(new T.HemisphereLight(0xffffff, 0x8aa86a, 0.9));
        const sun = new T.DirectionalLight(0xfff0d6, 1.6);
        sun.position.set(-3, 5, 4);
        scene.add(sun);
        const root = new T.Group();
        const ball = new T.Mesh(new T.SphereGeometry(0.9, 48, 32), new T.MeshStandardMaterial({ color: 0xe8453c, roughness: 0.5 }));
        const knot = new T.Mesh(new T.TorusKnotGeometry(0.42, 0.15, 120, 16), new T.MeshStandardMaterial({ color: 0x2ac0c8, roughness: 0.5 }));
        knot.position.set(0, -1.55, 0);
        knot.scale.setScalar(0.8);
        root.add(ball, knot);
        scene.add(root);
        return { scene, root };
      };
      const A = mk();
      const B = mk();
      const kit = makeToonKit([75, 168, 255]);
      kit.toonify(B.root, { outline: 0.035, minSize: 0.1 });
      const cam = new T.PerspectiveCamera(30, 1, 0.1, 50);
      cam.position.set(0, -0.3, 7);
      cam.lookAt(0, -0.45, 0);
      return {
        scene: A.scene,
        camera: cam,
        update(t) {
          for (const S of [A, B]) S.root.rotation.y = t * 0.6;
        },
        render(r, w, hh) {
          // 반씩 나눠 같은 카메라로 두 장면
          cam.aspect = w / 2 / hh;
          cam.updateProjectionMatrix();
          r.setScissorTest(true);
          r.toneMapping = T.ACESFilmicToneMapping;
          r.setViewport(0, 0, w / 2, hh);
          r.setScissor(0, 0, w / 2, hh);
          r.render(A.scene, cam);
          r.toneMapping = T.NoToneMapping;
          r.setViewport(w / 2, 0, w / 2, hh);
          r.setScissor(w / 2, 0, w / 2, hh);
          r.render(B.scene, cam);
          r.setScissorTest(false);
          r.setViewport(0, 0, w, hh);
        },
        controls: [
          { type: 'range', label: '그늘 밝기', min: 0, max: 255, step: 1, value: 75, on: (v) => kit.setSteps([v, 168, 255]) },
          { type: 'range', label: '외곽선 굵기', min: 0, max: 0.1, step: 0.005, value: 0.035, on: (v) => kit.setOutline(B.root, v) },
          { type: 'toggle', label: '오른쪽 툰 켜기', value: true, on: (v) => kit.setEnabled(B.root, v) },
        ],
        dispose() {
          kit.dispose();
          for (const S of [A, B])
            S.scene.traverse((o) => {
              const m = o as InstanceType<typeof T.Mesh>;
              if (m.isMesh && !o.userData['__ink']) {
                m.geometry.dispose();
              }
            });
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [3점 조명 (반구 + 해 + 테두리)](https://ai-techstudio.web.app/ai/t/u14.md) `u14` · [그림자 맵 (PCF · PCF Soft)](https://ai-techstudio.web.app/ai/t/u17.md) `u17`
- 다음에 해 볼 기술: [외곽선 (뒤집은 껍데기)](https://ai-techstudio.web.app/ai/t/u02.md) `u02` · [셰이더로 미는 외곽선](https://ai-techstudio.web.app/ai/t/u03.md) `u03`
- 참고 문서: [three.js 예제 — webgl_materials_variations_toon](https://threejs.org/examples/#webgl_materials_variations_toon) · [three.js 소스 — MeshToonMaterial](https://github.com/mrdoob/three.js/blob/dev/src/materials/MeshToonMaterial.js)
