# AI 꾸러미 — 높이 → 법선 맵 굽기 (가짜 디테일)
> 잡음 · 패턴 높이 그림을 법선 맵으로 바꿔 평평한 면에 나무결 · 벽돌 · 비늘 요철  
> 견본: https://ai-techstudio.web.app/#t/i454

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

## 주문서

### 만들어 줘: 높이 → 법선 맵 굽기 (가짜 디테일)

#### 1. 목표
벽에 「높이 → 법선 맵 굽기 (가짜 디테일)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 잡음 · 패턴 높이 그림을 법선 맵으로 바꿔 평평한 면에 나무결 · 벽돌 · 비늘 요철

#### 2. 핵심 기술 용어
- 높이 → 법선 맵 굽기 (가짜 디테일) — 잡음 · 패턴 높이 그림을 법선 맵으로 바꿔 평평한 면에 나무결 · 벽돌 · 비늘 요철

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

#### 4. 조건
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 화면 가장자리 흐림 · 비네트로 얼버무리지 않기
- 외부 라이브러리 추가 없이 three.js 와 three/examples 만

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「잡음 · 패턴 높이 그림을 법선 맵으로 바꿔 평평한 면에 나무결 · 벽돌 · 비늘 요철」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 잡음 · 패턴 높이 그림을 법선 맵으로 바꿔 평평한 면에 나무결 · 벽돌 · 비늘 요철


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「잡음 · 패턴 높이 그림을 법선 맵으로 바꿔 평평한 면에 나무결 · 벽돌 · 비늘 요철」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i454` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i454
- 쓰면 좋을 때: 쓰면 좋을 곳 — 벽 · 바닥 · 비늘 · 나무껍질

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### normalMapDemo — `src/demos/demosModelB.ts:2528`
```ts
function normalMapDemo(): Scene3D {
  const scene = new THREE.Scene();
  const bg = bgTex('#141822', '#2c3346');
  scene.background = bg;
  const { hemi, sun } = addLights(scene, { sky: 0xd8e2ff, ground: 0x3a3028, hemi: 0.35, sun: 3.2, dir: [3, 1.6, 0], ext: 3 });
  const env = new Env();
  const camera = new THREE.PerspectiveCamera(38, 1.6, 0.1, 50);
  const table = new THREE.Mesh(new THREE.PlaneGeometry(12, 8), new THREE.MeshStandardMaterial({ color: 0x262a36, roughness: 0.9 }));
  table.rotation.x = -PI / 2;
  table.receiveShadow = true;
  scene.add(table);
  const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.07, 16, 12), new THREE.MeshBasicMaterial({ color: 0xfff1c8, toneMapped: false }));
  scene.add(bulb);
  const tags = new Tags(6, ['rgba(12,16,34,0.72)', 'rgba(60,60,80,0.8)', 'rgba(110,60,150,0.85)', 'rgba(12,16,34,0.6)', 'rgba(12,16,34,0.6)', 'rgba(12,16,34,0.6)']);
  type Tile = { mesh: THREE.Mesh; plain: THREE.MeshStandardMaterial; nm: THREE.MeshStandardMaterial; hview: THREE.MeshBasicMaterial; nview: THREE.MeshBasicMaterial; texs: THREE.Texture[] };
  let tiles: Tile[] = [];
  const frames: THREE.Mesh[] = [];
  const frameMat = new THREE.MeshStandardMaterial({ color: 0x4b3426, roughness: 0.5 });
  const tileGeo = new THREE.PlaneGeometry(1.5, 1.5).rotateX(-PI / 2);
  for (let k = 0; k < 3; k++) {
    const f = new THREE.Mesh(new THREE.BoxGeometry(1.66, 0.08, 1.66), frameMat);
    f.position.set((k - 1) * 1.8, 0.04, 0);
    f.castShadow = f.receiveShadow = true;
    scene.add(f);
    frames.push(f);
  }
  let seed = 9;
  let ms = 0;
  let strength = 1.8;
  let auto = true;
  let split = true;
  let bakeView = false;
  let lightSpeed = 0.6;
  let wire = false;
  let frac = 0.5;
  let ang = 0;
  const job = new Job();
  const warm = new Warm();
  // 굽는 동안 자리에 놓는 회색 판
  const holdMat = new THREE.MeshStandardMaterial({ color: 0x5a5f6e, roughness: 0.9 });
  const holders = [0, 1, 2].map((k) => {
    const m = new THREE.Mesh(tileGeo, holdMat);
    m.position.set((k - 1) * 1.8, 0.081, 0);
    scene.add(m);
    return m;
  });

  function build(): void {
    for (const t of tiles) {
      scene.remove(t.mesh);
      for (const x of t.texs) x.dispose();
      t.plain.dispose();
      t.nm.dispose();
      t.hview.dispose();
      t.nview.dispose();
    }
    tiles = [];
    for (const ph of holders) ph.visible = true;
    const t0 = tiles;
    // 높이 그림 → 법선 맵, 한 장씩 나눠 굽는다 (프레임마다 3ms)
    job.start(
      (function* (): G {
        const makers = [() => patWood(seed), () => patBrick(seed + 1), () => patScales(seed + 2)];
        for (let k = 0; k < 3; k++) {
          const p = yield* makers[k]!();
          const nrm = yield* normalFrom(p.h, 9);
          const hb = new Uint8Array(NM * NM * 4);
          for (let i = 0; i < NM * NM; i++) {
            if ((i & 16383) === 16383 && late()) yield;
            const v = clamp(p.h[i]! * 255, 0, 255);
            hb[i * 4] = hb[i * 4 + 1] = hb[i * 4 + 2] = v;
            hb[i * 4 + 3] = 255;
          }
          yield;
          if (tiles !== t0) return;
          const ntex = dataTex(nrm, false);
          const ctex = dataTex(p.col, true);
          const htex = dataTex(hb, true);
          const rough = k === 2 ? 0.35 : k === 0 ? 0.6 : 0.85;
          const plain = new THREE.MeshStandardMaterial({ map: ctex, roughness: rough });
          const nm = new THREE.MeshStandardMaterial({ map: ctex, normalMap: ntex, normalScale: new THREE.Vector2(strength, strength), roughness: rough });
          const hview = new THREE.MeshBasicMaterial({ map: htex });
          const nview = new THREE.MeshBasicMaterial({ map: ntex });
          const mesh = new THREE.Mesh(tileGeo, nm);
          mesh.position.set((k - 1) * 1.8, 0.081, 0);
          mesh.receiveShadow = true;
          hiddenUsers(mesh, [plain, hview, nview]);
          const tile = { mesh, plain, nm, hview, nview, texs: [ntex, ctex, htex] };
          const hk = holders[k]!;
          mesh.onBeforeRender = () => {
            hk.visible = false;
            mesh.onBeforeRender = () => {};
          };
          warm.add(mesh, scene, () => tiles.includes(tile));
          tiles.push(tile);
          setWire();
        }
        ms = job.spent;
      })(),
    );
    setWire();
  }
  function setWire(): void {
    for (const t of tiles) t.plain.wireframe = t.nm.wireframe = t.hview.wireframe = t.nview.wireframe = wire;
  }
  build();
  void hemi;

  return {
    scene,
    camera,
    update(t, dt) {
      camera.position.set(Math.sin(t * 0.13) * 0.3, 3.85, 3.8);
      camera.lookAt(0, -0.1, 0.05);
      ang += dt * lightSpeed * 1.4;
      const el = 0.42;
      sun.position.set(Math.cos(ang) * 3 * Math.cos(el), 3 * Math.sin(el), Math.sin(ang) * 3 * Math.cos(el));
      bulb.position.copy(sun.position).setLength(1.9);
      bulb.position.y = 0.55;
      if (auto) frac = 0.5 + 0.38 * Math.sin(t * 0.7);
      if (job.busy) job.step();
    },
    render(r, w, h) {
      env.apply(r, scene, 0.25);
      tags.set(0, job.busy ? `법선 맵 굽는 중 ${tiles.length} / 3 · 나눠서 ${job.spent.toFixed(0)}ms` : `판 하나 = 삼각형 2개 · 굽기 512² × 3장 · ${ms.toFixed(0)}ms (나눠서)`);
      tags.set(1, bakeView ? '높이 그림' : '법선 맵 없음');
      tags.set(2, bakeView ? '구운 법선 맵' : '법선 맵 붙임');
      tags.set(3, '나무결');
      tags.set(4, '벽돌');
      tags.set(5, '비늘');
      const use = (m: 'a' | 'b'): void => {
        for (const t of tiles) t.mesh.material = bakeView ? (m === 'a' ? t.hview : t.nview) : m === 'a' ? t.plain : t.nm;
      };
      const f = split || bakeView ? frac : 0;
      if (warm.tick(r, scene, camera, 0x1c2130))
      shadowed(r, () =>
        f > 0
          ? slider(
              r,
              w,
              h,
              f,
              () => {
                use('a');
                r.render(scene, camera);
              },
              () => {
                use('b');
                r.render(scene, camera);
              },
            )
          : (use('b'), r.render(scene, camera)),
      );
      const lab = [-1, 0, 1].map((k) => toScreen(new THREE.Vector3(k * 1.8, 0.1, 0.95), camera));
      tags.draw(r, w, h, [
        [0.02, 0.06, -1],
        f > 0 ? [f - 0.015, 0.06 + 0.07, 1] : null,
        f > 0 ? [f + 0.015, 0.06 + 0.07, -1] : null,
        [lab[0]![0], lab[0]![1], 0],
        [lab[1]![0], lab[1]![1], 0],
        [lab[2]![0], lab[2]![1], 0],
      ]);
    },
    controls: [
      { type: 'toggle', label: '전/후 비교 막대', value: true, on: (v) => (split = v) },
      { type: 'toggle', label: '비교 막대 자동', value: true, on: (v) => (auto = v) },
      { type: 'range', label: '나눔 위치', min: 0, max: 1, step: 0.01, value: 0.5, on: (v) => ((auto = false), (frac = v)) },
      {
        type: 'range',
        label: '요철 세기',
        min: 0,
        max: 3,
        step: 0.1,
        value: 1.8,
        on: (v) => {
          strength = v;
          for (const t of tiles) t.nm.normalScale.set(v, v);
        },
      },
      { type: 'range', label: '빛 돌리기 빠르기', min: 0, max: 2, step: 0.1, value: 0.6, on: (v) => (lightSpeed = v) },
      { type: 'toggle', label: '구운 그림 보기 (높이 → 법선)', value: false, on: (v) => (bakeView = v) },
      {
        type: 'toggle',
        label: '와이어프레임 (판은 삼각형 2개)',
        value: false,
        on: (v) => {
          wire = v;
          setWire();
        },
      },
      {
        type: 'button',
        label: '새로 만들기',
        on: () => {
          seed = (seed * 7919 + 13) % 100000;
          build();
        },
      },
    ] as Control[],
    dispose() {
      for (const t of tiles) {
        for (const x of t.texs) x.dispose();
        t.plain.dispose();
        t.nm.dispose();
        t.hview.dispose();
        t.nview.dispose();
      }
      tileGeo.dispose();
      holdMat.dispose();
      for (const f of frames) f.geometry.dispose();
      frameMat.dispose();
      freeAll(table);
      freeAll(bulb);
      bg.dispose();
      env.dispose();
      tags.dispose();
    },
  };
}
```

### i454 견본 항목 — `src/demos/demosModelB.ts:3139`
```ts
  i454: { kind: '3d', caption: '삼각형 2개짜리 평평한 판에 높이 그림을 법선 맵으로 구워 붙이면 — 빛이 돌 때 나무결 · 벽돌 · 비늘 요철', make: () => normalMapDemo() }
```

## 관련 기술
- 이 효과를 만든 기술: [그린 요철 · 노멀 맵](https://ai-techstudio.web.app/ai/t/u10.md) `u10`
