# AI 꾸러미 — HDRI 환경 빛 · 반사 — HDRI image-based lighting (IBL)
> 실제 사진관 빛을 담은 HDR 사진 한 장을 PMREM 으로 구워 조명과 반사를 함께 맡겨, 크롬 · 금 · 유리가 진짜처럼 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i475

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

## 주문서

### 만들어 줘: HDRI 환경 빛 · 반사 — HDRI image-based lighting (IBL)

#### 1. 목표
크롬 공 · 유리 공 · 톱니 · 금 고리 같은 제품 진열을 HDRI 환경 빛으로 비춰 줘 — HDR 사진을 PMREM 으로 구워 scene.environment 에 넣고, 금속 · 유리 반사가 진짜처럼. 분위기는 깨끗한 사진관 (스튜디오 HDRI).

#### 2. 핵심 기술 용어
- **HDRI image-based lighting (IBL)** — HDR 사진 한 장으로 하는 조명 · 반사
- **HDRLoader + EquirectangularReflectionMapping** — three.js .hdr 읽기 + 파노라마 반사 매핑
- **PMREMGenerator.fromEquirectangular** — 거칠기마다 흐린 반사 지도를 미리 굽기
- **scene.environment / environmentRotation** — 장면 전체 환경 빛 · 그 빛 돌리기

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

#### 4. 조건
- HDRI 는 1k 로 충분 (반사용) — 2k · 4k 는 배경으로 크게 보일 때만
- PMREM 은 렌더러마다 한 번만 굽고 여러 장면이 같이 쓴다. 굽는 동안은 「빛 굽는 중」 화면을 보인다
- HDRI 를 쓰면 해 · 반구광을 따로 켜지 않는다 (겹치면 하얗게 날아감). 밝기는 노출(toneMappingExposure)로
- 금속(metalness 1) 재질은 env 가 없으면 검게 나온다 — env 가 준비된 뒤에 셰이더를 굽는다
- 배경으로 보이기 / 숨기기와 상관없이 빛 · 반사는 그대로여야 한다

#### 5. 완성 기준 (이게 보이면 성공)
- 같은 물체를 왼쪽 일반 조명 · 오른쪽 HDRI 로 나란히 두면, 오른쪽 크롬에 사진관 창 · 조명 모양이 비친다
- 「환경 회전」 슬라이더를 돌리면 반사 띠가 물체 위를 지나간다
- 「HDRI 배경 보이기」를 꺼도 물체 밝기 · 반사는 그대로다
- 첫 화면에서 멈칫 없이 「빛 굽는 중」 → 완성 화면으로 넘어간다

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

## 원리
- HDR 사진은 밝기를 0~1 로 자르지 않고 해 · 조명 같은 아주 밝은 값을 그대로 담는다.
- 그 사진을 물체 둘레를 감싼 공으로 보고, 표면마다 반사 방향의 색을 읽으면 조명과 반사가 한 번에 생긴다.
- 거친 표면은 넓은 범위를 흐리게 읽어야 하므로, PMREM 이 거칠기별로 미리 흐린 지도를 구워 둔다 (한 번만).
- environmentRotation 으로 환경을 돌리면 반사 띠가 물체 위를 미끄러지듯 지나간다.

## 핵심 코드 — HDR 읽기 → PMREM 굽기 → 환경 빛 · 배경 · 회전
(발췌: demos/demosRender.ts loadHDR() · envFor() · makeHdri() 를 정리)
```ts
import * as THREE from 'three';
import { HDRLoader } from 'three/examples/jsm/loaders/HDRLoader.js';

// 1) 사진관 HDRI (견본은 Poly Haven studio_small_09_1k.hdr)
const hdr = await new HDRLoader().loadAsync(HDR_URL);
hdr.mapping = THREE.EquirectangularReflectionMapping;

// 2) 거칠기마다 흐린 반사 지도를 한 번 굽는다 (렌더러마다 한 번 — 여러 장면이 같이 씀)
const pm = new THREE.PMREMGenerator(renderer);
const env = pm.fromEquirectangular(hdr).texture;
pm.dispose();

// 3) 조명 + 반사를 한 번에 — 해 · 반구광은 켜지 않는다
scene.environment = env;
scene.background = showBg ? hdr : new THREE.Color(0x1b1e27); // 배경만 바뀌고 빛은 그대로
scene.backgroundIntensity = 0.85;
scene.environmentRotation.y = rot; // 환경을 돌리면 반사 띠가 물체 위를 지나간다
scene.backgroundRotation.y = rot;
renderer.toneMappingExposure = exposure; // 노출 0.3 ~ 2.5

// 4) 반사가 드러나는 재질들
const chrome = new THREE.MeshStandardMaterial({ color: 0xf2f4f8, metalness: 1, roughness: 0.04 });
const gold = new THREE.MeshStandardMaterial({ color: 0xffc35a, metalness: 1, roughness: 0.18 });
const glass = new THREE.MeshPhysicalMaterial({
  color: 0xffffff, roughness: 0.02, transmission: 1, thickness: 0.7, ior: 1.5,
  attenuationColor: new THREE.Color(0xcfe8ff), attenuationDistance: 1.6, specularIntensity: 1,
});
const rubber = new THREE.MeshPhysicalMaterial({ color: 0xc8232c, roughness: 0.62, sheen: 0.4, sheenRoughness: 0.6 });
```

## 흔한 실수 · 확인 목록
- [ ] **PMREM 굽는 셰이더가 윈도에서 400ms 넘게 멈춘다** — 견본은 PMREM 의 재질만 먼저 만들어 renderer.compileAsync 로 병렬 컴파일한 뒤 굽는다. 굽는 동안은 null 을 돌려 「빛 굽는 중」을 보인다.
- [ ] **env 없이 셰이더를 먼저 구우면 나중에 다시 컴파일된다** — 환경 지도가 붙는 순간 셰이더 열쇠가 바뀐다. env 가 준비된 뒤에 미리 데우기(compileAsync)를 한다.
- [ ] **유리(transmission)가 처음 보일 때 또 멈칫한다** — 투과 재질은 불투명 물체를 반정밀(HalfFloat) 렌더 타깃에 한 번 더 그린다. 그 렌더 타깃을 걸어 둔 채로도 compileAsync 를 한 번 더 해 둔다.
- [ ] **HDRI 에 해 · 반구광까지 더하면 하얗게 날아간다** — HDRI 자체가 조명이다. 밝기는 노출로만 맞춘다 (견본 오른쪽은 빛을 하나도 켜지 않았다).

## 완성 기준 체크리스트
- [ ] 같은 물체를 왼쪽 일반 조명 · 오른쪽 HDRI 로 나란히 두면, 오른쪽 크롬에 사진관 창 · 조명 모양이 비친다
- [ ] 「환경 회전」 슬라이더를 돌리면 반사 띠가 물체 위를 지나간다
- [ ] 「HDRI 배경 보이기」를 꺼도 물체 밝기 · 반사는 그대로다
- [ ] 첫 화면에서 멈칫 없이 「빛 굽는 중」 → 완성 화면으로 넘어간다

## 이 기술 정보
- id: `i475` · 분류: 3D 모델 · 캐릭터 › 하드서피스 · 실사 렌더링 · 3D · 난이도 쉬움 · 폰 부담 보통 (폰 주의) — 그리는 비용은 표준 재질과 거의 같다. 비싼 것은 처음 한 번 — .hdr 내려받기(1k 약 1.5MB) · PMREM 굽기 · 그 셰이더 컴파일(윈도에서 400ms 넘음). 유리(transmission)는 장면을 한 번 더 그린다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i475
- 쓰면 좋을 때: 금속 · 유리 · 니스 칠처럼 반사가 주인공인 물체를 보여 줄 때 / 조명을 여럿 놓지 않고도 고르게 밝은 진열 장면이 필요할 때
- 쓰지 말 때: 툰 · 단색 그림체 장면 — 반사가 그림체를 깬다. 대신 반구광 + 해 (3점 조명) / 어두운 던전처럼 빛 위치가 이야기인 장면 — 대신 점광원 · 그림자 (쿼터뷰 던전 조명)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeHdri — `src/demos/demosRender.ts:849`
```ts
function makeHdri(T: typeof THREE): Scene3D {
  const cam = new T.PerspectiveCamera(36, 0.8, 0.1, 80);
  const hud = new Hud();
  const msg = hud.label('HDRI 받는 중 …', 0.5, 0.5, 0.5, 0.5, 'tag', 0.8);
  hud.vline(0.5);
  const la = hud.label('일반 조명', 0.25, 0.965, 0.5, 1, 'off', 0.72);
  const lb = hud.label('HDRI 환경 빛', 0.75, 0.965, 0.5, 1, 'on', 0.72);
  la.maxW = lb.maxW = 0.46;
  la.visible = lb.visible = false;

  const geos: THREE.BufferGeometry[] = [];
  const g = <G extends THREE.BufferGeometry>(x: G): G => (geos.push(x), x);
  const sphere = g(new T.SphereGeometry(0.4, 64, 40));
  const small = g(new T.SphereGeometry(0.3, 56, 36));
  const gear = g(gearGeometry(T, 14, 0.36, 0.44, 0.09, 0.12));
  const ring = g(new T.TorusGeometry(0.2, 0.055, 32, 80));
  const disc = g(new T.CylinderGeometry(1.55, 1.62, 0.18, 96));
  const mats: THREE.Material[] = [];
  const m = <M extends THREE.Material>(x: M): M => (mats.push(x), x);
  const chrome = m(new T.MeshStandardMaterial({ color: 0xf2f4f8, metalness: 1, roughness: 0.04 }));
  const glass = m(
    new T.MeshPhysicalMaterial({ color: 0xffffff, metalness: 0, roughness: 0.02, transmission: 1, thickness: 0.7, ior: 1.5, attenuationColor: new T.Color(0xcfe8ff), attenuationDistance: 1.6, specularIntensity: 1 }),
  );
  const rubber = m(new T.MeshPhysicalMaterial({ color: 0xc8232c, roughness: 0.62, metalness: 0, sheen: 0.4, sheenRoughness: 0.6, sheenColor: new T.Color(0xff9a9a) }));
  const gold = m(new T.MeshStandardMaterial({ color: 0xffc35a, metalness: 1, roughness: 0.18 }));
  const blobMat = m(new T.MeshBasicMaterial({ map: blobShadow(), transparent: true, depthWrite: false, opacity: 0.65, color: 0x000000, polygonOffset: true, polygonOffsetFactor: -2 }));
  const blobGeo = g(new T.PlaneGeometry(1, 1));
  let steel: THREE.MeshStandardMaterial | null = null;
  let plate: THREE.MeshStandardMaterial | null = null;

  interface Side {
    scene: THREE.Scene;
    spin: THREE.Object3D[];
    gear: THREE.Mesh;
    disc: THREE.Mesh;
  }
  const build = (hdri: boolean): Side => {
    const scene = new T.Scene();
    const root = new T.Group();
    scene.add(root);
    const put = (geo: THREE.BufferGeometry, mat: THREE.Material, x: number, y: number, z: number): THREE.Mesh => {
      const o = new T.Mesh(geo, mat);
      o.position.set(x, y, z);
      o.castShadow = o.receiveShadow = true;
      root.add(o);
      return o;
    };
    const d = put(disc, chrome, 0, 0.09, 0);
    put(sphere, chrome, -0.62, 0.58, -0.42);
    put(sphere, glass, 0.62, 0.58, -0.42);
    put(small, rubber, -0.66, 0.48, 0.62);
    const gm = put(gear, chrome, 0.5, 0.62, 0.55);
    gm.rotation.y = -0.5;
    const rg = put(ring, gold, 0.02, 0.235, 0.3);
    rg.rotation.x = -Math.PI / 2;
    const bl: [number, number, number][] = [
      [-0.62, -0.42, 1.0],
      [0.62, -0.42, 1.0],
      [-0.66, 0.62, 0.78],
      [0.5, 0.55, 0.85],
    ];
    for (const [x, z, s] of bl) {
      const b = new T.Mesh(blobGeo, blobMat);
      b.rotation.x = -Math.PI / 2;
      b.position.set(x, 0.182, z);
      b.scale.setScalar(s);
      root.add(b);
    }
    if (!hdri) {
      scene.background = new T.Color(0x1b1e27);
      scene.add(new T.AmbientLight(0xffffff, 0.55));
      const sun = new T.DirectionalLight(0xffffff, 2.4);
      sun.position.set(2.5, 4, 2.2);
      sun.castShadow = true;
      sun.shadow.mapSize.set(1024, 1024);
      Object.assign(sun.shadow.camera, { left: -2, right: 2, top: 2, bottom: -2, near: 0.5, far: 12 });
      sun.shadow.bias = -0.0006;
      scene.add(sun);
    }
    return { scene, spin: [gm], gear: gm, disc: d };
  };
  const A = build(false);
  const B = build(true);
  let kit: Kit | null = null;
  let dead = false;
  let exposure = 1;
  let rot = 0;
  let showBg = true;
  const plainBg = new T.Color(0x1b1e27);
  let now = 0;
  const warm = new Warm();
  loadKit().then((k) => {
    if (dead) return;
    kit = k;
    const mp = k.pbr.metal;
    steel = m(new T.MeshStandardMaterial({ color: 0xd4d8e0, metalness: 1, roughness: 0.3 }));
    plate = m(pbrMat(mp, 2.2, 2.2, { color: 0xc4c8d0 }));
    for (const s of [A, B]) {
      s.gear.material = steel;
      s.disc.material = plate;
    }
  });
  const target = new T.Vector3(0, 0.45, 0.05);

  return {
    scene: B.scene,
    camera: cam,
    update(t, dt) {
      now = t;
      void dt;
      for (const s of [A, B]) for (const o of s.spin) o.rotation.z = t * 0.6;
    },
    render(r, w, h) {
      if (!kit || !steel || !plate) return waiting(r, w, h, hud, msg, 'HDRI · 금속 재질 받는 중 …');
      if (!pump(r, allTex(plate))) 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 빛 굽는 중 …');
        B.scene.environment = env;
        B.scene.background = showBg ? kit.hdr : plainBg;
        B.scene.backgroundIntensity = 0.85;
        B.scene.environmentRotation.y = rot;
        B.scene.backgroundRotation.y = rot;
      }
      const a = now * 0.25;
      const dir = new T.Vector3(Math.sin(a) * 0.9, 0.55, Math.cos(a) * 0.9 + 0.3);
      const ok = warm.ready(r, (rr) =>
        withShadows(rr, true, () => {
          const c = fit(cam, w / 2 / h, target, 1.5, dir);
          const list = [rr.compileAsync(A.scene, c), rr.compileAsync(B.scene, c)];
          // 유리(투과)는 불투명 물체를 반정밀 렌더 타깃에 한 번 더 그린다 — 그 조건(톤 매핑 없음 · 선형 색)의 셰이더도 미리
          const rt = new THREE.WebGLRenderTarget(4, 4, { type: THREE.HalfFloatType });
          const old = rr.getRenderTarget();
          rr.setRenderTarget(rt);
          list.push(rr.compileAsync(A.scene, c), rr.compileAsync(B.scene, c));
          rr.setRenderTarget(old);
          return Promise.all(list).finally(() => rt.dispose());
        }),
      );
      if (!ok) return waiting(r, w, h, hud, msg, '셰이더 굽는 중 …');
      msg.visible = false;
      la.visible = lb.visible = true;
      withShadows(r, true, () =>
        panels(r, w, h, [{ scene: A.scene, exposure }, { scene: B.scene, exposure }], (asp) => fit(cam, asp, target, 1.5, dir)),
      );
      hud.draw(r, w, h);
    },
    controls: [
      { type: 'range', label: '노출', min: 0.3, max: 2.5, step: 0.05, value: 1, on: (v) => (exposure = v) },
      { type: 'range', label: '환경 회전(도)', min: 0, max: 360, step: 1, value: 0, on: (v) => (rot = THREE.MathUtils.degToRad(v)) },
      { type: 'toggle', label: 'HDRI 배경 보이기', value: true, on: (v) => (showBg = v) },
    ],
    dispose() {
      dead = true;
      for (const x of geos) x.dispose();
      for (const x of mats) x.dispose();
      hud.dispose();
    },
  };
}
```

### envFor — `src/demos/demosRender.ts:207`
```ts
function envFor(r: R, hdr: THREE.DataTexture): THREE.Texture | null {
  const e = pmrems.get(r);
  if (e === 'busy') return null;
  if (e) return e;
  pmrems.set(r, 'busy');
  const pm = new THREE.PMREMGenerator(r);
  const pv = pm as unknown as PmremPriv;
  let target: THREE.WebGLRenderTarget | null = null;
  let warm: Promise<unknown> = Promise.resolve();
  try {
    pv._setSize(hdr.image.width / 4);
    target = pv._allocateTargets();
    const real = pv._compileMaterial;
    pv._compileMaterial = () => {}; // 재질만 만들고 동기 컴파일은 건너뛴다
    pv.compileEquirectangularShader();
    pv._compileMaterial = real;
    const sc = new THREE.Scene();
    const geo = new THREE.BufferGeometry();
    for (const m of [pv._equirectMaterial, pv._blurMaterial, pv._ggxMaterial]) if (m) sc.add(new THREE.Mesh(geo, m));
    const old = r.getRenderTarget();
    r.setRenderTarget(target); // 실제로 그릴 곳(반정밀 렌더 타깃)과 같은 조건으로 컴파일
    warm = r.compileAsync(sc, new THREE.OrthographicCamera());
    r.setRenderTarget(old);
  } catch (err) {
    console.warn('[studio] PMREM 미리 굽기 실패 — 바로 굽는다', err);
    target = null;
  }
  const bake = (): void => {
    const tex = target ? pm.fromEquirectangular(hdr, target).texture : pm.fromEquirectangular(hdr).texture;
    pm.dispose();
    pmrems.set(r, tex);
  };
  warm.then(bake, bake);
  return null;
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [환경 반사 (RoomEnvironment + PMREM)](https://ai-techstudio.web.app/ai/t/u12.md) `u12` · [PBR + 클리어코트](https://ai-techstudio.web.app/ai/t/u04.md) `u04`
- 다음에 해 볼 기술: [실사 PBR 텍스처 (무료 CC0 스캔 재질)](https://ai-techstudio.web.app/ai/t/i474.md) `i474` · [쿼터뷰 던전 조명 (횃불 · 그림자 · 안개)](https://ai-techstudio.web.app/ai/t/i477.md) `i477`
- 참고 문서: [three.js 예제 — webgl_materials_envmaps_hdr](https://threejs.org/examples/#webgl_materials_envmaps_hdr) · [three.js 소스 — PMREMGenerator](https://github.com/mrdoob/three.js/blob/dev/src/extras/PMREMGenerator.js) · [Poly Haven — 무료 CC0 HDRI](https://polyhaven.com/hdris)
