# AI 꾸러미 — 투명 은신 (굴절 실루엣) — Refraction cloak (screen-space refraction)
> 몸을 뺀 배경을 먼저 텍스처로 그리고 몸 표면 방향만큼 밀어 읽어 일렁이는 투명 몸을 만들고, 발끝부터 올라가는 전기 경계로 은신을 켜고 끈다.  
> 견본: https://ai-techstudio.web.app/#t/i516

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

## 주문서

### 만들어 줘: 투명 은신 (굴절 실루엣) — Refraction cloak (screen-space refraction)

#### 1. 목표
갑옷 입은 숨은 적에 뒤 배경이 일렁이며 비치는 투명 은신을 넣어 줘 — 발끝부터 전기 경계가 올라가며 켜지고 꺼지게. 분위기는 차가운 하늘빛 전기.

#### 2. 핵심 기술 용어
- **Refraction cloak (screen-space refraction)** — 뒤 배경을 법선만큼 밀어 읽는 투명 몸
- **Dissolve edge (height threshold + noise)** — 높이 기준선 + 잡음으로 경계를 정해 바꾸기
- **Chromatic dispersion** — 빨강 · 파랑을 다르게 밀어 색 갈라짐
- **Fresnel rim** — 비스듬한 가장자리일수록 밝은 테두리

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

#### 4. 조건
- 배경을 그릴 때는 몸을 숨기고 그린다 (자기 자신을 읽으면 검은 얼룩)
- 미는 방향은 시점 공간 법선(normalMatrix × normal) — 세계 법선이 아니라
- 경계 잡음은 몸의 로컬 좌표로 읽어 걸어도 무늬가 몸에 붙어 있게
- 굴절 세기(0~2.5) · 색 갈라짐 · 발밑 먼지 켜고 끄기
- 잡음은 미리 구운 잡음 텍스처를 읽는다 (반복문 잡음 금지)

#### 5. 완성 기준 (이게 보이면 성공)
- 은신 중 몸 뒤 벽화 · 바닥 바둑무늬가 몸 모양대로 휘어 일렁이며 비친다
- 켤 때 발끝에서 머리로, 끌 때 머리에서 발끝으로 전기 경계가 지나가며 불티가 튄다
- 굴절 세기 0 이면 거의 안 보이는 유리, 2.5 면 크게 휜다
- 은신 중에도 걸음마다 발밑 먼지가 일어 위치를 짐작할 수 있다

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

## 원리
- 1) 몸을 숨기고(visible = false) 장면을 렌더 타깃에 그린다. 2) 몸을 보이게 하고 화면에 다시 그린다.
- 몸 셰이더는 그 텍스처를 화면 좌표 + 시점 공간 법선.xy × 0.055 만큼 밀어 읽는다 — 몸 모양대로 뒤가 휘어 보인다. 잡음 0.012 를 더해 일렁임.
- 빨강은 1.3배 · 파랑은 0.7배 밀어 색이 갈라지고, 프레넬(1 − N·V)³ 로 가장자리에 옅은 빛.
- 경계: 높이 y − (기준선 + 잡음) 이 0 보다 크면 갑옷, 작으면 투명. 기준선을 −0.3 → 2.0 으로 1.1초에 올리면 발끝부터 사라진다.
- 경계선 자리 exp(−(d × 28)²) 에 육각 무늬 전기빛 · 불티를 내고, 은신 중엔 발걸음마다 먼지만 남긴다.

## 핵심 코드 — 굴절 투명 몸 + 높이 경계 (몸 재질의 조각 셰이더)
(발췌: demos/demosVfx3.ts i516 의 cloakMat 셰이더 · render() 를 정리)
```glsl
// TS 쪽: fig.visible = false; renderer.setRenderTarget(rt); renderer.render(scene, cam);
//        renderer.setRenderTarget(null); fig.visible = true; renderer.render(scene, cam);
uniform sampler2D uRT, uNoise; uniform vec2 uRes; uniform float uCut, uStr, uChroma, uTime, uY0; uniform vec3 uCol;
varying vec3 vW; varying vec3 vN; varying vec3 vVN; varying vec3 vL; // 세계 · 세계 법선 · 시점 법선 · 로컬
void main(){
  vec3 N = normalize(vN); vec3 V = normalize(cameraPosition - vW); float ndv = max(dot(N, V), 0.0);
  vec3 sh = armorColor(N, V);                                     // 보일 때 갑옷 색 (빛 계산)
  // 굴절: 몸을 뺀 배경을 시점 법선만큼 밀어 읽기
  vec2 suv = gl_FragCoord.xy / uRes;
  float wob = texture2D(uNoise, vW.xy * 1.3 + vec2(uTime * 0.12, -uTime * 0.2)).r - 0.5;
  vec2 off = normalize(vVN).xy * 0.055 * uStr + vec2(wob) * 0.012 * uStr;
  float ch = 0.3 * uChroma;
  vec3 refr = vec3(texture2D(uRT, suv + off * (1.0 + ch)).r, texture2D(uRT, suv + off).g, texture2D(uRT, suv + off * (1.0 - ch)).b);
  vec3 cloak = refr * 0.94 + vec3(0.7, 0.9, 1.0) * pow(1.0 - ndv, 3.0) * 0.25;
  // 경계: 높이 − (기준선 + 잡음). 기준선 uCut 을 -0.3 → 2.0 으로 올리면 발끝부터 사라진다
  float n = texture2D(uNoise, vec2(vL.x * 2.0 + vL.z * 1.3, vL.y * 0.4)).r * 0.14 - 0.07;
  float d = (vW.y - uY0) - (uCut + n);
  float k = smoothstep(-0.015, 0.015, d);
  vec3 col = mix(cloak, sh, k);
  float hex = step(0.5, fract((vL.y + vL.x) * 22.0)) * step(0.5, fract((vL.y - vL.x) * 22.0));
  col += uCol * exp(-pow(d * 28.0, 2.0)) * (3.0 + 2.0 * hex) * (0.7 + 0.3 * sin(uTime * 40.0 + vW.x * 30.0));
  gl_FragColor = vec4(col, 1.0);
}
```

## 흔한 실수 · 확인 목록
- [ ] **배경을 그릴 때 몸을 숨기지 않으면 몸이 자기 자신을 읽어 검게 얼룩진다** — 렌더 타깃에 그릴 땐 visible = false, 화면에 그릴 땐 true.
- [ ] **세계 법선으로 밀면 몸이 돌 때 굴절 방향이 엉뚱하다** — 화면에서 미는 것이니 시점 공간 법선(normalMatrix × normal)의 xy 를 쓴다.
- [ ] **경계 잡음을 세계 좌표로 읽으면 걸을 때 무늬가 몸 위를 미끄러진다** — 로컬 좌표(position)로 읽는다. 걷기 출렁임(uY0)도 빼 준다.
- [ ] **렌더 타깃을 8비트로 하면 밝은 횃불 빛이 잘린다** — 견본은 HalfFloatType — 밝은 빛 그대로 비친다.

## 완성 기준 체크리스트
- [ ] 은신 중 몸 뒤 벽화 · 바닥 바둑무늬가 몸 모양대로 휘어 일렁이며 비친다
- [ ] 켤 때 발끝에서 머리로, 끌 때 머리에서 발끝으로 전기 경계가 지나가며 불티가 튄다
- [ ] 굴절 세기 0 이면 거의 안 보이는 유리, 2.5 면 크게 휜다
- [ ] 은신 중에도 걸음마다 발밑 먼지가 일어 위치를 짐작할 수 있다

## 이 기술 정보
- id: `i516` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 장면을 한 번 더 그린다(HalfFloat 렌더 타깃, 화면 크기). 몸 셰이더는 텍스처 3번 + 잡음 텍스처 2번이라 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i516
- 쓰면 좋을 때: 숨은 적 · 투명 망토처럼 「보일 듯 말 듯」이 놀이의 핵심일 때 / 굴절 원리를 장면으로 보여 줄 때
- 쓰지 말 때: 투명한 것이 여러 겹 겹치는 장면 — 한 번 그린 배경만 읽으니 뒤 투명체는 안 비친다 / 폰에서 큰 몸 여러 개 — 장면을 한 번 더 그리는 비용. 실루엣 반투명으로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i516 견본 항목 — `src/demos/demosVfx3.ts:4212`
```ts
  i516: {
    kind: '3d',
    caption: '뒤 배경을 먼저 그려 두고 몸 표면 방향만큼 밀어 읽어 일렁이는 투명 몸 — 발끝부터 올라가는 전기 경계로 켜고 끄고, 은신 중엔 발밑 먼지만 보인다',
    make(): Scene3D {
      const S = makeStage({ bg: 0x1a1612, cam: [0, 1.2, 4.3], look: [0, 1.0, 0], fov: 42 });
      const lab = S.hud.label('tl');
      S.scene.add(new THREE.HemisphereLight(0xfff0e0, 0x302820, 1.0));
      const key = new THREE.DirectionalLight(0xffffff, 1.6);
      key.position.set(-2, 4, 3);
      S.scene.add(key);
      const mural = cachedCanvas('v3mural', 1024, 512, (g, w, h) => {
        const r = rng(2);
        g.fillStyle = '#6a4a3a';
        g.fillRect(0, 0, w, h);
        const bh = 32;
        for (let y = 0; y < h; y += bh)
          for (let x = -64 + ((y / bh) % 2) * 32; x < w; x += 64) {
            g.fillStyle = `hsl(${12 + r() * 14}, ${30 + r() * 15}%, ${30 + r() * 12}%)`;
            g.fillRect(x + 2, y + 2, 60, bh - 4);
          }
        const cols = ['#ffcc33', '#22b5c8', '#ef4f6a', '#7ad04a', '#ffffff', '#3a5cff'];
        for (let i = 0; i < 9; i++) {
          g.save();
          g.translate(60 + i * 112 + r() * 20, 90 + r() * 200);
          g.rotate((r() - 0.5) * 0.25);
          g.fillStyle = cols[i % cols.length]!;
          g.fillRect(-42, -60, 84, 120);
          g.fillStyle = cols[(i + 2) % cols.length]!;
          g.beginPath();
          g.arc(0, -10, 26, 0, TAU);
          g.fill();
          g.fillStyle = '#1a1a1a';
          g.font = 'bold 26px sans-serif';
          g.textAlign = 'center';
          g.fillText(['π', '∑', '7', '√2', '∞', 'x²', '∆', '%', '½'][i]!, 0, 0);
          g.fillRect(-30, 30, 60, 6);
          g.fillRect(-30, 42, 40, 6);
          g.restore();
        }
        for (let i = 0; i < 14; i++) {
          g.strokeStyle = cols[i % cols.length]!;
          g.lineWidth = 10;
          g.beginPath();
          g.moveTo(i * 80, h);
          g.lineTo(i * 80 + 140, h - 120);
          g.stroke();
        }
      });
      const wall = new THREE.Mesh(new THREE.PlaneGeometry(10, 5), new THREE.MeshStandardMaterial({ map: texFrom(mural, true), roughness: 0.85 }));
      wall.position.set(0, 2.2, -1.6);
      S.scene.add(wall);
      const chk = cachedCanvas('v3chk', 256, 256, (g, s) => {
        for (let y = 0; y < 8; y++)
          for (let x = 0; x < 8; x++) {
            g.fillStyle = (x + y) % 2 ? '#e8e2d6' : '#2a2622';
            g.fillRect((x * s) / 8, (y * s) / 8, s / 8, s / 8);
          }
      });
      const floor = new THREE.Mesh(new THREE.PlaneGeometry(14, 10), new THREE.MeshStandardMaterial({ map: texFrom(chk, true, 4), roughness: 0.4 }));
      floor.rotation.x = -Math.PI / 2;
      floor.position.z = 2;
      S.scene.add(floor);
      const lamp = new THREE.PointLight(0xffb070, 6, 8, 1.5);
      lamp.position.set(2, 2.6, 0.5);
      S.scene.add(lamp);
      const rt = new THREE.WebGLRenderTarget(4, 4, { type: THREE.HalfFloatType });
      const geo = figureGeo('stand');
      const cloakMat = new THREE.ShaderMaterial({
        uniforms: { uRT: { value: rt.texture }, uRes: { value: new THREE.Vector2(4, 4) }, uCut: { value: -0.3 }, uStr: { value: 1 }, uChroma: { value: 1 }, uTime: { value: 0 }, uNoise: { value: S.noise }, uCol: { value: new THREE.Color(0x40e0ff) }, uY0: { value: 0 } },
        vertexShader: /* glsl */ `varying vec3 vW; varying vec3 vN; varying vec3 vVN; varying vec3 vL;
          void main(){ vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; vL = position; vN = normalize(mat3(modelMatrix) * normal); vVN = normalize(normalMatrix * normal);
            gl_Position = projectionMatrix * viewMatrix * w; }`,
        fragmentShader: /* glsl */ `uniform sampler2D uRT, uNoise; uniform vec2 uRes; uniform float uCut, uStr, uChroma, uTime, uY0; uniform vec3 uCol;
          varying vec3 vW; varying vec3 vN; varying vec3 vVN; varying vec3 vL;
          ${SHADE_GLSL}
          void main(){ vec3 N = normalize(vN); vec3 V = normalize(cameraPosition - vW); float ndv = max(dot(N, V), 0.0);
            float plate = smoothstep(0.88, 0.95, fract(vL.y * 6.0)) + smoothstep(0.9, 0.97, fract(vL.x * 5.0 + 0.3)) * 0.5;
            vec3 base = mix(vec3(0.16, 0.2, 0.24), vec3(0.32, 0.38, 0.44), plate);
            vec3 sh = shade(base, N, V, normalize(vec3(-0.5, 0.8, 0.6)), vec3(1.0, 0.96, 0.9));
            vec3 Rf = reflect(-V, N); sh += mix(vec3(0.03), vec3(0.3, 0.32, 0.36), smoothstep(-0.1, 0.9, Rf.y)) * 0.5;
            sh += uCol * pow(1.0 - ndv, 3.0) * 0.4 + uCol * 0.6 * step(0.97, fract(vL.y * 6.0));
            vec2 suv = gl_FragCoord.xy / uRes;
            vec3 vn = normalize(vVN);
            float wob = texture2D(uNoise, vW.xy * 1.3 + vec2(uTime * 0.12, -uTime * 0.2)).r - 0.5;
            vec2 off = vn.xy * 0.055 * uStr + vec2(wob) * 0.012 * uStr;
            float ch = 0.3 * uChroma;
            vec3 refr = vec3(texture2D(uRT, suv + off * (1.0 + ch)).r, texture2D(uRT, suv + off).g, texture2D(uRT, suv + off * (1.0 - ch)).b);
            float fres = pow(1.0 - ndv, 3.0);
            vec3 cloak = refr * 0.94 + vec3(0.7, 0.9, 1.0) * fres * 0.25;
            float n = texture2D(uNoise, vec2(vL.x * 2.0 + vL.z * 1.3, vL.y * 0.4)).r * 0.14 - 0.07;
            float d = (vW.y - uY0) - (uCut + n);
            float k = smoothstep(-0.015, 0.015, d);
            vec3 col = mix(cloak, sh, k);
            float hex = step(0.5, fract((vL.y + vL.x) * 22.0)) * step(0.5, fract((vL.y - vL.x) * 22.0));
            float edge = exp(-pow(d * 28.0, 2.0));
            col += uCol * edge * (3.0 + 2.0 * hex) * (0.7 + 0.3 * sin(uTime * 40.0 + vW.x * 30.0));
            col += uCol * exp(-max(-d, 0.0) * 8.0) * (1.0 - k) * hex * 0.5;
            gl_FragColor = vec4(col, 1.0); ${TONE} }`,
      });
      const fig = new THREE.Mesh(geo, cloakMat);
      S.scene.add(fig);
      const samp = surfacePoints(geo, 1600);
      const sparks = S.pool(800, 6);
      const dust = S.pool(300, 3);
      const SPARK = ramp([[0, 0xffffff, 6, 1, 1], [0.3, 0x80f0ff, 4, 1, 1], [1, 0x2080ff, 1.5, 0, 1]]);
      const DUST = ramp([[0, 0xc8b8a0, 1.1, 0, 0], [0.15, 0xb8a890, 1.1, 0.5, 0], [1, 0xa09880, 1, 0, 0]]);
      let str = 1;
      let foot = true;
      const controls: Control[] = [
        ctrlRange('굴절 세기', 0, 2.5, 0.05, 1, (v) => (str = v)),
        ctrlTog('색 갈라짐', true, (v) => (cloakMat.uniforms.uChroma!.value = v ? 1 : 0)),
        ctrlTog('발밑 먼지 (숨은 적 단서)', true, (v) => (foot = v)),
      ];
      let cyc = 0;
      let x = -1.4;
      let dir = 1;
      let walkPh = 0;
      let lastStep = 0;
      const tmp = new THREE.Vector3();
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          cyc = (cyc + dt) % 8;
          x += dir * dt * 0.55;
          if (x > 1.5) dir = -1;
          if (x < -1.5) dir = 1;
          walkPh += dt * 7;
          const bob = Math.abs(Math.sin(walkPh)) * 0.035;
          fig.position.set(x, bob, 0);
          const want = dir > 0 ? 0.55 : -0.55;
          fig.rotation.y += (want - fig.rotation.y) * (1 - Math.exp(-dt * 6));
          fig.rotation.z = Math.sin(walkPh) * 0.03;
          fig.updateMatrixWorld();
          let cut: number;
          let state: string;
          if (cyc < 1.5) {
            cut = -0.3;
            state = '보임';
          } else if (cyc < 2.6) {
            cut = lerp(-0.3, 2.0, smooth((cyc - 1.5) / 1.1));
            state = '은신 켜짐 — 발끝부터 전기 경계가 올라감';
          } else if (cyc < 5.6) {
            cut = 2.0;
            state = '은신 중 — 뒤 배경이 일렁이며 비침 · 발밑 먼지만';
          } else if (cyc < 6.7) {
            cut = lerp(2.0, -0.3, smooth((cyc - 5.6) / 1.1));
            state = '은신 풀림 — 위에서부터 드러남';
          } else {
            cut = -0.3;
            state = '보임';
          }
          lab.set(state, '#40e0ff');
          cloakMat.uniforms.uCut!.value = cut;
          cloakMat.uniforms.uY0!.value = bob;
          cloakMat.uniforms.uTime!.value = t;
          cloakMat.uniforms.uStr!.value = str;
          const sweeping = cut > -0.25 && cut < 1.95;
          if (sweeping)
            for (let k = 0; k < 18; k++) {
              const i = Math.floor(Math.random() * 1600);
              const ly = samp[i * 6 + 1]!;
              if (Math.abs(ly - cut) > 0.05) continue;
              tmp.set(samp[i * 6]!, ly, samp[i * 6 + 2]!).applyMatrix4(fig.matrixWorld);
              const d = sphereDir();
              sparks.spawn({ x: tmp.x, y: tmp.y, z: tmp.z, vx: d.x * 1.2, vy: d.y * 1.2 + 0.4, vz: d.z * 1.2, life: R(0.15, 0.35), s0: R(0.015, 0.03), ramp: SPARK, st: 0.05, shape: 2 });
            }
          // 발자국 먼지
          const stepNow = Math.floor(walkPh / Math.PI);
          if (stepNow !== lastStep) {
            lastStep = stepNow;
            if (foot && cut > 0.3) {
              const side = stepNow % 2 ? 0.12 : -0.12;
              const fx = x + Math.cos(fig.rotation.y) * side;
              const fz = -Math.sin(fig.rotation.y) * side;
              for (let k = 0; k < 6; k++) {
                const a = R(0, TAU);
                dust.spawn({ x: fx, y: 0.03, z: fz, vx: Math.cos(a) * 0.4, vy: R(0.05, 0.25), vz: Math.sin(a) * 0.4, life: R(0.7, 1.1), s0: 0.06, s1: 0.28, ramp: DUST, shape: 1, rot: R(-0.4, 0.4), drag: 2.5 });
              }
            }
          }
          sparks.tick(dt);
          dust.tick(dt);
          S.tick(dt);
        },
        render(r, w, h) {
          if (rt.width !== w || rt.height !== h) rt.setSize(w, h);
          (cloakMat.uniforms.uRes!.value as THREE.Vector2).set(w, h);
          fig.visible = false;
          r.setRenderTarget(rt);
          r.clear();
          r.render(S.scene, S.cam);
          r.setRenderTarget(null);
          fig.visible = true;
          r.render(S.scene, S.cam);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => {
          S.dispose();
          rt.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [디졸브 (잡음으로 사라짐)](https://ai-techstudio.web.app/ai/t/i12.md) `i12` · [투과 · 굴절 (유리 · 얼음)](https://ai-techstudio.web.app/ai/t/u07.md) `u07`
- 다음에 해 볼 기술: [테두리 빛 (rim light, 프레넬)](https://ai-techstudio.web.app/ai/t/i10.md) `i10`
