# AI 꾸러미 — 포털 — Portal (render-to-texture, screen-space UV)
> 다른 세상을 같은 카메라로 텍스처에 그린 뒤 문 모양 판에 화면 좌표로 붙이고, 가장자리를 소용돌이치게 해 진짜 다른 곳이 보이는 문을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i64

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

## 주문서

### 만들어 줘: 포털 — Portal (render-to-texture, screen-space UV)

#### 1. 목표
다음 단계로 가는 문에 다른 세상이 보이는 포털 문을 만들어 줘 — 가장자리는 소용돌이. 분위기는 낮 풀밭 → 보랏빛 밤하늘.

#### 2. 핵심 기술 용어
- **Portal (render-to-texture, screen-space UV)** — 다른 장면을 텍스처로 그려 화면 좌표로 붙이기
- **WebGLRenderTarget** — three.js 에서 화면 대신 텍스처에 그리기
- **Swirl edge distortion** — 문 가장자리를 각도 방향으로 비틀어 소용돌이

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

#### 4. 조건
- 문 셰이더는 화면 좌표로 텍스처를 읽는다 (자기 UV 로 읽으면 그림이 붙은 판처럼 보인다)
- 다른 세상은 같은 카메라로, 문보다 먼저 렌더 타깃에 그린다
- 렌더 타깃 크기 = 화면 크기, 창이 바뀌면 다시
- 소용돌이 세기 슬라이더(0~2)
- 문 둘레 빛 알갱이는 가산 섞기 · depthWrite 끔

#### 5. 완성 기준 (이게 보이면 성공)
- 카메라가 좌우로 돌면 문 안 행성 · 수정의 보이는 각도도 함께 바뀐다 (그림 판이 아니다)
- 문 가장자리가 돌아가는 소용돌이 빛으로 일렁이고, 가운데는 또렷하다
- 소용돌이 0 이면 깨끗한 둥근 창, 2 면 크게 비틀린다

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

## 원리
- 다른 세상은 따로 만든 장면(other)이다. 매 장면 같은 카메라로 other 를 렌더 타깃에 먼저 그린다.
- 문(원판)의 조각 셰이더는 자기 UV 가 아니라 화면 좌표(gl_FragCoord / 화면 크기)로 그 텍스처를 읽는다 — 그래서 카메라가 돌면 문 안 풍경도 시점이 맞게 바뀐다.
- 문 반지름 0.45 바깥부터 각도 방향(a + 90°)으로 읽는 자리를 밀면 가장자리가 소용돌이친다.
- 나선 무늬 sin(a × 5 + r × 12 − 시간 × 4) 로 보라 · 하늘색 빛을 섞고, 맨 가장자리는 밝은 테두리.

## 핵심 코드 — 다른 세상을 텍스처로 → 문은 화면 좌표로 읽기
(발췌: demos/demosShader.ts portalDemo() 를 정리)
```ts
const rt = new THREE.WebGLRenderTarget(1, 1);
const pu = { tWorld: { value: rt.texture }, uRes: { value: new THREE.Vector2(1, 1) }, uTime: { value: 0 }, uSwirl: { value: 1 } };
const door = new THREE.Mesh(new THREE.CircleGeometry(1.08, 64), new THREE.ShaderMaterial({
  uniforms: pu,
  vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
  fragmentShader: [
    'uniform sampler2D tWorld; uniform vec2 uRes; uniform float uTime; uniform float uSwirl; varying vec2 vUv;',
    'void main(){',
    '  vec2 c = vUv - 0.5; float r = length(c) * 2.0; float a = atan(c.y, c.x);',
    '  vec2 suv = gl_FragCoord.xy / uRes;                     // 자기 UV 가 아니라 화면 좌표',
    '  float sw = smoothstep(0.45, 1.0, r) * uSwirl;',
    '  suv += vec2(cos(a + 1.57), sin(a + 1.57)) * sw * 0.035 * (0.6 + 0.4 * sin(r * 18.0 - uTime * 5.0));',
    '  vec3 col = texture2D(tWorld, suv).rgb;',
    '  float spiral = 0.5 + 0.5 * sin(a * 5.0 + r * 12.0 - uTime * 4.0);',
    '  col = mix(col, mix(vec3(0.6, 0.2, 1.0), vec3(0.2, 0.9, 1.0), spiral) * 2.0, sw * sw * (0.35 + 0.65 * spiral));',
    '  col += vec3(0.9, 0.7, 1.0) * smoothstep(0.85, 1.0, r) * 1.5;',
    '  gl_FragColor = vec4(col, 1.0);',
    '}',
  ].join('\n'),
}));
scene.add(door);

function render(renderer: THREE.WebGLRenderer, w: number, h: number): void {
  if (rt.width !== w || rt.height !== h) rt.setSize(w, h);
  renderer.setRenderTarget(rt);
  renderer.render(otherScene, camera);   // 같은 카메라로 저쪽 세상 먼저
  renderer.setRenderTarget(null);
  pu.uRes.value.set(w, h);
  renderer.render(scene, camera);        // 이쪽 세상 (문이 그 텍스처를 읽는다)
}
```

## 흔한 실수 · 확인 목록
- [ ] **문 UV 로 텍스처를 읽으면 그림 붙인 판처럼 납작하다** — 화면 좌표(gl_FragCoord / 화면 크기)로 읽어야 카메라가 돌 때 저쪽 풍경 시점이 맞는다.
- [ ] **uRes 를 CSS 크기로 넣으면 문 안 그림이 어긋난다** — gl_FragCoord 는 실제 픽셀이다 — 그리기 버퍼 크기(픽셀 비율 곱한 값)를 넣는다.
- [ ] **문 둘레 알갱이가 문 그림을 가린다** — 가산 섞기 + depthWrite: false 로 빛만 더한다.

## 완성 기준 체크리스트
- [ ] 카메라가 좌우로 돌면 문 안 행성 · 수정의 보이는 각도도 함께 바뀐다 (그림 판이 아니다)
- [ ] 문 가장자리가 돌아가는 소용돌이 빛으로 일렁이고, 가운데는 또렷하다
- [ ] 소용돌이 0 이면 깨끗한 둥근 창, 2 면 크게 비틀린다

## 이 기술 정보
- id: `i64` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 다른 세상을 한 번 더 그린다 (그 장면 물체 수만큼 비용). 문이 하나면 대체로 OK, 둘 이상이면 무겁다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i64
- 쓰면 좋을 때: 다음 단계 · 다른 방으로 넘어가는 문 / 「저 너머가 보인다」가 연출의 핵심일 때
- 쓰지 말 때: 문으로 걸어 들어가 시점까지 이어져야 할 때 — 문 위치 기준 가상 카메라(경사 투영)가 필요하다 / 폰에서 문이 여러 개 — 장면을 문 수만큼 더 그린다. 정지 그림 텍스처로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### portalDemo — `src/demos/demosShader.ts:2023`
```ts
function portalDemo(): Scene3D {
  // 이쪽 세상 — 낮 풀밭
  const scene = new THREE.Scene();
  const bg = gradTex([
    [0, '#5aa8ec'],
    [1, '#d8f0ff'],
  ]);
  scene.background = bg;
  lights(scene);
  const world = new THREE.Group();
  const ground = new THREE.Mesh(new THREE.CircleGeometry(12, 48), new THREE.MeshStandardMaterial({ color: 0x86cc5a, roughness: 1 }));
  ground.rotation.x = -Math.PI / 2;
  world.add(ground);
  const leaf = new THREE.MeshStandardMaterial({ color: 0x3c9c48, flatShading: true });
  const trunkM = new THREE.MeshStandardMaterial({ color: 0x8a5a32 });
  for (const [x, z] of [
    [-2.6, -1.5],
    [2.8, -2.2],
    [-3.5, -4],
    [1.2, -4.5],
    [3.6, 0.6],
  ] as const) {
    const tr = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.12, 0.6, 8), trunkM);
    tr.position.set(x, 0.3, z);
    const tp = new THREE.Mesh(new THREE.IcosahedronGeometry(0.55, 0), leaf);
    tp.position.set(x, 0.95, z);
    world.add(tr, tp);
  }
  const stoneM = new THREE.MeshStandardMaterial({ color: 0x8e86a8, roughness: 0.8, flatShading: true });
  const arch = new THREE.Mesh(new THREE.TorusGeometry(1.2, 0.16, 10, 40), stoneM);
  arch.position.set(0, 1.4, 0);
  const baseL = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.3, 0.5), stoneM);
  baseL.position.set(-1.0, 0.15, 0);
  const baseR = baseL.clone();
  baseR.position.x = 1.0;
  world.add(arch, baseL, baseR);
  scene.add(world);
  // 저쪽 세상 — 보랏빛 밤하늘 · 떠 있는 행성 · 빛나는 수정
  const other = new THREE.Scene();
  const bg2 = gradTex([
    [0, '#120a3a'],
    [0.6, '#5a2a8a'],
    [1, '#ff8ab8'],
  ]);
  other.background = bg2;
  other.add(new THREE.HemisphereLight(0xc0a0ff, 0x401a60, 1.0));
  const ol = new THREE.DirectionalLight(0xffc0f0, 1.5);
  ol.position.set(2, 3, 2);
  other.add(ol);
  const ow = new THREE.Group();
  const og = new THREE.Mesh(new THREE.CircleGeometry(14, 48), new THREE.MeshStandardMaterial({ color: 0x2a1a5a, roughness: 0.9 }));
  og.rotation.x = -Math.PI / 2;
  ow.add(og);
  const planet = new THREE.Mesh(new THREE.SphereGeometry(1.1, 32, 24), new THREE.MeshStandardMaterial({ color: 0xffa04a, emissive: 0x803010, emissiveIntensity: 0.4 }));
  planet.position.set(1.5, 3.2, -6);
  const pring = new THREE.Mesh(new THREE.TorusGeometry(1.7, 0.06, 6, 48), new THREE.MeshBasicMaterial({ color: 0xffe0a0 }));
  pring.rotation.x = 1.2;
  planet.add(pring);
  const moon = new THREE.Mesh(new THREE.SphereGeometry(0.4, 20, 14), new THREE.MeshStandardMaterial({ color: 0x9ad8ff, emissive: 0x2050a0, emissiveIntensity: 0.5 }));
  moon.position.set(-2, 2.6, -4);
  ow.add(planet, moon);
  const crysM = new THREE.MeshStandardMaterial({ color: 0x7af0ff, emissive: 0x20a0c0, emissiveIntensity: 1.2, roughness: 0.2, flatShading: true });
  const crystals: THREE.Mesh[] = [];
  for (let i = 0; i < 7; i++) {
    const c = new THREE.Mesh(new THREE.OctahedronGeometry(0.25 + (i % 3) * 0.12, 0), crysM);
    c.position.set(-2.5 + i * 0.85, 0.5 + (i % 2) * 0.4, -1.5 - (i % 3) * 1.2);
    c.scale.y = 1.8;
    crystals.push(c);
    ow.add(c);
  }
  const SN = 300;
  const sp = new Float32Array(SN * 3);
  for (let i = 0; i < SN; i++) {
    const a = i * 2.399;
    const r = 8 + (i % 7);
    sp.set([Math.cos(a) * r, 1 + ((i * 37) % 100) / 12, -Math.abs(Math.sin(a)) * r - 2], i * 3);
  }
  const sg = new THREE.BufferGeometry();
  sg.setAttribute('position', new THREE.BufferAttribute(sp, 3));
  ow.add(new THREE.Points(sg, new THREE.PointsMaterial({ color: 0xffffff, size: 0.08 })));
  other.add(ow);
  // 문 — 저쪽 장면을 같은 카메라로 그려 화면 좌표로 붙인다 + 가장자리 소용돌이
  const rt = makeRT();
  const pu = { tWorld: { value: null as THREE.Texture | null }, uRes: { value: new THREE.Vector2(1, 1) }, uTime: { value: 0 }, uSwirl: { value: 1 } };
  const door = new THREE.Mesh(
    new THREE.CircleGeometry(1.08, 64),
    new THREE.ShaderMaterial({
      uniforms: pu,
      vertexShader: /* glsl */ `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
      fragmentShader: /* glsl */ `uniform sampler2D tWorld; uniform vec2 uRes; uniform float uTime; uniform float uSwirl; varying vec2 vUv;
        void main(){
          vec2 c = vUv - 0.5; float r = length(c) * 2.0; float a = atan(c.y, c.x);
          vec2 suv = gl_FragCoord.xy / uRes;
          float sw = smoothstep(0.45, 1.0, r) * uSwirl;
          suv += vec2(cos(a + 1.57), sin(a + 1.57)) * sw * 0.035 * (0.6 + 0.4 * sin(r * 18.0 - uTime * 5.0));
          vec3 col = texture2D(tWorld, suv).rgb;
          float spiral = 0.5 + 0.5 * sin(a * 5.0 + r * 12.0 - uTime * 4.0);
          vec3 glow = mix(vec3(0.6, 0.2, 1.0), vec3(0.2, 0.9, 1.0), spiral) * 2.0;
          col = mix(col, glow, sw * sw * (0.35 + 0.65 * spiral));
          col += vec3(0.9, 0.7, 1.0) * smoothstep(0.85, 1.0, r) * 1.5;
          gl_FragColor = vec4(col, 1.0);
          #include <tonemapping_fragment>
          #include <colorspace_fragment>
        }`,
    }),
  );
  door.position.set(0, 1.4, 0.01);
  scene.add(door);
  // 문 둘레 빛 알갱이
  const PN = 80;
  const pp = new Float32Array(PN * 3);
  const pg = new THREE.BufferGeometry();
  pg.setAttribute('position', new THREE.BufferAttribute(pp, 3));
  const sparks = new THREE.Points(pg, new THREE.PointsMaterial({ color: 0xc8a0ff, size: 0.07, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false }));
  scene.add(sparks);
  const cam = new THREE.PerspectiveCamera(42, 1, 0.1, 80);
  return {
    scene,
    camera: cam,
    update(t) {
      pu.uTime.value = t;
      const a = Math.sin(t * 0.35) * 0.65;
      cam.position.set(Math.sin(a) * 5.4, 1.6, Math.cos(a) * 5.4);
      cam.lookAt(0, 1.2, 0);
      planet.rotation.y = t * 0.3;
      crystals.forEach((c, i) => {
        c.rotation.y = t + i;
        c.position.y = 0.6 + (i % 2) * 0.4 + Math.sin(t * 1.5 + i) * 0.12;
      });
      for (let i = 0; i < PN; i++) {
        const an = (i / PN) * Math.PI * 2 + t * (i % 2 ? 0.8 : -0.5);
        const r = 1.25 + Math.sin(t * 3 + i) * 0.08;
        pp.set([Math.cos(an) * r, 1.4 + Math.sin(an) * r, 0.1 + Math.sin(i) * 0.1], i * 3);
      }
      pg.attributes['position']!.needsUpdate = true;
    },
    render(r, w, h) {
      const target = rt.get(w, h);
      const prev = r.getRenderTarget();
      r.setRenderTarget(target);
      r.clear();
      r.render(other, cam);
      r.setRenderTarget(prev);
      pu.tWorld.value = target.texture;
      pu.uRes.value.set(w, h);
      r.render(scene, cam);
    },
    controls: [{ type: 'range', label: '소용돌이', min: 0, max: 2, step: 0.05, value: 1, on: (v) => (pu.uSwirl.value = v) }],
    dispose() {
      freeAll(world, ow, door, sparks);
      bg.dispose();
      bg2.dispose();
      rt.dispose();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [후처리 사슬 (EffectComposer → OutputPass)](https://ai-techstudio.web.app/ai/t/u21.md) `u21`
- 다음에 해 볼 기술: [워프 · 초공간 이동](https://ai-techstudio.web.app/ai/t/i530.md) `i530`
- 참고 문서: [three.js 문서 — WebGLRenderTarget](https://threejs.org/docs/#api/en/renderers/WebGLRenderTarget)
