baesw.

BLOG / / / 22 MIN READ

문을 지나면 1/8이 되는 집

부엌 문을 지나면 아일랜드 위 모형 집 안에 서 있게 되는 공간을 컷 없이 걸어서 지나기. 지도와 닮음변환, 홀로노미, 화면 좌표 보존, 경사 근접 평면의 특이점, 선분 교차를 따라 「문 너머의 방」을 처음부터 다시 만들어 봅니다.

눈 쌓인 중정의 담장 너머로 거대한 부엌 찬장과 머그잔, 선반이 보이는 장면. 중정 전체가 부엌 아일랜드 위의 축소 모형이다.

처음 들어온 집에서, 알고 있던 방을 만납니다.

오후의 햇빛이 드는 방에서 북쪽 문을 열면 눈 내린 중정이 나옵니다. 중정을 가로질러 건너편 문으로 들어가면 세이지색 찬장이 있는 부엌이고, 부엌 서쪽 문을 지나면 다시 처음 그 오후의 방입니다. 그런데 창밖을 내다보면 창문 너머에 방금 지나온 부엌이 산처럼 크게 서 있습니다. 지금 서 있는 방은 부엌 아일랜드 위에 놓인 1:8 축소 모형이기 때문입니다. 모형 집의 동쪽 문으로 나가면 다시 원래 크기의 오후의 방으로 돌아옵니다.

이 산책에는 카메라 컷도, 암전도, 시야각 변화도 없습니다. 관객은 걸어서 문을 지날 뿐이고, 문턱의 앞과 뒤 화면은 눈에 띄는 단절 없이 이어집니다.

플레이그라운드의 「문 너머의 방」은 이런 불가능한 집을 브라우저 안에 지은 작품입니다. 이 글은 두 편 가운데 첫 편으로, 공간을 꿰매는 수학과 그것을 화면에 그리는 방법을 다룹니다. 사진처럼 보이게 하는 조명과 재질 이야기는 다음 편으로 미룹니다.

  1. 하나의 3D 공간에 담을 수 없는 집 — 지도와 닮음변환
  2. 한 바퀴 돌았더니 세계가 90° 돌아 있다 — 홀로노미
  3. 문 너머를 그리는 카메라 — 화면 좌표가 보존되는 이유
  4. 문턱에서 무너진 투영 — 경사 근접 평면의 특이점
  5. 문을 건넌다는 것 — 선분 교차와 1:8의 몸
  6. 재귀의 비용 — 조상의 화면을 물려받기

그림의 수치는 대부분 작품의 실제 코드에서 나옵니다. 위젯은 작품의 좌표 함수(spaces.ts)를 그대로 불러 이 페이지에서 계산합니다.

오후 햇빛이 드는 방. 열린 북쪽 문 너머로 눈 쌓인 중정이 보인다.
FIG. 0출발점인 오후의 방. 열린 문 너머의 눈 쌓인 중정은 이 방과 다른 좌표계에 있는 별개의 장면이다. 문틀 안에 보이는 것은 그 장면을 다른 카메라로 그린 영상이다.

네 개의 방, 네 장의 지도

하나의 공간에는 담을 수 없다

이 집을 평범한 3D 장면 하나로 만들 수 있을까요? 시도해 보면 금방 막힙니다. 부엌의 서쪽 문 너머에는 오후의 방이 원래 크기로 있어야 합니다. 그런데 바로 그 방이 부엌 아일랜드 위에 1/8 크기로도 놓여 있어야 합니다. 같은 자리가 두 크기로 동시에 존재할 수는 없습니다.

그래서 작품은 공간 전체를 하나의 좌표계로 만드는 일을 처음부터 포기합니다. 방마다 자기 좌표계를 따로 갖게 하고, 코드에서는 이 좌표계를 chart(지도)라고 부릅니다. 미분기하에서 굽은 면을 평평한 지도 여러 장으로 나눠 그리고 겹치는 가장자리의 대응 관계를 적어 두는 것과 같은 발상입니다. 지도 한 장 한 장은 완벽하게 평범한 유클리드 공간이고, 이상한 일은 지도와 지도를 잇는 문에서만 일어납니다.

지도장소크기바닥 높이
A오후의 방7.2 × 6.8 m0
B눈 오는 중정10 × 10 m0
C오후의 부엌7.6 × 6.8 m0
D다시, 오후의 방0.9 × 0.85 m (A의 1/8)0.96 m (아일랜드 상판)

D는 A를 찍은 화면이 아닙니다. A의 건축과 가구를 정확히 1/8로 다시 만들어 C의 아일랜드 위에 실제로 올려놓은 모형입니다. 그래서 C와 D는 같은 Three.js 장면을 공유합니다. 부엌에서 걸어가 모형을 들여다볼 수도 있고, 부엌 서쪽 문을 지나 그 모형 안으로 들어갈 수도 있습니다.

부엌 서쪽 문틀 너머로 의자와 책상이 있는 사람 크기의 방이 보인다

C의 서쪽 문 너머 — D가 원래 크기로 보인다

부엌 아일랜드 위에 놓인 작은 집 모형과 중정 모형

같은 부엌의 아일랜드 위 — 실제 D

FIG. 1같은 D를 두 번 보기. 왼쪽은 부엌(C)의 서쪽 문 앞에서 본 D로, 문틀 안의 영상은 사람 크기의 방처럼 보인다. 오른쪽은 같은 부엌의 아일랜드 위에 실제로 놓인 D다. 그 옆의 회색 상자는 중정(B)의 1:8 모형이다.

문 하나, 닮음변환 하나

문은 두 지도 사이의 대응 규칙입니다. 문마다 문턱 가운데를 원점으로 하는 작은 좌표계를 둡니다. +Y는 위쪽이고, +Z는 방 안쪽입니다. 출발 문의 좌표계를 , 도착 문의 좌표계를 , 두 문의 크기 비를 라 하면, 출발 지도의 점이 도착 지도로 옮겨 가는 규칙은 하나의 식입니다.

오른쪽부터 읽습니다. 먼저 출발 문의 좌표로 바꾸고, Y축 둘레로 반 바퀴 돌리고, 크기를 맞춘 다음, 도착 문의 좌표로 내보냅니다. 반 바퀴 회전이 필요한 이유는 출발 방의 안쪽(+Z)으로 걸어 나간 사람이 도착 방에서도 안쪽(+Z)으로 들어와야 하기 때문입니다. Z만 뒤집으면 좌우가 바뀐 거울 세계가 되므로, X도 함께 뒤집습니다. X와 Z를 함께 뒤집는 것이 곧 Y축 반 바퀴 회전입니다.

spaces.ts — 점 하나를 문 너머로
/** T = destination × uniform scale × Ry(pi) × inverse(source). */
export function mapPoint(p: Vec3, source: Door, dest = doorById(source.to)): Vec3 {
  const q = toLocal(p, source), ratio = dest.height / source.height;
  return fromLocal({ x: -q.x * ratio, y: q.y * ratio, z: -q.z * ratio }, dest);
}

이 변환은 회전과 평행이동과 균일한 확대·축소만으로 이루어진 닮음변환입니다. 각도와 길이의 비율은 지키고 크기만 바꿉니다. 작품에서 문으로 이어진 짝은 네 쌍입니다.

출발 문도착 문크기 비
A 북쪽 (1.45 × 2.55 m)B 남쪽1
B 북쪽C 남쪽1
C 서쪽D 북쪽 (1.45/8 × 2.55/8 m)1/8
D 동쪽 (1.4/8 × 2.55/8 m)A 동쪽8

반대 방향은 따로 정의하지 않고 위 변환의 역변환을 씁니다. 그래서 D에서 C로 나가는 점은 C에서 D로 들어올 때의 자리로 정확히 돌아옵니다. 작품의 단위 검사가 이 왕복을 점, 시선 방향, 크기 비율 각각에 대해 확인합니다.

● INTERACTIVE네 장의 지도 위를 걷기
보는 방식
지금 있는 지도
A
몸의 배율
1
눈높이 (지도 좌표)
1.6200 m
카메라 near
12.0 mm
지나온 문
0개
FIG. 2네 방을 각자의 지도로 그렸다. 같은 색 문이 서로 이어진 짝이다. 관객(보라색 화살표)이 C의 서쪽 문을 지나면 D의 지도로 옮겨 가는데, D의 칸은 8배 확대해서 그렸으므로 왼쪽 아래 막대가 1 m가 아니라 10 cm다. 몸의 배율, 눈높이, 카메라 near가 한꺼번에 1/8이 되는 것을 확인할 수 있다.

여기서 작은 설계 하나가 숫자 오차를 막습니다. 문을 지날 때 몸의 크기에 비율을 곱해 나가면, 1/8과 8을 반복해서 곱하는 사이에 부동소수점 오차가 조금씩 쌓입니다. 작품은 몸의 크기를 곱셈으로 들고 다니지 않습니다. 지금 있는 지도의 이름에서 절대값을 다시 읽습니다(bodyScale = ROOM_SPECS[room].scale). A에 돌아오면 몸의 크기는 근사적으로 1이 아니라 정확히 1입니다.

한 바퀴 돌면 90°

네 변환을 곱해 보기

A에서 출발해 네 문을 차례로 지나 A로 돌아왔습니다. 이 한 바퀴를 변환으로 쓰면 네 닮음변환의 곱입니다. 크기 비는 이니, 몸은 원래 크기로 돌아옵니다. 그러면 네 변환의 곱은 항등변환일까요?

작품의 mapPoint로 직접 곱해 보면 그렇지 않습니다.

크기는 그대로지만 90° 회전입니다. 문들은 각자 완벽하게 앞뒤를 맞춰 이어졌는데, 네 개를 이어 붙인 결과는 출발했던 좌표계에서 90° 돌아간 좌표계입니다. 이렇게 한 바퀴를 돌았을 때 남는 변환을 기하학에서는 홀로노미(holonomy) 라고 부릅니다.

관객이 이 회전을 느끼지 못하는 이유는 간단합니다. A에 돌아왔을 때 관객이 쓰는 것은 다시 A의 지도이기 때문입니다. 회전은 지도를 이어 붙이는 방식에만 숨어 있습니다. 이 회전을 눈으로 보려면, 지나온 방들을 모두 처음 A의 좌표로 옮겨 한 장으로 펼쳐 보면 됩니다. 이런 그림을 전개 사상(developing map) 이라고 합니다.

● INTERACTIVE한 장으로 펼친 집
보는 방식
돌아오는 문 (D → A) 배율
한 바퀴의 회전
+90.0°
한 바퀴의 배율
×1
고정점 (A 좌표)
(-8.65, -0.80)
제자리로 돌아오는 바퀴 수
4바퀴
FIG. 3B, C, D와 돌아온 A를 모두 처음 A의 좌표로 옮겨 이어 붙였다. 한 바퀴를 돌아온 A(흐린 사본)는 원래 A에서 90° 돌아간 자리에 놓인다. 네 바퀴를 펼치면 네 번째 사본이 처음 A와 정확히 겹친다. 돌아오는 문의 배율을 1:16으로 바꾸면 사본이 바퀴마다 절반으로 줄어들며 고정점을 향해 소용돌이친다.

원뿔 위를 걷는 것과 같다

펼친 그림을 보면 이 회전의 정체를 알 수 있습니다. 홀로노미 는 A 좌표로 인 한 점을 중심으로 도는 90° 회전입니다. 이 점은 네 방 중 어디에도 속하지 않습니다. 산책 경로가 둘러싸는 '구멍' 안에 있습니다. 펼친 그림 위에서 관객의 경로는 이 점 둘레를 정확히 90°만 돌고 출발점의 사본에 도착합니다.

평범한 집에서 방 네 개를 돌아 제자리로 오려면, 꺾은 각을 모두 더했을 때 360°가 되어야 합니다. 이 집에서는 90°만 꺾어도 돌아옵니다. 부채꼴 종이를 말아 원뿔을 만들면 이런 일이 생깁니다. 원 전체(360°)가 아닌 90° 부채꼴로 원뿔을 만들면, 원뿔 겉면은 어디서나 평평하지만 꼭짓점을 한 바퀴 도는 데는 90°면 충분합니다. 가우스–보네 정리로 말하면, 이 집의 구멍 한가운데에는 만큼의 곡률이 한 점에 몰려 있습니다. 방 안 어디서도 휘어짐을 느낄 수 없지만, 구멍을 둘러싼 고리에는 그 곡률이 기록되어 있습니다.

90°를 네 번 돌면 360°이므로 네 바퀴를 돌면 홀로노미가 항등이 됩니다. FIG. 3에서 바퀴 수를 4로 올리면 네 번째 사본이 처음의 A와 정확히 겹치는 것을 볼 수 있습니다.

문만 공간을 잇는 것은 아닙니다. A의 남쪽 작은 창은 시선 전용 연결로 B의 서쪽 벽 높은 창과 이어져 있습니다. 두 창의 크기와 방향은 맞지만 높이가 1.03 m 어긋납니다. 그래서 A의 북쪽 문으로 B에 나가 그 높은 창으로 A를 들여다보면, 같은 방을 바닥에서 59 cm 높이의 눈으로 올려다보게 됩니다. 반대로 A의 창으로 B를 내다보면 2.65 m 높이에서 중정을 내려다봅니다. 이 고리의 홀로노미에는 수직 이동이 들어 있는 셈입니다. 관객은 이 창을 걸어서 지날 수 없고 바라볼 수만 있습니다. 다만 렌더러에게는 문과 똑같은 닮음변환입니다.

문 너머를 그리는 카메라

같은 변환을 카메라에

문틀 안의 영상은 이렇게 만듭니다. 관객의 카메라에 그 문의 변환 를 그대로 적용해 도착 지도 안에 가상 카메라를 세우고, 그 카메라로 도착 장면을 그린 다음, 결과를 문 모양의 면에 붙입니다. 관객이 문 옆으로 비켜서면 가상 카메라도 비켜서므로 문 너머의 시차가 자연스럽게 생깁니다.

portals.ts — viewCamera
const ratio = dest.height / source.height;
const viewScale = sourceScale * ratio;
const v = new THREE.PerspectiveCamera(camera.fov, camera.aspect, .012 * viewScale, 160);
// 위치: 출발 문 기준 좌표로 → 반 바퀴 → 배율 → 도착 문 기준
point.set(-point.x, point.y, -point.z).multiplyScalar(ratio).applyQuaternion(destination);
// 회전: destination × Ry(π) × inverse(source) × camera
v.quaternion.copy(destination.clone().multiply(halfTurn).multiply(inverseSource).multiply(camera.quaternion));

시야각은 그대로 둡니다. 크기가 바뀌는 문에서도 마찬가지입니다. 1:8 문을 볼 때 가상 카메라는 1/8 크기의 몸에 달린 눈이 되고, 카메라의 near도 함께 1/8이 됩니다(.012 * viewScale). 이 배율은 재귀의 다음 단계로도 이어집니다. 모형 창문 너머로 원래 크기의 부엌을 볼 때, 그 부엌의 문 너머를 그리는 카메라는 다시 1/8 크기의 눈에서 출발해야 합니다. 그래서 배율을 방 이름에서 다시 읽지 않고 camera.userData.viewScale로 넘겨줍니다.

화면 좌표가 보존된다

이 방식에는 렌더링을 아주 단순하게 만드는 성질이 하나 있습니다. 닮음변환으로 옮긴 카메라는 옮긴 장면을 원래와 똑같은 화면 좌표에 그립니다.

이유는 원근 투영의 모양에 있습니다. 카메라 좌표계에서 점 는 화면의 에 맺힙니다(시야각에서 오는 상수는 생략합니다). 카메라와 장면을 함께 회전하고 이동하면 카메라 좌표는 변하지 않습니다. 둘을 함께 배 하면 카메라 좌표가 가 되는데, 나눗셈에서 가 약분됩니다. 그래서 문 너머의 어떤 점이 관객 화면의 어디에 보여야 하는지 알면, 가상 카메라의 화면에서도 그 점은 정확히 같은 자리에 있습니다.

덕분에 문틀 면에 영상을 붙이는 셰이더는 문의 UV를 쓰지 않습니다. 지금 그리는 조각(fragment)의 화면 좌표로 가상 카메라의 영상을 읽습니다. 비스듬히 봐도 원근이 어긋나지 않습니다.

portals.ts — 문틀 면의 fragment shader
vec2 uvScreen = vScreen.xy / vScreen.w * .5 + .5;      // 이 조각의 화면 좌표
vec2 uvTexture = clamp(uvScreen * viewRect.xy + viewOffset, viewCrop.xy, viewCrop.zw);
gl_FragColor = vec4(hasView ? texture2D(viewTexture, uvTexture).rgb : recess, 1.0);

viewRect와 viewOffset은 가상 카메라의 영상이 렌더 타깃의 일부만 쓸 때 그 위치를 알려 주는 값입니다. clamp는 이웃한 다른 문의 영상이 쌍선형 필터에 섞이지 않도록 반 텍셀 안쪽에서 멈춥니다.

출구 평면 앞은 지운다

가상 카메라는 대개 도착 방의 벽 바깥에 섭니다. A의 관객이 북쪽 문에서 2 m 떨어져 있으면, B의 가상 카메라는 B의 남쪽 벽에서 2 m 바깥에 있습니다. D는 사정이 더 나쁩니다. D와 C는 같은 장면이므로, D를 보는 가상 카메라는 부엌 공기 속에 떠서 모형의 바깥벽과 아일랜드 위 소품 너머를 봅니다. 그대로 그리면 문틀 안에 모형의 벽이나 부엌 소품이 찍힙니다.

그래서 도착 문의 평면보다 카메라 쪽에 있는 것은 모두 지워야 합니다. 이 평면을 출구 평면이라 부르겠습니다.

● INTERACTIVE문 너머의 가상 카메라
문
문까지 거리
1.90 m
가상 카메라까지 거리
1.90 m
화면 속 문의 범위 (NDC)
-0.407 ~ 0.315
두 화면의 차이
< 10⁻¹²

왼쪽 그림을 끌어 눈의 위치를 옮겨 보세요. 아래 두 줄은 각 카메라의 화면을 가로로 자른 것입니다. 노란 세로선이 문틀, 칠한 칸이 그 문을 지나는 시선입니다.

FIG. 4왼쪽에서 눈을 끌어 옮기면 오른쪽 가상 카메라가 같은 변환으로 따라간다. 아래 두 줄은 각 카메라의 화면을 가로로 자른 것이고, 노란 선은 문틀이 맺히는 자리다. 두 화면의 문 범위는 부동소수점 오차 안에서 같다. C → D를 고르면 가상 카메라가 1/8 크기가 된다. '출구 평면으로 자르기'를 끄면 출구 평면 앞의 물체(붉은 상자)가 문 너머 영상의 일부를 가린다.

문턱에서 무너진 투영

교과서적인 해법

출구 평면 앞을 지우는 표준 기법이 있습니다. Eric Lengyel이 2004년에 정리한 경사 근접 평면(oblique near-plane clipping) 입니다. 투영 행렬의 근접 평면은 원래 카메라와 마주 보는 평면인데, 이 평면을 출구 평면으로 기울여 바꿔 끼웁니다. 그러면 GPU의 클리핑 하드웨어가 출구 평면 앞을 공짜로 잘라 줍니다. Three.js의 Reflector(거울)도 이 방법을 쓰고, 포털 렌더링을 설명하는 자료에서도 가장 먼저 등장하는 기법입니다.

작품의 첫 구현도 이 방법을 썼습니다. 문 앞에서 볼 때는 완벽했습니다. 문제는 관객이 실제로 문을 지나가는 순간에 생겼습니다.

눈이 평면에 닿을 때

관객이 문턱에 서면 관객의 눈은 출발 문의 평면 위에 있고, 가상 카메라의 눈은 출구 평면 위에 있습니다. 근접 평면이 눈을 지나게 되는 것입니다. 원근 투영은 근접 평면까지의 거리로 깊이를 나눠 0~1에 펼칩니다. 그 거리가 0이 되면 모든 깊이가 한 값으로 접힙니다.

작품의 실제 portalCamera를 문턱에 세워 검사해 보니, 경사 투영 행렬의 행렬식은 약 까지 줄었고, 역행렬의 성분은 약 까지 커졌습니다. 같은 시선 위의 가까운 점과 먼 점이 거의 같은 깊이를 받았습니다. 평면을 아주 조금 옮겨 수학적 특이점만 피해 봤지만, 이번에는 실제 WebGL 화면의 바닥과 나무가 닿는 곳에서 깊이 충돌(z-fighting)이 넓게 보였습니다.

● INTERACTIVE문턱에서 무너지는 깊이
1 cm10 cm1 m10 m00.51눈에서 시선 방향으로 떨어진 거리 (로그 눈금)깊이 버퍼 값바닥 2 m벽 6 m일반 투영 (near 12 mm) + 클리핑 평면경사 근접 평면 (Lengyel)
경사 투영 행렬의 행렬식
-1.35e+0
2 m ~ 6 m 깊이 단계 · 경사
2,067,697
2 m ~ 6 m 깊이 단계 · 일반
67,113
FIG. 5시선 방향으로 떨어진 거리에 따라 깊이 버퍼에 기록되는 값. 경사 근접 평면(붉은 선)은 눈이 문 평면에 다가갈수록 곡선 전체가 한 값으로 납작해진다. 24비트 깊이 버퍼에서 2 m의 바닥과 6 m의 벽 사이에 남는 단계 수는 1 cm에서 약 4만, 1 µm에서 4, 1 nm에서 0이다. 작품이 고른 일반 투영(청록색 선)은 눈의 위치와 상관없이 같은 곡선을 유지한다.

문턱은 이 작품에서 가장 중요한 순간입니다. 관객이 문을 지나는 그 프레임에 깊이 정밀도가 사라지면, 화면은 바로 그 순간에 깨집니다. 작품은 문턱에서만 쓰는 예외 처리를 두지 않고 방법 자체를 바꿨습니다.

일반 투영 + 세계 좌표 클리핑 평면

최종 구현은 가상 카메라에 평범한 원근 투영을 그대로 쓰고, 출구 평면은 투영 행렬이 아니라 fragment shader에서 처리합니다. Three.js의 renderer.clippingPlanes에 세계 좌표의 출구 평면을 넘기면, 재질 셰이더가 그 평면 뒤쪽 조각을 버립니다. 깊이는 원래의 near와 far 사이에서 정상적으로 펼쳐집니다.

portals.ts — viewCamera (계속)
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(destination);
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, destCenter);
// Three.js clips the negative half-space of a global plane in the material
// shader. Unlike moving the near plane this retains normal depth precision.
v.userData.exitPlane = plane;

이렇게 바꾸고 나서도 문턱에는 처리할 일이 두 가지 더 남았습니다.

첫째, 눈이 문틀 면보다 near만큼 가까워질 때입니다. 관객 카메라의 near는 12 mm입니다. 문을 지나는 순간 문틀 면이 12 mm 안쪽으로 들어오면, 이번에는 관객 카메라의 근접 평면이 문틀 면을 잘라 냅니다. 화면에 순간적으로 구멍이 뚫리는 것입니다. 그래서 문 가까이에서는 정점 셰이더가 문틀 면의 깊이만 근접 평면으로 끌어당기고, X·Y·W는 그대로 둡니다. 화면을 덮는 모양은 바뀌지 않습니다. 실제 깊이는 fragment shader가 gl_FragDepth로 다시 써서, 문틀과 벽이 영상을 올바르게 가리게 합니다.

portals.ts — 문틀 면의 vertex shader
vScreen = gl_Position;
// The aperture must still cover the screen when the eye is closer than near.
// Keep X/Y/W unchanged: only its depth is pulled onto the near plane.
if (nearAperture) gl_Position.z = 0.0;

둘째, 눈이 문 평면 위에 정확히 놓일 때입니다. 이때 문틀 면의 정점들은 클립 좌표의 가 0이 되어 투영 자체가 정의되지 않습니다. 작품은 문틀 면을 문 높이의 0.001%(2.55 m 문에서 약 26 µm)만큼 뒤로 밉니다. 관객의 좌표와 통과 판정에 쓰는 평면은 그대로 두고, 그리는 면만 옮깁니다.

문을 건넌다는 것

보는 것만으로는 건너지 않는다

관객이 언제 다른 지도로 넘어가는지도 정해야 합니다. 가장 쉬운 방법은 '카메라가 문에 충분히 가까우면 순간이동'이지만, 그러면 문 앞에서 뒤돌아보거나 문틀을 스칠 때 엉뚱하게 넘어갑니다. 작품의 규칙은 하나입니다. 이전 위치와 다음 위치를 잇는 선분이 문 평면을 앞에서 뒤로 지나고, 그 교차점이 몸의 반지름만큼 줄인 개구부 안에 있을 때만 건넌다.

spaces.ts — crossing
/** A view alone cannot cross. Only a swept front-to-back segment within the
 * clear aperture changes charts; no time-based cooldown blocks a return. */
export function crossing(old: Vec3, next: Vec3, door: Door, radius: number): number | null {
  const a = toLocal(old, door), b = toLocal(next, door);
  const epsilon = door.height * 1e-10;
  if (!(a.z >= -epsilon && b.z < -epsilon)) return null;
  const t = Math.max(0, a.z / (a.z - b.z)), x = a.x + (b.x - a.x) * t, y = a.y + (b.y - a.y) * t;
  return Math.abs(x) <= door.width / 2 - radius + radius * 1e-6 && y > radius && y < door.height - radius ? t : null;
}

몇 가지 세부가 이 짧은 함수를 지탱합니다.

  • 허용 오차는 문 크기에 비례합니다. 회전각이 π인 문에서는 가 정확히 0이 아니라 같은 값이 나오고, 그 부호 때문에 경계 위의 점이 앞인지 뒤인지가 흔들렸습니다. 고정된 오차값은 1/8 문에서 8배 커지는 셈이므로, 오차를 door.height에 곱해 씁니다.
  • 한 프레임을 잘게 나눕니다. 프레임이 느려 한 번에 많이 움직여도 가구나 문을 뚫고 지나가지 않도록, 이동을 몸 크기에 비례하는 6 cm 단위의 작은 걸음으로 나눠 적분합니다.
  • 문틀은 둥근 몸과 유한한 벽 조각으로 계산합니다. 처음에는 '문 근처면 예외, 아니면 방 전체 경계로 밀어내기'라는 두 규칙을 오갔는데, 문설주를 스치는 순간 규칙이 바뀌면서 몸이 17 cm 뒤로 튀었습니다. 지금은 벽을 문 구멍이 뚫린 선분들로 만들고, 둥근 몸이 늘 같은 규칙으로 밀려납니다. 몸이 아직 문설주에 닿아 있으면 문턱에서 멈추고, 지도를 바꾸지 않습니다.
  • 되돌아오기를 막지 않습니다. 문을 지나자마자 뒤로 한 걸음 물러서면 바로 원래 방으로 돌아갑니다. 시간 기반의 대기(cooldown)는 두지 않습니다.

문을 건너는 걸음은 원래 지도의 '출발점 → 문턱'과 새 지도의 '문턱 → 도착점'이라는 두 구간으로 나눠 기록합니다. 그래서 눈밭의 발자국이 좌표가 뛰는 거리를 한 걸음으로 세지 않습니다.

끊기지 않았다는 것을 어떻게 아는가

작품은 매끄러움을 눈대중에 맡기지 않습니다. 개발용 탐침으로 여덟 방향의 문 앞에 관객을 세우고, 문에서 0.2 mm 앞(모형에서는 0.025 mm), 정확한 경계, 0.2 mm 뒤의 실제 WebGL 화면을 저장해 비교합니다. 세 가지 화면비(1440×900, 1262×624, 390×844)에서 문마다 다섯 가지 시선으로 찍으니 모두 120개 사례입니다.

비교RGB 평균 절대 차이 (최댓값)
경계 직전 ↔ 정확한 경계0.238 / 255 미만
경계 직전 ↔ 통과 직후0.678 / 255 미만

이 수치로 화질 전체를 증명할 수는 없습니다. 검사의 목적은 작은 이동에서 생기는 검은 프레임, 위치 도약, 노출 단절을 잡아내는 것입니다. 실제로 이 검사가 부엌에서 중정으로 넘어가는 경계의 문제를 하나 찾아냈습니다. 지붕 연결의 재귀 말단이 프레임마다 먼 하늘과 평평한 문으로 번갈아 바뀌고 있었습니다. 이 문제를 고친 뒤 120개 모두를 다시 통과했습니다.

문턱 직전, 문틀 안 영상으로 보이는 눈 쌓인 중정

A에서 · 문 너머의 영상

문턱 직후, 직접 그린 눈 쌓인 중정

B에서 · 직접 그린 장면

FIG. 6A의 북쪽 문 0.2 mm 앞(왼쪽)과 B의 남쪽 문 0.2 mm 뒤(오른쪽). 왼쪽은 A의 장면 위에 가상 카메라의 영상을 합성한 화면이고, 오른쪽은 B의 장면을 직접 그린 화면이다. 남는 차이는 대부분 계속 내리는 눈 입자에서 온다.

1/8의 몸

D에 들어가면 관객의 몸 전체가 1/8이 됩니다. 눈높이는 1.62 m에서 20.25 cm가 되고(상판 높이 0.96 m를 더해 지도 좌표로 1.1625 m), 걸음 폭, 충돌 반지름(17 cm → 2.1 cm), 카메라 near(12 mm → 1.5 mm)도 함께 줄어듭니다. 시야각을 넓히거나 카메라를 끌어당겨 '작아진 느낌'을 흉내 내지 않습니다. 크기가 다르면 같은 방도 다르게 보여야 한다는 것을 기하가 스스로 보여 주게 둡니다.

D가 A의 화면 복제가 아니라 실제 복제라는 사실은 연출에도 쓰입니다. 부엌에서 모형 속 빨간 의자를 돌리면 원래 크기의 A에 있는 의자도 같이 돌아가 있습니다. 두 의자는 하나의 상태를 공유하는 메시 두 개입니다. 원본을 복제할 때 XYZ 오일러 각이 같은 회전을 다른 숫자 조합으로 표현하는 바람에 두 의자의 회전이 어긋나는 문제가 있었습니다. 지금은 YXZ 순서로 절대 회전값을 대입합니다. 의자를 1,000회 조작한 뒤에도 쿼터니언, 위치, 배율, 충돌 범위가 변하지 않는지도 검사합니다.

하늘도 책상 위였다

중정 B에도 1:8 모형이 하나 더 있습니다. 부엌 아일랜드에서 D 옆에 놓인 회색 상자가 바로 B입니다. 이 연결은 걸어서 지나는 문이 아닙니다. B의 하늘 전체(10 × 10 m의 지붕 면)와 모형 B의 윗면(1.25 × 1.25 m)을 잇는 수평 시선 연결입니다. 두 면의 좌표축을 B에서는 , 모형에서는 로 맞추면 일반식이 회전 없이 정확히 다음과 같이 줄어듭니다.

중정에서 하늘을 올려다보면 부엌 천장과 천창, 그리고 그 아래 매달린 거대한 초록 등갓이 보입니다. 부엌에서 아일랜드 위 모형을 내려다보면 같은 나무와 같은 눈과 방금 남긴 발자국이 보입니다.

중정에서 올려다본 하늘에 부엌 천창과 거대한 초록 등갓이 보인다

B에서 올려다본 하늘

부엌 아일랜드 위의 중정 모형을 위에서 내려다본 모습

C에서 내려다본 B

FIG. 7같은 연결을 양쪽에서. 왼쪽은 중정(B)에서 올려다본 하늘로, 부엌의 책상등이 8배 크기로 매달려 있다. 오른쪽은 부엌(C)에서 내려다본 중정 모형이다.

거대한 등의 빛은 포털을 건너오지 않습니다. 작품은 포털 너머로 빛을 전달하는 계산을 하지 않습니다. 대신 이 등 하나에 한해 B에 보조 점광원을 둡니다. 거리가 8배로 늘어난 장면에서 같은 밝기로 보이려면, 역제곱 법칙에 따라 광도를 로 올려야 합니다. 이 광원은 부엌의 등 스위치에 함께 연결되어 있습니다.

재귀의 비용

깊이 3에서 멈추기

문 너머에 또 문이 보이면 그 너머도 그려야 합니다. A에서 B의 문이 보이고, B의 문 너머로 C가 보이고, C의 문 너머로 D가 보입니다. 작품은 이 재귀를 세 단계에서 멈춥니다. 네 장소를 꺾인 동선으로 배치해서, 먼 재귀의 끝은 대개 다음 문 옆의 벽이나 열린 문짝에 가려집니다. 그래도 끝까지 보이는 극단적인 경우에는 검은 면 대신 어두운 회벽색으로 마감하고, 지붕 연결에서는 실제 하늘이나 모형의 기하를 남깁니다.

각 단계의 가상 카메라 영상은 렌더 타깃에 그립니다. 같은 부모 화면에 보이는 형제 문들은 부모가 합성될 때까지 살아 있어야 하므로 서로 다른 슬롯을 씁니다. 부모 합성이 끝나면 더 깊은 단계의 슬롯은 다시 씁니다. 슬롯은 깊이별로 미리 정해 두고(현재 구성에서 5/4/4개), 로딩 화면이 떠 있는 동안 전부 할당하고 한 번씩 지웁니다. 걷는 동안 문의 화면 면적이 바뀌면 viewport만 256 픽셀 단위로 조절합니다. 그래서 GPU 저장소를 새로 만드는 일은 창 크기가 바뀔 때 말고는 일어나지 않습니다.

조상의 화면을 물려받는다

여기서 앞에서 본 '화면 좌표 보존'이 다시 쓰입니다. 깊이 1의 가상 카메라가 그린 영상 가운데 관객에게 실제로 보이는 것은 문틀 안쪽의 사각형뿐입니다. 닮음 카메라는 화면 좌표를 보존하므로, 깊이 2에서 보이는 것도 그 사각형 안쪽뿐입니다. 그래서 각 단계는 부모가 넘겨준 화면 사각형을 받아 세 가지 일에 씁니다.

관객의 화면 (NDC −1 ~ 1)깊이 1 · 문 너머 B깊이 2 · B의 문 너머 C깊이 3에서 멈춤걸친 문: 겹친 부분만조상 문 밖:그리지 않음
깊이 0 — 관객
화면 전체를 그린다. 보이는 문마다 화면 속 사각형을 구한다.
깊이 1 — 가상 카메라
화면 전체 크기의 투영을 그대로 쓰되, 그 사각형 안만 래스터화(scissor)한다.
깊이 2
부모 사각형과 겹친 부분으로 범위가 다시 줄어든다.
깊이 3
더 들어가지 않고, 어두운 회벽색 또는 실제 하늘·모형으로 마감한다.

가상 카메라는 관객 카메라와 시야각·화면비가 같은 닮음 카메라이므로, 문 너머의 점은 관객 화면에서와 같은 NDC 좌표에 맺힌다. 그래서 조상의 사각형을 자식에게 그대로 물려주어 그리기와 객체 컬링의 범위로 쓸 수 있다.

FIG. 8재귀의 각 단계는 조상 문의 화면 사각형을 물려받는다. 그 밖에 있는 문은 아예 그리지 않고, 걸쳐 있는 문은 겹친 부분만 래스터화한다. 투영 행렬은 바꾸지 않으므로 문틀의 화면 좌표 샘플링은 그대로 맞는다.
  1. 가지치기. 자식 문의 실제 사각형 다각형을 클립 공간의 화면 경계와 출구 평면, 조상 사각형으로 차례로 잘라 냅니다. 남는 것이 없으면 그 가지는 그리지 않습니다. 처음에는 문의 경계 구(bounding sphere)로 판정했는데, 눈 높이에 걸친 옆문을 화면 전체로 오판해 쓸데없는 패스를 돌렸습니다.
  2. 래스터화 범위. 투영 행렬과 viewport는 그대로 두고, scissor로 사각형 안쪽만 지우고 그립니다. 쌍선형 샘플링이 사각형 바깥 텍셀을 조금 읽을 수 있어서 양쪽에 두 텍셀씩 여유를 둡니다.
  3. 객체 컬링. 사각형만큼 잘라 낸 절두체와 출구 평면으로 각 메시의 경계 상자를 검사해, 조상 문 밖에만 있는 가구는 제출하지 않습니다.

같은 PC의 기본 시점에서 컬링만 켜고 끈 결과는 이렇습니다.

항목컬링 없음컬링 적용
프레임당 드로우콜448248
제출 삼각형7,072,0643,156,523
CPU 제출 중앙값5.1 ms2.6 ms
GPU 중앙값6.31 ms3.42 ms

처음 문을 지날 때의 멈춤

재귀 렌더러의 비용 가운데 가장 눈에 띈 것은 GPU 시간이 아니라 처음 문을 지날 때의 멈춤이었습니다. 로딩 화면에서 미리 컴파일한 셰이더는 화면에 직접 그리는 조건이었습니다. 그런데 실제 공간은 선형 HDR 타깃에 클리핑 평면 0개 또는 1개를 켜고 그렸습니다. Three.js는 이 차이를 다른 셰이더 프로그램으로 취급하므로, 처음 문을 지나거나 뒤돌아볼 때 새 프로그램을 동기로 컴파일했습니다.

해결의 핵심은 모든 패스를 같은 조건으로 만드는 것입니다. 관객 카메라가 직접 그리는 루트 패스에도 장면 바깥 아주 먼 곳에 중립 클리핑 평면 하나를 켭니다(ROOM_VIEW_CLIP_PLANE). 재귀 패스는 그 평면을 출구 평면으로 바꿔 끼울 뿐이라 프로그램이 새로 생기지 않습니다. 준비 단계에서는 실제 HDR 타깃에 빈 장면을 한 번 그려 클리핑 상태를 맞추고, 실제 광원 조합마다 장면을 컴파일합니다.

지표 (데스크톱)수정 전수정 후
탐색 시작 → 화면 준비19,237.6 ms2,400.7 ms
오후의 방 → 중정 첫 통과의 최대 프레임 간격2,399.1 ms6.2 ms
중정 → 부엌 첫 통과의 최대 프레임 간격3,507.9 ms6.2 ms
전체 경로에서 만든 GL 프로그램15549

해상도가 높은 화면에서는 한 단계 더 나아갑니다. 깊이마다 첫 슬롯 하나를 공유 아틀라스로 쓰고, 보이는 문들의 화면 사각형을 겹치지 않게 그 안에 배치합니다. 투영은 그대로 둔 채 viewport만 옮겨 해당 부분만 그립니다. 덕분에 4K 화면에서도 세 단계의 문 영상이 부모 화면과 같은 픽셀 밀도를 유지합니다. 렌더 해상도는 GPU 타이머를 보며 210만, 419만, 829만 픽셀 단계로 오르내립니다.

얼마나 맞는가

검증 항목결과
순수 좌표·문턱·시선 연결·의자 상태 등 단위 검사150 / 150 통과
실제 WebGL 문턱 픽셀 비교 (3개 화면비 × 40)120 / 120 통과
준비 이후 셰이더 컴파일·GPU 저장소 할당이 없는지 (데스크톱 + 모바일 에뮬레이션)192 / 192 통과
문마다 정확히 한 번씩 통과16 / 16 통과
마우스 드래그와 W 키만으로 걷는 실제 산책A→B→C→D→A, 몸의 배율 1 → 1 → 1/8 → 1

마치며

이 집을 받치는 수학은 한 줄짜리 식 하나입니다. 문마다 닮음변환 을 하나씩 두고, 걷는 몸과 문 너머를 그리는 카메라가 같은 식을 씁니다. 몸은 그 식으로 문을 건너고, 카메라는 그 식으로 문 너머를 봅니다. 둘이 같은 식을 쓰기 때문에 문턱의 앞과 뒤가 같은 그림이 됩니다.

나머지는 그 식이 컴퓨터 안에서 무너지지 않게 지키는 일이었습니다. 평면이 눈을 지나는 순간 깊이를 잃는 투영 대신 일반 투영과 클리핑 평면을 썼고, 절대 배율을 지도 이름에서 다시 읽게 했고, 허용 오차를 문 크기에 비례하게 만들었습니다. 셰이더 프로그램은 미리 같은 조건으로 맞춰 두었습니다. 덕분에 지도 네 장을 이어 붙여 꼭짓점 각이 90°인 원뿔을 만들어도, 관객은 그저 문을 열고 걸어갈 뿐입니다.

작품을 열고 한 바퀴 걸어 보세요. 모형 집의 창가에 서면 방금 지나온 부엌이 창밖을 가득 채웁니다. 다음 글에서는 1:1의 방과 1:8의 모형이 같은 조명 데이터를 나눠 쓰는 방법과, 사진처럼 보이는 실내를 브라우저에서 그리기 위한 베이크와 재질 이야기를 이어 갑니다.

참고 자료

  • E. Lengyel, "Modifying the Projection Matrix to Perform Oblique Near-Plane Clipping" (2004) — 경사 근접 평면 기법의 원전. 이 작품에서는 검토 후 문턱 때문에 쓰지 않았다.
  • Three.js r181, examples/webgl_portal.html과 CameraUtils.frameCorners — 문 좌표계로 카메라를 옮기는 기본 구조.
  • S. Lague, Portals (GitHub, MIT) — 가시성 검사와 통과 판정의 분리, 근접 평면이 문 면을 뚫는 문제에 대한 두께 보정.
  • W. P. Thurston, Three-Dimensional Geometry and Topology (1997) — 기하 구조, 전개 사상, 홀로노미. 닮음 구조와 유클리드 구조의 구분.
  • M. do Carmo, Differential Geometry of Curves and Surfaces — 가우스–보네 정리와 원뿔 꼭짓점의 각 결손.