# AI 꾸러미 — 시간 정지 · 왜곡 구 — Screen-space refraction (scene texture)
> 장면을 먼저 텍스처로 그려 두고 구 표면에서 그 텍스처를 휘게 · 흑백으로 다시 읽어, 구 안쪽만 색이 빠지고 굴절되며 안에 든 것들이 그 자리에 멈춘다.  
> 견본: https://ai-techstudio.web.app/#t/i195

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

## 주문서

### 만들어 줘: 시간 정지 · 왜곡 구 — Screen-space refraction (scene texture)

#### 1. 목표
뛰는 괴물 · 날아가는 미사일 둘레에 시간 정지 구를 만들어 줘 — 구 안쪽만 색이 빠지고 굴절되어 휘어 보이며, 안에 든 불티 · 미사일 · 괴물은 그 자리에 멈추게. 분위기는 금빛 시계 테두리 + 푸른 흑백.

#### 2. 핵심 기술 용어
- **Screen-space refraction (scene texture)** — 화면 굴절 — 그려 둔 장면을 휘게 다시 읽기
- **Desaturation (luminance)** — 흑백 — 밝기만 남기기
- **Chromatic aberration** — 빨강 · 초록 · 파랑을 조금씩 다르게 휘기
- **Local time scale** — 자리마다 다른 시간 빠르기 (구 안 = 0)

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

#### 4. 조건
- 장면 텍스처 패스는 구가 보일 때만, 구 · 시계 무늬는 그 패스에서 숨긴다
- 렌더 타깃 크기 = 화면 크기, uRes 도 같은 값 (gl_FragCoord 로 읽으니 픽셀 단위)
- 굴절은 법선 xy 만큼 밀어 읽기, 빨강 · 초록 · 파랑을 조금씩 다르게
- 멈춤은 재생을 멈추는 게 아니라 자리마다 시간 배율을 곱하기 — 구 밖은 그대로 움직인다
- 굴절 세기(0 ~ 3) · 흑백 켬/끔 조절

#### 5. 완성 기준 (이게 보이면 성공)
- 구가 커지면 그 안만 푸른 흑백이 되고 뒤 배경이 휘어 보인다
- 구 안의 불티 분수 · 미사일 · 뛰는 괴물이 공중에서 멈추고, 구 밖 괴물은 계속 뛴다
- 구가 줄어들면 멈췄던 것들이 다시 움직인다
- 「굴절 세기」 0 이면 휘지 않고 색만 빠진다

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

## 원리
- 구를 숨기고 장면을 렌더 타깃(HalfFloat)에 먼저 그린 뒤, 구를 보이고 진짜 화면을 그린다.
- 구 셰이더는 화면 좌표(gl_FragCoord / 해상도)로 그 텍스처를 읽되 법선 xy 만큼 밀어 읽는다: 밀기 = n.xy × 세기 × (0.03 + 0.09·fr²). 가장자리일수록 많이 휜다.
- 빨강 1.15 · 초록 1 · 파랑 0.85 배로 다르게 밀어 무지갯빛 가장자리, 밝기 l 로 흑백 + 푸른빛(0.62, 0.78, 1.1)을 섞는다.
- 테두리는 금빛 fr⁴ · 1.3. 구 반지름은 0.4 ~ 1.0초에 easeOut 으로 2.1 까지 커졌다 4.9 ~ 5.6초에 줄어든다.
- 입자 · 미사일 · 괴물은 자리마다 시간 배율 smoothstep(0.88R, 1.02R, 중심까지 거리)을 곱한다 — 구 안은 0 이라 멈춘다.

## 핵심 코드 — 장면 텍스처 패스 + 굴절 · 흑백 구 셰이더 + 자리별 시간 배율
(발췌: demos/demosSkillB.ts i195 make() · render() 를 정리)
```ts
const rt = new THREE.WebGLRenderTarget(4, 4, { type: THREE.HalfFloatType });
const sMat = new THREE.ShaderMaterial({
  uniforms: { uTex: { value: rt.texture }, uRes: { value: new THREE.Vector2(4, 4) }, uRef: { value: 1 }, uGray: { value: 1 }, uA: { value: 1 } },
  vertexShader: 'varying vec3 vN; varying vec3 vV; void main(){ vec4 mv = modelViewMatrix * vec4(position, 1.0);' +
    ' vN = normalize(normalMatrix * normal); vV = normalize(-mv.xyz); gl_Position = projectionMatrix * mv; }',
  fragmentShader: 'uniform sampler2D uTex; uniform vec2 uRes; uniform float uRef, uGray, uA; varying vec3 vN; varying vec3 vV;' +
    'void main(){ vec3 n = normalize(vN); float ndv = abs(dot(n, normalize(vV))); float fr = 1.0 - ndv;' +
    ' vec2 uv = gl_FragCoord.xy / uRes;' +
    ' vec2 off = n.xy * uRef * (0.03 + 0.09 * fr * fr) * uA;' +                    // 가장자리일수록 많이 휨
    ' vec3 c; c.r = texture2D(uTex, uv - off * 1.15).r; c.g = texture2D(uTex, uv - off).g; c.b = texture2D(uTex, uv - off * 0.85).b;' +
    ' float l = dot(c, vec3(0.299, 0.587, 0.114));' +
    ' vec3 g = mix(c, vec3(l / (1.0 + l)) * vec3(0.62, 0.78, 1.1) * 0.85, uGray * uA);' +
    ' vec3 rim = vec3(1.0, 0.78, 0.4) * pow(fr, 4.0) * 1.3;' +
    ' gl_FragColor = vec4(g * (0.7 + 0.2 * ndv) + rim * uA, 1.0); }',
});

function render(r: THREE.WebGLRenderer, w: number, h: number): void {
  if (sphere.visible) {
    if (rt.width !== w || rt.height !== h) rt.setSize(w, h);
    sMat.uniforms.uRes.value.set(w, h);
    sphere.visible = false;            // 구 빼고 장면만 텍스처로
    r.setRenderTarget(rt); r.render(scene, cam); r.setRenderTarget(null);
    sphere.visible = true;
  }
  r.render(scene, cam);
}

// 자리마다 시간 배율: 구 안 0, 밖 1 (경계는 부드럽게)
const tsAt = (x: number, y: number, z: number): number =>
  R < 0.05 ? 1 : smoothstep(R * 0.88, R * 1.02, Math.hypot(x - CEN[0], y - CEN[1], z - CEN[2]));
// 입자 한 걸음에 dt * tsAt(입자 자리) 를 넘긴다. 미사일: m.x += dt * 3.2 * tsAt(m.x, m.y, m.z)
```

## 흔한 실수 · 확인 목록
- [ ] **장면 텍스처 패스에 구를 같이 그리면 구가 자기 자신을 비춰 이상해진다** — 패스 전에 구 · 시계 무늬를 숨기고, 끝나면 다시 보인다.
- [ ] **렌더 타깃을 화면과 다른 크기로 두면 휘는 자리가 어긋난다** — render 마다 크기를 비교해 setSize, uRes 도 같은 값.
- [ ] **「멈춤」을 전체 재생 정지로 하면 구 밖까지 멈춘다** — 물체 · 입자마다 자기 자리의 시간 배율을 곱한다. 경계는 smoothstep 으로 부드럽게.

## 완성 기준 체크리스트
- [ ] 구가 커지면 그 안만 푸른 흑백이 되고 뒤 배경이 휘어 보인다
- [ ] 구 안의 불티 분수 · 미사일 · 뛰는 괴물이 공중에서 멈추고, 구 밖 괴물은 계속 뛴다
- [ ] 구가 줄어들면 멈췄던 것들이 다시 움직인다
- [ ] 「굴절 세기」 0 이면 휘지 않고 색만 빠진다

## 이 기술 정보
- id: `i195` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 구가 보이는 동안만 장면을 한 번 더 그린다(화면 크기 HalfFloat 타깃). 구 셰이더 자체는 텍스처 3번 읽기로 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i195
- 쓰면 좋을 때: 시간 정지 · 느리게 · 멈춤 마법 / 「이 안만 다르다」를 강하게 보여 줄 때 (구역 표시)
- 쓰지 말 때: 폰에서 장면이 무거울 때 — 구가 보이는 동안 장면을 두 번 그린다 / 구가 화면 대부분을 덮는 가까운 카메라 — 휘기가 심해 어지럽다. 굴절 세기를 낮춘다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i195 견본 항목 — `src/demos/demosSkillB.ts:2412`
```ts
  i195: {
    kind: '3d',
    caption: '구 안쪽만 색이 빠지고 굴절되어 휘어 보이며, 안에 든 불티 · 미사일 · 괴물은 그 자리에 멈춘다',
    make() {
      const A = arena({ fx: 900 });
      const CEN: V3 = [0.2, 0.9, 0];
      const mon = monster(A, 0x5d4a78, 0);
      mon.g.position.set(-0.6, 0, 0.5);
      A.scene.add(mon.g);
      const mon2 = monster(A, 0x4a5c6e, 1);
      mon2.g.position.set(3.2, 0, -0.8);
      A.scene.add(mon2.g);
      // 화로 두 개 (불티 분수)
      const braz = ([[0.9, 0, -0.5], [-3.0, 0, -1.2]] as V3[]).map((p) => {
        const m = new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.2, 0.45, 12), new THREE.MeshStandardMaterial({ color: 0x3a3430, roughness: 0.6, metalness: 0.4 }));
        m.position.set(p[0], 0.22, p[2]);
        A.scene.add(m);
        return p;
      });
      const brazL = braz.map((p) => {
        const l = new THREE.PointLight(0xff9040, 14, 5, 2);
        l.position.set(p[0], 0.9, p[2]);
        A.scene.add(l);
        return l;
      });
      // 시간 정지 구 (화면 굴절)
      const rt = new THREE.WebGLRenderTarget(4, 4, { type: THREE.HalfFloatType });
      const sMat = new THREE.ShaderMaterial({
        uniforms: { uTex: { value: rt.texture }, uRes: { value: new THREE.Vector2(4, 4) }, uRef: { value: 1 }, uGray: { value: 1 }, uT: { value: 0 }, uA: { value: 0 } },
        vertexShader: FRESNEL_V,
        fragmentShader: `uniform sampler2D uTex; uniform vec2 uRes; uniform float uRef; uniform float uGray; uniform float uT; uniform float uA;
          varying vec3 vN; varying vec3 vV; varying vec2 vUv; varying vec3 vP;
          void main(){ vec3 n = normalize(vN); vec3 v = normalize(vV); float ndv = abs(dot(n, v)); float fr = 1.0 - ndv;
            vec2 uv = gl_FragCoord.xy / uRes;
            vec2 off = n.xy * uRef * (0.03 + 0.09 * fr * fr) * uA;
            vec3 c; c.r = texture2D(uTex, uv - off * 1.15).r; c.g = texture2D(uTex, uv - off).g; c.b = texture2D(uTex, uv - off * 0.85).b;
            float l = dot(c, vec3(0.299, 0.587, 0.114));
            vec3 g = mix(c, vec3(l / (1.0 + l)) * vec3(0.62, 0.78, 1.1) * 0.85, uGray * uA);
            float wave = 0.5 + 0.5 * sin(fr * 26.0 + uT * 1.5);
            vec3 rim = vec3(1.0, 0.78, 0.4) * pow(fr, 4.0) * 1.3 + vec3(0.9, 0.7, 0.35) * pow(fr, 2.0) * wave * 0.05;
            gl_FragColor = vec4(g * (0.7 + 0.2 * ndv) + rim * uA, 1.0);
            ${TONE} }`,
      });
      const sphere = new THREE.Mesh(new THREE.SphereGeometry(1, 48, 32), sMat);
      sphere.position.set(CEN[0], CEN[1], CEN[2]);
      sphere.renderOrder = 9;
      A.scene.add(sphere);
      // 시계 무늬 (구 가운데, 늘 화면을 봄)
      const face = new THREE.Sprite(new THREE.SpriteMaterial({ map: A.tx.clock(), color: new THREE.Color(1.5, 1.0, 0.35), transparent: true, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending }));
      const hH = new THREE.Sprite(new THREE.SpriteMaterial({ map: A.tx.hand(false), color: new THREE.Color(1.8, 1.4, 0.7), transparent: true, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending }));
      const hM = new THREE.Sprite(new THREE.SpriteMaterial({ map: A.tx.hand(true), color: new THREE.Color(1.8, 1.4, 0.7), transparent: true, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending }));
      for (const s of [face, hH, hM]) {
        s.position.set(CEN[0], CEN[1], CEN[2]);
        s.renderOrder = 10;
        A.scene.add(s);
      }
      const ground = decal(A.tx.ring(), 1, C(0xffb850, 1.6), 0);
      ground.position.set(CEN[0], 0.03, CEN[2]);
      A.scene.add(ground);
      const loop = new Loop(6.4);
      let R = 0, refr = 1, gray = true;
      const RMAX = 2.1;
      const tsAt = (x: number, y: number, z: number): number => (R < 0.05 ? 1 : sstep(R * 0.88, R * 1.02, Math.hypot(x - CEN[0], y - CEN[1], z - CEN[2])));
      const tsP = (q: Part): number => tsAt(q.x, q.y, q.z);
      // 가로지르는 미사일 셋
      const MC = C(0x9a7aff, 1.8);
      const mis = [-1.0, 0.25, 1.4].map((z, i) => ({ x: -6 + i * 3.6, z, y: 0.9 + i * 0.12, trail: [] as V3[] }));
      const bAcc = braz.map(() => ({ acc: 0 }));
      const mAcc = { acc: 0 };
      let monT = 0, handA = 0;
      return base3d(A, (t, dt) => {
        const lt = loop.tick(t);
        const grow = sstep(0.4, 1.0, lt);
        const shrink = 1 - sstep(4.9, 5.6, lt);
        R = RMAX * easeOut(grow) * shrink;
        const on = R > 0.05 ? 1 : 0;
        sphere.visible = on > 0;
        sphere.scale.setScalar(Math.max(R, 0.01));
        sMat.uniforms.uT!.value = t;
        sMat.uniforms.uA!.value = clamp(R / 0.6, 0, 1);
        sMat.uniforms.uRef!.value = refr;
        sMat.uniforms.uGray!.value = gray ? 1 : 0;
        // 시계: 커질 땐 바늘이 빨리 돌다가 멈추고 째깍째깍 떨림
        handA += dt * (lt < 1 ? 14 * (1 - grow) + 0.5 : 0);
        const tick = Math.sin(t * 9) > 0.9 ? 0.03 : 0;
        const fa = clamp(R / RMAX, 0, 1);
        face.scale.setScalar(R * 1.25);
        face.material.opacity = 0.16 * fa;
        face.material.rotation = -lt * 0.05;
        hH.scale.set(R * 0.16, R * 1.25, 1);
        hM.scale.set(R * 0.16, R * 1.25, 1);
        hH.material.rotation = -handA * 0.25 + 1.1 + tick;
        hM.material.rotation = -handA * 3 - 0.6 - tick;
        hH.material.opacity = hM.material.opacity = 0.45 * fa;
        ground.scale.setScalar(R * 2.25);
        ground.rotation.z = handA * 0.02;
        ground.material.opacity = 0.8 * fa;
        if (loop.hit(0.4)) {
          flashAt(A.fx, CEN, 3, C(0xffe0a0, 2), 0.3);
        }
        // 불티 분수
        braz.forEach((p, i) => {
          for (let k = rate(bAcc[i]!, 55, dt); k > 0; k--) {
            const top: V3 = [p[0], 0.5, p[2]];
            if (tsAt(top[0], top[1], top[2]) < 0.5) continue;
            A.st.spark({ p: top, v: [rr(-0.7, 0.7), rr(3, 4.6), rr(-0.7, 0.7)], life: rr(1.0, 1.5), c: C(0xffc070, 3), c2: C(0xff3a00, 0.8), len: 0.03, w: 0.022, grav: 4, drag: 0.3, fadeIn: 0 });
          }
          A.fx.put([p[0], 0.55, p[2]], 0.9, C(0xff8a30, 1.5), 0.5 * tsAt(p[0], 0.55, p[2]) + 0.15);
          brazL[i]!.intensity = 10 + 6 * tsAt(p[0], 0.6, p[2]) * Math.random();
        });
        // 미사일
        for (const m of mis) {
          const s = tsAt(m.x, m.y, m.z);
          m.x += dt * 3.2 * s;
          if (m.x > 6) { m.x = -6; m.trail.length = 0; }
          const p: V3 = [m.x, m.y + Math.sin(m.x * 2) * 0.1, m.z];
          if (s > 0.05) { m.trail.unshift(p); if (m.trail.length > 12) m.trail.pop(); }
          orbGlow(A.fx, p, MC, 0.2, 1);
          const n = m.trail.length;
          for (let i = 0; i < n - 1; i++) {
            const k = 1 - i / (n - 1);
            A.st.seg(m.trail[i + 1]!, m.trail[i]!, 0.08 * k + 0.01, MC, 0.6 * k);
          }
          for (let i = rate(mAcc, 20 * s, dt); i > 0; i--) A.fx.emit({ p, v: sc3(randDir(), 0.3), life: 0.6, size: 0.1, size2: 0.02, c: MC, a: 0.9 });
        }
        // 뛰는 괴물 (안에 있으면 공중에 멈춤)
        const ms = tsAt(mon.g.position.x, 0.8, mon.g.position.z);
        monT += dt * ms;
        mon.g.position.y = Math.abs(Math.sin(monT * 3.2)) * 0.9;
        mon.tick(dt * ms, monT);
        mon2.g.position.y = Math.abs(Math.sin(t * 3.2 + 1)) * 0.9;
        mon2.tick(dt, t);
        A.fx.step(dt, tsP);
        A.st.step(dt, tsP);
        A.tickCam(dt);
      }, [
        { type: 'button', label: '다시 걸기', on: () => loop.restart() },
        { type: 'range', label: '굴절 세기', min: 0, max: 3, step: 0.1, value: refr, on: (v) => (refr = v) },
        { type: 'toggle', label: '흑백 (색 빠짐)', value: gray, on: (v) => (gray = v) },
      ], {
        render(r, w, h) {
          if (sphere.visible) {
            if (rt.width !== w || rt.height !== h) rt.setSize(w, h);
            (sMat.uniforms.uRes!.value as THREE.Vector2).set(w, h);
            sphere.visible = false;
            face.visible = hH.visible = hM.visible = false;
            r.setRenderTarget(rt);
            r.render(A.scene, A.cam);
            r.setRenderTarget(null);
            sphere.visible = true;
            face.visible = hH.visible = hM.visible = true;
          }
          r.render(A.scene, A.cam);
        },
        dispose() {
          rt.dispose();
          A.dispose();
        },
      });
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [부드러운 입자 (soft particles)](https://ai-techstudio.web.app/ai/t/i175.md) `i175`
- 다음에 해 볼 기술: [슬로모션 · 시간 배율 곡선](https://ai-techstudio.web.app/ai/t/i204.md) `i204`
