이전 글에서는 Model 클래스에서 OBJ 파일을 파싱하고 정점 데이터를 처리하는 흐름을 살펴보았습니다.
이번 글에서는 그 정점들이 실제로 화면에 어떻게 그려지는지를 결정짓는 핵심 요소, 바로 "행렬(Matrix)"에 대해 깊이 있게 분석합니다.
3D 그래픽스에서 행렬은 단순한 계산 도구가 아니라, '시점', '위치', '존재'를 결정하는 철학적 도구입니다.
1. 왜 행렬이 필요한가?
3D 그래픽스에서 모델을 불러온다고 해서 곧바로 화면에 보이는 건 아닙니다.
그 이유는 모델의 정점(Vertex)들이 여전히 '자기 내부(Local Space)'에 존재하기 때문입니다. 예를 들어, 정점이 (0, 0, 0)이라고 하면, 이는 오브젝트 기준의 원점을 의미하지 화면 기준의 원점은 아닙니다.
우리는 화면(스크린)이라는 2차원 공간 위에 3차원 물체를 투영하여 보여주어야 합니다. 이를 위해선 여러 좌표계의 변환이 필요하며, 그 핵심이 바로 행렬입니다.
3D 모델을 제대로 화면에 나타내기 위해선 다음과 같은 좌표계 변환이 반드시 필요합니다:
- 로컬 공간 → 월드 공간 (Model 행렬)
- 월드 공간 → 카메라(뷰) 공간 (View 행렬)
- 카메라 공간 → 클립 공간 → NDC → 스크린 공간 (Projection 행렬)
이 변환 과정이 없으면 아무리 모델을 잘 불러왔어도 화면에는 보이지 않습니다. 즉, 행렬은 화면에 "존재하게 만드는" 유일한 길입니다.
2. Scop에서 사용되는 3대 변환 행렬
Scop의 렌더링 파이프라인에서는 아래 세 가지 행렬이 필수적으로 사용됩니다:
- Model 행렬: 오브젝트의 위치, 크기, 회전 상태를 결정합니다. 예를 들어 2배 확대하고 오른쪽으로 이동시키는 등의 작업은 이 단계에서 처리됩니다.
- View 행렬: 카메라가 위치한 좌표와 그 시점을 기준으로 월드 공간의 모델들을 상대적으로 재배치합니다. 카메라가 이동하는 게 아니라 세상을 역으로 이동시키는 방식입니다.
- Projection 행렬: 원근 투영 혹은 직교 투영 방식으로 3D 공간을 2D 화면에 투영합니다. 원근감을 표현하기 위해 필수적인 구성입니다.
이 행렬들은 최종적으로 셰이더에 전달되어 정점의 화면 좌표 gl_Position을 결정합니다.
3. Model 행렬 구성 (Model.cpp)
Scop에서는 Model::Draw() 함수 내에서 다음과 같은 순서로 모델 변환 행렬을 구성합니다:
multiplyMatrix(model, scaleMatrix, model);
rotateY(mRotY, model);
rotateX(mRotX, model);
multiplyMatrix(model, translate, model);
- 먼저 정점의 크기를 조절하는 스케일 행렬을 적용하고,
- 이후 Y축과 X축에 대해 각각 회전(rotateY, rotateX)을 수행하며,
- 마지막으로 위치를 이동시키는 이동 행렬을 곱합니다.
OpenGL에서의 행렬 곱 연산은 오른쪽에서 왼쪽으로 적용되므로, 실제로는 "스케일 → 회전 → 이동" 순서로 변환됩니다.
4. View 행렬 구성 (카메라 시점 정의)
View 행렬은 카메라가 어떤 위치에서 어떤 방향으로 세상을 보는지를 수학적으로 정의합니다. Scop에서는 이를 직접 createLookAtMatrix()로 구현합니다:
createLookAtMatrix({0, 0, 3}, {0, 0, 0}, {0, 1, 0}, view);
위 코드는 "카메라가 (0, 0, 3) 위치에서 (0, 0, 0)을 바라보며 위 방향은 Y축"임을 의미합니다.
LookAt 행렬의 구성은 다음과 같은 벡터 연산으로 이뤄집니다:
- Forward = normalize(center - eye)
- Right = normalize(Forward × Up)
- Up' = Right × Forward
이 벡터들을 기반으로 한 4x4 행렬은 "카메라 기준 좌표계"를 만들어내며, 모델들은 이 좌표계 기준으로 재정렬됩니다.
5. Projection 행렬 구성 (원근 투영 정의)
Scop에서는 원근 투영 행렬을 아래처럼 직접 계산합니다. 이 방식은 OpenGL의 gluPerspective()와 동일한 역할을 수행합니다.
inline void createPerspectiveMatrix(float fov, float aspect, float neer, float far, float* matrix) {
float rad = fov * DEG2RAD;
float tanHalfFov = tan(rad / 2.0f);
for (int i = 0; i < 16; ++i)
matrix[i] = 0.0f;
matrix[0] = 1.0f / (aspect * tanHalfFov);
matrix[5] = 1.0f / tanHalfFov;
matrix[10] = -(far + neer) / (far - neer);
matrix[11] = -1.0f;
matrix[14] = -(2.0f * far * neer) / (far - neer);
}
수학적으로 위 함수는 아래와 같은 행렬을 의미합니다:
$$ P = \begin{bmatrix} \frac{1}{a \cdot \tan(\theta/2)} & 0 & 0 & 0 \\\\ 0 & \frac{1}{\tan(\theta/2)} & 0 & 0 \\\\ 0 & 0 & -\frac{f + n}{f - n} & -1 \\\\ 0 & 0 & -\frac{2fn}{f - n} & 0 \end{bmatrix} $$
다만 이 행렬은 OpenGL이 사용하는 column-major 메모리 구조에 맞춰 작성되어야 하므로,
matrix[11]과 matrix[14]의 인덱스 위치가 수식과 달라 보일 수 있습니다. 이는 순서가 바뀐 것이 아니라 메모리 상 배치 기준이 다르기 때문입니다.
6. 최종 정점 위치 계산
위 세 행렬은 다음과 같은 순서로 곱해져서 셰이더에 전달되며, 최종적으로 정점의 위치가 결정됩니다:
gl_Position = projection * view * model * vec4(vertexPosition, 1.0);
이 수식은 다음과 같은 의미를 담고 있습니다:
- Model 행렬: 오브젝트의 로컬 공간에서 월드 공간으로 이동
- View 행렬: 월드 공간에서 카메라 기준 공간으로 이동
- Projection 행렬: 카메라 공간에서 정규화 장치 좌표계(NDC)로 투영
- gl_Position: GPU의 래스터라이저에 의해 화면 픽셀로 변환
즉, 우리가 OBJ 파일을 파싱하고 정점 정보를 넘기며 행렬을 구성하는 모든 과정은 이 한 줄의 수식을 위해 존재하는 것입니다.
'Dev > Grapics' 카테고리의 다른 글
| Scop 구현기 4편: Model 클래스 (0) | 2025.04.14 |
|---|---|
| Scop 구현기 3편: 셰이더 클래스를 만들기로 했다 (0) | 2025.04.11 |
| Scop 구현기 2편: VAO와 VBO의 세계 (0) | 2025.04.09 |
| Scop 구현기 1편: 윈도우 하나 띄우는 게 뭐라고 (Object Viewer) (0) | 2025.04.09 |
| Rasterization vs Ray Tracing (0) | 2025.04.09 |
