# AI 꾸러미 — 시차 차폐 매핑 (POM) — Parallax occlusion mapping (POM)
> 평평한 판에서 눈길을 따라 높이 그림 속으로 광선을 한 걸음씩 걸어 들어가 닿은 곳의 색을 읽어, 벽돌 · 타일이 진짜로 움푹 파여 보이고 스스로 그림자까지 진다.  
> 견본: https://ai-techstudio.web.app/#t/i230

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

## 주문서

### 만들어 줘: 시차 차폐 매핑 (POM) — Parallax occlusion mapping (POM)

#### 1. 목표
등불 비친 벽돌 바닥에 시차 차폐 매핑을 넣어 줘 — 평면인데 높이 그림 속으로 광선을 걸어 움푹 파여 보이고, 빛 쪽으로도 걸어 스스로 그림자. 분위기는 어두운 지하 등불.

#### 2. 핵심 기술 용어
- **Parallax occlusion mapping (POM)** — 시차 차폐 매핑 — 높이 속으로 광선 걷기
- **Height map ray march** — 높이 그림 위를 여러 층으로 나눠 걷기
- **Self-shadowing** — 튀어나온 곳이 옆에 드리우는 그림자
- **textureGrad** — 어긋난 좌표에서도 밉맵이 튀지 않게 기울기를 직접 주기

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

#### 4. 조건
- 눈 방향 · 빛 방향을 판의 접선 공간으로 바꿔 계산 (견본 바닥은 x · −z · y)
- 층 수는 보는 각에 따라 mix(36, 10, 정면도) — 정면은 적게, 비스듬하면 많게
- 반복문 최대 횟수는 상수(40)로 두고 break — 셰이더 컴파일이 길어지지 않게
- 텍스처 읽기는 textureGrad(원래 좌표의 dFdx · dFdy) — 어긋난 좌표에서 밉맵 경계 줄 방지
- 「깊이」 0~0.14 · 「스스로 드리운 그림자」 · 나누는 줄(왼쪽 노멀 맵만 · 오른쪽 POM)

#### 5. 완성 기준 (이게 보이면 성공)
- 등불이 도는 벽돌 바닥 — 나누는 줄 왼쪽은 납작, 오른쪽은 벽돌이 솟고 줄눈이 깊게 파여 보인다
- 카메라가 비스듬해질수록 오른쪽 벽돌이 뒤 줄눈을 가린다
- 「스스로 드리운 그림자」를 켜면 벽돌 옆 줄눈에 등불 반대쪽 그림자가 진다

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

## 원리
- 노멀 맵은 빛만 바꿔 여전히 납작하다. 비스듬히 보면 튀어나온 벽돌이 뒤를 가려야 하는데 그게 없다.
- 시차 차폐는 눈 방향을 판의 접선 공간으로 바꿔, 높이 그림 위를 n 층(정면 10 ~ 비스듬 36)으로 나눠 걷는다.
- 광선의 깊이가 그 자리의 파인 깊이(1 − 높이)를 넘는 첫 층에서 멈추고, 앞 층과 사이를 직선으로 보간해 정확한 자리를 찾는다.
- 찾은 자리에서 빛 쪽으로 12걸음 다시 걸어, 더 높은 곳이 막고 있으면 그림자(최대 0.9)를 준다.
- 법선은 찾은 자리의 높이 차이로 새로 만들어 빛도 요철을 따른다.

## 핵심 코드 — 높이 속으로 광선 걷기 + 보간 (POM 핵심)
(발췌: demos/demosLook2.ts makePom() 의 floor 조각 셰이더 (pom 부분))
```glsl
uniform sampler2D uH; uniform float uDepth;      // uH: 흰 = 높음, uDepth 0.07
varying vec3 vW;
// ... main() 안
vec2 uv = vec2(vW.x, -vW.z) * 0.32;
vec2 dx = dFdx(uv), dy = dFdy(uv);                 // 원래 좌표의 기울기 — 밉맵용
vec3 V = normalize(cameraPosition - vW);
vec3 vT = vec3(V.x, -V.z, V.y);                    // 바닥 판의 접선 공간
vec2 tuv = uv;
float n = mix(36.0, 10.0, clamp(vT.z, 0.0, 1.0));  // 비스듬할수록 층을 많이
float layer = 1.0 / n; float cur = 0.0;
vec2 P = vT.xy / max(vT.z, 0.12) * uDepth; vec2 d = P / n;
float depth = 1.0 - textureGrad(uH, tuv, dx, dy).r;
for (int i = 0; i < 40; i++) {                     // 상수 최대 + break
  if (cur >= depth || float(i) >= n) break;
  tuv -= d;
  depth = 1.0 - textureGrad(uH, tuv, dx, dy).r;
  cur += layer;
}
vec2 prev = tuv + d;                               // 앞 층과 사이를 직선 보간
float after = depth - cur;
float before = (1.0 - textureGrad(uH, prev, dx, dy).r) - cur + layer;
tuv = mix(tuv, prev, after / (after - before));
// 이 tuv 로 색 · 법선을 읽고, 빛 쪽으로 12 걸음 다시 걸어 그림자:
//   duv = lT.xy / max(lT.z, 0.08) * uDepth * dh;  occ = max(occ, (h(p) - cur) * (1 - i/12));
//   sh = 1.0 - clamp(occ * 9.0, 0.0, 1.0) * 0.9;
```

## 흔한 실수 · 확인 목록
- [ ] **보간 없이 층에서 바로 멈추면 계단 무늬가 보인다** — 앞 층과 뒤 층의 깊이 차로 직선 보간한다 (after / (after − before)).
- [ ] **texture() 로 어긋난 좌표를 읽으면 벽돌 경계에 밉맵 줄이 생긴다** — 원래 좌표의 dFdx · dFdy 를 미리 구해 textureGrad 로 읽는다.
- [ ] **거의 수평으로 보면 광선이 아주 멀리 뻗어 무늬가 찢어진다** — vT.z 를 0.12 아래로 내려가지 않게 묶는다.
- [ ] **반복문 길이가 uniform 이면 일부 GPU 에서 컴파일이 느리거나 실패한다** — 최대 횟수는 상수 40 으로 두고 안에서 break.

## 완성 기준 체크리스트
- [ ] 등불이 도는 벽돌 바닥 — 나누는 줄 왼쪽은 납작, 오른쪽은 벽돌이 솟고 줄눈이 깊게 파여 보인다
- [ ] 카메라가 비스듬해질수록 오른쪽 벽돌이 뒤 줄눈을 가린다
- [ ] 「스스로 드리운 그림자」를 켜면 벽돌 옆 줄눈에 등불 반대쪽 그림자가 진다

## 이 기술 정보
- id: `i230` · 분류: 그래픽 · 셰이더 › 재질 · 그림체 · 3D · 난이도 어려움 · 폰 부담 무거움 — 조각마다 높이 그림 최대 40번(광선) + 12번(그림자) + 법선 4번 읽기. 화면 가득 넓은 바닥이면 폰에서 무겁다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i230
- 쓰면 좋을 때: 바닥 · 벽처럼 넓은 평면을 가까이서 비스듬히 볼 때 / 모델 정점을 늘리지 않고 깊은 요철이 필요할 때
- 쓰지 말 때: 물체 윤곽까지 울퉁불퉁해야 할 때 — POM 은 실루엣을 못 바꾼다. 대신 진짜 변위 / 폰 저사양 · 화면 가득 — 조각마다 최대 40 + 12 번 텍스처 읽기. 대신 노멀 맵(u10)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makePom — `src/demos/demosLook2.ts:1796`
```ts
function makePom(): Scene3D {
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x0c0a10);
  const cam = new THREE.PerspectiveCamera(42, 1.6, 0.1, 50);
  const { h: hTex, a: aTex } = brickMaps();
  const uni = {
    uH: { value: hTex },
    uA: { value: aTex },
    uLight: { value: new THREE.Vector3() },
    uDepth: { value: 0.07 },
    uSplit: { value: 0 },
    uShadow: { value: 1 },
  };
  const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(14, 14).rotateX(-Math.PI / 2),
    new THREE.ShaderMaterial({
      uniforms: uni,
      vertexShader: WORLD_VERT,
      fragmentShader: /* glsl */ `
        uniform sampler2D uH, uA; uniform vec3 uLight; uniform float uDepth, uSplit, uShadow;
        varying vec3 vW;
        void main(){
          vec2 uv = vec2(vW.x, -vW.z) * 0.32;
          vec2 dx = dFdx(uv), dy = dFdy(uv);
          vec3 V = normalize(cameraPosition - vW);
          vec3 vT = vec3(V.x, -V.z, V.y);
          bool pom = gl_FragCoord.x > uSplit;
          vec2 tuv = uv;
          if (pom){
            float n = mix(36.0, 10.0, clamp(vT.z, 0.0, 1.0));
            float layer = 1.0 / n; float cur = 0.0;
            vec2 P = vT.xy / max(vT.z, 0.12) * uDepth; vec2 d = P / n;
            float depth = 1.0 - textureGrad(uH, tuv, dx, dy).r;
            for (int i = 0; i < 40; i++){ if (cur >= depth || float(i) >= n) break; tuv -= d; depth = 1.0 - textureGrad(uH, tuv, dx, dy).r; cur += layer; }
            vec2 prev = tuv + d;
            float after = depth - cur; float before = (1.0 - textureGrad(uH, prev, dx, dy).r) - cur + layer;
            tuv = mix(tuv, prev, after / (after - before));
          }
          vec2 e = vec2(1.0 / 512.0, 0.0);
          float hl = textureGrad(uH, tuv - e.xy, dx, dy).r, hr = textureGrad(uH, tuv + e.xy, dx, dy).r;
          float hd = textureGrad(uH, tuv - e.yx, dx, dy).r, hu = textureGrad(uH, tuv + e.yx, dx, dy).r;
          vec3 nT = normalize(vec3((hl - hr) * uDepth * 256.0, (hd - hu) * uDepth * 256.0, 1.0));
          vec3 N = vec3(nT.x, nT.z, -nT.y);
          vec3 Lv = uLight - vW; float ld = length(Lv); vec3 L = Lv / ld;
          float sh = 1.0;
          vec3 lT = vec3(L.x, -L.z, L.y);
          if (pom && uShadow > 0.5 && lT.z > 0.0){
            float h0 = textureGrad(uH, tuv, dx, dy).r;
            float dh = (1.0 - h0) / 12.0;
            vec2 duv = lT.xy / max(lT.z, 0.08) * uDepth * dh;
            vec2 p = tuv; float cur = h0; float occ = 0.0;
            for (int i = 0; i < 12; i++){ p += duv; cur += dh; occ = max(occ, (textureGrad(uH, p, dx, dy).r - cur) * (1.0 - float(i) / 12.0)); }
            sh = 1.0 - clamp(occ * 9.0, 0.0, 1.0) * 0.9;
          }
          vec3 alb = textureGrad(uA, tuv, dx, dy).rgb;
          float diff = max(dot(N, L), 0.0);
          vec3 Hh = normalize(L + V);
          float spec = pow(max(dot(N, Hh), 0.0), 32.0) * 0.25;
          float att = 1.0 / (1.0 + ld * ld * 0.35);
          vec3 lc = vec3(1.0, 0.72, 0.42);
          float ao = mix(0.55, 1.0, textureGrad(uH, tuv, dx, dy).r);
          vec3 col = alb * (vec3(0.05, 0.06, 0.1) * ao + lc * diff * att * 5.0 * sh) + lc * spec * att * 4.0 * sh;
          col *= 1.0 - smoothstep(4.0, 9.0, length(vW.xz));
          gl_FragColor = vec4(col, 1.0);
          ${OUT}
        }`,
    }),
  );
  scene.add(floor);
  // 등불 (빛나는 구 + 빛무리)
  const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.09, 20, 14), new THREE.MeshBasicMaterial({ color: 0xffe2b0 }));
  const halo = new THREE.Sprite(
    new THREE.SpriteMaterial({
      map: (() => {
        const [c, g] = canvas(64, 64);
        const gr = g.createRadialGradient(32, 32, 0, 32, 32, 32);
        gr.addColorStop(0, 'rgba(255,200,130,0.9)');
        gr.addColorStop(1, 'rgba(255,160,80,0)');
        g.fillStyle = gr;
        g.fillRect(0, 0, 64, 64);
        return canvasTex(c);
      })(),
      blending: THREE.AdditiveBlending,
      depthWrite: false,
      transparent: true,
    }),
  );
  halo.scale.setScalar(1.1);
  bulb.add(halo);
  scene.add(bulb);
  const hud = new Hud();
  const a = hud.add('노멀 맵만 (납작)');
  const b = hud.add('시차 차폐 (POM)', 'rgba(150,70,30,0.8)');
  hud.at(a, 0.03, 0.05, 0, 0);
  hud.at(b, 0.97, 0.05, 1, 0);
  let split = 0.5;
  let moveSplit = true;
  let time = 0;
  return {
    scene,
    camera: cam,
    update(t) {
      time = t;
      const ca = Math.sin(t * 0.18) * 0.5;
      cam.position.set(Math.sin(ca) * 4.6, 1.55, Math.cos(ca) * 4.6);
      cam.lookAt(0, 0, -0.6);
      bulb.position.set(Math.cos(t * 0.7) * 1.8, 0.55 + Math.sin(t * 1.3) * 0.15, Math.sin(t * 0.7) * 1.2 - 0.3);
      uni.uLight.value.copy(bulb.position);
    },
    render(r, w, h) {
      const s = moveSplit ? 0.5 + Math.sin(time * 0.5) * 0.2 : split;
      uni.uSplit.value = s * w;
      r.render(scene, cam);
      drawDivider(r, s * w, h, w);
      hud.draw(r, w, h);
    },
    controls: [
      { type: 'range', label: '깊이', min: 0, max: 0.14, step: 0.005, value: 0.07, on: (v) => (uni.uDepth.value = v) },
      { type: 'toggle', label: '스스로 드리운 그림자', value: true, on: (v) => (uni.uShadow.value = v ? 1 : 0) },
      { type: 'toggle', label: '나누는 줄 움직이기', value: true, on: (v) => (moveSplit = v) },
      { type: 'range', label: '나누는 자리', min: 0, max: 1, step: 0.01, value: 0.5, on: (v) => ((split = v), (moveSplit = false)) },
    ],
    dispose() {
      freeAll(scene);
      halo.material.map?.dispose();
      halo.material.dispose();
      hTex.dispose();
      aTex.dispose();
      hud.dispose();
    },
  };
}
```

### i230 견본 항목 — `src/demos/demosLook2.ts:4514`
```ts
  i230: { kind: '3d', caption: '같은 평면 — 왼쪽 노멀 맵만 ↔ 오른쪽 시차 차폐(높이를 따라 광선 걸음) + 스스로 그림자, 벽돌이 움푹', make: () => makePom() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [그린 요철 · 노멀 맵](https://ai-techstudio.web.app/ai/t/u10.md) `u10`
- 다음에 해 볼 기술: [가짜 방 창문 (interior mapping)](https://ai-techstudio.web.app/ai/t/i231.md) `i231`
- 참고 문서: [Wikipedia — Parallax occlusion mapping](https://en.wikipedia.org/wiki/Parallax_occlusion_mapping)
