사전 원근 투영
개념

원근 투영

gabury1고친 사람 github-actions[bot]

원근 투영은 3차원 장면을 평평한 화면에 옮기면서 멀리 있는 것을 작게 그려 주는 변환입니다. 점마다 카메라에서 얼마나 먼지를 보고 먼 만큼 작게 줄입니다. 그래서 멀어지는 철길이 점점 좁아 보이듯, 화면에도 눈으로 볼 때와 같은 거리감이 생깁니다.

쉽고 빠른 이해

원근 투영은 입체 장면을 화면에 옮길 때 먼 것일수록 작게 그리는 계산입니다. 같은 크기의 나무 두 그루가 있으면, 두 배 멀리 선 나무가 화면에서 절반 높이로 그려집니다.

이게 없으면 먼 것과 가까운 것이 같은 크기로 그려집니다. 화면이 도면처럼 납작해져서 무엇이 앞에 있고 얼마나 먼지를 눈으로 가늠할 수 없습니다.

어떻게 도나:

  1. 카메라를 기준으로 점의 위치를 잽니다
  2. 점의 가로·세로 위치를 카메라에서 떨어진 거리로 나눕니다
  3. 나온 값을 화면 크기에 맞춰 늘려 픽셀 위치를 정합니다

대가는 화면 칸마다 적어 두는 거리 값이 고르게 나뉘지 않는다는 것입니다. 가까운 곳은 촘촘하게, 먼 곳은 성기게 적혀서, 먼 곳에서는 두 물체 중 어느 쪽이 앞인지 가리는 계산이 흔들릴 수 있습니다. 카메라 바로 앞과 너무 먼 곳은 그리지 않도록 범위를 잘라 둬야 합니다.

거리감이 필요한 3차원 장면(게임·건물 둘러보기)에는 원근 투영을 씁니다. 치수를 재야 하는 도면이나 화면 위 메뉴에는 거리와 상관없이 크기가 그대로인 직교 투영을 씁니다.

상세

이 절은 창문에 바깥 풍경을 따라 그리는 장면에서 출발합니다. 거기서 거리로 나누는 계산 하나를 뽑아내고, 그 계산 하나가 먼 것을 작게 만드는 과정을 숫자로 따라가 봅니다.

뒤쪽 소절들은 이 계산이 실제 그래픽 처리 과정에 들어가는 방법을 봅니다. 그릴 범위를 정하는 법, 나눗셈을 행렬 곱셈에 싣는 요령, 그리고 그 요령이 깊이 값에 남기는 치우침까지 차례로 다룹니다.

창문에 따라 그린 풍경

창가에 서서 한쪽 눈을 감고, 유리창에 바깥 풍경의 윤곽을 펜으로 따라 그린다고 해 보겠습니다. 눈과 나무 꼭대기를 잇는 실을 떠올리고, 그 실이 유리를 지나는 곳에 점을 찍는 식입니다.

다 그리고 나면 가까운 나무는 유리를 크게 차지합니다. 같은 크기의 먼 나무는 작은 그림으로 남습니다.

카메라와 투영면

원근 투영은 이 따라 그리기를 계산으로 하는 방법입니다. 장면을 바라보는 눈의 위치를 카메라라고 부릅니다. 유리창에 해당하는 평면은 투영면이라고 부릅니다. 투영면은 카메라 앞에 일정한 거리를 두고 놓습니다.

계산할 일은 하나입니다. 장면의 점마다 카메라와 그 점을 직선으로 잇고, 그 직선이 투영면을 지나는 곳을 구합니다. 그 교점들을 이어 그린 것이 화면에 뜨는 그림입니다.

이 계산은 카메라를 기준으로 잰 좌표에서 합니다. 카메라를 원점에 두고, 카메라가 보는 방향을 깊이 축으로 삼은 좌표계입니다. 이 좌표계를 뷰 공간이라고 부릅니다. 이렇게 두면 점의 깊이 좌표가 곧 카메라에서 앞으로 떨어진 거리가 되어, 거리를 따로 계산하지 않아도 됩니다.

거리로 나누는 계산

이 소절은 교점을 구하는 식을 봅니다. 쓰는 것은 모양이 같고 크기만 다른 삼각형 한 쌍뿐입니다.

카메라에서 깊이 z 만큼 떨어진 곳에 높이 y 인 점이 있다고 하겠습니다. 투영면은 카메라에서 d 만큼 앞에 있습니다. 카메라와 점을 잇는 직선은 투영면에서 높이 y × d ÷ z 인 곳을 지납니다.

이 식은 닮은 삼각형에서 나옵니다. 첫째 삼각형은 카메라, 점, 그리고 점에서 깊이 축으로 내린 발을 잇습니다. 이 삼각형의 높이는 y, 밑변은 z 입니다.

둘째 삼각형은 카메라, 교점, 그리고 깊이 축이 투영면을 지나는 곳을 잇습니다. 이 삼각형의 밑변은 d 입니다. 두 삼각형은 카메라에서 같은 각도로 벌어지므로 모양이 같고 크기만 다릅니다.

그래서 높이의 비가 깊이의 비와 같습니다. 교점의 높이는 y 에 d ÷ z 를 곱한 y × d ÷ z 입니다.

가로 위치도 같은 식을 따릅니다. 가로 x 는 x × d ÷ z 로 옮겨집니다. 원근 투영의 알맹이는 이렇게 가로·세로 위치를 깊이로 나누는 것 하나입니다.

아래는 높이 2 인 기둥 셋을 깊이만 바꿔 가며 투영면에 옮긴 것입니다. 투영면까지의 거리 d 는 1 로 두었습니다.

JavaScript
const project = (y, z, d) => y * d / z;

project(2, 2, 1);  // 1
project(2, 4, 1);  // 0.5
project(2, 8, 1);  // 0.25

깊이가 두 배가 될 때마다 화면 높이가 절반이 됩니다. 먼 것이 작게 보이는 까닭이 이 나눗셈 하나입니다.

깊이가 0 에 가까운 점은 나눈 결과가 한없이 커집니다. 그래서 카메라 바로 앞은 그리지 않습니다. 이 문제는 뒤에서 그릴 범위를 정할 때 다시 나옵니다.

직교 투영과 가르는 기준

깊이로 나누지 않고 깊이 좌표를 버리기만 하는 투영도 있습니다. 이것을 직교 투영이라고 부릅니다. 물체가 얼마나 멀든 화면에서의 크기가 같습니다.

사람은 같은 물체가 얼마나 작아 보이는지로 거리를 가늠합니다. 직교 투영으로 그린 장면에는 이 단서가 없어서, 무엇이 앞이고 얼마나 먼지가 한눈에 안 들어옵니다. 원근 투영이 필요한 까닭이 이것입니다.

평행선도 다르게 그려집니다. 직교 투영에서는 평행한 선이 화면에서도 평행합니다. 원근 투영에서는 멀리 뻗는 평행선이 한 점으로 모이고, 이 점을 소실점이라고 부릅니다.

어느 쪽을 고를지는 화면이 무엇을 전해야 하는지가 정합니다.

고르는 투영 화면이 전해야 하는 것 쓰는 예
원근 투영 거리감 · 눈으로 본 모습 3차원 게임 · 건물 둘러보기 · 가상 현실
직교 투영 길이 · 각도 · 크기 비교 설계 도면 · 2차원 게임 · 화면 위의 메뉴와 버튼

도면을 원근 투영으로 그리면 먼 쪽 부품이 작게 그려져서 화면에서 치수를 잴 수 없습니다. 반대로 게임 장면을 직교 투영으로 그리면 앞뒤를 가늠하기 어렵습니다.

그릴 범위를 정하는 뷰 절두체

원근 투영은 카메라 앞의 모든 것을 그리지 않습니다. 그릴 범위를 먼저 정하고, 그 안에 든 것만 화면에 옮깁니다. 범위는 값 네 개로 정합니다.

범위의 앞쪽 끝은 근평면입니다. 카메라에서 이만큼 떨어진 곳부터 그리기 시작한다는 경계입니다. 깊이가 0 에 가까우면 나눗셈 결과가 한없이 커지므로, 근평면은 0 보다 큰 값으로 둡니다.

범위의 뒤쪽 끝은 원평면입니다. 이보다 먼 것은 그리지 않습니다. 화면 칸마다 가장 가까운 것의 깊이를 정해진 비트 수로 적어 두는데(뒤의 깊이 소절), 그러려면 범위의 끝이 있어야 합니다.

위아래로 얼마나 넓게 볼지는 시야각이 정합니다. 카메라에서 위아래로 벌어진 각도입니다. 넓히면 더 많은 것이 담기는 대신 하나하나가 작게 그려집니다.

좌우 폭은 화면의 가로 대 세로 비율로 정합니다. 이 비율을 종횡비라고 부릅니다. 가로 폭을 세로와 같게 두면 넓은 화면에 같은 범위를 옆으로 펼쳐 그리게 되어 물체가 옆으로 늘어납니다. 그래서 세로로 벌어진 각도에 이 비율을 곱해 가로 폭을 맞춥니다.

이 네 값이 정하는 입체는 꼭대기를 잘라 낸 피라미드 모양입니다. 카메라 쪽이 좁고 먼 쪽이 넓습니다. 이 모양을 뷰 절두체라고 부릅니다. 절두체 밖에 있는 것은 화면에 나오지 않습니다.

나눗셈을 행렬에 싣는 방법

그래픽스에서는 점을 옮기는 계산을 행렬 곱셈으로 적습니다. 이 소절은 깊이로 나누는 계산이 그 틀에 어떻게 들어가는지를 봅니다.

점을 옮기고 돌리고 키우는 계산은 변환 행렬 하나로 적습니다. 행렬을 점에 곱하면 그 변환이 한 번에 적용됩니다. 여러 변환을 미리 곱해 하나로 합쳐 둘 수 있어서, 점마다 곱셈 한 번이면 끝납니다.

그런데 깊이로 나누는 계산은 행렬 곱셈으로 적을 수 없습니다. 행렬 곱셈은 좌표에 정해진 수를 곱해 더하는 계산뿐입니다. 점마다 다른 값으로 나누는 일은 이 틀에 안 들어갑니다.

그래서 좌표에 넷째 성분 w 를 붙입니다. 3차원 점 (x, y, z) 를 (x, y, z, 1) 로 적는 것입니다.

이렇게 성분을 하나 늘린 좌표를 동차 좌표라고 부릅니다. 동차 좌표에는 약속이 하나 따라옵니다. 앞 셋을 w 로 나눈 값이 진짜 위치라는 약속입니다.

투영 행렬은 이 약속을 이용합니다. 점의 깊이 z 를 w 칸에 옮겨 담아 두기만 합니다. 나눗셈은 행렬이 하지 않고, 뒤 단계가 앞 셋을 w 로 나눌 때 저절로 일어납니다. 이 나눗셈을 원근 나눗셈이라고 부릅니다.

아래 표는 한 점이 투영 행렬을 지나고 w 로 나뉘는 동안 성분마다 무엇이 되는지를 보입니다. sx 와 sy 는 시야각과 종횡비에서 나오는 가로·세로 배율입니다.

a 와 b 는 근평면과 원평면에서 나오는 상수입니다. 근평면부터 원평면까지의 깊이를 정해진 범위(예: 0 부터 1 까지)로 옮기려고 고른 수입니다.

성분 투영 행렬을 곱한 뒤 w 로 나눈 뒤
가로 sx · x sx · x / z
세로 sy · y sy · y / z
깊이 a · z + b a + b / z
w z 1

가로와 세로는 앞에서 본 식처럼 깊이로 나눈 꼴이 됐습니다. 깊이 좌표도 함께 나뉘어서 a + b / z 꼴로 바뀝니다. 이 꼴이 마지막 소절에서 다룰 문제의 뿌리입니다.

화면 픽셀까지 가는 차례

투영 행렬을 곱하는 일과 w 로 나누는 일은 서로 다른 단계가 맡습니다. 이 소절은 점 하나가 뷰 공간에서 화면의 픽셀까지 가는 차례를 봅니다.

3차원 물체는 작은 삼각형을 이어 붙여 적습니다. 그래서 투영도 삼각형의 꼭짓점마다 한 점씩 합니다.

투영 행렬을 곱하는 것은 정점 셰이더입니다. 삼각형의 꼭짓점마다 한 번씩 도는 프로그램입니다. 이 프로그램은 그래픽 프로그램을 짜는 개발자가 직접 적습니다. 곱한 결과인 네 성분 좌표를 내놓으면 이 프로그램의 일은 끝납니다.

이 네 성분 좌표가 놓이는 공간을 클립 공간이라고 부릅니다. 아직 w 로 나누기 전의 좌표입니다.

클립 공간에서는 절두체 밖으로 삐져나간 삼각형을 잘라 냅니다. 이 잘라 내기를 클리핑이라고 부릅니다.

나누기 전에 자르는 데는 까닭이 있습니다. 카메라 뒤에 있는 점은 깊이가 음수라서 w 도 음수가 됩니다. 이 점을 먼저 나누면 부호가 뒤집혀 화면 반대편에 엉뚱하게 나타납니다.

잘라 낸 뒤에는 GPU(Graphics Processing Unit, 그래픽 처리 장치)가 원근 나눗셈을 합니다. GPU 는 그래픽 계산을 맡는 전용 칩이라, 이 나눗셈은 개발자가 적지 않아도 칩이 알아서 합니다.

나눈 결과는 절두체가 반듯한 상자로 펴진 좌표입니다. 이것을 정규화 장치 좌표라고 부릅니다. 가로와 세로는 -1 부터 1 까지입니다. 깊이의 범위는 쓰는 그래픽 라이브러리마다 조금 다릅니다.

마지막으로 이 상자를 화면 크기에 맞춰 늘립니다. -1 부터 1 까지를 0 부터 화면 너비까지로 옮기는 식입니다. 이 단계를 뷰포트 변환이라고 부릅니다.

그 뒤에는 래스터화 단계가 이어집니다. 화면 위치가 정해진 삼각형이 어느 픽셀을 덮는지 골라내는 단계입니다.

지금까지의 차례를 그림으로 모으면 이렇습니다.

flowchart TD
    A["뷰 공간 · 카메라 기준 좌표"] -->|"투영 행렬을 곱한다"| B["클립 공간 · 네 성분 좌표"]
    B -->|"절두체 밖을 잘라 낸다"| C["클리핑을 마친 삼각형"]
    C -->|"w 로 나눈다"| D["정규화 장치 좌표 · 반듯한 상자"]
    D -->|"화면 크기로 늘린다"| E["화면의 픽셀 위치"]

깊이 값이 고르게 놓이지 않는 까닭

앞의 표에서 깊이는 a + b / z 꼴로 바뀌었습니다. 이 값은 깊이 버퍼에 적힙니다. 깊이 버퍼는 화면 칸마다 지금까지 가장 가까웠던 것의 깊이를 적어 두는 메모리입니다. 깊이 테스트가 이 값을 읽어 앞에 있는 것만 남깁니다.

b / z 는 z 가 커질수록 느리게 변합니다. 그래서 깊이 값의 범위 대부분을 가까운 곳이 차지하고, 먼 곳은 좁은 범위에 몰려 담깁니다.

아래는 근평면을 1, 원평면을 100 으로 두고 계산한 것입니다. 코드의 n 은 근평면, f 는 원평면까지의 거리입니다. 앞의 a + b / z 에서 a 를 f / (f − n), b 를 −n · f / (f − n) 로 둔 꼴입니다. 이렇게 고르면 근평면의 깊이가 0, 원평면의 깊이가 1 이 됩니다.

JavaScript
const n = 1, f = 100;
const depth = z => (1 - n / z) * f / (f - n);

depth(1);    // 0
depth(2);    // 약 0.505
depth(10);   // 약 0.909
depth(100);  // 1

카메라에서 1 과 2 사이는 전체 범위의 백분의 일밖에 안 되는 구간입니다. 그런데 이 구간이 깊이 값의 절반을 씁니다. 10 부터 100 까지는 남은 0.09 남짓에 몰려 담깁니다.

깊이 버퍼의 비트 수는 정해져 있어서, 먼 곳의 두 면이 같은 값으로 뭉개질 수 있습니다. 그러면 어느 쪽이 앞인지가 픽셀마다 다르게 갈려 화면에 얼룩덜룩한 무늬가 생깁니다. 이 현상을 Z 파이팅이라고 부릅니다.

깊이를 거리마다 얼마나 촘촘하게 가를 수 있는지를 깊이 정밀도라고 부릅니다. 이것을 가장 크게 좌우하는 값은 근평면입니다. 위 식에서 n 을 줄이면 깊이 값이 카메라 바로 앞에 더 몰립니다. 그래서 멀리까지 그려야 하는 장면이면 근평면을 되도록 뒤로 물립니다.

관련 항목

원근 투영이 속하는 상위 분류

그래픽스 · 3차원 그래픽스 · 렌더링 · 실시간 렌더링 · 투영 · 좌표 변환

원근 투영과 맞세워지는 다른 투영

직교 투영 · 평행 투영 · 경사 투영 · 등각 투영 · 어안 투영

원근 투영을 정하는 구성 요소

카메라 · 투영면 · 시야각 · 종횡비 · 근평면 · 원평면 · 뷰 절두체 · 소실점

원근 투영을 적는 수학 도구

변환 행렬 · 투영 행렬 · 동차 좌표 · 원근 나눗셈 · 닮은 삼각형 · 행렬 곱 · 사영 기하학

한 점이 화면까지 거치는 좌표 공간

모델 좌표계 · 월드 좌표계 · 뷰 공간 · 클립 공간 · 정규화 장치 좌표 · 화면 좌표

원근 투영이 거치는 처리 단계

그래픽스 파이프라인 · 정점 셰이더 · 클리핑 · 뷰포트 변환 · 래스터화 · 원근 보정 보간 · 절두체 컬링

원근 투영이 남기는 깊이 문제

깊이 버퍼 · 깊이 테스트 · 깊이 정밀도 · Z 파이팅 · 리버스 Z · 로그 깊이 버퍼

원근 투영과 같은 원리를 쓰는 분야

원근법 · 핀홀 카메라 · 카메라 보정 · 컴퓨터 비전 · 레이 트레이싱

원근 나눗셈을 맡아 하는 그래픽 라이브러리

OpenGL · WebGL · Vulkan · Direct3D · Metal · WebGPU

투영 행렬을 만들어 주는 수학 라이브러리

GLM · three.js · DirectXMath · gl-matrix

다른 이름: perspective projection · 투시 투영 · 원근 투영 변환