# AI 꾸러미 — 그림자 맵 (PCF · PCF Soft) — Shadow mapping
> 해에서 본 깊이를 그림자 지도에 찍어, 물체가 바닥에 그림자를 드리우게 한다 — 반지름으로 또렷하게(만화) · 부드럽게 고른다.  
> 견본: https://ai-techstudio.web.app/#t/u17

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

## 주문서

### 만들어 줘: 그림자 맵 (PCF · PCF Soft) — Shadow mapping

#### 1. 목표
판 위 캐릭터 · 블록에 해 그림자를 넣어 줘 — 또렷한 만화 그림자 그림자로, 해가 돌면 그림자도 따라 돌게.

#### 2. 핵심 기술 용어
- **Shadow mapping** — 빛 쪽에서 본 깊이 그림으로 그림자 정하기
- **PCFShadowMap / PCFSoftShadowMap** — 가장자리를 여러 번 읽어 매끈하게 하는 방식
- **shadow.radius · mapSize · bias · normalBias** — 흐림 반지름 · 지도 크기 · 줄무늬 막기 값
- **Shadow camera frustum** — 그림자를 찍는 상자 범위 (left · right · top · bottom)

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

#### 4. 조건
- renderer.shadowMap.enabled = true, 물체마다 castShadow · 바닥은 receiveShadow
- 그림자 상자를 판 크기에 맞게 (견본 left/right/top/bottom ±2.2, near 0.5 · far 14)
- bias -0.0008 · normalBias 0.02 부터 — 줄무늬가 보이면 조금씩
- 또렷한 그림자는 PCFShadowMap + radius 1, 부드러운 그림자는 radius 를 키운다 (견본 10)
- 그림자는 해 하나에만

#### 5. 완성 기준 (이게 보이면 성공)
- 세 칸 — 없음 · 또렷하게 · 부드럽게 — 을 나란히 보면 둘째는 날 선 그림자, 셋째는 번진 그림자
- 해가 돌면 캐릭터 · 쌓은 블록 · 공 그림자가 같이 돈다
- 캐릭터 몸에 줄무늬 얼룩(그림자 여드름)이 없다
- 「부드러움」 1 ~ 20 으로 가장자리 흐림이 바뀌고, 폰에서도 60fps

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

## 원리
- 빛 자리에 카메라를 두고 장면 깊이를 그림(그림자 지도)으로 찍는다. 화면의 각 점이 그 깊이보다 멀면 그늘이다.
- 방향 빛(해)은 직교 상자로 찍는다. 상자를 판 크기에 딱 맞게 줄일수록 같은 지도 크기에서 그림자가 또렷하다.
- PCF 는 가장자리 주변 지도를 여러 번 읽어 평균 낸다. shadow.radius 를 1 로 두면 또렷, 10 이면 부드럽다.
- 자기 몸에 줄무늬(그림자 여드름)가 생기면 bias 를 조금 음수로, normalBias 를 조금 양수로 민다.

## 핵심 코드 — 해 그림자 — 상자 범위 · 줄무늬 막기 · 또렷/부드럽게
(발췌: demos/demosLight.ts u17 build(mode) · withShadows() 를 정리)
```ts
import * as THREE from 'three';

renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap; // radius 로 흐림을 조절할 수 있는 방식

const sun = new THREE.DirectionalLight(0xfff0d6, 2.2);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024); // 또렷하게는 1024, 부드럽게는 512 로도 충분
sun.shadow.radius = 1;              // 1 = 또렷한 만화 그림자, 10 = 부드럽게
sun.shadow.bias = -0.0008;          // 자기 몸 줄무늬 막기
sun.shadow.normalBias = 0.02;
// 그림자 찍는 상자를 판 크기에 딱 맞게 — 좁을수록 또렷하다
Object.assign(sun.shadow.camera, { left: -2.2, right: 2.2, top: 2.2, bottom: -2.2, near: 0.5, far: 14 });
scene.add(sun, sun.target);

const ground = new THREE.Mesh(new THREE.CircleGeometry(2.2, 48), new THREE.MeshStandardMaterial({ color: 0xf3ead8, roughness: 0.9 }));
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// 그림자를 드리울 물체마다: mesh.castShadow = true;

function update(t: number): void {
  const a = t * 0.5; // 해가 둘레를 돈다
  sun.position.set(Math.cos(a) * 3, 4, Math.sin(a) * 3);
}
```

## 흔한 실수 · 확인 목록
- [ ] **그림자 상자를 너무 크게 잡으면 그림자가 뭉개진다** — 지도 한 장을 넓은 범위에 펴는 셈이다. 판 크기에 맞게 left/right/top/bottom 을 줄인다.
- [ ] **bias 를 0 으로 두면 물체 표면에 줄무늬가 생긴다** — bias 를 조금 음수(-0.0005 ~ -0.001), normalBias 를 조금 양수(0.02 ~ 0.03)로.
- [ ] **위에서 내려다보는 툰 장면에 부드러운 그림자를 쓰면 흐리멍덩하다** — 빵빵 주차장은 PCFShadowMap 의 또렷한 그림자로 맞췄다.
- [ ] **그림자 지도를 매 장면 안 그리게 하면 움직이는 그림자가 늦는다** — 속도를 위해 그림자 갱신을 건너뛰는 꾀는 고정 장면에만. 이 사이트는 움직이는 그림자 때문에 쓰지 않았다.

## 완성 기준 체크리스트
- [ ] 세 칸 — 없음 · 또렷하게 · 부드럽게 — 을 나란히 보면 둘째는 날 선 그림자, 셋째는 번진 그림자
- [ ] 해가 돌면 캐릭터 · 쌓은 블록 · 공 그림자가 같이 돈다
- [ ] 캐릭터 몸에 줄무늬 얼룩(그림자 여드름)이 없다
- [ ] 「부드러움」 1 ~ 20 으로 가장자리 흐림이 바뀌고, 폰에서도 60fps

## 이 기술 정보
- id: `u17` · 분류: 그래픽 · 셰이더 › 빛 · 환경 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 그림자 켠 방향 빛 하나 = 장면을 한 번 더 그림 (지도 512² ~ 2048²). 게임들은 2048² 하나, 폰에서는 1024² 가 무난.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u17
- 쓰면 좋을 때: 물체가 바닥에서 떠 보이는 것을 막을 때 / 해 방향 · 시간 흐름을 보여 줄 때 / 판 게임에서 말 위치를 또렷하게
- 쓰지 말 때: 폰에서 캐릭터 몇 개 발밑만 필요할 때 — 대신 접촉 그림자(i15) / 모든 빛에 그림자 — 그림자는 주광 하나만

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### panels — `src/demos/demosLight.ts:96`
```ts
function panels(r: R, w: number, h: number, list: Panel[], frame: (aspect: number) => THREE.Camera): void {
  const n = list.length;
  const tm = r.toneMapping;
  const ex = r.toneMappingExposure;
  r.setScissorTest(true);
  list.forEach((p, i) => {
    const x0 = Math.round((i * w) / n);
    const x1 = Math.round(((i + 1) * w) / n);
    const cam = frame((x1 - x0) / h);
    r.setViewport(x0, 0, x1 - x0, h);
    r.setScissor(x0, 0, x1 - x0, h);
    if (p.tone !== undefined) r.toneMapping = p.tone;
    if (p.exposure !== undefined) r.toneMappingExposure = p.exposure;
    r.render(p.scene, cam);
    r.toneMapping = tm;
    r.toneMappingExposure = ex;
  });
  r.setScissorTest(false);
  r.setViewport(0, 0, w, h);
}
```

### u17 견본 항목 — `src/demos/demosLight.ts:1131`
```ts
  u17: {
    kind: '3d',
    caption: '그림자 없음 · 또렷한 그림자(만화풍) · 부드러운 그림자 — 해가 돌면 그림자도 따라 돌아요',
    make() {
      const build = (mode: 0 | 1 | 2): { scene: THREE.Scene; sun: THREE.DirectionalLight; ch: THREE.Group } => {
        const scene = new THREE.Scene();
        scene.background = gradTex('#9fd3ff', '#e8f5ff');
        scene.add(new THREE.HemisphereLight(0xffffff, 0x9a8a70, 0.9));
        const sun = new THREE.DirectionalLight(0xfff0d6, 2.2);
        sun.castShadow = mode > 0;
        sun.shadow.mapSize.set(mode === 1 ? 1024 : 512, mode === 1 ? 1024 : 512);
        sun.shadow.radius = mode === 1 ? 1 : 10;
        sun.shadow.bias = -0.0008;
        sun.shadow.normalBias = 0.02;
        Object.assign(sun.shadow.camera, { left: -2.2, right: 2.2, top: 2.2, bottom: -2.2, near: 0.5, far: 14 });
        scene.add(sun, sun.target);
        const ground = new THREE.Mesh(new THREE.CircleGeometry(2.2, 48), std(0xf3ead8, 0.9));
        ground.rotation.x = -Math.PI / 2;
        ground.receiveShadow = true;
        scene.add(ground);
        const ch = cutie(0x6fd3c7);
        ch.scale.setScalar(1.1);
        scene.add(ch);
        const cube = mesh(new RoundedBoxGeometry(0.5, 0.5, 0.5, 2, 0.06), std(0xff6b6b, 0.5), 1.05, 0.25, -0.35);
        const cube2 = mesh(new RoundedBoxGeometry(0.5, 0.5, 0.5, 2, 0.06), std(0xffd23f, 0.5), 1.05, 0.75, -0.35);
        const ball = mesh(new THREE.SphereGeometry(0.3, 32, 20), std(0x4aa3ff, 0.4), -1.0, 0.3, 0.3);
        for (const m of [cube, cube2, ball]) m.castShadow = true;
        scene.add(cube, cube2, ball);
        return { scene, sun, ch };
      };
      const S = [build(0), build(1), build(2)];
      const cam = new THREE.PerspectiveCamera(34, 1, 0.1, 50);
      const hud = new Hud();
      hud.vline(1 / 3);
      hud.vline(2 / 3);
      ['없음', '또렷하게', '부드럽게'].forEach((s, i) => hud.label(s, (i + 0.5) / 3, 0.95, 0.5, 1, i ? 'on' : 'tag'));
      let soft = 10;
      return {
        scene: S[0]!.scene,
        camera: cam,
        update(t) {
          const a = t * 0.5;
          for (const s of S) {
            s.sun.position.set(Math.cos(a) * 3, 4, Math.sin(a) * 3);
            s.ch.rotation.y = Math.sin(t * 0.7) * 0.6;
          }
          S[2]!.sun.shadow.radius = soft;
        },
        render(r, w, h) {
          withShadows(r, () => panels(r, w, h, S.map((s) => ({ scene: s.scene })), (a) => fit(cam, a, V3(0, 0.35, 0), 1.75, V3(0, 1.1, 1.3))));
          hud.draw(r, w, h);
        },
        controls: [{ type: 'range', label: '부드러움 (반지름)', min: 1, max: 20, step: 0.5, value: 10, on: (v) => (soft = v) }],
        dispose() {
          hud.dispose();
          for (const s of S) s.sun.shadow.map?.dispose();
          disposeTree(...S.map((s) => s.scene));
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [3점 조명 (반구 + 해 + 테두리)](https://ai-techstudio.web.app/ai/t/u14.md) `u14`
- 다음에 해 볼 기술: [접촉 그림자 (Contact shadow)](https://ai-techstudio.web.app/ai/t/i15.md) `i15` · [하루 시간 흐름 (해 · 하늘색)](https://ai-techstudio.web.app/ai/t/i431.md) `i431`
- 참고 문서: [three.js 예제 — webgl_shadowmap](https://threejs.org/examples/#webgl_shadowmap) · [three.js 문서 — LightShadow](https://threejs.org/docs/#api/en/lights/shadows/LightShadow)
