# AI 꾸러미 — 평면 반사 (Reflector) — Planar reflection
> 바닥 아래에서 본 장면을 한 장 더 그려 바닥에 붙여, 얼음 · 거울 · 젖은 바닥에 물체가 거꾸로 비치게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i14

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

## 주문서

### 만들어 줘: 평면 반사 (Reflector) — Planar reflection

#### 1. 목표
얼음 바닥 위 캐릭터 · 별 바닥에 Reflector 평면 반사를 넣어 물체가 반들반들 얼음 느낌으로 거꾸로 비치게 해 줘.

#### 2. 핵심 기술 용어
- **Planar reflection** — 평평한 면에 비친 모습
- **three.js Reflector** — 거울 카메라로 렌더 타깃에 그려 붙여 주는 물체 (addons)
- **clipBias / textureWidth** — 거울 면 아래를 자르는 여유 · 비친 그림 해상도

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

#### 4. 조건
- Reflector 는 three/examples/jsm/objects/Reflector.js 에서, 평면 모양(CircleGeometry 등)으로
- textureWidth · textureHeight 512 (폰 256), clipBias 0.003
- 거울 위에 반투명 막(MeshStandardMaterial opacity 0.38, y +0.004)을 덮어 빛깔 · 흐림을 낸다
- 거울은 장면에 하나만, 끝나면 reflector.dispose()

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽 그냥 얼음 · 오른쪽 거울 얼음을 나란히 두면 오른쪽 바닥에 별 · 펭귄 · 눈사람이 거꾸로 비친다
- 별이 통통 뛰면 비친 별도 바닥에서 멀어졌다 가까워진다
- 카메라가 살짝 돌아도 비친 모습이 어긋나지 않는다
- 거울 끄고 켜기로 그리기 호출 수가 약 두 배가 되는 것을 확인 (폰에서 30fps 아래로 떨어지면 해상도를 낮춘다)

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

## 원리
- 바닥 면을 거울로 삼아 카메라를 면 아래로 뒤집은 자리에 하나 더 둔다.
- 그 거울 카메라로 장면을 렌더 타깃(견본 512 × 512)에 그리고, 그 그림을 바닥에 화면 좌표로 붙인다.
- 면 아래에 있는 것까지 비치면 이상하므로 clipBias 로 면 근처를 잘라 낸다 (견본 0.003).
- 거울만 있으면 너무 맑다. 위에 반투명 얇은 막(opacity 0.38)을 덮어 얼음 빛깔을 낸다.

## 핵심 코드 — Reflector 얼음 바닥 + 얼음빛 막
(발췌: demos/demosLight.ts i14 build(true) 를 정리)
```ts
import * as THREE from 'three';
import { Reflector } from 'three/examples/jsm/objects/Reflector.js';

// 거울 바닥 — 아래 거울 카메라로 512² 에 그려 붙인다
const refl = new Reflector(new THREE.CircleGeometry(2.4, 64), {
  textureWidth: 512,
  textureHeight: 512,
  color: 0xa8c4dc,   // 비친 그림에 곱하는 색 (얼음빛)
  clipBias: 0.003,   // 면 아래 것이 비치지 않게
});
refl.rotation.x = -Math.PI / 2;
scene.add(refl);

// 맑은 거울 위에 반투명 얇은 막 — 얼음 빛깔 · 살짝 흐린 느낌
const film = new THREE.Mesh(
  new THREE.CircleGeometry(2.4, 64),
  new THREE.MeshStandardMaterial({ color: 0xd8f2ff, roughness: 0.2, transparent: true, opacity: 0.38 }),
);
film.rotation.x = -Math.PI / 2;
film.position.y = 0.004; // 거울과 겹쳐 깜빡이지 않게 조금 위
scene.add(film);

// 정리: refl.dispose();
```

## 흔한 실수 · 확인 목록
- [ ] **막을 거울과 같은 높이에 두면 깜빡인다** — 두 면이 같은 깊이라 서로 이긴다. 막을 조금(0.004) 올린다.
- [ ] **거울을 여러 개 두면 프레임이 반 토막 난다** — 거울 하나마다 장면을 한 번 더 그린다. 장면에 하나, 폰은 해상도를 256 으로.
- [ ] **물결 있는 물에 Reflector 를 쓰면 판유리처럼 보인다** — 평면 반사는 평평한 면 전용. 물은 셰이더에서 하늘색 반사(u22)로.

## 완성 기준 체크리스트
- [ ] 왼쪽 그냥 얼음 · 오른쪽 거울 얼음을 나란히 두면 오른쪽 바닥에 별 · 펭귄 · 눈사람이 거꾸로 비친다
- [ ] 별이 통통 뛰면 비친 별도 바닥에서 멀어졌다 가까워진다
- [ ] 카메라가 살짝 돌아도 비친 모습이 어긋나지 않는다
- [ ] 거울 끄고 켜기로 그리기 호출 수가 약 두 배가 되는 것을 확인 (폰에서 30fps 아래로 떨어지면 해상도를 낮춘다)

## 이 기술 정보
- id: `i14` · 분류: 그래픽 · 셰이더 › 빛 · 환경 · 3D · 난이도 보통 · 폰 부담 무거움 — 거울 하나 = 장면 한 번 더 그리기 (512² 타깃). 물체가 많은 장면이면 그리기 호출이 두 배가 된다. 폰은 256² 로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i14
- 쓰면 좋을 때: 얼음 · 거울 · 대리석처럼 평평한 바닥 하나가 주인공일 때 / 물웅덩이 · 젖은 광장에 등불이 비치는 장면
- 쓰지 말 때: 울퉁불퉁하거나 여러 방향의 면 — 대신 환경 반사(u12) / 물결치는 물 — 대신 하늘색 반사를 셰이더로(u22) / 폰에서 거울이 여럿 — 거울 하나마다 장면을 한 번 더 그린다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### fit — `src/demos/demosLight.ts:80`
```ts
function fit(cam: THREE.PerspectiveCamera, aspect: number, target: THREE.Vector3, radius: number, dir: THREE.Vector3): THREE.PerspectiveCamera {
  cam.aspect = aspect;
  cam.updateProjectionMatrix();
  const v = THREE.MathUtils.degToRad(cam.fov) / 2;
  const hf = Math.atan(Math.tan(v) * aspect);
  const d = radius / Math.sin(Math.min(v, hf));
  cam.position.copy(target).addScaledVector(dir.clone().normalize(), d);
  cam.lookAt(target);
  return cam;
}
```

### i14 견본 항목 — `src/demos/demosLight.ts:2358`
```ts
  i14: {
    kind: '3d',
    caption: '왼쪽 그냥 얼음 바닥 · 오른쪽 Reflector — 별 · 펭귄 · 눈사람이 반들반들 얼음에 거꾸로 비쳐요',
    make() {
      const build = (mirror: boolean): { scene: THREE.Scene; star: THREE.Mesh; peng: THREE.Group; refl: Reflector | null } => {
        const scene = new THREE.Scene();
        scene.background = gradTex('#7fc8ff', '#e8f7ff');
        basicLights(scene, 1.0, 1.6, V3(-3, 5, 3));
        let refl: Reflector | null = null;
        if (mirror) {
          refl = new Reflector(new THREE.CircleGeometry(2.4, 64), { textureWidth: 512, textureHeight: 512, color: 0xa8c4dc, clipBias: 0.003 });
          refl.rotation.x = -Math.PI / 2;
          scene.add(refl);
          const film = new THREE.Mesh(new THREE.CircleGeometry(2.4, 64), new THREE.MeshStandardMaterial({ color: 0xd8f2ff, roughness: 0.2, transparent: true, opacity: 0.38 }));
          film.rotation.x = -Math.PI / 2;
          film.position.y = 0.004;
          scene.add(film);
        } else {
          const ice = new THREE.Mesh(new THREE.CircleGeometry(2.4, 64), std(0xc6e8fa, 0.25, 0.05));
          ice.rotation.x = -Math.PI / 2;
          scene.add(ice);
        }
        const rim = mesh(new THREE.TorusGeometry(2.4, 0.12, 12, 80), std(0xffffff, 0.8), 0, 0, 0);
        rim.rotation.x = Math.PI / 2;
        scene.add(rim);
        const star = new THREE.Mesh(starGeo(0.42, 0.2, 0.18), new THREE.MeshStandardMaterial({ color: 0xffd84a, emissive: 0xff9a00, emissiveIntensity: 0.25, roughness: 0.4 }));
        star.position.set(0, 0.7, 0);
        const peng = cutie(0x2b3550, 0xffffff);
        peng.scale.setScalar(0.8);
        peng.position.set(-1.1, 0, 0.3);
        const snow = new THREE.Group();
        const sm = std(0xffffff, 0.7);
        snow.add(mesh(new THREE.SphereGeometry(0.38, 24, 16), sm, 0, 0.36, 0));
        snow.add(mesh(new THREE.SphereGeometry(0.26, 24, 16), sm, 0, 0.88, 0));
        snow.add(mesh(new THREE.CylinderGeometry(0.17, 0.17, 0.26, 20), std(0x22223a, 0.5), 0, 1.22, 0));
        snow.add(mesh(new THREE.CylinderGeometry(0.26, 0.26, 0.03, 20), std(0x22223a, 0.5), 0, 1.09, 0));
        snow.add(mesh(new THREE.ConeGeometry(0.05, 0.2, 12), std(0xff8a3a, 0.5), 0, 0.88, 0.3).rotateX(Math.PI / 2));
        snow.position.set(1.1, 0, -0.2);
        scene.add(star, peng, snow);
        return { scene, star, peng, refl };
      };
      const A = build(false);
      const B = build(true);
      const cam = new THREE.PerspectiveCamera(36, 1, 0.1, 40);
      const hud = new Hud();
      hud.vline(0.5);
      hud.label('그냥 바닥', 0.25, 0.95, 0.5, 1);
      hud.label('평면 반사', 0.75, 0.95, 0.5, 1, 'on');
      let now = 0;
      return {
        scene: A.scene,
        camera: cam,
        update(t) {
          now = t;
          for (const S of [A, B]) {
            S.star.rotation.y = t * 1.2;
            S.star.position.y = 0.7 + Math.abs(Math.sin(t * 1.8)) * 0.5;
            S.peng.position.y = Math.abs(Math.sin(t * 2.4 + 1)) * 0.25;
            S.peng.rotation.y = Math.sin(t * 0.7) * 0.6;
          }
        },
        render(r, w, h) {
          const a = Math.sin(now * 0.25) * 0.3;
          panels(r, w, h, [{ scene: A.scene }, { scene: B.scene }], (asp) => fit(cam, asp, V3(0, 0.45, 0), 1.75, V3(Math.sin(a), 0.42, Math.cos(a))));
          hud.draw(r, w, h);
        },
        dispose() {
          B.refl?.dispose();
          hud.dispose();
          disposeTree(A.scene, B.scene);
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [환경 반사 (RoomEnvironment + PMREM)](https://ai-techstudio.web.app/ai/t/u12.md) `u12`
- 다음에 해 볼 기술: [날씨 (비 · 눈 · 안개 바꾸기)](https://ai-techstudio.web.app/ai/t/i434.md) `i434` · [흐르는 강물 셰이더](https://ai-techstudio.web.app/ai/t/u22.md) `u22`
- 참고 문서: [three.js 예제 — webgl_mirror](https://threejs.org/examples/#webgl_mirror) · [three.js 소스 — Reflector](https://github.com/mrdoob/three.js/blob/dev/examples/jsm/objects/Reflector.js)
