수정 및 추가된 부분
먼저 이전에 원근 투영 변환 행렬에 대해서 조금 잘 못 알고있던 부분을 수정했고 투영 변환 행렬에 대한 정확한 의미?를 추가하였습니다.
원근 투영 변환 행렬이란?
우선 원근 투영은 인간의 눈과 비슷하게 3차원 공간의 객체를 2차원 평면에 사영(Projection)하여 화면에 나타내는 방식이다. 즉, 3D 뷰 공간(space)으로 부터 2D 뷰포트 공간에 정점들을 변환 하는 행렬을 말한다.
근데, 보통 구글링을 하면 3차원 공간에서 바로 2차원 뷰포트로 가는듯이 말을하는 글들이 많은데 중간 과정이 있다.
바로 아래와 같이 카메라 시야각(FOV)안에 담긴 물체들을 -1~1크기의 정육면체 안으로 변환한다.
NDC좌표계로 변경된 좌표를 이제 뷰포트 매핑을 해주어 화면에 보이게 되는 것이다.
왜 변환을 하나면 아래 노란색 사각형의 경우 일부분은 절두체 안에 들어와있고 일부분은 나가 있다.
이런경우 절두체 컬링을 통해서 그릴 정점과 그리지 않을 정점을 구분 해야하는데 절두체 안에서 이것을 처리를 하려고 하면 복잡하다고 한다.
그래서 계산하기 편한 어떤 직(정)육면체로 절두체를 '변형'하여 이런 부분들을 쉽게 처리하고자 절두체를 입방체로 변환하는 투영 행렬의 개념이 나온 것이다.
투영 변환 행렬의 의미
이 목록이 추가 및 수정된 부분이다.
사실 투영 자체는 투영 변환 행렬을 특정 좌표 A에 곱하지 않아도 투영이 된다.
그냥 A(3, 0, 4)가 뷰스페이스 상의 좌표일 때, Z나누기만 수행해주어도 투영이 된다.
실제로 그런지 보도록 하자. A의 각 성분에 Z나누기를 하면 A(3/4. 0, 1)이 된다. A의 x, y가 -1~1사이에 들어오게된다.
근데 1을 벗어나게되면? ClipSpace(NDC)좌표계의 범위를 벗어났기 때문에 클리핑 되어 보이지 않게된다.
어? 그럼 Z나누기를 바로 수행하면되는데 왜 투영 변환 행렬을 곱하나?라는 의문이 들 수 있다.
이에 대한 이유를 설명을 하도록 하겠다.
우선 막바로 Z나누기를 해버리게 되면 NDC 좌표계로 변환되는 것은 맞지만 문제점이 2가지가 있다. 이 두가지 문제점들을 살펴보자.
첫번째 문제는 FOV를 조절할 수 없다.
왜냐하면 뷰스페이스 상의 A(a, b, c)에서 a좌표는 1 / tan(theta/2) * aspect와 곱해지게 되는데 Z나누기를 수행한 결과와 FOV가 90도인 투영 변환 행렬을 곱한 결과가 같기 때문이다.
tan(45)는 1이기때문에 3 / 4 * aspect한 것(막바로 Z나누기를 한 것)과 투영 변환 행렬 3 / 4 * tan(45) * aspect 의 결과가 같기때문이다.
FOV는 게임에서 유용하게 사용되는 값이다. 스나이퍼 총 확대를 한다거나 등등 많이 쓰이는데 Z나누기를 막바로 수행하면 첫번째 문제로 FOV를 컨트롤 할 수 없다. 는 문제점이 있다.
즉 시야각이 90도로 고정된다는 문제이다.
두번째 문제는, 깊이 버퍼에 깊이값을 기록 할 수 없다.
Z나누기를 막 바로 A에 수행하면 A의 z값은 1이 된다. 깊이 버퍼는 0~1사이의 값을 기록하고 현재 기록된 값보다 작은 경우에 기록을 하는데 1은 1보다 작지 않기때문에 깊이 버퍼에 제대로된 깊이값을 기록 할 수 없다.
그.래.서 Z나누기를 올바르게 하기 위해서 W값에 Z값을 보관하는 방법을 선택한 것이다.
Z값이 1이라 가정하고 Z값에 투영 변환 행렬을 곱하면 f가 나온다. 0~f사이의 값을 W에 보관해둔 Z값으로 나눈다.
그래서 투영 변환 행렬을 W나누기라고도 하고 Z나누기 라고도 하는것이다. 그럼 0~1사이의 값이 나오게 되고 깊이버퍼에도 정상적으로 깊이 값을 기록 할 수 있다.
주의할 부분!!!
한가지더 중요한 사실이 있다.!!! 꼭 기억하자.
현재 투영 변환 행렬을 곱한 좌표는 뷰 스페이스 상의 좌표이다.
혹은 ClipSpace라고 해도 무방하다. (개인적으로 쨋든 값이 변하긴 하니까 ClipSpace가 더 맞는듯 하다)
투영 변환 행렬 이름 자체때문에 투영변환 행렬을 뷰스페이스 상의 좌표에 곱하면 이게 NDC좌표가 된다고 오해하기 쉬운데, 투영 변환 행렬은 사실 '투영 하기전의 사전 준비를 해주는 행렬'이다.
우리는 뷰 스페이스 상의 좌표에 투영변환 행렬을 곱했을 뿐이지 아직 NDC로의 변환은 수행한적 없다.
Z나누기를 해주어야만 NDC좌표로 변경되었다 할 수 있다.
하지만 투영변환 행렬을 곱했을 때는 W값에 Z값만 기록을 해두었을 뿐 Z나누기를 수행하지 않았다는 사실을 기억하도록 하자.
ClipSpace와 NDC 공간을 나누는 기준은 아마 W나누기의 유무인듯하다.
그럼 객체가 뷰포트 상에 렌더링 되는 이유는 무엇인가? 이는 장치에서 W나누기를 알아서 해주기 때문에 NDC로 변경된 좌표가 다시 뷰포트로 매핑되어서 보이는 것이다.
아래 쉐이더 코드는 본인이 짠 코드이다.
struct VS_IN
{
float3 vPosition : POSITION;
float3 vNormal : NORMAL;
float2 vTexcoord : TEXCOORD0;
float3 vTangent : TANGENT;
};
struct VS_OUT
{
float4 vPosition : SV_POSITION;
float4 vNormal : NORMAL;
float2 vTexcoord : TEXCOORD0;
float4 vWorldPos : TEXCOORD1;
};
/* 1. 정점위치에 대한 기초적인 변환과정을 수행한다.(월드변환, 뷰변환, ) */
VS_OUT VS_MAIN(VS_IN In)
{
VS_OUT Out = (VS_OUT) 0;
matrix matWV, matWVP;
matWV = mul(g_WorldMatrix, g_ViewMatrix);
matWVP = mul(matWV, g_ProjMatrix);
Out.vPosition = mul(float4(In.vPosition, 1.f), matWVP);
Out.vNormal = normalize(mul(float4(In.vNormal, 0.f), g_WorldMatrix));
Out.vTexcoord = In.vTexcoord;
Out.vWorldPos = mul(float4(In.vPosition, 1.f), g_WorldMatrix);
return Out;
}
DrawCall을 호출하면 VS함수가 호출된다. VS return으로 Out을 반환하고 있는데 Out의 vPosition은 투영변환 행렬을 곱한 뷰스페이스 상의 좌표이고 VS단계가 끝나면 장치가 W나누기를 수행하여 NDC좌표계로 변환되는 것이다.
때문에 VS_IN의 vPosition의 타입은 float3이지만 반환할 때 사용하는 VS_OUT의 vPosition의 타입은 float4이다. float4인 이유도 Z값을 W에 임시 보관하기 위한 용도로 사용하기 위해서 float4의 vPosition을 다음단계로 return하여 장치가 W값으로 나눌 수 있게 하는 것이다.
투영 변환 행렬의 의미를 정리하도록 하겠다.
투영 변환 행렬의 의미는 '투영을 하기 위한 사전 준비를 해주는 행렬' 이다.
투영 변환 행렬을 뷰스페으스 상의 좌표에 곱하게 되면(Shader에서) 이는 ClipSpace상의 좌표가 되고 장치에서 W나누기를 수행하면 비로소 NDC가 된다.
투영은 3D공간 상의 객체를 2D화면에 투영하는 것인데 이를 위한 사전준비를 해주는 행렬이라고 생각해주면 좋다.
투영 변환 행렬 유도하기
원평면(far)와 근평면(near) 사이에 있는 어떤 점(뷰 스페이스 상에 해당하는 정점 또는 오브젝트)를 근평면에 투영시켜야햔다. 즉, 납작하게 만든다는 것이다. 이 근평면에 해당하는 것을 투영 평면이라 하고 카메라로 부터 투영 평면까지의 거리를 초점 거리라 한다.
투영 평면까지의 거리를 d라 했을 때 우리는 화각(Feild Of View)를 통해 쉽게 구할 수 있다. tan(FOV/2) = 1/d
d = 1/ tan(theta/2)로 알 수 있다.
그리고 (Xview, Yview, Zview)의 값들도 닮음과 비율을 통해 알 수 있다.
d : Zview = Yndc : Yview 를 계산하면 Yndc = (d * Yview) / Zview가 된다.
마찬가지로 Xndc 도 비를 통해서 구할 수 있다.
여기서 a는 aspect로 화면의 비를 말한다. 800 : 600 이라하면 가로 세로 비가 1.33333 정도가 나온다.
이제 투영행렬 원소를 하나하나 채워보도록 하자. 아래 식은 오른손 좌표계 기준이다.
우리는 Xndc, Yndc를 알기 때문에 행렬 원소를 채워 넣으면 아래처럼 원소들이 나온다.
여기서 X', Y' 는 Zview라는 공통 분모를 가진다. 여기서 동차 좌표계의 특징인 x, y, z 성분을 w'로 나눈 것이다.
즉, Zview를 w'라 생각을 하고 행렬을 다시 구성해보도록 하자.
위처럼 4열의 값을 구할 수 있고 이제 i, j, k, l을 채워야 한다. Xview, Yview 가 어떤 값이든 z'에 값에 영향을 주지 않기 때문에 i, j는 0으로 두고 k, l을 A, B로 변경을 해서 다시 보도록 하자.
그럼 위처럼 식이 세워진다. 이제 여기서 Zview로 나누어지면 아래처럼 행렬곱의 결과가 나온다.
이제 A, B만 구하면 된다.
Zndc가 근평면(near)일때 값은 0이고 원평면(far)일 때 값이 1이라는 것을 알고있다. (DirectX 기준)
아래는 직접 계산한 결과이다.
코드
void MakeProjectionMatrix(_matrix * pOut, const _float & fFov, const _float & fAspect, const _float & fNear, const _float & fFar)
{
D3DXMatrixIdentity(pOut);
pOut->_11 = (1.f / (tan(fFov / 2.f))) / fAspect;
pOut->_22 = 1.f / (tan(fFov / 2.f));
pOut->_33 = fFar / (fFar - fNear);
pOut->_34 = 1.f;
pOut->_43 = (-fNear * fFar) / (fFar - fNear);
pOut->_44 = 0.f;
}
이렇게 구성한 투영 변환 행렬(Clip공간 변환 행렬)을 장치의 SetTramsform 함수를 통해 전달 해줄 것이다.
그럼 렌더링 파이프 라인을 거치면서 장치가 Z Buffer의 값으로 알아서 나누어서 클립 스페이스로 변환을 수행한다.
위 코드에 대한 설명이 다소 잘 못된 부분이 있다. Z값을 W에 저장해두고 장치가 이 W값으로 나누어 NDC좌표로 변환하는 것이다.
참고자료
https://marmelo12.tistory.com/338
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!