# AI 꾸러미 — 그린 요철 · 노멀 맵 — Bump mapping (bumpMap · bumpScale)
> 캔버스로 그린 흑백 높이 그림을 요철 맵으로 붙여, 모양은 매끈한 그대로인데 빛이 돌면 점 · 결이 오돌토돌 솟아 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/u10

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

## 주문서

### 만들어 줘: 그린 요철 · 노멀 맵 — Bump mapping (bumpMap · bumpScale)

#### 1. 목표
점박이 버섯 갓에 캔버스로 그린 요철 맵을 넣어 줘 — 색 무늬와 같은 자리에 높이 무늬. 느낌은 손으로 만든 소품.

#### 2. 핵심 기술 용어
- **Bump mapping (bumpMap · bumpScale)** — 높이 그림으로 법선을 흔들어 요철처럼
- **Normal map** — 법선 방향을 색으로 담은 그림 (같은 일, 더 정확)
- **Height map** — 흰 곳 = 높은 곳인 흑백 그림

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

#### 4. 조건
- 높이 그림은 흑백, colorSpace 는 그대로 (sRGB 로 바꾸지 않기)
- 색 무늬와 높이 무늬를 같은 좌표에 함께 그리기
- 빛을 천천히 돌려 요철이 드러나게
- 「요철 세기」 0~10 슬라이더, 왼쪽 무늬만 · 오른쪽 + 요철 맵 비교

#### 5. 완성 기준 (이게 보이면 성공)
- 빛이 돌면 오른쪽 갓의 흰 점들이 볼록하게 솟아 보이고, 왼쪽은 납작하다
- 오른쪽 매트에는 뜨개 코가 하나하나 솟아 보인다
- 「요철 세기」 0 이면 양쪽이 같아진다

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

## 원리
- 진짜로 정점을 올리지 않고, 빛 계산에 쓰는 법선만 높이 그림의 기울기만큼 기울인다.
- 그래서 빛이 옆에서 비칠 때 점의 한쪽은 밝고 반대쪽은 어두워져 솟아 보인다.
- 색 그림(점)과 높이 그림(같은 자리에 흰 원)을 같은 좌표로 그려야 무늬와 요철이 맞는다.
- bumpScale 로 세기를 조절한다 (견본 4, 펠트는 3).

## 핵심 코드 — 색 그림과 높이 그림을 같은 자리에 그리기
(발췌: demos/demosMaterial.ts u10 make() 를 정리)
```ts
const [cc, gc] = cv(512, 256); // 색
const [hc, gh] = cv(512, 256); // 높이 (흑 = 낮음, 백 = 높음)
gc.fillStyle = '#d8384a'; gc.fillRect(0, 0, 512, 256);
gh.fillStyle = '#000';    gh.fillRect(0, 0, 512, 256);
for (let i = 0; i < 2500; i++) {          // 잔 결
  gh.fillStyle = 'rgba(255,255,255,' + Math.random() * 0.12 + ')';
  gh.fillRect(Math.random() * 512, Math.random() * 256, 2, 2);
}
for (let i = 0; i < 26; i++) {            // 점: 색과 높이를 같은 자리에
  const x = Math.random() * 512, y = 40 + Math.random() * 120, r = 10 + Math.random() * 16;
  gc.fillStyle = '#fff4e6';
  gc.beginPath(); gc.ellipse(x, y, r, r * 0.9, 0, 0, Math.PI * 2); gc.fill();
  const rg = gh.createRadialGradient(x, y, 0, x, y, r * 1.1);
  rg.addColorStop(0, '#fff'); rg.addColorStop(0.7, '#ddd'); rg.addColorStop(1, 'rgba(0,0,0,0)');
  gh.fillStyle = rg;
  gh.beginPath(); gh.ellipse(x, y, r * 1.1, r, 0, 0, Math.PI * 2); gh.fill();
}
const colT = new THREE.CanvasTexture(cc);
colT.colorSpace = THREE.SRGBColorSpace;   // 색만 sRGB
const bumpT = new THREE.CanvasTexture(hc); // 높이는 그대로

const cap = new THREE.MeshStandardMaterial({ map: colT, roughness: 0.55, bumpMap: bumpT, bumpScale: 4 });

// 빛이 돌아야 요철이 보인다
function update(t: number) { sun.position.set(Math.cos(t * 0.9) * 4, 1.6, Math.sin(t * 0.9) * 2 + 1.5); }
```

## 흔한 실수 · 확인 목록
- [ ] **높이 그림을 sRGB 로 두면 요철 세기가 엉뚱해진다** — 높이 · 노멀 · 거칠기 그림은 데이터라 colorSpace 를 바꾸지 않는다.
- [ ] **정면에서 빛을 비추면 요철이 안 보인다** — 빛을 비스듬히, 가능하면 움직이게 둔다.
- [ ] **높이 그림이 너무 날카로우면 계단 · 지글거림이 생긴다** — 원 가장자리를 그러데이션으로 부드럽게 (견본은 0.7 에서 바깥으로 흐려짐).

## 완성 기준 체크리스트
- [ ] 빛이 돌면 오른쪽 갓의 흰 점들이 볼록하게 솟아 보이고, 왼쪽은 납작하다
- [ ] 오른쪽 매트에는 뜨개 코가 하나하나 솟아 보인다
- [ ] 「요철 세기」 0 이면 양쪽이 같아진다

## 이 기술 정보
- id: `u10` · 분류: 그래픽 · 셰이더 › 재질 · 그림체 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 텍스처 한 장을 더 읽을 뿐. 표준 재질에서도 폰 OK.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u10
- 쓰면 좋을 때: 점 · 결 · 바느질처럼 잔 요철이 많아 모양으로 만들기엔 너무 잘 때 / 빛이 움직여 질감이 살아야 할 때
- 쓰지 말 때: 옆에서 볼 때 윤곽까지 울퉁불퉁해야 할 때 — 대신 진짜 변위 또는 시차 차폐(i230) / 정면 고정 조명 — 요철이 거의 안 보인다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u10 견본 항목 — `src/demos/demosMaterial.ts:831`
```ts
  u10: {
    kind: '3d',
    caption: '왼쪽 무늬만 · 오른쪽 그린 요철 맵 — 빛이 돌면 점 · 결이 오돌토돌 솟아 보여요',
    make() {
      const B = base({ bg: gradBg('#22305c', '#5a70a8'), labels: ['무늬만', '+ 요철 맵'], fit: [2.5, 0.95], target: [0, 0.05, 0], dir: [0, 0.4, 1] });
      B.scene.add(new THREE.HemisphereLight(0xffffff, 0x404060, 0.35));
      const sun = new THREE.DirectionalLight(0xfff0dc, 2.8);
      B.scene.add(sun);
      // 같은 자리에 색 무늬 · 높이 무늬
      const [cc, gc] = cv(512, 256);
      const [hc, gh] = cv(512, 256);
      gc.fillStyle = '#d8384a';
      gc.fillRect(0, 0, 512, 256);
      gh.fillStyle = '#000';
      gh.fillRect(0, 0, 512, 256);
      for (let i = 0; i < 2500; i++) {
        gh.fillStyle = `rgba(255,255,255,${Math.random() * 0.12})`;
        gh.fillRect(Math.random() * 512, Math.random() * 256, 2, 2);
      }
      for (let i = 0; i < 26; i++) {
        const x = Math.random() * 512;
        const y = 40 + Math.random() * 120;
        const r = 10 + Math.random() * 16;
        gc.fillStyle = '#fff4e6';
        gc.beginPath();
        gc.ellipse(x, y, r, r * 0.9, 0, 0, Math.PI * 2);
        gc.fill();
        const rg = gh.createRadialGradient(x, y, 0, x, y, r * 1.1);
        rg.addColorStop(0, '#fff');
        rg.addColorStop(0.7, '#ddd');
        rg.addColorStop(1, 'rgba(0,0,0,0)');
        gh.fillStyle = rg;
        gh.beginPath();
        gh.ellipse(x, y, r * 1.1, r, 0, 0, Math.PI * 2);
        gh.fill();
      }
      const colT = tex(cc);
      const bumpT = tex(hc, false);
      // 펠트 · 뜨개 결
      const [fc, gf] = cv(256, 256);
      gf.fillStyle = '#808080';
      gf.fillRect(0, 0, 256, 256);
      for (let y = 0; y < 256; y += 16)
        for (let x = 0; x < 256; x += 16) {
          const rg = gf.createRadialGradient(x + 8, y + 8, 0, x + 8, y + 8, 9);
          rg.addColorStop(0, '#fff');
          rg.addColorStop(1, '#202020');
          gf.fillStyle = rg;
          gf.beginPath();
          gf.ellipse(x + 8, y + 8, 5, 8, (x + y) % 32 ? 0.5 : -0.5, 0, Math.PI * 2);
          gf.fill();
        }
      const feltT = tex(fc, false);
      feltT.wrapS = feltT.wrapT = THREE.RepeatWrapping;
      feltT.repeat.set(3, 3);
      const capA = new THREE.MeshStandardMaterial({ map: colT, roughness: 0.55 });
      const capB = new THREE.MeshStandardMaterial({ map: colT, roughness: 0.55, bumpMap: bumpT, bumpScale: 4 });
      const matA = new THREE.MeshStandardMaterial({ color: 0x5aa0e0, roughness: 0.8 });
      const matB = new THREE.MeshStandardMaterial({ color: 0x5aa0e0, roughness: 0.8, bumpMap: feltT, bumpScale: 3 });
      const sg = new THREE.SphereGeometry(0.55, 64, 40);
      const mg = new RoundedBoxGeometry(1.9, 0.18, 1.3, 3, 0.06);
      const set = (cm: THREE.Material, mm: THREE.Material): { g: THREE.Group; s: THREE.Mesh } => {
        const g = new THREE.Group();
        const s = new THREE.Mesh(sg, cm);
        s.position.y = 0.15;
        const mat = new THREE.Mesh(mg, mm);
        mat.position.y = -0.5;
        g.add(s, mat);
        return { g, s };
      };
      const L = set(capA, matA);
      const R = set(capB, matB);
      L.g.position.x = -1.2;
      R.g.position.x = 1.2;
      B.scene.add(L.g, R.g);
      return B.done({
        update(t) {
          sun.position.set(Math.cos(t * 0.9) * 4, 1.6, Math.sin(t * 0.9) * 2 + 1.5);
          for (const S of [L, R]) S.s.rotation.y = t * 0.3;
        },
        controls: [{ type: 'range', label: '요철 세기', min: 0, max: 10, step: 0.5, value: 4, on: (v) => { capB.bumpScale = v; matB.bumpScale = v * 0.75; } }],
      });
    },
  }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [높이 → 법선 맵 굽기 (가짜 디테일)](https://ai-techstudio.web.app/ai/t/i454.md) `i454` · [미끄럼 방지 무늬 · 각인 (요철 패턴)](https://ai-techstudio.web.app/ai/t/i471.md) `i471`
- 먼저 알면 좋은 기술: [캔버스로 그린 무늬 (CanvasTexture)](https://ai-techstudio.web.app/ai/t/u09.md) `u09`
- 다음에 해 볼 기술: [시차 차폐 매핑 (POM)](https://ai-techstudio.web.app/ai/t/i230.md) `i230` · [절차 마모 재질 (CG 티 없애기)](https://ai-techstudio.web.app/ai/t/i84.md) `i84`
- 참고 문서: [three.js 문서 — MeshStandardMaterial (bumpMap)](https://threejs.org/docs/#api/en/materials/MeshStandardMaterial) · [three.js 예제 — webgl_materials_bumpmap](https://threejs.org/examples/#webgl_materials_bumpmap)
