포털(다른 세계로 창)

Portal (window into another world)

한 장면을 미리 텍스처로 찍어서 다른 장면의 문·거울·창 표면에 실시간으로 붙이는 기법.

다른 이름: Render-to-texture portalMagic windowWebGLRenderTarget scene
···
js
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);

const rt = new THREE.WebGLRenderTarget(512, 512);

const innerScene = new THREE.Scene();
innerScene.background = new THREE.Color(0x2a0e08);
innerScene.fog = new THREE.FogExp2(0x2a0e08, 0.05);
const innerCamera = new THREE.PerspectiveCamera(55, 1, 0.1, 30);
innerCamera.position.set(0, 1, 6);
innerCamera.lookAt(0, 0, -1);
const innerLight = new THREE.DirectionalLight(0xffb37a, 1.8);
innerLight.position.set(2, 4, 3);
innerScene.add(innerLight);
innerScene.add(new THREE.AmbientLight(0x552211, 1.3));
const innerGroup = new THREE.Group();
for (let i = 0; i < 9; i++) {
  const m = new THREE.Mesh(new THREE.ConeGeometry(0.5, 1.2, 4), new THREE.MeshStandardMaterial({ color: 0xff8a3d, roughness: 0.5 }));
  const a = (i / 9) * Math.PI * 2;
  m.position.set(Math.cos(a) * 2.4, -0.2, Math.sin(a) * 2.4 - 1);
  innerGroup.add(m);
}
innerScene.add(innerGroup);

const mainScene = new THREE.Scene();
mainScene.background = new THREE.Color(0x05060c);
const mainCamera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
mainCamera.position.set(0, 0.3, 5.4);

const portalPlane = new THREE.Mesh(new THREE.CircleGeometry(1.35, 48), new THREE.MeshBasicMaterial({ map: rt.texture }));
mainScene.add(portalPlane);
const frame = new THREE.Mesh(new THREE.TorusGeometry(1.4, 0.09, 16, 100), new THREE.MeshBasicMaterial({ color: 0x6fe8ff }));
mainScene.add(frame);
const deco = new THREE.Group();
for (let i = 0; i < 5; i++) {
  const s = new THREE.Mesh(new THREE.IcosahedronGeometry(0.16, 0), new THREE.MeshBasicMaterial({ color: 0x4d5cff, wireframe: true }));
  s.position.set((Math.random() - 0.5) * 4.6, (Math.random() - 0.5) * 2.4, -0.6 - Math.random());
  deco.add(s);
}
mainScene.add(deco);

function resize() {
  renderer.setSize(innerWidth, innerHeight);
  mainCamera.aspect = innerWidth / innerHeight;
  mainCamera.updateProjectionMatrix();
}
addEventListener('resize', resize); resize();

renderer.setAnimationLoop((t) => {
  const time = t * 0.001;
  innerGroup.rotation.y = time * 0.3;
  innerCamera.position.x = Math.sin(time * 0.2) * 1.2;
  innerCamera.lookAt(0, 0, -1);
  frame.rotation.z = time * 0.1;
  deco.rotation.y = time * 0.15;
  mainCamera.position.x = Math.sin(time * 0.25) * 0.6;
  mainCamera.lookAt(0, 0, 0);
  renderer.setRenderTarget(rt);
  renderer.render(innerScene, innerCamera);
  renderer.setRenderTarget(null);
  renderer.render(mainScene, mainCamera);
});

three.js는 렌더러가 화면(캔버스) 대신 THREE.WebGLRenderTarget이라는 오프스크린 버퍼에 그리게 할 수 있습니다. 이 데모는 매 프레임 렌더러를 두 번 씁니다 — 먼저 "안쪽 세계"(다른 배경·다른 조명의 작은 씬)를 렌더 타깃에 그리고(renderer.setRenderTarget(rt)), 그다음 렌더 타깃을 다시 화면(null)으로 돌려 "바깥 세계"를 그리는데, 이때 바깥 세계 안의 원형 평면이 그 렌더 타깃 텍스처를 map으로 물고 있습니다.

핵심은 순서입니다: 안쪽 씬을 먼저 완성해서 텍스처에 구워 둬야, 바깥 씬을 그릴 때 그 텍스처를 읽을 수 있습니다. 두 씬은 카메라도, 조명도, 배경색도 완전히 독립적이라 포털 너머가 "다른 물리 법칙의 공간"처럼 보여도 이상하지 않습니다.

렌더 타깃 해상도(예제는 512×512)는 화면 해상도와 무관하게 고정할 수 있어 성능을 조절하기 쉽습니다. 실무에서는 같은 방식으로 감시 카메라 화면, 거울, TV 스크린-속-스크린 같은 걸 만듭니다 — 다만 매 프레임 씬 하나를 통째로 더 그리는 것이므로 드로우콜이 대략 두 배가 된다는 점은 감안해야 합니다.

언제 쓰나

포털·차원문 연출, 감시 카메라/TV 화면, 미니맵처럼 "다른 시점을 텍스처로 보여주는" UI에.