# AI 꾸러미 — 화면 반사 (SSR) — Screen-space reflections (SSR)
> 화면에 이미 그려진 색 · 깊이 · 법선만으로 반사 광선을 화면 위에서 걸어, 젖은 바닥 웅덩이에 횃불 · 기둥 · 푸른 문이 비치게 한다 (WebGPU · TSL).  
> 견본: https://ai-techstudio.web.app/#t/i496

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

## 주문서

### 만들어 줘: 화면 반사 (SSR) — Screen-space reflections (SSR)

#### 1. 목표
젖은 던전 바닥에 화면 반사(SSR)를 넣어 줘 — 화면에 보이는 것을 매끈한 곳(웅덩이)에만 비추게. 분위기는 어둡고 축축한 던전.

#### 2. 핵심 기술 용어
- **Screen-space reflections (SSR)** — 화면 반사 — 화면에 보이는 것만 비춤
- **MRT (multiple render targets)** — 한 번 그리면서 색 · 법선 · 금속/거칠기를 함께 받기
- **three/tsl ssr() · RenderPipeline** — three.js WebGPU 노드 후처리의 SSR
- **Roughness-driven reflection mask** — 거칠기 낮은(젖은) 곳만 반사

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표
- WebGPURenderer(three/webgpu) + three/tsl 의 ssr(). WebGL 백엔드로 떨어지면 반사가 거의 안 나올 수 있다.

#### 4. 조건
- WebGPURenderer + RenderPipeline: pass(scene, cam).setMRT(mrt({ output, normal: directionToColor(normalView), metalrough }))
- 반사 여부 · 세기는 재질의 mrtNode 로 — 웅덩이(거칠기 낮은 곳)만 크게, 다른 물체는 metalrough = (0, 1)
- ssr(색, 깊이, 법선, { metalnessNode, roughnessNode, reflectNonMetals: false, camera }) 후 blendColor(색, ssr)
- maxDistance · thickness · quality · intensity 를 조절값으로, 켬/끔은 outputNode 를 바꾸고 needsUpdate
- WebGL 대체일 때는 반사가 약하다는 안내를 화면에

#### 5. 완성 기준 (이게 보이면 성공)
- 켜면 젖은 웅덩이에 횃불 불꽃 · 기둥 · 안쪽 푸른 문이 거꾸로 비치고, 마른 돌 판에는 비치지 않는다
- 끄면 바닥이 빛만 받고 비침이 사라진다
- 「반사 세기」 0~1.5 · 「반사 거리」 2~24 가 바로 반영된다

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

## 원리
- 장면을 한 번 그리면서(MRT) 색 · 깊이 · 보기 공간 법선 · 금속/거칠기를 함께 받는다.
- 반사할 곳의 픽셀마다 법선으로 반사 방향을 구해, 깊이 그림 위를 화면에서 한 걸음씩 걸어 다른 물체에 닿는 곳의 색을 가져온다.
- 바닥 재질은 거칠기 그림에서 웅덩이(거칠기 낮음)일수록 반사 표시값을 크게 — smoothstep(0.5, 0.12, 거칠기) × 0.9 + 0.06.
- 최대 거리(12) · 두께(0.06) · 품질(0.5)로 걷는 범위를 정하고, 결과를 원래 색과 섞는다(blendColor).
- 화면 밖 · 가려진 것은 비치지 않는다 — 화면 기법의 한계.

## 핵심 코드 — MRT + 바닥만 반사 표시 + SSR 노드
(발췌: demos/demosWebGPU.ts i496 build() 를 정리)
```ts
import * as GPU from 'three/webgpu';
import { pass, mrt, output, normalView, directionToColor, colorToDirection, sample, vec2, smoothstep, roughness, blendColor } from 'three/tsl';
import { ssr } from 'three/examples/jsm/tsl/display/SSRNode.js';

// 바닥: 거칠기 그림의 웅덩이(낮은 곳)일수록 반사 표시를 크게
const floorMat = new GPU.MeshStandardNodeMaterial({ map: colorMap, roughnessMap: roughMap, roughness: 1, metalness: 0 });
floorMat.mrtNode = mrt({ metalrough: vec2(smoothstep(0.5, 0.12, roughness).mul(0.9).add(0.06), roughness) });

const pipe = new GPU.RenderPipeline(renderer);
const sp = pass(scene, cam);
sp.setMRT(mrt({ output, normal: directionToColor(normalView), metalrough: vec2(0, 1) })); // 기본: 반사 없음
const col = sp.getTextureNode('output');
const dep = sp.getTextureNode('depth');
const nrmT = sp.getTextureNode('normal');
const mr = sp.getTextureNode('metalrough');
const nrm = sample((u: any) => colorToDirection(nrmT.sample(u)));
const ssrN: any = ssr(col, dep, nrm, { metalnessNode: mr.r, roughnessNode: mr.g, reflectNonMetals: false, camera: cam });
ssrN.maxDistance.value = 12;
ssrN.thickness.value = 0.06;
ssrN.quality.value = 0.5;
const withSSR = blendColor(col, ssrN);

let on = true;
function setOut() { pipe.outputNode = on ? withSSR : col; pipe.needsUpdate = true; }
setOut();
function frame() {
  ssrN.intensity.value = 1;
  pipe.render();                    // renderer.render 대신
}
```

## 흔한 실수 · 확인 목록
- [ ] **WebGL2 대체 백엔드에서는 이 SSR 노드의 반사가 거의 안 나온다** — 견본도 WebGPU 가 없으면 안내 글을 띄운다. 지원 여부를 보고 꺼진 판(환경 반사)으로 바꾼다.
- [ ] **모든 물체에 반사를 켜면 벽 · 기둥까지 번들거린다** — 기본 metalrough 를 (0, 1)로 두고, 바닥 재질만 mrtNode 로 반사 표시를 준다.
- [ ] **화면 가장자리 · 가려진 것은 비치지 않아 반사가 뚝 끊긴다** — 화면 기법의 한계. 카메라를 바닥 쪽으로 낮게 두지 않고, 끊김은 거리 감쇠로 부드럽게.
- [ ] **카드처럼 작은 미리 보기에서 WebGPU 렌더러를 여럿 만들면 무겁다** — 견본은 카드에서는 그림만, 크게 보기에서만 렌더러를 하나 만든다.

## 완성 기준 체크리스트
- [ ] 켜면 젖은 웅덩이에 횃불 불꽃 · 기둥 · 안쪽 푸른 문이 거꾸로 비치고, 마른 돌 판에는 비치지 않는다
- [ ] 끄면 바닥이 빛만 받고 비침이 사라진다
- [ ] 「반사 세기」 0~1.5 · 「반사 거리」 2~24 가 바로 반영된다

## 이 기술 정보
- id: `i496` · 분류: 그래픽 · 셰이더 › 후처리 · 3D · 난이도 어려움 · 폰 부담 무거움 — MRT 로 그림 4장을 받고, 반사 픽셀마다 화면 위 수십 걸음. 해상도 배율(resolutionScale)로 줄일 수 있다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i496
- 쓰면 좋을 때: 젖은 바닥 · 물웅덩이 · 반들한 바닥이 넓게 보이는 장면 / 평면 반사(i14)를 쓸 수 없는 울퉁불퉁 · 여러 높이의 바닥
- 쓰지 말 때: 폰 · 저사양 — 화면 걸음이 무겁다 / 거울처럼 화면 밖까지 비쳐야 할 때 — 대신 평면 반사(Reflector, i14)나 환경 반사(u12) / WebGL 만 쓰는 프로젝트 — 이 견본은 WebGPU 노드 후처리다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i496 — `src/demos/demosWebGPU.ts:1295`
```ts
const i496 = gpuDemo<{ on: boolean; intensity: number; dist: number }>({
  caption: '화면에 보이는 횃불 · 기둥을 젖은 던전 바닥에 비추는 화면 반사(SSR) — 켬 / 끔 비교 (크게 보기에서 실제 실행)',
  state: () => ({ on: true, intensity: 1, dist: 12 }),
  card: card496,
  controls: (st, api) => [
    {
      type: 'toggle',
      label: 'SSR 켜기',
      value: true,
      on: (v) => {
        st.on = v;
        (api.ctx() as Any)?.__ssrSet?.();
      },
    },
    { type: 'range', label: '반사 세기', min: 0, max: 1.5, step: 0.05, value: 1, on: (v) => (st.intensity = v) },
    { type: 'range', label: '반사 거리', min: 2, max: 24, step: 0.5, value: 12, on: (v) => (st.dist = v) },
  ],
  async build(ctx, st) {
    const { GPU, TSL, renderer } = ctx;
    const { pass, mrt, output, normalView, directionToColor, colorToDirection, sample, vec2, smoothstep, roughness, blendColor } = TSL;
    const { ssr } = await import('three/examples/jsm/tsl/display/SSRNode.js');
    renderer.toneMapping = GPU.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.15;
    const scene = new GPU.Scene();
    scene.background = new GPU.Color(0x050407);
    scene.fog = new GPU.Fog(0x050407, 10, 30);
    const cam = new GPU.PerspectiveCamera(50, ctx.w / ctx.h, 0.1, 60);

    const tex = wetFloorCanvases();
    const cmap = new GPU.CanvasTexture(tex.color);
    cmap.colorSpace = GPU.SRGBColorSpace;
    const rmap = new GPU.CanvasTexture(tex.rough);
    for (const m of [cmap, rmap]) {
      m.wrapS = m.wrapT = GPU.RepeatWrapping;
      m.repeat.set(3, 3);
      m.anisotropy = 8;
    }
    const floorMat = new GPU.MeshStandardNodeMaterial({ map: cmap, roughnessMap: rmap, roughness: 1, metalness: 0 });
    // 반사 표시는 바닥만: 웅덩이(거칠기 낮은 곳)일수록 세게
    floorMat.mrtNode = mrt({ metalrough: vec2(smoothstep(0.5, 0.12, roughness).mul(0.9).add(0.06), roughness) });
    const floor = new GPU.Mesh(new GPU.PlaneGeometry(24, 24), floorMat);
    floor.rotation.x = -Math.PI / 2;
    scene.add(floor);
    // 벽: 같은 돌 그림을 세워서
    const wmap = cmap.clone();
    wmap.repeat.set(4, 1.2);
    wmap.needsUpdate = true;
    const wallMat = new GPU.MeshStandardMaterial({ map: wmap, roughness: 0.9, color: 0xc8bcc4 });
    const back = new GPU.Mesh(new GPU.PlaneGeometry(14, 7), wallMat);
    back.position.set(0, 3.5, -9);
    scene.add(back);
    for (const sx of [-1, 1]) {
      const side = new GPU.Mesh(new GPU.PlaneGeometry(24, 7), wallMat);
      side.position.set(sx * 6.5, 3.5, 0);
      side.rotation.y = -sx * Math.PI * 0.5;
      scene.add(side);
    }
    // 안쪽 문: 푸른 빛
    const door = new GPU.Mesh(new GPU.PlaneGeometry(2.2, 3.6), new GPU.MeshBasicMaterial({ color: new GPU.Color(0x3a8cff).multiplyScalar(0.9) }));
    door.position.set(0, 1.8, -8.95);
    scene.add(door);
    const doorL = new GPU.PointLight(0x4a9cff, 14, 12, 2);
    doorL.position.set(0, 2, -8);
    scene.add(doorL);
    // 기둥 · 횃불
    const stone = new GPU.MeshStandardMaterial({ color: 0x6b6170, roughness: 0.85 });
    const colGeo = new GPU.CylinderGeometry(0.42, 0.48, 5, 20);
    const capGeo = new GPU.BoxGeometry(1.25, 0.35, 1.25);
    const flameGeo = new GPU.SphereGeometry(0.14, 16, 12);
    const flameMat = new GPU.MeshBasicMaterial({ color: new GPU.Color(0xffa040).multiplyScalar(4) });
    const brMat = new GPU.MeshStandardMaterial({ color: 0x2a2018, metalness: 0.7, roughness: 0.4 });
    const brGeo = new GPU.BoxGeometry(0.12, 0.12, 0.45);
    const flames: Any[] = [];
    const lights: Any[] = [];
    for (const z of [-6, -2, 2]) {
      for (const sx of [-1, 1]) {
        const x = sx * 3.2;
        const c = new GPU.Mesh(colGeo, stone);
        c.position.set(x, 2.5, z);
        const b = new GPU.Mesh(capGeo, stone);
        b.position.set(x, 0.17, z);
        const tcap = new GPU.Mesh(capGeo, stone);
        tcap.position.set(x, 4.9, z);
        scene.add(c, b, tcap);
        const br = new GPU.Mesh(brGeo, brMat);
        br.position.set(x - sx * 0.62, 2.4, z);
        br.rotation.y = Math.PI / 2;
        const f = new GPU.Mesh(flameGeo, flameMat);
        f.position.set(x - sx * 0.85, 2.62, z);
        f.scale.set(1, 1.6, 1);
        scene.add(br, f);
        flames.push(f);
      }
      const L = new GPU.PointLight(0xff9a48, 15, 12, 2);
      L.position.set(0, 2.6, z);
      lights.push(L);
      scene.add(L);
    }
    // 가운데 떠 있는 수정
    const crystal = new GPU.Mesh(new GPU.OctahedronGeometry(0.45, 0), new GPU.MeshStandardMaterial({ color: 0x66e0ff, emissive: 0x28b8ff, emissiveIntensity: 2.2, roughness: 0.2 }));
    crystal.scale.set(1, 1.7, 1);
    scene.add(crystal);
    const pedestal = new GPU.Mesh(new GPU.CylinderGeometry(0.5, 0.65, 0.8, 8), stone);
    pedestal.position.set(0, 0.4, -3);
    scene.add(pedestal);
    const crL = new GPU.PointLight(0x5ad0ff, 6, 6, 2);
    scene.add(crL, new GPU.AmbientLight(0x4a4060, 1.3));

    // 후처리: 장면 → (색 · 깊이 · 법선 · 금속/거칠기) → SSR → 섞기
    const pipe = new GPU.RenderPipeline(renderer);
    const sp = pass(scene, cam);
    sp.setMRT(mrt({ output, normal: directionToColor(normalView), metalrough: vec2(0, 1) }));
    const col = sp.getTextureNode('output');
    const dep = sp.getTextureNode('depth');
    const nrmT = sp.getTextureNode('normal');
    const mr = sp.getTextureNode('metalrough');
    const nrm = sample((u: Any) => colorToDirection(nrmT.sample(u)));
    const ssrN: Any = ssr(col, dep, nrm, { metalnessNode: mr.r, roughnessNode: mr.g, reflectNonMetals: false, camera: cam });
    ssrN.maxDistance.value = st.dist;
    ssrN.thickness.value = 0.06;
    ssrN.quality.value = 0.5;
    ssrN.resolutionScale = 1;
    const withSSR = blendColor(col, ssrN);
    const setOut = (): void => {
      pipe.outputNode = st.on ? withSSR : col;
      pipe.needsUpdate = true;
    };
    setOut();
    (ctx as Any).__ssrSet = setOut;
    return {
      scene,
      camera: cam,
      frame(t) {
        ssrN.intensity.value = st.intensity;
        ssrN.maxDistance.value = st.dist;
        flames.forEach((f, i) => {
          const k = 0.85 + 0.15 * Math.sin(t * 13 + i * 2.1) * Math.sin(t * 7.3 + i);
          f.scale.set(k, 1.6 * k, k);
        });
        lights.forEach((L, i) => (L.intensity = 15 * (0.85 + 0.15 * Math.sin(t * 11 + i * 1.7))));
        crystal.position.set(0, 1.5 + Math.sin(t * 1.2) * 0.12, -3);
        crystal.rotation.y = t * 0.8;
        crL.position.copy(crystal.position);
        cam.position.set(Math.sin(t * 0.15) * 1.6, 1.25 + Math.sin(t * 0.21) * 0.15, 8.5);
        cam.lookAt(0, 1.4, -4);
      },
      render() {
        pipe.render();
      },
      dispose() {
        pipe.dispose?.();
        wmap.dispose();
        delete (ctx as Any).__ssrSet;
      },
      note: () =>
        (st.on ? 'SSR 켬 — 젖은 웅덩이(거칠기 낮은 곳)에 횃불 · 기둥 · 푸른 문이 비친다' : 'SSR 끔 — 바닥은 빛만 받고 비침이 없다') +
        (ctx.webgpu ? '' : '\nWebGL2 대체: 이 SSR 노드는 WebGL2 백엔드에서 비침이 거의 안 나온다 (WebGPU 에서 보세요)'),
    };
  },
});
```

### wetFloorCanvases — `src/demos/demosWebGPU.ts:1226`
```ts
function wetFloorCanvases(): { color: HTMLCanvasElement; rough: HTMLCanvasElement } {
  const S = 1024;
  const mk = (): [HTMLCanvasElement, CanvasRenderingContext2D] => {
    const c = document.createElement('canvas');
    c.width = S;
    c.height = S;
    return [c, c.getContext('2d')!];
  };
  const [cc, g] = mk();
  const [rc, r] = mk();
  let seed = 11;
  const rnd = (): number => (seed = (seed * 16807) % 2147483647) / 2147483647;
  g.fillStyle = '#0d0b0f';
  g.fillRect(0, 0, S, S);
  r.fillStyle = '#e6e6e6';
  r.fillRect(0, 0, S, S);
  const rows = 6;
  const rh = S / rows;
  for (let y = 0; y < rows; y++) {
    let x = -rnd() * 100;
    while (x < S) {
      const w = 120 + rnd() * 120;
      const v = 38 + rnd() * 26;
      const tint = rnd() * 8;
      const gap = 6;
      g.fillStyle = `rgb(${v + tint},${v},${v + tint * 0.6 + 4})`;
      g.beginPath();
      g.roundRect(x + gap, y * rh + gap, w - gap * 2, rh - gap * 2, 10);
      g.fill();
      // 돌결 얼룩
      for (let k = 0; k < 14; k++) {
        g.fillStyle = `rgba(${rnd() < 0.5 ? '0,0,0' : '255,240,230'},${0.03 + rnd() * 0.05})`;
        g.beginPath();
        g.ellipse(x + rnd() * w, y * rh + rnd() * rh, 8 + rnd() * 30, 4 + rnd() * 14, rnd() * 3, 0, TAU);
        g.fill();
      }
      const rv = 120 + Math.floor(rnd() * 50);
      r.fillStyle = `rgb(${rv},${rv},${rv})`;
      r.beginPath();
      r.roundRect(x + gap, y * rh + gap, w - gap * 2, rh - gap * 2, 10);
      r.fill();
      x += w;
    }
  }
  // 물웅덩이 — 색은 어둡게, 거칠기는 아주 낮게
  for (let k = 0; k < 9; k++) {
    const px = rnd() * S;
    const py = rnd() * S;
    const rad = 70 + rnd() * 140;
    for (let s = 0; s < 6; s++) {
      const ox = px + (rnd() - 0.5) * rad;
      const oy = py + (rnd() - 0.5) * rad;
      const rr = rad * (0.4 + rnd() * 0.5);
      const gg = r.createRadialGradient(ox, oy, 0, ox, oy, rr);
      gg.addColorStop(0, 'rgba(10,10,10,1)');
      gg.addColorStop(0.7, 'rgba(10,10,10,.9)');
      gg.addColorStop(1, 'rgba(10,10,10,0)');
      r.fillStyle = gg;
      r.fillRect(ox - rr, oy - rr, rr * 2, rr * 2);
      const gc = g.createRadialGradient(ox, oy, 0, ox, oy, rr);
      gc.addColorStop(0, 'rgba(6,6,10,.55)');
      gc.addColorStop(1, 'rgba(6,6,10,0)');
      g.fillStyle = gc;
      g.fillRect(ox - rr, oy - rr, rr * 2, rr * 2);
    }
  }
  return { color: cc, rough: rc };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [후처리 사슬 (EffectComposer → OutputPass)](https://ai-techstudio.web.app/ai/t/u21.md) `u21` · [환경 반사 (RoomEnvironment + PMREM)](https://ai-techstudio.web.app/ai/t/u12.md) `u12`
- 다음에 해 볼 기술: [평면 반사 (Reflector)](https://ai-techstudio.web.app/ai/t/i14.md) `i14`
- 참고 문서: [three.js 예제 — webgpu_postprocessing_ssr](https://threejs.org/examples/#webgpu_postprocessing_ssr) · [three.js 소스 — SSRNode](https://github.com/mrdoob/three.js/blob/dev/examples/jsm/tsl/display/SSRNode.js)
