「모뉴먼트 밸리」에는 이런 장면이 있다. 공주 아이다가 길 끝에 서 있다. 길은 끊어져 있고, 건너편 발판은 아무리 봐도 닿을 수 없는 곳에 떠 있다. 그런데 화면 구석의 크랭크를 돌려 탑을 90도 돌리면, 어느 순간 끊어진 두 길이 이어져 보인다. 그리고 아이다는 그 길을 아무렇지 않게 걸어서 건넌다. 3D 공간에서 두 길은 여전히 만나지 않는데도 말이다.
이런 게임은 하나가 아니다. 2008년 PSP로 나온 「무한회랑」(echochrome)은 아예 착시만으로 게임 전체를 만들었고, 모바일 게임 「일루전 볼링」은 착시로 이어 붙인 길에 볼링공을 굴린다. 「Superliminal」은 원근감을 이용해 손에 든 체스 말을 건물만 한 크기로 불려버린다. 장르로 자리 잡았다고 해도 될 만큼 계보가 뚜렷하다.
처음 이런 게임을 봤을 때 드는 의문은 대체로 하나다. 물리 엔진이 어떻게 저걸 허용하지? 충돌 판정은? 좌표는? 그런데 구현을 파헤쳐 보면 답은 의외의 방향에 있다. 이 게임들은 3D 물리를 속이는 게 아니다. 판정이 필요한 순간에 3D 좌표를 버린다. 눈에 보이는 2D 화면, 그것을 유일한 진실로 삼는 것이다.
이 글에서는 그 원리를 바닥부터 파헤치고, 실제로 돌아가는 착시 퍼즐 게임을 만들어본다. 투영이라는 수학에서 출발해서, 펜로즈 삼각형을 3D로 세워보고, "보이는 대로 걷는" 판정 시스템과 길찾기를 붙여 미니 모뉴먼트 밸리를 완성할 것이다. 본문의 코드는 핵심만 추린 TypeScript이고, 글 중간중간의 데모는 그 코드를 실제로 구현한 같은 엔진을 브라우저에서 돌린 것이다. 수학은 삼각함수 몇 개가 전부다. 침착하게 따라오면 생각보다 간단하다.
착시는 버그가 아니라 사양이다
착시 게임을 만들려면 먼저 착시가 어디서 오는지 알아야 한다. 결론부터 말하면, 착시는 렌더링이라는 작업에 원래부터 뚫려 있는 구멍이다.
모니터는 2차원 평면이다. 3D 게임이라는 것은 결국 3차원 좌표를 2차원 화면 좌표로 바꾸는 작업이고, 이 변환을 투영Projection이라고 부른다. 게임에서 쓰는 투영은 크게 두 가지다.
원근 투영Perspective projection은 우리 눈이 세상을 보는 방식이다. 멀리 있는 것은 작게, 가까이 있는 것은 크게. 구현은 놀랄 만큼 단순해서, 좌표를 깊이로 나누면 끝이다.
// 원근 투영: 깊이로 나눈다. 멀수록 작게 보인다.
function perspectiveProject(
v: Vec3,
yaw: number,
pitch: number,
camDist: number,
) {
const t = viewTransform(v, yaw, pitch); // 카메라 방향으로 세계를 돌려 눕히고
const d = camDist - t.z; // 카메라에서 이 점까지의 깊이를 구해
const scale = camDist / d; // 깊이에 반비례하는 배율을 곱한다
return { x: t.x * scale, y: -t.y * scale };
}평행 투영Orthographic projection은 더 단순하다. 깊이로 나누지 않고 그냥 버린다.
// 평행 투영(직교 투영): 깊이를 화면 위치에 쓰지 않는다. 멀어도 작아지지 않는다.
function orthoProject(v: Vec3, yaw: number, pitch: number) {
const t = viewTransform(v, yaw, pitch);
return { x: t.x, y: -t.y, depth: -t.z }; // 깊이는 나중을 위해 챙겨만 둔다
}깊이를 버린다는 게 어떤 느낌인지 직접 보자. 아래 데모는 같은 큐브 다섯 개를 두 방식으로 나란히 투영한 것이다.
원근 쪽은 카메라를 돌리면 가까운 큐브와 먼 큐브의 크기가 계속 달라진다. 평행 투영 쪽은 아무리 돌려도 다섯 큐브가 전부 같은 크기다. 화면만 보고는 어느 큐브가 가까운지 알 방법이 없다.
여기서 중요한 사실 하나를 짚자. 투영은 다대일 함수다. 3차원 공간의 서로 다른 무수히 많은 점이 화면의 같은 픽셀 하나로 모인다. 화면의 한 점을 보고 "이건 3D의 어느 점이야"라고 되짚는 것은 원리적으로 불가능하다. 정보가 이미 사라졌기 때문이다. 그런데 우리 뇌는 사진 한 장, 화면 한 장을 보고도 곧잘 입체를 알아본다. 사라진 깊이 정보를 경험으로 지어내서 채우는 것이다. 착시란 바로 이 지어내는 과정을 역이용하는 일이다. 뇌가 그럴듯한 3D를 복원하도록 유도하되, 실제 3D는 전혀 다른 모양인 장면을 만들면 된다.1
착시 게임이 하나같이 평행 투영을 쓰는 이유도 여기 있다. 원근 투영은 깊이를 완전히 버리지 못한다. 나누기의 흔적, 즉 크기 변화가 남는다. 멀리 있는 것이 작게 보이는 순간 관객은 거리를 읽어낸다. 평행 투영은 깊이의 흔적을 남김없이 지우기 때문에 속이기가 훨씬 쉽다. 얼마나 차이가 나는지는 다음 장에서 숫자로 확인한다.
아이소메트릭: 게임이 사랑한 각도
평행 투영 중에서도 게임이 특히 사랑해온 각도가 있다. 카메라를 수평으로 45도 돌리고(코드의 yaw, 좌우 회전각), 아래로 약 35.26도 기울인(pitch, 내려다보는 각도) 각도. 이걸 아이소메트릭Isometric(등각 투영)이라고 부른다.2 35.26이라는 어정쩡한 숫자는 atan(1/√2)에서 온다. 이 각도에서 정확히 정육면체의 세 면이 같은 넓이로 보이고, x·y·z 세 축이 화면에서 서로 120도씩 벌어진다.
이 엔진의 좌표계로 실제 계산해보면, 아이소메트릭 카메라에서 각 축으로 한 칸 이동은 화면에서 이렇게 움직인다.
| 3D에서 한 칸 이동 | 화면 이동 (가로, 세로) |
|---|---|
| +x (오른쪽) | (+0.71, +0.41) — 오른쪽 아래 |
| +z (안쪽) | (−0.71, +0.41) — 왼쪽 아래 |
| +y (위) | (0, −0.82) — 위 |
세 줄을 더해보자. 가로는 0.71 − 0.71 + 0 = 0. 세로는 0.41 + 0.41 − 0.82 = 0. 즉 x, y, z로 한 칸씩 움직이면 화면에서는 제자리다. 지금은 사소한 산수처럼 보이는 이 성질이, 잠시 후 이 글 전체를 지탱하는 기둥이 된다.
하나의 각도에서만 존재하는 삼각형
착시 도형의 대명사는 펜로즈 삼각형이다. 세 개의 사각 기둥이 서로 직각으로 맞물려 삼각형을 이루는 그 도형. 어디를 봐도 국소적으로는 멀쩡한데, 전체를 따라가면 모순이 생긴다. 앞에 있던 기둥이 한 바퀴 돌아오면 뒤에 가 있다.
이 도형을 처음 그린 사람은 스웨덴의 화가 오스카르 로이테르스베르드다. 1934년, 학생 시절 라틴어 수업 낙서로 큐브를 늘어놓다가 이 형태를 발견했다고 한다. 그 뒤 1958년 수학자 로저 펜로즈가 아버지 라이오넬 펜로즈와 함께 심리학 저널에 이 도형을 발표하면서3 "펜로즈 삼각형"이라는 이름이 붙었다. 논문에서 그들은 이 도형을 "가장 순수한 형태의 불가능성"이라고 불렀다. 그리고 이 논문을 받아본 판화가 M. C. 에셔가 이 원리로 「폭포」(1961)를 그렸다. 물이 수평으로 흐르는 것처럼 보이는 수로를 따라가다 보면 어느새 폭포 꼭대기에 도착해 있는 그 그림 말이다.4
그런데 "불가능 도형"이라는 이름은 절반만 맞다. 종이 위에 그릴 수 있다는 것 자체가 증거다. 이 도형은 3차원 물체로는 불가능하지만, 2차원 그림으로는 아무 모순이 없다. 그렇다면 앞 장의 논리를 뒤집어서 이렇게 물을 수 있다. 화면에 펜로즈 삼각형이 보이게 하는 3D 물체를, 끊거나 비틀어서라도 만들 수 있지 않을까?
만들 수 있다. 실제로 호주 퍼스에는 높이 13.5미터짜리 펜로즈 삼각형 조형물이 서 있다. 물론 진짜로 맞물린 삼각형은 아니고, 끊어진 구조물이 특정 관람 지점에서만 삼각형으로 보이는 것이다. 우리도 같은 것을 큐브로 만들어보자. 준비물은 앞 장에서 챙겨둔 성질 하나다. 아이소메트릭에서 (1,1,1) 이동은 화면에서 제자리다.
// 큐브를 x축으로 n개, y축으로 n개, z축으로 n개 이어 붙인 ㄷ자 구조물.
function penroseCubes(n: number): Vec3[] {
const cubes: Vec3[] = [];
for (let i = 0; i <= n; i++) cubes.push(vec3(i, 0, 0)); // 바닥 팔: +x
for (let i = 1; i <= n; i++) cubes.push(vec3(n, i, 0)); // 기둥 팔: +y
for (let i = 1; i <= n; i++) cubes.push(vec3(n, n, i)); // 안쪽 팔: +z
return cubes;
}바닥을 따라 x로 n칸, 꺾어서 위로 n칸, 다시 꺾어서 화면 안쪽으로 n칸. ㄷ자로 꺾인 막대기일 뿐이고 어디에도 불가능한 부분이 없다. 시작점은 (0,0,0), 끝점은 (n,n,n)이다. 3D 공간에서 두 점은 공간 대각선만큼 떨어져 있다. 그런데 (n,n,n)은 (1,1,1)을 n번 더한 것이고, (1,1,1) 이동은 화면에서 제자리였다. 그러니 아이소메트릭 카메라로 보는 순간 끝점이 시작점 위에 정확히 겹쳐 보인다. 열린 ㄷ자가 화면에서 닫힌 삼각형이 되는 것이다.
직접 확인해보자. 일부러 어긋난 각도에서 시작하니, 구조물의 정체를 본 다음 각도를 맞춰보면 된다.
이 착시가 얼마나 아슬아슬한 균형 위에 서 있는지도 재봤다. 타일 하나가 48픽셀인 화면에서 n=4짜리 구조물을 놓고, 카메라를 정답 각도에서 조금씩 돌리며 이음새가 몇 픽셀 벌어지는지 측정한 결과다.
| 카메라 수평 각도(yaw) 어긋남 | 이음새 벌어짐 |
|---|---|
| 0° | 0.0px |
| 0.5° | 2.4px |
| 1° | 4.7px |
| 2° | 9.5px |
| 5° | 23.7px |
| 10° | 47.2px |
1도만 틀어져도 5픽셀 가까이 벌어진다. 10도면 타일 하나만큼 어긋나서 누가 봐도 끊어진 막대기다. 퍼스의 조형물이 바닥에 관람 지점을 박아두는 이유이고, 착시 게임이 카메라를 플레이어에게 자유롭게 내주지 않는 이유다. 위 데모에도 같은 조치가 들어 있다. 드래그로 1도를 맞추는 건 고문에 가까워서, 정답 근처에서 손을 놓으면 자석처럼 정확한 각도로 빨려 들어가게 해뒀다. 착시는 시점의 산물이라서, 시점을 지키는 것이 게임 디자인의 일부가 된다.
원근 투영으로 보면 어떻게 되는지도 재봤다. 시작점과 끝점은 카메라 시선 축 위에 나란히 놓이기 때문에 중심 위치 자체는 여전히 겹친다. 대신 두께가 배신한다. 가까운 팔과 먼 팔의 배율이 달라지는 것이다.
| 카메라 거리 | 이음새에서 두께 차이 |
|---|---|
| 12칸 | 2.37배 |
| 20칸 | 1.53배 |
| 40칸 | 1.21배 |
| 100칸 | 1.07배 |
카메라가 가까우면 이음새에서 두께가 2배 넘게 어긋나 착시가 바로 들통난다. 카메라를 뒤로 뺄수록 차이가 줄어드는데, 그 극한이 바로 평행 투영이다. 위 데모의 "원근 투영으로 보기"를 켜면 이 어긋남을 직접 볼 수 있다.
보이는 것이 곧 현실이다
여기까지는 눈으로 보는 착시였다. 이제 게임을 만들자. 캐릭터가 있고, 클릭하면 걸어가고, 착시로 이어진 길도 진짜 길처럼 건너는 게임. 문제는 판정이다. 화면에서 이어져 보이는 두 발판이 3D 좌표로는 축을 따라 예닐곱 칸씩 떨어져 있을 때, "걸어갈 수 있다"를 어떻게 판정할 것인가.
먼저 보통의 3D 게임이라면 어떻게 할지 보자. 캐릭터가 설 수 있는 칸을 노드로 삼고, 3D 좌표가 인접한 노드끼리 간선으로 잇는다. 그래프 이론에서 말하는 그 그래프다.
// 비교용: 화면을 보지 않고 3D 좌표로만 잇는 "정직한" 그래프
function buildWorldGraph(nodes: WalkNode[]): Map<string, string[]> {
const byKey = new Map(nodes.map((n) => [n.id, n])); // 좌표 → 노드 색인
const graph = new Map<string, string[]>();
for (const n of nodes) {
const edges: string[] = [];
for (const d of DIRS) {
// 동서남북 네 방향
const neighbor = keyOf(add(n.pos, d)); // 3D 좌표로 옆 칸에
if (byKey.has(neighbor)) edges.push(neighbor); // 노드가 실제로 있으면 잇는다
}
graph.set(n.id, edges);
}
return graph;
}이 그래프에서 착시 다리는 절대 이어지지 않는다. 3D 좌표로 5칸 떨어진 두 노드는 남남이니까. 착시 게임은 여기서 발상을 뒤집는다. 노드를 3D 좌표가 아니라 화면 좌표로 비교하는 것이다.
이 아이디어를 가장 급진적으로 밀어붙인 사람이 「무한회랑」을 만든 후지키 준이다. 그는 이 시스템에 OLE 좌표계Object Locative Environment라는 이름을 붙이고, 게임의 판정 규칙 전체를 "시점의 5법칙"으로 정리했다. 두 길이 이어져 보이면 이어진 것이고(이동), 구멍이 다른 물체에 가려 안 보이면 없는 것이며(부재), 떨어지는 캐릭터 뒤에 발판이 보이면 실제 깊이와 무관하게 그 위에 착지한다(착지). 요컨대 게임의 모든 규칙이 하나로 수렴한다. 보이는 것이 곧 현실이다. 이 중 이동의 법칙은 지금부터 구현할 것이고, 부재의 법칙은 글 후반에 미니 무한회랑을 만들며 직접 구현해본다.
이걸 코드로 옮기면 뭐가 될까. 거창한 것이 아니다. 그래프를 만들 때 "옆 칸" 판정을 화면 좌표로 하면 된다.
// 두 노드를 잇는 규칙은 단 하나다.
// "A의 이웃 칸이 있어야 할 화면 위치에, 어떤 노드 B가 보이면 연결한다."
function buildScreenGraph(
nodes: WalkNode[],
yaw: number,
pitch: number,
epsilon = 0.05,
) {
const graph = new Map<string, string[]>();
const projected = nodes.map((n) => orthoProject(n.pos, yaw, pitch)); // 전부 화면 좌표로
for (let i = 0; i < nodes.length; i++) {
const edges: string[] = [];
for (const d of DIRS) {
// A의 이웃 칸이 화면 어디에 보여야 하는지 계산하고,
const target = orthoProject(add(nodes[i].pos, d), yaw, pitch);
// 그 자리에 보이는 노드를 찾는다. 3D 거리는 따지지 않는다.
let found = -1;
for (let j = 0; j < nodes.length; j++) {
if (j === i) continue;
if (dist2d(projected[j], target) < epsilon) {
// 같은 자리에 여러 노드가 겹쳐 보이면 카메라에 가까운 쪽이 이긴다
if (found < 0 || projected[j].depth < projected[found].depth)
found = j;
}
}
if (found >= 0) edges.push(nodes[found].id);
}
graph.set(nodes[i].id, edges);
}
return graph;
}buildWorldGraph와 비교해보면 바뀐 것은 딱 하나, 이웃을 찾는 좌표계다. 그런데 이 그래프에는 아름다운 성질이 하나 있다. 진짜 이웃을 따로 처리할 필요가 없다는 것이다. 3D에서 실제로 붙어 있는 이웃 B = A + d는 투영해도 정확히 그 이웃 슬롯에 찍힌다. 투영이 선형 함수라서 그렇다.5 즉 정직한 연결은 이 규칙의 특수한 경우로 공짜로 딸려 오고, 거기에 더해서 우연히 그 슬롯에 겹쳐 보이는 남남까지 이어진다. 이 남남 간선이 바로 착시 다리다.
어떤 노드가 이웃 슬롯에 겹쳐 보일 수 있을까? 앞서 본 성질 그대로다. 아이소메트릭에서 (1,1,1) 오프셋은 화면에서 안 보이니까, 이웃 슬롯에서 (k,k,k)만큼 떨어진 모든 칸이 후보다. 엔진에 실제로 세 점을 넣어 투영해본 로그다.
(2,1,0) → 화면 (1.4142, -0.0000) depth -1.732
(3,2,1) → 화면 (1.4142, -0.0000) depth -3.464
(4,3,2) → 화면 (1.4142, 0.0000) depth -5.1963D에서 뚝뚝 떨어진 세 점이 화면에서는 소수점 넷째 자리까지 같은 곳에 찍힌다. 다른 것은 깊이뿐인데, 평행 투영은 깊이를 버린다. 아래 데모에서 이 판정이 실제로 일어나는 순간을 볼 수 있다. 카메라를 돌리다 45도 근처, 판정 반경 안에 들어서는 순간 그래프에 없던 간선이 태어난다.
사실 이 "화면 좌표로 판정한다"는 발상 자체는 낯선 것이 아니다. 웹 프론트엔드의 클릭 판정이 정확히 이 원리다. 마우스 클릭은 화면 좌표로 들어오고, 브라우저는 그 좌표에 그려진 요소를 찾아 이벤트를 준다. 요소의 데이터가 어디에 있든, DOM 트리 어디에 붙어 있든 상관없다. 착시 게임이 한 일은 히트 테스트에나 쓰던 화면 좌표 판정을 게임 세계의 물리 법칙으로 승격시킨 것뿐이다.
한 가지 실전적인 디테일도 짚어두자. 코드의 epsilon은 "같은 자리"의 판정 반경이다. 부동소수점 좌표가 정확히 일치하는 일은 드무니 어느 정도의 오차는 허용해야 하는데, 이 값을 얼마나 느슨하게 잡느냐가 게임의 성격을 바꾼다. 이 값이 일으키는 사고는 다음 장에서 실측으로 확인한다.
길찾기: 그래프가 되면 나머지는 공짜다
화면공간 그래프를 손에 넣었으니 이동은 다 끝난 것이나 다름없다. "클릭한 곳까지 걸어가라"는 그래프의 최단 경로 문제이고, 간선에 가중치가 없으니 너비 우선 탐색BFS이면 충분하다. 출발 노드에서 가까운 순서로 물결처럼 번져나가다 목적지를 만나면 왔던 길을 되짚는, 그래프 알고리즘의 기본기다.
function findPath(graph: Map<string, string[]>, from: string, to: string) {
const cameFrom = new Map([[from, from]]); // 각 노드에 "어디서 왔는지" 기록
const queue = [from];
while (queue.length > 0) {
const cur = queue.shift()!;
for (const next of graph.get(cur) ?? []) {
if (cameFrom.has(next)) continue; // 이미 방문했으면 통과
cameFrom.set(next, cur);
if (next === to) return reconstruct(cameFrom, from, to); // 도착: 경로 복원
queue.push(next);
}
}
return null; // 길이 없다
}주목할 점은 BFS가 착시에 대해 아무것도 모른다는 것이다. 착시 간선이라고 특별 취급하는 코드는 어디에도 없다. 착시라는 개념은 그래프를 만드는 쪽에 전부 녹아 있고, 그래프를 쓰는 쪽은 평범한 교과서 알고리즘이다. 관심사가 이렇게 갈라지면 확장도 쉬워진다. 나중에 이동 비용을 넣고 싶으면 BFS를 다익스트라로 갈아끼우면 되고, 착시 규칙을 바꾸고 싶으면 그래프 생성만 고치면 된다.
이 글의 마지막에 만들 게임 레벨(바닥 복도 + 공중 다리)을 두 그래프에 넣고 길을 찾아본 결과가 이렇다.
| 그래프 | 시작 → 목표 경로 |
|---|---|
| 월드 그래프 (3D 좌표 판정) | 경로 없음 — 어떤 회전 각도에서도 |
| 화면 그래프 (화면 좌표 판정) | 10칸 경로 존재 — 착시 간선 1개 경유 |
같은 블록, 같은 알고리즘인데 그래프를 만든 좌표계만으로 "갈 수 없는 곳"이 "갈 수 있는 곳"이 된다.
걷기 애니메이션에도 공짜 선물이 하나 있다. 착시 간선을 건널 때 캐릭터의 3D 좌표는 (4,1,0)에서 (6,3,3)으로 순간이동한다. 월드 좌표를 그냥 선형 보간하면, 즉 출발 좌표에서 도착 좌표까지 직선으로 일정하게 이동시키면, 3D에서는 캐릭터가 허공을 가로질러 대각선 4칸이 넘는 거리를 날아가는 셈이다. 끔찍한 연출 같지만 화면에서는 아무 문제가 없다. 투영이 선형이라 월드 좌표의 직선 보간은 화면에서도 직선 보간이고, 두 노드는 화면에서 딱 한 칸 이웃이다. 관객의 눈에는 그저 자연스러운 한 걸음으로 보인다. 속임수의 뒤처리까지 투영의 선형성이 대신해주는 것이다.
회전: 착시를 부수고 다시 만드는 스위치
모뉴먼트 밸리를 퍼즐 게임으로 만들어주는 것은 크랭크다. 구조물을 돌려서 착시를 만들고 부수는 것이 곧 퍼즐이니까. 회전 자체는 어렵지 않다. 회전 그룹에 속한 블록들을 피벗 중심으로 y축 회전시키면 된다.
// 그룹 회전을 반영한 블록의 실제 월드 좌표
function blockWorldPos(b: Block, groups, rotations): Vec3 {
if (!b.group) return vec3(b.x, b.y, b.z); // 회전과 무관한 블록
const g = groups[b.group];
const rel = sub(b, g.pivot); // 피벗 기준 상대 좌표로
return add(rotateY(rel, rotations[b.group]), g.pivot); // 돌려서 다시 제자리에
}어려운 것은 회전 그 자체가 아니라, 회전하는 동안 그래프를 어떻게 할 것인가다. 순진하게 생각하면 매 프레임 buildScreenGraph를 다시 돌리면 된다. 실제로 그렇게 해봤다. 크랭크를 0도에서 90도까지 0.25도 간격으로 돌리면서 간선 집합이 어떻게 변하는지 추적한 결과, 90도를 도는 동안 간선 집합이 30번 바뀌었다. 문제의 착시 간선은 약 7.3도 구간에서만 살아 있었다. 회전 중에 길이 생겼다 사라졌다를 반복하며 그래프가 요동친다는 뜻이다. 캐릭터가 걷는 도중에 발밑의 간선이 사라지기라도 하면 게임이 즉시 깨진다.
판정 반경 epsilon을 키우면 나아질까 싶어 이것도 재봤다.
| 판정 반경 ε (타일 단위) | 착시 간선 생존 구간 | 회전 90° 동안 간선 집합 변화 (0.25° 간격 측정) |
|---|---|---|
| 0.01 | 약 1.3° | 6회 |
| 0.05 | 약 7.3° | 30회 |
| 0.1 | 약 14.8° | 60회 |
| 0.2 | 약 30.3° | 122회 |
| 0.3 | 약 47.3° | 190회 |
반경을 키울수록 착시가 살아 있는 구간은 넓어지지만, 그만큼 "거의 겹쳐 보이는" 애매한 순간들이 늘어나 그래프는 더 자주 요동친다. 0.3쯤 되면 90도 도는 동안 간선 집합이 190번 바뀐다. 엄격하게 잡자니 착시가 눈 깜짝할 새 지나가고, 느슨하게 잡자니 판정이 널을 뛴다. 반경 조절만으로는 답이 없는 것이다.
모뉴먼트 밸리 팀이 찾은 답은 판정 시점을 옮기는 것이었다. 개발사 ustwo가 GDC와 여러 강연에서 밝힌 바에 따르면, 이들은 연결 판정을 구조물이 스냅 위치에 있을 때만 다시 계산했다. 크랭크가 90도의 배수 각도에 딱 맞아떨어진 순간에만 그래프를 갱신하고, 회전 중에는 이동을 잠그는 것이다. 여기에 더해 레벨 디자이너가 노드 연결을 직접 표시할 수 있게 해서, 자동 판정이 만드는 애매한 경우를 사람이 정리했다. 테크 디렉터 피터 패슐리는 이 과정을 한마디로 정리했는데, 불가능 도형을 다루는 비결은 결국 단순화라는 것이다. 연속적인 문제를 이산적인 문제로 바꾸는 순간, 요동치던 그래프는 "각 스냅 각도마다 하나씩 존재하는 잘 정의된 그래프 몇 개"로 줄어든다.
우리 게임도 같은 전략을 쓴다. 크랭크를 놓으면 가장 가까운 90도 배수로 스냅하고, 그 순간에만 그래프를 다시 만든다. 덤으로 골치 아픈 문제 하나도 같이 풀린다. 캐릭터가 회전하는 다리 위에 서 있으면 어떻게 될까? 캐릭터의 위치를 노드 좌표가 아니라 "딛고 있는 블록"으로 저장하면 끝이다. 블록이 돌면 캐릭터도 따라 돌고, 스냅이 끝나면 새 좌표에서 노드를 다시 찾는다.
그리기: 화가처럼 뒤에서부터
판정은 끝났고 이제 그리는 문제가 남았다. 블록이 수십 개면 어떤 것을 먼저 그려야 할까. 답은 화가가 유화를 그리는 순서와 같다. 먼 산을 먼저 칠하고 가까운 나무를 나중에 덧칠하는 것. 그래서 이름도 화가 알고리즘Painter's algorithm이다.
// 깊이가 큰(먼) 블록부터 그리면 가까운 블록이 자연스럽게 그 위를 덮는다
function paintersOrder(
positions: Vec3[],
yaw: number,
pitch: number,
): number[] {
const depths = positions.map(
(p) => orthoProject(centerOf(p), yaw, pitch).depth,
);
return positions.map((_, i) => i).sort((a, b) => depths[b] - depths[a]);
}투영할 때 화면 위치에는 쓰지 않고 챙겨만 뒀던 깊이를 여기서 주워 쓴다. 화면 좌표를 만들 때는 쓸모없던 depth가 그리는 순서를 정할 때는 유일한 기준이 된다.6 아이소메트릭 카메라에서 이 깊이는 좌표 합 x+y+z가 클수록 작아지는 값이다. 앞서 본 투영 로그의 depth(-1.732, -3.464, -5.196)가 그랬다. 즉 좌표 합이 큰 블록일수록 카메라에 가깝고, 나중에 그려진다.
이 정렬이 착시의 마지막 조각이다. 펜로즈 삼각형에서 시작 큐브 (0,0,0)과 끝 큐브 (4,4,4)는 같은 픽셀 자리를 두고 경쟁하는데, 좌표 합이 큰 끝 큐브가 카메라에 가까우므로 나중에 그려져 시작 큐브를 덮는다. 열린 ㄷ자의 두 끝이 자연스럽게 포개지며 삼각형이 닫히는 것이다. 누가 누구를 덮는지가 어긋나면 착시는 성립하지 않는다. 참고로 웹 개발자라면 이 알고리즘을 매일 쓰고 있다. SVG는 문서 뒤쪽 요소가 앞쪽을 덮고, CSS의 z-index는 말 그대로 화가 알고리즘의 정렬 키다. 다만 진짜 3D처럼 물체가 서로 얽히면 "누가 더 멀다"를 하나의 순서로 정할 수 없는 경우가 생기는데, 우리는 모든 블록이 단위 큐브라 이 함정을 비켜간다.7
조립: 미니 모뉴먼트 밸리
부품이 다 모였다. 투영, 화면공간 그래프, BFS, 회전 스냅, 화가 알고리즘. 이제 레벨 하나를 조립하자.
const GROUPS = { crank: { pivot: vec3(6, 2, 4) } }; // 크랭크로 도는 그룹
const BLOCKS: Block[] = [
// 바닥 복도: (0,0,0) ~ (4,0,0)
{ x: 0, y: 0, z: 0 },
{ x: 1, y: 0, z: 0 },
{ x: 2, y: 0, z: 0 },
{ x: 3, y: 0, z: 0 },
{ x: 4, y: 0, z: 0 },
// 공중 다리: 크랭크로 회전한다
{ x: 6, y: 2, z: 3, group: "crank" },
{ x: 6, y: 2, z: 4, group: "crank" },
{ x: 6, y: 2, z: 5, group: "crank" },
// 도착 발판
{ x: 6, y: 2, z: 6 },
{ x: 6, y: 2, z: 7 },
];
const START = vec3(0, 1, 0);
const GOAL = vec3(6, 3, 7);바닥 복도는 y=0에 깔려 있고, 다리와 도착 발판은 복도 끝에서 오른쪽으로 두 칸, 화면 안쪽으로 세 칸, 위로 두 칸 비껴난 허공에 떠 있다. 3D에서 이 둘은 영원히 만나지 않는다. 실제로 월드 그래프에서는 어느 크랭크 각도에서도 경로가 없다. 하지만 화면에서는 사정이 다르다. 복도 끝 노드 (4,1,0)의 +z 이웃 슬롯은 (4,1,1)이고, 다리 끝 노드 (6,3,3)은 거기서 (6,3,3) − (4,1,1) = (2,2,2), 정확히 (1,1,1)×2만큼 떨어져 있다. 화면에서는 보이지 않는 오프셋이다. 크랭크가 0도(다리가 z축 방향)일 때 이 정렬이 살아나 착시 간선이 생기고, 90도로 돌리면 다리가 x축 방향으로 누우면서 간선이 사라진다.
엔진의 판정 결과를 정리하면 이렇다.
크랭크 0°: 화면 그래프 경로 10칸 — 착시 간선 (4,1,0) ↔ (6,3,3) 경유
크랭크 90°: 화면 그래프 경로 없음
(월드 그래프: 모든 각도에서 경로 없음)게임이 완성됐다. 다이얼을 돌려 다리를 잇고, 블록을 클릭해 캐릭터를 목표 깃발까지 데려가보자. 참고로 클릭이 어느 노드를 고를지 정하는 것도 각 노드의 투영 좌표와 클릭 좌표를 비교하는 화면공간 판정이다. 이 게임에서는 걷기부터 입력까지 모든 판정이 한 좌표계에서 일어난다.
이 데모에 3D 물리 엔진은 한 줄도 없다. 있는 것은 투영 함수 하나, 그래프 생성기 하나, BFS 하나, 정렬 하나다. 캐릭터가 허공을 걸어 다리로 건너가는 순간에도 엔진은 아무 특수 처리를 하지 않는다. 화면 그래프에 간선이 있으니 걷는 것뿐이다.
시점을 무기로: 미니 무한회랑
방금 만든 게임에서 플레이어가 돌리는 것은 구조물이었다. 카메라는 못 박혀 있고 세계가 회전한다. 무한회랑은 반대편 극단을 택했다. 세계에는 손을 못 대게 하고, 플레이어에게 카메라를 통째로 쥐여준 것이다. 조작 수단이 시점 그 자체가 되는 셈인데, 우리 엔진으로 이것도 만들 수 있다. 카메라가 돌면 yaw가 바뀌고, 바뀐 yaw로 화면 그래프를 다시 만들면 이동의 법칙은 끝이다.
새로 구현할 것은 앞에서 예고한 부재의 법칙이다. 구멍은 보일 때만 존재한다. 복도에 뚫린 구멍이 기둥 뒤로 숨는 각도에서는, 구멍이 없는 셈 치고 그 위를 걸어야 한다. "가려졌다"를 코드로 옮기면 이런 질문이 된다. 구멍의 화면 위치에, 구멍보다 카메라에 가까운 다른 물체가 그려져 있는가.
// 이 블록이 화면에서 이 점을 가리는가. 조건은 둘이다.
function blockCovers(
block: Vec3,
point: Vec3,
yaw: number,
pitch: number,
): boolean {
const p = orthoProject(point, yaw, pitch);
const center = orthoProject(centerOf(block), yaw, pitch);
if (center.depth >= p.depth - 0.05) return false; // ① 점 뒤의 블록은 가릴 수 없다
// ② 블록 여섯 면의 투영 다각형 중 하나라도 점을 품으면 가려진 것
return UNIT_FACES.some((face) =>
pointInPolygon(p, projectFace(block, face, yaw, pitch)),
);
}
// 부재의 법칙: 구멍은 가려지면 없는 셈이 된다
const holeHidden = blocks.some((b) => blockCovers(b, holePoint, yaw, pitch));
if (!holeHidden) nodes = nodes.filter((n) => n.id !== holeNodeId); // 보이는 구멍 = 못 지나감눈여겨볼 것은 이 판정의 재료다. 투영된 위치, 깊이 비교, 실루엣(블록이 화면에 만드는 윤곽) 포함 여부. 전부 그리기 장에서 화가 알고리즘이 쓰던 것들이다. 화가가 "누가 누구를 덮는가"에 답하려고 계산한 바로 그 값들을 판정이 한 번 더 묻는 것뿐이다. 그래서 이 데모에서는 기둥이 구멍을 덮어 그리는 순간과 구멍이 없는 셈이 되는 순간이 사실상 일치한다. 판정 자체는 구멍의 중심점 하나로 하니 완전히 같지는 않지만, 보이는 것과 판정이 하나가 되도록 붙여둔 것이다.
레벨은 구멍 뚫린 복도, 기둥 하나, 허공의 도착 발판으로 짰다. 카메라를 돌리며 재보면 이 레벨에는 서로 겹치지 않는 두 개의 "가능의 창"이 있다.
| 카메라 yaw | 구멍 | 착시 간선 | 가능한 일 |
|---|---|---|---|
| 약 44°~46° | 보임 | 있음 | 복도 끝 → 목표 |
| 약 10.5°~27.5° | 가려짐 | 없음 | 출발 → 복도 끝 (구멍 위 통과) |
| 그 외 | 보임 | 없음 | 복도 안에서 서성이기 |
두 창이 겹치지 않는다는 것이 곧 퍼즐이다. 한 번의 시점으로는 목표에 갈 수 없다. 20도쯤으로 돌려 구멍을 지우고 복도 끝까지 걸은 다음, 45도로 돌려 착시 다리를 놓아야 한다. 정렬 창이 2도 남짓이라 이 데모에도 펜로즈 데모와 같은 자석 스냅이 들어 있으니, 45도 근처에서 손을 놓으면 된다. 조심할 것이 하나 있는데, 구멍 위에 서 있는 채로 카메라를 돌려 구멍이 다시 보이게 만들면 캐릭터는 그대로 떨어진다. 보이는 것이 곧 현실이라는 법칙은 양날이라서, 길을 만들어주는 만큼 발밑도 꺼뜨린다.
이쯤 되면 「일루전 볼링」이 왜 하필 볼링이 됐는지도 보인다. 걷는 캐릭터를 구르는 공으로 바꾸면 된다. 공은 던진 방향으로 계속 구르고, 화면에서 이어져 보이는 레인이라면 3D에서 어디에 떠 있든 그대로 타고 넘어간다. 판정의 뼈대는 같고, 그 위에서 움직이는 주체만 다른 것이다.
다른 유파: 크기를 속이는 게임
지금까지의 착시가 평행 투영의 착시였다면, 반대편에는 원근 투영의 착시를 파고든 유파가 있다. 대표작이 「Superliminal」이다. 이 게임에서는 체스 말을 집어 들고 시선을 먼 벽으로 옮긴 채 놓으면, 말이 방만 한 크기로 커져 있다.
원리는 원근 투영 코드에 이미 들어 있다. 화면에 보이는 크기는 실제 크기 s를 거리 d로 나눈 s/d에 비례한다. 나눗셈 하나가 전부라서, s/d가 같으면 화면에서 구분할 수 없다. 1미터 앞의 1미터짜리 큐브와 10미터 앞의 10미터짜리 큐브는 같은 그림이다. 영화가 백 년 전부터 써온 강제 원근Forced perspective 트릭이고,8 Superliminal은 이것을 실시간으로 뒤집는다. 물체를 잡는 순간의 비율 s/d를 기억해두고, 놓는 순간 시선 방향으로 가상의 광선을 쏘아 처음 부딪히는 지점을 찾은 다음(이 검사를 레이캐스트Raycast라고 부른다), 거기까지의 새 거리 d′로 크기를 다시 정하는 것이다.
// 놓는 순간: 화면에서 보이던 크기가 그대로 유지되도록 실제 크기를 다시 정한다
const newSize = grabSize * (newDist / grabDist); // s' = s × (d'/d)멀리 놓을수록 커지고 가까이 놓을수록 작아지지만, 화면에서는 아무 일도 일어나지 않는다. 플레이어의 눈에 보이는 것은 그대로인데 세계 쪽이 바뀌는 것이다. 아래 데모에서 슬라이더를 밀어 물체를 점점 멀리 놓아보자. 일반 물리의 큐브는 멀어지는 만큼 정직하게 작아진다. 착시 모드의 큐브는 화면에서 꿈쩍도 하지 않는다. 대신 큐브 옆에 같은 거리로 멀어지는 사람을 세워뒀다. 사람은 원근을 따라 점점 작아지는데 큐브는 그대로라면, 결론은 하나뿐이다. 큐브가 어느새 사람 키를 넘어선 것이다.
모뉴먼트 밸리와 Superliminal은 정반대의 투영을 골랐지만 하는 일은 같다. 투영이 버린 정보(깊이, 크기)의 빈자리를 게임 쪽에서 마음대로 채워 넣는 것. 앞서 우리가 만든 착시 다리도, 가려지면 없는 셈이 되는 구멍도, 그 위를 굴러갈 볼링공도 전부 이 빈자리에서 나온 트릭이다. 렌더링 파이프라인의 정보 손실은 보통 극복할 대상으로 취급되지만, 이 게임들에게는 그 손실이 곧 게임플레이의 원천이다.
마치며
정리해보자. 3D 착시 게임의 재료는 이것이 전부다.
투영은 3D를 2D로 누르면서 정보를 잃는 다대일 함수다. 잃어버린 정보 덕분에 서로 다른 3D 장면이 같은 화면을 만들 수 있고, 펜로즈 삼각형처럼 3D로 불가능한 그림도 "특정 시점에서 그렇게 보이는" 3D 구조물로 실존시킬 수 있다. 여기까지는 눈의 문제다. 이것을 게임으로 만드는 것은 판정의 문제이고, 답은 판정 좌표계를 화면으로 옮기는 것이다. 화면에서 이웃이면 그래프에서 이웃이고, 그래프가 되고 나면 길찾기도 애니메이션도 교과서 알고리즘이 알아서 한다. 회전이 그래프를 요동치게 하면 스냅 각도에서만 판정해서 연속을 이산으로 바꾸고, 그리는 순서는 깊이 정렬에 맡긴다. 가림이 게임 규칙이 되면, 그 깊이 정렬이 쓰던 값을 판정에서 한 번 더 쓰면 된다.
돌아보면 새로 발명한 것이 하나도 없다. 투영은 선형대수 첫 학기에 나오고, BFS는 알고리즘 수업 둘째 주에 나오며, 화가 알고리즘은 컴퓨터 그래픽스의 유물이다. 모뉴먼트 밸리가 특별한 것은 재료가 아니라 관점이었다. 모두가 "어떻게 하면 화면이 3D 세계를 정확히 반영할까"를 고민할 때, 이 게임들은 반대로 물었다. 화면이 진실이고 3D 세계가 그림자라면 어떤 게임이 가능할까. 좋은 착시 게임과 좋은 시스템 설계가 닮은 지점이 있다면, 어느 좌표계를 진실로 삼을지 정하는 그 한 번의 결정이 나머지 전부를 결정한다는 것이다.
이 글의 전체 코드는 GitHub 저장소에서 볼 수 있다.
Footnotes
-
이 역이용의 고전이 에임스의 방(Ames room)이다. 한쪽 구석이 다른 쪽보다 훨씬 멀고 천장도 기울어진 왜곡된 방인데, 특정 구멍으로 들여다보면 평범한 직육면체 방으로 보인다. 그래서 두 사람이 양쪽 구석에 서면 한 명이 거인처럼 보인다. ↩
-
엄밀히는 게임에서 "아이소메트릭"이라 부르는 것 중 상당수가 진짜 등각이 아니다. 고전 픽셀아트 게임들은 계단 현상을 피하려고 타일 비율을 2:1(각도로는 약 26.57도)로 잡은 다이메트릭(dimetric) 투영을 썼다. 이 글은 수학이 깔끔한 진짜 등각 투영을 쓴다. ↩
-
L. S. Penrose & R. Penrose, "Impossible Objects: A Special Type of Visual Illusion", British Journal of Psychology, 1958. 아버지 라이오넬은 유전학자, 아들 로저는 훗날 블랙홀 연구로 노벨 물리학상을 받는 수리물리학자다. ↩
-
영감의 방향이 한 번 오간 것이 재미있는 부분이다. 로저 펜로즈가 삼각형을 떠올린 계기가 1954년 에셔 전시회였고, 그 결과물인 1958년 논문이 다시 에셔에게 전해져 「폭포」가 나왔다. ↩
-
평행 투영은 회전과 좌표 버리기의 조합이라 P(A + d) = P(A) + P(d)가 정확히 성립한다. 원근 투영은 나눗셈 때문에 이 성질이 깨진다. 착시 게임이 평행 투영을 쓰는 또 하나의 이유다. ↩
-
요즘 GPU는 화가 알고리즘 대신 픽셀마다 깊이를 기록하는 z-버퍼로 이 문제를 푼다. 하지만 평행 투영에서 정확히 겹친 두 물체는 z-버퍼로도 "가까운 쪽이 이긴다"로 그려지므로, 착시는 상용 3D 엔진에서도 똑같이 성립한다. 모뉴먼트 밸리도 Unity로 만들어졌다. ↩
-
서로 상대를 감싸는 ㄱ자 블록 두 개처럼 A가 B보다 멀면서 동시에 가까운 배치가 가능하다. 정렬로는 답이 없어서 물체를 쪼개거나 z-버퍼로 도망가야 한다. 아이소메트릭 렌더러를 직접 짜본 사람들이 한 번씩 빠지는 유명한 함정이다. ↩
-
「반지의 제왕」이 호빗을 찍은 방법으로 유명하다. 프로도를 카메라에서 먼 자리에 앉히고 간달프를 가까이 앉힌 다음 한 화면에 담으면, 같은 키의 배우가 반토막으로 보인다. 카메라가 움직이면 들통나기 때문에, 카메라 이동에 맞춰 세트를 통째로 움직이는 장치까지 만들었다. ↩