# AI 꾸러미 — 실사 PBR 텍스처 (무료 CC0 스캔 재질) — PBR texture set (albedo · normal · ARM)
> Poly Haven 사진 스캔 재질의 색 · 법선 · ARM(AO · 거칠기 · 금속) 지도를 입혀, 같은 벽 · 바닥 · 상자 · 기둥이 단색에서 실사로 바뀌는 과정을 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/i474

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

## 주문서

### 만들어 줘: 실사 PBR 텍스처 (무료 CC0 스캔 재질) — PBR texture set (albedo · normal · ARM)

#### 1. 목표
던전 벽 · 바닥 · 상자 · 기둥에 실사 PBR 재질을 입혀 줘 — 무료 CC0 스캔 재질의 색 · 법선 · AO · 거칠기 · 금속 지도로, 단색 → 색 → 법선 → 전부 단계 비교. 분위기는 어두운 던전 · 낮게 훑는 빛.

#### 2. 핵심 기술 용어
- **PBR texture set (albedo · normal · ARM)** — 실사 재질 지도 묶음
- **ARM map (AO · Roughness · Metalness in R · G · B)** — 구석 그늘 · 거칠기 · 금속을 한 장에
- **Color space (SRGB for color, NoColorSpace for data)** — 색 지도만 sRGB, 나머지는 데이터
- **CC0 scanned materials (Poly Haven)** — 무료 사진 스캔 재질

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

#### 4. 조건
- ARM 한 장을 aoMap · roughnessMap · metalnessMap 에 함께, roughness · metalness 값은 1
- 색 지도만 SRGBColorSpace, 법선 · ARM 은 NoColorSpace
- 법선 맵은 OpenGL 방식(nor_gl) — DirectX 방식이면 normalScale.y = −1
- 텍스처 GPU 올리기는 renderer.initTexture 로 나눠서, 셰이더는 compileAsync 로 미리 — 첫 화면 멈춤 없이 「받는 중 · 올리는 중 · 굽는 중」 표시
- 반복은 texture.repeat, 같은 그림은 source 를 나눠 쓰기
- 라이선스(CC0) · 출처를 남긴다

#### 5. 완성 기준 (이게 보이면 성공)
- 같은 장면이 네 칸으로 — ① 단색 ② 색 지도 ③ 법선 ④ AO · 거칠기 · 금속 — 오른쪽으로 갈수록 실사가 된다
- 낮게 훑는 빛이 지나가면 ③ ④ 칸에서 돌 · 벽돌 틈이 입체로 드러난다
- 「재질 바꾸기」로 모두 돌바닥 · 벽돌 · 나무 · 금속 판으로 바꿔 볼 수 있다
- 「빛 높이」 슬라이더로 빛을 낮추면 요철이 더 강하게 보인다

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

## 원리
- 스캔 재질은 보통 색(diff) · 법선(nor_gl — OpenGL 방식) · ARM 세 장이다. ARM 은 R = AO, G = 거칠기, B = 금속.
- 같은 ARM 텍스처를 aoMap · roughnessMap · metalnessMap 에 함께 넣고 roughness · metalness 값은 1 로 둔다 (지도 값이 그대로 쓰이게).
- 색 지도만 SRGBColorSpace, 법선 · ARM 은 NoColorSpace — 데이터를 색으로 바꾸면 값이 틀어진다.
- 같은 그림을 반복 수만 다르게 여러 물체에 쓸 땐 source 를 나눠 쓰는 복사 텍스처로 — GPU 에 다시 올리지 않는다.
- 빛을 낮게 훑으면 법선 · AO 차이가 가장 잘 드러난다. 화면을 넷으로 나눠 단계마다 재질만 바꿔 그린다.

## 핵심 코드 — 스캔 재질 3장 → MeshStandardMaterial (ARM 한 장을 세 칸에)
(발췌: demos/demosRender.ts texFrom() · pbrMat() · rep() 를 정리)
```ts
const loader = new THREE.TextureLoader();
function tex(url: string, isColor: boolean): THREE.Texture {
  const t = loader.load(url);
  t.colorSpace = isColor ? THREE.SRGBColorSpace : THREE.NoColorSpace; // 데이터는 색 변환 금지
  t.wrapS = t.wrapT = THREE.RepeatWrapping;
  t.anisotropy = 16; // 렌더러가 최대치로 자른다
  return t;
}
const brick = {
  map: tex('/polyhaven/castle_brick_07_diff_1k.jpg', true),
  normal: tex('/polyhaven/castle_brick_07_nor_gl_1k.jpg', false),  // OpenGL 방식 법선
  arm: tex('/polyhaven/castle_brick_07_arm_1k.jpg', false),        // R AO · G 거칠기 · B 금속
};
// 같은 그림을 반복만 다르게 — source 를 나눠 써서 GPU 에 다시 올리지 않는다
function rep(t: THREE.Texture, x: number, y = x): THREE.Texture {
  const c = new THREE.Texture();
  c.source = t.source;
  c.colorSpace = t.colorSpace;
  c.wrapS = c.wrapT = THREE.RepeatWrapping;
  c.anisotropy = t.anisotropy;
  c.repeat.set(x, y);
  c.version = Math.max(1, t.version);
  return c;
}
function pbrMat(p: typeof brick, rx: number, ry = rx): THREE.MeshStandardMaterial {
  const arm = rep(p.arm, rx, ry);
  return new THREE.MeshStandardMaterial({
    map: rep(p.map, rx, ry),
    normalMap: rep(p.normal, rx, ry),
    aoMap: arm, roughnessMap: arm, metalnessMap: arm, // 한 장을 세 칸에 (채널은 three 가 알아서)
    roughness: 1, metalness: 1,                        // 지도 값 그대로
  });
}
const wall = new THREE.Mesh(new THREE.BoxGeometry(6.6, 2.7, 0.3), pbrMat(brick, 3.2, 1.3));
scene.add(wall);
// 첫 화면 멈춤 막기: renderer.initTexture(t) 를 프레임당 3ms 씩 → await renderer.compileAsync(scene, camera)
```

## 흔한 실수 · 확인 목록
- [ ] **법선 · ARM 에 sRGB 를 걸면 요철이 뭉개지고 거칠기가 틀어진다** — 색 지도만 SRGBColorSpace, 나머지는 NoColorSpace.
- [ ] **metalness 를 기본값 0 으로 두면 금속 지도가 0 과 곱해져 금속이 사라진다** — 지도 값은 재질 값과 곱해진다. 지도를 쓸 땐 roughness = 1, metalness = 1 로 두어 지도 값이 그대로 쓰이게 한다.
- [ ] **같은 그림을 물체마다 clone 해서 needsUpdate 하면 GPU 에 여러 번 올라간다** — source 를 나눠 쓰는 복사 텍스처를 만들고 version 만 맞춘다 (견본 rep).
- [ ] **1K 텍스처 열두 장을 한 프레임에 올리면 화면이 멈춘다** — initTexture 를 프레임당 3ms 로 나누고, 셰이더는 compileAsync 로 미리 굽는다.
- [ ] **DirectX 법선(nor_dx)을 받으면 요철이 뒤집혀 보인다** — nor_gl 을 받거나 normalScale.y = −1 로 뒤집는다.

## 완성 기준 체크리스트
- [ ] 같은 장면이 네 칸으로 — ① 단색 ② 색 지도 ③ 법선 ④ AO · 거칠기 · 금속 — 오른쪽으로 갈수록 실사가 된다
- [ ] 낮게 훑는 빛이 지나가면 ③ ④ 칸에서 돌 · 벽돌 틈이 입체로 드러난다
- [ ] 「재질 바꾸기」로 모두 돌바닥 · 벽돌 · 나무 · 금속 판으로 바꿔 볼 수 있다
- [ ] 「빛 높이」 슬라이더로 빛을 낮추면 요철이 더 강하게 보인다

## 이 기술 정보
- id: `i474` · 분류: 3D 모델 · 캐릭터 › 하드서피스 · 실사 렌더링 · 3D · 난이도 쉬움 · 폰 부담 보통 (폰 주의) — 1K 재질 한 묶음 = 3장, 4종이면 12장 · 약 10MB 내려받기. GPU 에 올리는 것은 프레임당 3ms 로 나누고, 셰이더는 compileAsync 로 미리.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i474
- 쓰면 좋을 때: 던전 · 건물처럼 사실적인 벽 · 바닥 · 나무 · 금속이 필요할 때 / 코드로 그린 무늬로는 실사 느낌이 안 날 때
- 쓰지 말 때: 툰 · 동화풍 게임 — 사진 재질은 그림체와 따로 논다. 대신 캔버스 무늬(u09) · 절차 마모(i84) / 폰에서 1K 텍스처 수십 장 — 메모리가 모자란다. 512 로 줄이거나 몇 장만

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makePbrStages — `src/demos/demosRender.ts:639`
```ts
function makePbrStages(T: typeof THREE): Scene3D {
  const scene = new T.Scene();
  scene.background = new T.Color(0x0c0e15);
  const cam = new T.PerspectiveCamera(28, 1.6, 0.1, 60);
  const hud = new Hud();
  const msg = hud.label('실사 재질 받는 중 …', 0.5, 0.5, 0.5, 0.5, 'tag', 0.8);
  const heads = STAGE_NAME.map((s, i) => {
    const l = hud.label(s, (i + 0.5) / 4, 0.965, 0.5, 1, i === 3 ? 'on' : 'tag', 0.7);
    l.maxW = 0.235;
    l.visible = false;
    return l;
  });
  const foot = hud.label(MODE_NAME[0]!, 0.5, 0.03, 0.5, 0, 'off', 0.62);
  foot.visible = false;
  for (let i = 1; i < 4; i++) hud.vline(i / 4);

  scene.add(new T.HemisphereLight(0xbfd4ff, 0x2a2018, 0.35));
  const key = new T.DirectionalLight(0xfff1dd, 1.4);
  key.position.set(-4, 6, 5);
  key.castShadow = true;
  key.shadow.mapSize.set(1024, 1024);
  Object.assign(key.shadow.camera, { left: -4, right: 4, top: 3, bottom: -3, near: 1, far: 20 });
  key.shadow.bias = -0.0008;
  key.shadow.normalBias = 0.02;
  scene.add(key);
  // 낮게 훑는 빛 — 벽 · 바닥 요철이 드러난다
  const sweep = new T.PointLight(0xffd7a0, 9, 7, 2);
  scene.add(sweep);
  const bulb = new T.Mesh(new T.SphereGeometry(0.05, 16, 10), new T.MeshBasicMaterial({ color: 0xfff0d0, toneMapped: false }));
  bulb.add(glowSprite(0xffc070, 0.7, 0.8));
  sweep.add(bulb);

  type Role = 'floor' | 'wall' | 'crate' | 'pillar';
  const NATURAL: Record<Role, SetId> = { floor: 'rock', wall: 'brick', crate: 'metal', pillar: 'planks' };
  const REP: Record<Role, [number, number]> = { floor: [3.2, 1.5], wall: [3.2, 1.3], crate: [0.6, 0.6], pillar: [1, 1.2] };
  const geos: THREE.BufferGeometry[] = [];
  const meshes: { m: THREE.Mesh; role: Role }[] = [];
  const add = (g: THREE.BufferGeometry, role: Role, x: number, y: number, z: number, ry = 0): THREE.Mesh => {
    geos.push(g);
    const m = new T.Mesh(g);
    m.position.set(x, y, z);
    m.rotation.y = ry;
    m.castShadow = role !== 'floor';
    m.receiveShadow = true;
    m.visible = false;
    scene.add(m);
    meshes.push({ m, role });
    return m;
  };
  add(new T.BoxGeometry(6.6, 0.3, 3.2), 'floor', 0, -0.15, 0.1);
  add(new T.BoxGeometry(6.6, 2.7, 0.3), 'wall', 0, 1.35, -1.35);
  const crate = new T.BoxGeometry(0.85, 0.85, 0.85);
  const pillar = new T.LatheGeometry(columnProfile(2.3, 0.24, 12), 40);
  add(crate, 'crate', -2.45, 0.425, 0.2, 0.35);
  add(pillar, 'pillar', -0.82, 0, -0.35);
  add(crate.clone(), 'crate', 0.82, 0.425, 0.2, -0.3);
  add(pillar.clone(), 'pillar', 2.45, 0, -0.35);
  const blobs = [-2.45, -0.82, 0.82, 2.45].map((x, i) => {
    const b = blob(i % 2 ? 0.9 : 1.4, 0.55);
    b.position.set(x, 0.005, i % 2 ? -0.35 : 0.2);
    b.visible = false;
    scene.add(b);
    return b;
  });

  let kit: Kit | null = null;
  let dead = false;
  let mode = 0;
  let moving = true;
  let lightH = 1.2;
  let now = 0;
  const cache = new Map<string, THREE.MeshStandardMaterial>();
  const matFor = (role: Role, stage: number): THREE.MeshStandardMaterial => {
    const set = mode === 0 ? NATURAL[role] : SETS[mode - 1]!;
    const k = `${set}|${role}|${stage}`;
    let m = cache.get(k);
    if (!m) {
      const [rx, ry] = REP[role];
      m = stageMat(kit!.pbr[set], stage, rx, ry);
      cache.set(k, m);
    }
    return m;
  };
  const applyStage = (stage: number): void => {
    for (const { m, role } of meshes) m.material = matFor(role, stage);
  };
  const warm = new Warm();
  const texList = (): THREE.Texture[] => {
    const out: THREE.Texture[] = [];
    for (let s = 0; s < 4; s++) for (const { role } of meshes) out.push(...allTex(matFor(role, s)));
    return out;
  };
  loadKit().then((k) => {
    if (dead) return;
    kit = k;
    for (const { m } of meshes) m.visible = true;
    for (const b of blobs) b.visible = true;
  });
  const target = new T.Vector3(0, 1.0, 0);
  const dir = new T.Vector3(0, 0.22, 1);

  return {
    scene,
    camera: cam,
    update(t) {
      now = moving ? t : now;
      sweep.position.set(Math.sin(now * 0.55) * 3.1, lightH + 0.35 * Math.sin(now * 0.9), -0.75);
    },
    render(r, w, h) {
      if (!kit) return waiting(r, w, h, hud, msg, '실사 재질 받는 중 … (약 10MB · 한 번만)');
      if (!pump(r, texList())) return waiting(r, w, h, hud, msg, 'GPU 에 텍스처 올리는 중 …');
      if (kit.hdr) {
        const env = envFor(r, kit.hdr);
        if (!env) return waiting(r, w, h, hud, msg, 'HDRI 빛 굽는 중 …');
        scene.environment = env;
        scene.environmentIntensity = 0.35;
      }
      // 폭에 맞춰 — 네 칸 모두 같은 벽 · 바닥이 이어 보이게
      cam.aspect = w / h;
      cam.updateProjectionMatrix();
      const vh = THREE.MathUtils.degToRad(cam.fov) / 2;
      const hf = Math.atan(Math.tan(vh) * cam.aspect);
      const dd = Math.max(3.7 / Math.tan(hf), 2.0 / Math.tan(vh));
      cam.position.copy(target).addScaledVector(dir.clone().normalize(), dd);
      cam.lookAt(target);
      const c = cam;
      const ok = warm.ready(r, (rr) =>
        withShadows(rr, true, () =>
          Promise.all([0, 1, 2, 3].map((s) => {
            applyStage(s);
            return rr.compileAsync(scene, c);
          })),
        ),
      );
      if (!ok) return waiting(r, w, h, hud, msg, '셰이더 굽는 중 …');
      msg.visible = false;
      heads.forEach((l) => (l.visible = true));
      foot.visible = true;
      foot.set('재질: ' + MODE_NAME[mode]);
      withShadows(r, true, () => {
        const au = r.shadowMap.autoUpdate;
        r.shadowMap.autoUpdate = false;
        r.setScissorTest(true);
        r.setViewport(0, 0, w, h);
        for (let s = 0; s < 4; s++) {
          const x0 = Math.round((s * w) / 4);
          const x1 = Math.round(((s + 1) * w) / 4);
          r.setScissor(x0, 0, x1 - x0, h);
          applyStage(s);
          r.shadowMap.needsUpdate = s === 0;
          r.render(scene, c);
        }
        r.setScissorTest(false);
        r.shadowMap.autoUpdate = au;
      });
      hud.draw(r, w, h);
    },
    controls: [
      {
        type: 'button',
        label: '재질 바꾸기',
        on: () => {
          mode = (mode + 1) % MODE_NAME.length;
          warm.reset();
        },
      },
      { type: 'range', label: '빛 높이', min: 0.4, max: 2.2, step: 0.05, value: lightH, on: (v) => (lightH = v) },
      { type: 'toggle', label: '빛 움직이기', value: true, on: (v) => (moving = v) },
    ],
    dispose() {
      dead = true;
      for (const g of geos) g.dispose();
      for (const m of cache.values()) m.dispose();
      for (const b of blobs) disposeTree(b);
      disposeTree(bulb);
      hud.dispose();
    },
  };
}
```

### i474 견본 항목 — `src/demos/demosRender.ts:2348`
```ts
  i474: { kind: '3d', caption: '같은 벽 · 바닥 · 상자 · 기둥 — 단색 → 색 지도 → 법선 → AO·거칠기·금속, 낮게 훑는 빛', make: makePbrStages }
```

## 관련 기술
- 먼저 알면 좋은 기술: [환경 반사 (RoomEnvironment + PMREM)](https://ai-techstudio.web.app/ai/t/u12.md) `u12` · [나사 · 볼트 · 패널 이음선](https://ai-techstudio.web.app/ai/t/i470.md) `i470`
- 다음에 해 볼 기술: [HDRI 환경 빛 · 반사](https://ai-techstudio.web.app/ai/t/i475.md) `i475` · [쿼터뷰 던전 조명 (횃불 · 그림자 · 안개)](https://ai-techstudio.web.app/ai/t/i477.md) `i477`
- 참고 문서: [Poly Haven — 무료 CC0 텍스처](https://polyhaven.com/textures) · [three.js 문서 — MeshStandardMaterial](https://threejs.org/docs/#api/en/materials/MeshStandardMaterial)
