1.9 카메라 구현하기
컴퓨터 그래픽스 탐색에서 우리는 지금까지 주로 2D 객체 렌더링에 중점을 두었습니다. 이제 3D 렌더링 영역으로 나아가 코드에 카메라 개념을 도입할 때입니다. 카메라는 GPU 파이프라인이나 WebGPU API 표준 내에 정의된 내장 기능이 아니라는 사실에 놀랄 수도 있습니다. 대신, 모델-뷰 변환과 정점 투영을 행렬 곱셈을 통해 사용하여 핀홀 카메라를 시뮬레이션하여 우리만의 카메라를 만들 것입니다.
플레이그라운드 실행 - 1_09_cameras카메라 구현에 뛰어들기 전에 먼저 그래픽스 파이프라인에서 사용되는 좌표계와 이들 간의 변환 방법에 대해 살펴보겠습니다. 모든 좌표계가 WebGPU 사양의 일부는 아니지만, 이들은 좌표 변환을 처리하는 가장 일반적인 접근 방식을 나타냅니다.
오른손/왼손 좌표계
어떤 좌표계를 이해하려면 먼저 오른손/왼손 좌표계의 개념을 파악해야 합니다. 3D 좌표계는 왼손 좌표계 또는 오른손 좌표계일 수 있습니다. x축이 오른쪽을 가리키고 y축이 위를 가리키는 xy 평면을 바라보고 있다고 상상해 보세요. 오른손/왼손 좌표계의 선택에 따라 z축의 방향이 결정됩니다.
오른손 좌표계에서는 오른손의 엄지손가락을 양의 x 방향으로, 검지손가락을 양의 y 방향으로 맞추면 중지손가락이 양의 z 방향을 가리킵니다. 반대로 왼손 좌표계에서는 같은 방식으로 왼손을 사용하면 양의 z 방향이 결정됩니다.
이를 시각화하려면 컴퓨터 화면을 xy 좌표 평면으로 상상해 보세요. 오른손 좌표계에서는 z축이 자신을 향하고, 왼손 좌표계에서는 화면 안쪽으로 향합니다.
오른손/왼손 좌표계의 선택은 프로그램에서 설정하는 관례입니다. 특정 시스템이 중요하지는 않지만, 이 개념을 이해하는 것이 애플리케이션의 수학적 의미를 파악하는 데 중요합니다. 특정 좌표계의 오른손/왼손 좌표계는 WebGPU 사양에 의해 결정되며, 다른 좌표계의 경우 선택한 수학 라이브러리에 따라 달라집니다. 우리의 경우, glMatrix 라이브러리를 사용하며 이는 OpenGL 관례에 맞춰 오른손 좌표계를 채택합니다.
로컬 좌표
대부분의 3D 모델은 로컬 좌표라고 알려진 자체 좌표계로 생성됩니다. 예를 들어, 비디오 게임 개발에서 3D 캐릭터는 일반적으로 자체 좌표계를 사용하여 3D 애플리케이션에서 모델링됩니다. 편의를 위해 캐릭터는 이 로컬 공간의 원점에 위치할 수 있습니다.
월드 좌표
단일 모델로는 전체 게임 세계를 만들 수 없습니다. 종종 우리는 수많은 3D 모델을 로드하고 이를 기반으로 3D 장면을 구성해야 합니다. 대부분의 3D 애플리케이션에서 모델은 동적입니다. 3D 장면 내에서 모델을 이동, 회전, 스케일링할 수 있습니다. 따라서 각 모델의 로컬 좌표에만 의존할 수는 없습니다. 좌표계를 통합된 하나로 변환해야 합니다.
이 단계에서 우리의 주요 관심사는 모델의 변환, 회전 및 스케일링과 상대적인 위치입니다. 3D 공간의 어떤 점이든 세계의 원점으로 선택하고 모든 모델을 그에 따라 오프셋할 수 있습니다. 어떤 좌표계를 월드 좌표계로 지정하든, 모델의 로컬 좌표에서 월드 좌표로의 변환은 단일 행렬 곱셈을 통해 이루어져야 합니다. 이 행렬을 모델 행렬이라고 합니다. 이 변환 후 장면의 모든 모델은 동일한 좌표계를 사용해야 합니다.
뷰 좌표
우리의 궁극적인 목표는 3D 장면을 2D 뷰 평면에 투영하는 것입니다. 이 투영은 카메라의 위치인 시점에 상대적으로 계산됩니다. 이 프로세스를 단순화하기 위해 좌표계를 한 번 더 변환하여 뷰 좌표계라고 알려진 것을 만듭니다.
이 새로운 시스템에서 카메라의 위치는 원점이 됩니다. y축은 위를 가리키고, x축은 오른쪽을 가리키며, 오른손 좌표계에서는 z축이 카메라를 향합니다. 결과적으로 음의 z축은 장면 안으로 확장됩니다. 이 변환을 통해 카메라의 관점에서 투영을 더 쉽게 계산할 수 있습니다.
투영
투영은 3D 장면을 2D 평면에 변환하는 프로세스이며, 원근 투영과 직교 투영의 두 가지 주요 유형이 있습니다.
원근 투영은 현실 세계에서 우리가 깊이를 인지하는 방식을 모방하여 시청자로부터 멀리 떨어진 객체가 더 작게 보이도록 합니다. 이는 렌더링된 장면에 깊이감과 사실감을 부여합니다. 반면에 직교 투영은 거리에 상관없이 객체 크기를 유지합니다. 사실적인 렌더링에서는 덜 일반적이지만, 직교 투영은 기술 도면 및 특정 유형의 게임에서 유용하게 사용됩니다.
이 논의에서는 원근 투영에 중점을 둘 것이며, 직교 투영은 향후 탐구를 위해 남겨두겠습니다. 다음 이미지는 이 두 가지 투영 유형의 주요 차이점을 보여줍니다:
카메라를 모델링하기 위해 간단한 핀홀 모델을 사용합니다. 이 모델에서 가시적인 볼륨은 절두체(frustum)라고 불리는 형태, 즉 본질적으로 잘린 피라미드를 형성합니다. 이 절두체의 앞면은 우리의 화면 또는 뷰 평면으로 생각할 수 있습니다.
원근 투영의 주요 목표는 이 사다리꼴 절두체를 직육면체로 변환하는 것입니다. 이 변환에서 절두체의 앞면과 뒷면은 모두 동일한 크기로 조정됩니다. 특히, 투영 후 앞면과 뒷면, 그리고 z-범위는 [-1, 1] 범위 내에 있어야 합니다. 즉, 우리의 투영은 원래의 가시 볼륨을 한 변의 길이가 2인 정육면체로 변형합니다.
이 결과 직육면체는 정규화된 장치 좌표(NDC)라고 부르는 곳에 존재합니다. NDC 시스템은 원래 장면의 스케일이나 사용된 특정 투영에 관계없이 렌더링의 최종 단계를 단순화하는 표준화된 3D 공간입니다.
정규화된 장치 좌표(NDC)는 (-1,-1,-1)과 (1,1,1) 점에 의해 둘러싸인 표준화된 3D 공간을 나타냅니다. 투영 프로세스를 통해 이루어지는 뷰 좌표에서 NDC로의 변환은 위 이미지에 설명되어 있습니다. 이 변환 중에 발생하는 중요한 변화에 주목하는 것이 중요합니다: 뷰 좌표는 오른손 좌표계이지만, 결과 NDC 시스템은 왼손 좌표계가 되어 양의 z축이 이제 카메라에서 멀어지는 방향을 가리킵니다.
이러한 오른손/왼손 좌표계의 전환은 중요한 구별점입니다. NDC 이전에는 우리가 논의한 모든 좌표계는 WebGPU 사양에 의해 엄격하게 정의되지 않아 개발자에게 선호하는 오른손/왼손 좌표계를 선택할 수 있는 유연성을 제공합니다. 그러나 NDC 시스템은 WebGPU 사양에 명시적으로 정의되어 엄격한 준수가 필요합니다. 투영 프로세스는 z축을 본질적으로 뒤집는, 미러링과 개념적으로 유사한 작업을 통해 오른손 좌표계에서 왼손 좌표계로의 전환을 용이하게 합니다.
우리가 접했던 다른 변환들과 마찬가지로, 투영은 단일 행렬 곱셈을 통해 달성될 수 있습니다. 그러나 이 투영 행렬을 도출하는 것은 처음 보기보다 간단하지 않습니다. 그 구성을 이해하기 위해 단계별로 접근해 봅시다.
먼저 x축에 집중해 봅시다. 점 e를 가까운 평면에 투영하여 점 p를 얻습니다. p의 x 및 y 좌표는 NDC에서 사용하기 위해 [-1, 1] 범위로 매핑되어야 합니다.
다음 관계를 설정할 수 있습니다:
\begin{aligned}
\frac{x_p}{x_e} &= \frac{-n}{z_e} \\
x_p &= \frac{-n*x_e}{z_e}
\end{aligned}
여기서 n은 우리의 핀홀 카메라 모델의 초점 거리(또는 근거리 평면 거리)를 나타냅니다. y좌표도 동일한 관계를 따릅니다:
\begin{aligned}
\frac{y_p}{y_e} &= \frac{-n}{z_e} \\
y_p &= \frac{-n*y_e}{z_e}
\end{aligned}
(x_p, y_p)는 가까운 평면에 투영된 점 p의 좌표를 제공하지만, 이들은 아직 NDC 좌표가 아닙니다. NDC 요구 사항을 충족하기 위해 이 좌표들을 [-1, 1] 범위로 매핑해야 합니다. 이 매핑은 선형 변환입니다:
\begin{aligned}
x_{ndc} &= \frac{1--1}{r-l}*x_p + \beta_{1} \\
y_{ndc} &= \frac{1--1}{t-b}*y_p + \beta_{2}
\end{aligned}
여기서 r과 l은 가까운 평면의 오른쪽과 왼쪽 경계를 나타내고, t와 b는 위쪽과 아래쪽 경계를 나타냅니다. p가 가까운 평면의 중앙(즉, (\frac{r+l}{2},\frac{t+b}{2}))에 있을 때, (x_{ndc}, y_{ndc})도 중앙(0, 0)에 있어야 합니다. 이 관계를 사용하여 \beta_{1}과 \beta_{2}를 결정할 수 있습니다:
\begin{aligned}
\beta_{1} &= - \frac{1--1}{r-l}*\frac{r+l}{2} = - \frac{r+l}{r-l} \\
\beta_{2} &= - \frac{1--1}{t-b}*\frac{t+b}{2} = - \frac{t+b}{t-b}
\end{aligned}
따라서 우리의 방정식은 다음과 같습니다:
\begin{aligned}
x_{ndc} &= \frac{1--1}{r-l}*x_p - \frac{r+l}{r-l} \\
&= \frac{1--1}{r-l}* \frac{-n*x_e}{z_e} - \frac{r+l}{r-l} \\
&= \frac{-2n*x_e}{z_e*(r-l)} - \frac{r+l}{r-l} \\
&= - \frac{\frac{2n*x_e}{r-l} + \frac{r+l}{r-l}}{z_e} \\
y_{ndc} &= \frac{1--1}{t-b}*y_p - \frac{t+b}{t-b} \\
&= \frac{1--1}{t-b}* \frac{-n*y_e}{z_e} - \frac{t+b}{t-b} \\
&= \frac{-2n*y_e}{z_e*(t-b)} - \frac{t+b}{t-b} \\
&= - \frac{\frac{2n*y_e}{t-b} + \frac{t+b}{t-b}}{z_e}
\end{aligned}
이제 이 변환을 행렬 형태로 표현해 봅시다:
\begin{pmatrix}
\frac{2n}{r-l} & 0 & \frac{r+l}{r-l} & 0 \\
0 & \frac{2n}{t-b} & \frac{t+b}{t-b} & 0 \\
0 & 0 & A & B \\
0 & 0 & -1 & 0
\end{pmatrix} \times
\begin{pmatrix}
x_e \\
y_e \\
z_e \\
1
\end{pmatrix}
x_{ndc}와 y_{ndc}를 계산하려면 마지막 단계에서 -z_e로 나누어야 합니다. 우리는 동차 좌표의 스케일링 인자 w_{ndc} = -z_e를 활용하여 이를 달성합니다. 이 접근 방식은 행렬 곱셈에서 직접적인 요소별 나눗셈이 불가능하기 때문에 나눗셈을 행렬 곱셈에 통합할 수 있게 해줍니다.
투영 퍼즐의 마지막 조각은 z_e를 z_{ndc}로 매핑하는 것입니다. x_e에서 x_p로의 선형 매핑과는 달리, z_e와 z_p 사이의 관계는 비선형적입니다. 비록 이들 사이의 선형 매핑을 찾을 수 있지만, 우리는 행렬 곱셈 형식에 맞추기 위해 다른 접근 방식을 선택합니다. 투영 행렬에서 A와 B의 값을 도출해 봅시다:
\begin{aligned}
z_{ndc} &= \frac{A*z_e + B}{-z_e} \\
\frac{-An+B}{n} &= -1 \\
\frac{-Af+B}{f} &= 1
\end{aligned}
여기서 -n과 -f는 각각 근거리 및 원거리 평면 거리를 나타냅니다. A와 B에 대해 풀면:
\begin{aligned}
A &= - \frac{f+n}{f-n}\\
B &= - \frac{2fn}{f-n} \\
z_{ndc} &= \frac{- \frac{f+n}{f-n}*z_e - \frac{2fn}{f-n}}{-z_e}
\end{aligned}
이제 전체 투영 계산을 단일 행렬 곱셈으로 표현할 수 있습니다:
\begin{pmatrix}
\frac{2n}{r-l} & 0 & \frac{r+l}{r-l} & 0 \\
0 & \frac{2n}{t-b} & \frac{t+b}{t-b} & 0 \\
0 & 0 & - \frac{f+n}{f-n} & - \frac{2fn}{f-n} \\
0 & 0 & -1 & 0
\end{pmatrix} \times
\begin{pmatrix}
x_e \\
y_e \\
z_e \\
1
\end{pmatrix}
z_e와 z_{ndc} 간의 매핑이 비선형이라는 점을 기억하는 것이 중요합니다. 이 관계를 더 잘 이해하기 위해 f = 50, n = 10으로 가정한 플롯으로 시각화해 봅시다:
이 그래프는 흥미로운 속성을 보여줍니다. 원거리 평면에서 근거리 평면으로 이동할수록 z_{ndc}의 변화율이 증가합니다. 실제로는 z_{ndc}는 깊이 테스트에 사용되는 깊이 값으로, 어떤 프래그먼트가 카메라에 가장 가까이 있는지, 따라서 뒤에 있는 프래그먼트를 가려야 하는지 결정합니다.
이 매핑의 비선형적 특성은 중요한 함의를 가집니다. 카메라에 가까운 프래그먼트는 멀리 떨어진 프래그먼트에 비해 더 높은 깊이 정확도를 얻습니다. 이러한 특성은 대부분의 3D 애플리케이션에서 인체 인식 및 요구 사항에 잘 부합하며, 일반적으로 가까운 객체에 대한 정확한 깊이 구분이 더 중요합니다.
개발 관점에서 우리의 주요 작업은 로컬 공간에서 정규화된 장치 좌표(NDC)로 좌표를 변환하는 것입니다. NDC에서 2D 프래그먼트로의 후속 단계는 GPU가 자동으로 처리합니다. WebGPU의 좌표계에 대한 포괄적인 이해를 위해 WebGPU 사양의 관련 섹션을 검토하는 것이 좋습니다.
렌더링 파이프라인의 나머지 단계를 간략하게 설명해 봅시다:
클립 공간으로 변환: GPU는
(\frac{x_{ndc}}{w_{ndc}},\frac{y_{ndc}}{w_{ndc}},\frac{z_{ndc}}{w_{ndc}})공식을 사용하여 NDC를 클립 공간 좌표로 변환합니다. 이 단계는 이전에 논의했던-z_{ndc}로의 나눗셈을 달성합니다.뷰포트 변환: 다음으로, 클립 좌표는 뷰포트 좌표로 변환됩니다. 여기서 뷰포트 설정이 적용됩니다. 뷰포트 공간에서:
x축은 오른쪽을 가리킵니다.
y축은 아래쪽을 가리킵니다.
좌표 범위는 setViewport 함수에 지정된 뷰포트의 너비와 높이에 일치합니다.
깊이는
[-1,1]에서[minDepth, maxDepth]로 매핑되며, 일반적으로[0,1]입니다.
이 이미지는 NDC와 뷰포트 좌표 간의 매핑을 보여줍니다:
래스터화 후 프래그먼트 좌표는 뷰포트 좌표에 있다는 점에 유의해야 합니다. 이는 우리가 정점 셰이더에서 클립 좌표를 출력하지만, 이 변수들이 동일한 이름을 공유하더라도 프래그먼트 셰이더에서는 뷰포트 좌표를 받는다는 점에서 직관적이지 않을 수 있습니다. 이 차이점은 향후 챕터에서 그림자와 같은 고급 효과를 구현할 때 중요해집니다.
이론적 기반을 바탕으로 이제 WebGPU에서 카메라를 구현할 수 있습니다. 본질적으로 카메라는 두 개의 행렬로 표현됩니다:
카메라의 위치와 방향에 의해 결정되는 뷰 행렬
카메라의 종횡비와 초점 거리를 기반으로 하는 투영 행렬
glMatrix 라이브러리는 이러한 행렬을 효율적으로 생성하는 편리한 함수를 제공합니다. 이러한 행렬을 정점에 적용하기 위해 이전과 동일한 기술을 사용합니다. 즉, 행렬을 유니폼으로 전달하고 셰이더에서 정점 위치에 행렬 곱셈을 수행합니다.
코드의 구현 세부 사항을 살펴보겠습니다:
@group(0) @binding(0)
var transform: mat4x4;
@group(0) @binding(1)
var projection: mat4x4;
@vertex
fn vs_main(
@location(0) inPos: vec3,
@location(1) inTexCoords: vec2
) -> VertexOutput {
var out: VertexOutput;
out.clip_position = projection * transform * vec4(inPos, 1.0);
out.tex_coords = inTexCoords;
return out;
}
정점 셰이더는 이 구현에서 최소한의 변경만 거칩니다. 기존 변환 행렬과 함께 투영 행렬을 추가했습니다. 변환 행렬은 객체를 카메라 앞에 재배치하여 좌표를 로컬 공간에서 뷰 공간으로 변환합니다. 그런 다음 투영 행렬은 최종 변환을 적용하여 뷰 공간을 NDC로 투영합니다. 두 변환 모두 간단한 행렬 곱셈을 통해 적용됩니다.
let transformationMatrix = glMatrix.mat4.lookAt(glMatrix.mat4.create(),
glMatrix.vec3.fromValues(100, 100, 100), glMatrix.vec3.fromValues(0,0,0), glMatrix.vec3.fromValues(0.0, 0.0, 1.0));
let projectionMatrix = glMatrix.mat4.perspective(glMatrix.mat4.create(),
1.4, 640.0 / 480.0, 0.1, 1000.0);
다음은 우리에게 새로운 것입니다: glMatrix를 사용하여 변환(뷰) 행렬과 투영 행렬을 모두 생성하는 것. 단순화를 위해 객체가 이미 월드 좌표에 있다고 가정하고, 뷰 행렬을 사용하여 뷰 좌표로의 변환에 중점을 둡니다.
뷰 행렬을 생성하기 위해 세 가지 매개변수를 사용하는 lookAt 함수를 사용합니다:
뷰어의 위치
뷰어가 바라보는 지점
카메라의 업 벡터
업 벡터는 이상적으로는 뷰 방향에 직교해야 하지만, glMatrix는 비직교 벡터를 자동으로 조정하여 올바른 카메라 방향을 보장합니다.
투영 행렬의 경우, 다음을 필요로 하는 perspective 함수를 사용합니다:
라디안 단위의 수직 시야(
fovy), 초점 거리와 관련됨종횡비, 일반적으로 뷰포트 너비/높이
절두체의 근거리 경계
절두체의 원거리 경계
glMatrix를 사용하는 장점 중 하나는 행렬을 Float32Array 객체로 생성하여 GPU 버퍼에 직접 로드할 수 있다는 것입니다:
let transformationMatrixUniformBuffer = createGPUBuffer(device, transformationMatrix, GPUBufferUsage.UNIFORM);
let projectionMatrixUniformBuffer = createGPUBuffer(device, projectionMatrix, GPUBufferUsage.UNIFORM);
let uniformBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 1,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,
texture: {}
},
{
binding: 3,
visibility: GPUShaderStage.FRAGMENT,
sampler: {}
}
]
});
let uniformBindGroup = device.createBindGroup({
layout: uniformBindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: transformationMatrixUniformBuffer
}
},
{
binding: 1,
resource: {
buffer: projectionMatrixUniformBuffer
}
},
{
binding: 2,
resource: texture.createView()
},
{
binding: 3,
resource:
sampler
}
]
});
유니폼 설정은 이전 예제와 크게 달라지지 않았습니다. 텍스처가 적용된 삼각형을 한 각도에서 보는 렌더링 결과가 그리 흥미롭지 않게 보일지 모르지만, 이 접근 방식은 점진적인 학습을 가능하게 하고 더 복잡한 구현을 위한 기반을 제공합니다.
카메라 시스템을 더 잘 이해하고 스스로에게 도전하기 위해, 카메라가 삼각형 주위를 회전하는 애니메이션을 만들도록 이 샘플을 수정하는 것을 고려해 보세요. 이 연습에는 다음이 포함됩니다:
각 프레임에서 카메라 위치 업데이트
뷰 행렬 재계산
새로운 행렬로 유니폼 버퍼 업데이트
연속적인 애니메이션을 위한 새 프레임 요청
이 애니메이션을 구현함으로써 3D 카메라 시스템을 조작하는 실제 경험을 얻고 3D 공간의 보다 동적이고 매력적인 시각화를 만들 수 있습니다.