Skip to content

원본: Coordinate Systems

좌표 공간

지난 장에서는 변환 행렬을 사용하여 모든 정점을 변환하는 방법을 배웠습니다. OpenGL은 각 정점 셰이더 실행 후 화면에 표시될 모든 정점이 정규화된 장치 좌표(NDC)로 변환되어 있어야 한다고 요구합니다. 즉, 각 정점의 x, y, z 좌표는 -1.0에서 1.0 사이여야 하며, 이 범위를 벗어나는 좌표는 화면에 표시되지 않습니다. 일반적으로 우리는 좌표 범위를 직접 지정하고 정점 셰이더에서 이러한 좌표를 정규화된 장치 좌표(NDC)로 변환합니다. 이렇게 변환된 NDC 좌표는 래스터라이저에 전달되어 화면의 2D 좌표/픽셀로 변환됩니다.

좌표를 NDC로 변환하는 작업은 일반적으로 객체의 정점을 여러 좌표계로 변환한 후 최종적으로 NDC로 변환하는 단계별 방식으로 수행됩니다. 여러 중간 좌표계로 변환하는 장점은 특정 좌표계에서 일부 연산/계산이 더 쉽다는 점이며, 이는 곧 명확해질 것입니다. 우리가 다룰 중요한 좌표계는 총 5가지입니다.

  • 로컬 공간(또는 객체 공간)
  • 세계 공간
  • 화면 공간 (또는 시선 공간)
  • 클립 공간
  • 화면 공간

이것들은 모두 정점들이 최종적으로 프래그먼트로 변하기 전에 거치게 될 서로 다른 상태들입니다.

아마 지금쯤 공간이나 좌표계가 실제로 무엇인지 꽤 혼란스러우실 테니, 먼저 전체적인 개념과 각 공간이 나타내는 바를 보여드리면서 좀 더 개략적으로 설명해 드리겠습니다.

큰 흐름

한 좌표 공간에서 다른 좌표 공간으로 좌표를 변환하기 위해 여러 변환 행렬을 사용하는데, 그중 가장 중요한 것은 모델 행렬, 뷰 행렬, 그리고 투영 행렬입니다. 정점 좌표는 먼저 로컬 좌표로 시작하여 월드 좌표, 뷰 좌표, 클립 좌표를 거쳐 최종적으로 화면 좌표가 됩니다. 다음 이미지는 이러한 변환 과정과 각 변환이 수행하는 작업을 보여줍니다.

  1. 로컬 좌표는 객체가 위치한 로컬 원점을 기준으로 한 객체의 좌표입니다. 즉, 객체가 처음 정의되는 기준 좌표입니다.
  2. 다음 단계는 로컬 좌표를 더 큰 세계를 기준으로 하는 월드 좌표로 변환하는 것입니다. 이 좌표는 세계의 어떤 전역 원점을 기준으로 하며, 세계의 원점을 기준으로 배치된 다른 많은 객체들과 함께 상대적인 좌표입니다.
  3. 다음으로, 월드 좌표를 뷰 좌표로 변환하여 각 좌표가 카메라 또는 시청자의 시점에서 보이는 좌표가 되도록 합니다.
  4. 좌표를 뷰포트에 표현하려면 클립 좌표로 투영해야 합니다. 클립 좌표는 -1.0에서 1.0 범위로 처리되어 화면에 표시될 정점을 결정합니다. 원근 투영을 사용하는 경우 클립 공간 좌표로의 투영은 원근감을 추가할 수 있습니다.
  5. 마지막으로, 클립 좌표를 화면 좌표(뷰포트 좌표)로 변환하는 과정을 거치는데, 이 과정을 뷰포트 변환이라고 합니다. 이 변환은 -1.0에서 1.0 사이의 좌표를 glViewport 함수로 정의된 좌표 범위로 변환합니다. 이렇게 얻은 좌표는 래스터라이저로 전달되어 프래그먼트로 변환됩니다.

각 공간이 어떤 용도로 쓰이는지 대략적으로 이해하셨을 겁니다. 정점을 이렇게 다양한 공간으로 변환하는 이유는 일부 연산이 특정 좌표계에서 더 효율적이거나 사용하기 편리하기 때문입니다. 예를 들어, 객체를 수정할 때는 로컬 좌표 공간에서 작업하는 것이 가장 적절하고, 다른 객체의 위치를 ​​고려하여 객체에 대한 특정 연산을 계산할 때는 월드 좌표계가 가장 적합합니다. 물론 로컬 공간에서 클립 공간으로 한 번에 변환하는 변환 행렬을 정의할 수도 있지만, 그렇게 하면 유연성이 떨어집니다.

각 좌표계에 대해서는 아래에서 더 자세히 설명하겠습니다.

로컬 공간

로컬 공간이란 객체에 국한된 좌표 공간, 즉 객체가 처음 위치하는 좌표 공간을 말합니다. 블렌더와 같은 모델링 소프트웨어로 큐브를 만들었다고 가정해 보세요. 큐브의 원점은 아마도 (0,0,0)일 것입니다. 최종 결과물에서는 큐브의 위치가 다를 수 있지만요. 아마도 여러분이 만든 모든 모델은 초기 위치가 (0,0,0)일 것입니다. 따라서 모델의 모든 정점은 로컬 공간에 있습니다. 즉, 모든 정점이 객체에 국한되어 있는 것입니다.

우리가 사용해 온 컨테이너의 정점은 원점 0.0을 기준으로 -0.5에서 0.5 사이의 좌표로 지정되었습니다. 이는 로컬 좌표입니다.

이해하기 쉽게 말하자면 오브젝트의 모든 정점의 위치는 그 객체의 로컬 좌표로 써있다는 뜻입니다.

월드 공간

만약 모든 객체를 애플리케이션에 직접 불러온다면, 아마도 모든 객체가 월드 원점인 (0,0,0)에 서로 겹쳐서 배치될 것입니다. 이는 우리가 원하는 결과가 아닙니다. 우리는 각 객체가 더 큰 월드 안에서 적절한 위치에 배치될 수 있도록 위치를 정의해야 합니다. 월드 공간 좌표는 말 그대로 (게임) 월드를 기준으로 모든 정점의 좌표를 나타냅니다. 객체들이 (가급적 현실적인 방식으로) 월드 공간에 흩어져 있도록 변환해야 하는 좌표 공간이 바로 이 월드 공간입니다. 객체의 좌표는 모델 행렬을 이용하여 로컬 공간에서 월드 공간으로 변환됩니다.

모델 행렬은 객체를 변환하여 월드 상의 적절한 위치/방향으로 배치하기 위해 객체를 이동, 확대/축소 및/또는 회전시키는 변환 행렬입니다. 예를 들어, 집이 로컬 공간에서 너무 커서 크기를 줄이고, 교외 마을로 이동한 다음, 주변 집들과 잘 어울리도록 y축을 기준으로 왼쪽으로 약간 회전시키는 것을 생각해 보세요. 이전 장에서 컨테이너를 장면 전체에 배치하기 위해 사용했던 행렬도 일종의 모델 행렬로 생각할 수 있습니다. 컨테이너의 로컬 좌표를 장면/월드 상의 다른 위치로 변환한 것입니다.

뷰 공간

뷰 공간은 일반적으로 OpenGL에서 카메라라고 부르는 영역입니다(카메라 공간 또는 아이 공간(eye space)이라고도 함). 뷰 공간은 사용자의 월드 공간 좌표를 사용자의 시야 앞에 있는 좌표로 변환한 결과입니다. 즉, 뷰 공간은 카메라의 시점에서 보이는 공간입니다. 일반적으로 장면을 이동/회전시켜 특정 요소들을 카메라 앞으로 이동시키는 변환을 조합하여 이를 구현합니다. 이러한 변환들은 일반적으로 월드 좌표를 뷰 공간으로 변환하는 뷰 행렬에 저장됩니다. 다음 장에서는 카메라를 시뮬레이션하기 위한 뷰 행렬 생성 방법에 대해 자세히 살펴보겠습니다.

클립 공간

각 정점 셰이더의 실행이 끝나면, OpenGL은 정점 좌표가 특정 범위 안에 들어와 있기를 기대하며 이 범위를 벗어난 좌표는 클립합니다. 잘려 나간 좌표는 버려지고, 남은 좌표만이 최종적으로 화면에 보이는 프래그먼트로 남게 됩니다. 클립 공간이라는 이름은 이러한 과정에서 유래했습니다.

모든 보이는 좌표를 처음부터 -1.0에서 1.0 사이로 지정하는 것은 직관적이지 않기 때문에, 우리는 작업에 적합한 자체 좌표계를 사용하고, 이를 OpenGL이 기대하는 NDC 형식으로 다시 변환합니다.

뷰 좌표를 클립 좌표계로 변환하기 위해, 각 차원에서의 좌표 범위(예: -1000 ~ 1000)를 정의하는 투영 행렬을 사용합니다. 이 투영 행렬은 지정된 범위 내의 좌표를 정규화된 장치 좌표(-1.0 ~ 1.0)로 변환합니다. 이 변환은 한 번에 직접 이루어지는 것이 아니라, 이후에 수행되는 원근 분할 단계를 통해 완성됩니다. 이 범위를 벗어나는 모든 좌표는 -1.0과 1.0 사이로 매핑되지 않기 때문에 잘리게 됩니다. 예를 들어, 투영 행렬에 지정된 범위 내에서 (1250, 500, 750)이라는 좌표는 x값이 범위를 벗어나므로 NDC에서 1.0보다 큰 값으로 변환되고, 그 결과 화면에 표시되지 않습니다.

참고로 삼각형과 같은 기본 도형의 일부만 클리핑 영역 밖에 위치한 경우, OpenGL은 클리핑 영역 안에 맞도록 해당 도형을 하나 이상의 삼각형으로 재구성합니다.

투영 행렬이 만들어내는 이 보기 영역을 절두체(frustum)라고 하며, 이 절두체 안에 포함된 모든 좌표는 사용자의 화면에 표시됩니다. 지정된 범위 내의 좌표를 2D 화면 좌표로 쉽게 매핑할 수 있는 NDC로 변환하는 전체 과정을 투영이라고 합니다. 이는 투영 행렬이 3D 좌표를 2D로 매핑하기 쉬운 정규화된 장치 좌표로 사영하기 때문입니다.

모든 정점이 클립 공간으로 변환된 이후에는, 마지막으로 원근 분할이라는 연산이 수행됩니다. 이 연산에서는 위치 벡터의 x, y, z 성분을 동차 좌표의 w 성분으로 나눕니다. 원근 분할은 4차원 클립 공간 좌표를 3차원 정규화된 장치 좌표로 변환하는 과정이며, 이 단계는 정점 셰이더의 마지막에서 자동으로 수행됩니다.

이 단계가 끝나면, 결과 좌표는 glViewport 설정에 따라 화면 좌표로 변환되고, 이후 프래그먼트로 생성됩니다.

뷰 좌표를 클립 좌표로 변환하는 투영 행렬에는 일반적으로 두 가지 형태가 있습니다. 하나는 직교(orthographic) 투영 행렬이고, 다른 하나는 원근(perspective) 투영 행렬이며, 각 행렬은 서로 다른 형태의 절두체를 정의합니다.

직교 투영

직교 투영 행렬은 정점이 잘리는 클리핑 공간을 정의하는 정육면체 형태의 절두체를 생성합니다. 직교 투영 행렬을 만들 때는 보이는 절두체의 너비, 높이, 깊이를 직접 지정합니다. 이 절두체 내부에 위치한 모든 좌표는 행렬 변환 이후에도 NDC 범위 안에 존재하므로 잘리지 않습니다. 이러한 절두체는 컨테이너를 닮은 형태를 가집니다.

절두체는 보이는 좌표를 정의하며 너비, 높이, 근거리 평면 및 원거리 평면으로 지정됩니다. 근거리 평면 앞쪽의 좌표는 잘리고, 원거리 평면 뒤쪽의 좌표도 마찬가지입니다. 직교 절두체는 변환된 벡터의 w 성분을 건드리지 않으므로 특별한 부작용 없이 절두체 내부의 모든 좌표를 정규화된 장치 좌표로 직접 매핑합니다. w 성분이 1.0으로 유지되면 원근 분할은 좌표를 변경하지 않습니다.

직교 투영 행렬을 생성하려면 GLM의 내장 함수인 glm::ortho를 사용할 수 있습니다.

glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);

첫 번째와 두 번째 매개변수는 절두체의 좌우 좌표를 지정하고, 세 번째와 네 번째 매개변수는 절두체의 상하좌표를 지정합니다. 이 네 개의 좌표를 이용하여 근거리 평면과 원거리 평면의 크기를 정의하고, 다섯 번째와 여섯 번째 매개변수는 근거리 평면과 원거리 평면 사이의 거리를 정의합니다. 이 특정 투영 행렬은 이러한 x, y, z 범위 내의 모든 좌표를 정규화된 장치 좌표로 변환합니다.

직교 투영 행렬은 좌표를 화면이라는 2차원 평면에 직접 매핑하지만, 실제로는 원근감을 고려하지 않기 때문에 비현실적인 결과를 생성합니다. 원근 투영 행렬은 이러한 문제를 해결해 줍니다.

원근 투영

실생활에서 볼 수 있는 그래픽을 감상해 본 적이 있다면 멀리 있는 물체가 훨씬 작게 보이는 것을 알 수 있을 것입니다. 이러한 신기한 현상을 우리는 원근법이라고 부릅니다. 원근법은 특히 다음 이미지에서처럼 끝없이 펼쳐진 고속도로나 철로의 끝자락을 내려다볼 때 두드러지게 나타납니다.

보시다시피, 원근법 때문에 충분히 먼 거리에서는 선들이 일치하는 것처럼 보입니다. 이것이 바로 원근 투영이 모방하려는 효과이며, 원근 투영 행렬을 사용하여 이를 구현합니다. 투영 행렬은 주어진 절두체 범위를 클립 공간으로 매핑할 뿐만 아니라, 각 정점 좌표의 w 값을 조작하여 정점 좌표가 보는 사람으로부터 멀어질수록 w 값이 커지도록 합니다. 좌표가 클립 공간으로 변환되면 -w에서 w 사이의 범위에 있게 됩니다(이 범위를 벗어나는 모든 값은 잘립니다). OpenGL은 최종 정점 셰이더 출력에서 ​​보이는 좌표가 -1.0에서 1.0 사이의 범위에 있어야 하므로, 좌표들이 클립 공간에 들어온 뒤에는 클립 공간 좌표에 아래와 같은 원근 분할 이 적용됩니다.

\[ out = \begin{pmatrix} x /w \\ y / w \\ z / w \end{pmatrix} \]

정점 좌표의 각 성분은 w 성분으로 나누어지므로, 정점이 관찰자로부터 멀어질수록 좌표값이 작아집니다. w 성분이 중요한 또 다른 이유는 바로 원근 투영에 도움을 주기 때문입니다. 이렇게 얻은 좌표는 정규화된 장치 공간 좌표계로 표현됩니다. 직교 투영 및 원근 투영 행렬이 실제로 어떻게 계산되는지 궁금하고 (수학에 크게 부담을 느끼지 않는다면) 성호님의 이 훌륭한 글을 읽어보시기를 추천합니다.

GLM에서 원근 투영 행렬은 다음과 같이 생성할 수 있습니다.

glm::mat4 proj = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);

glm::perspective 함수는 가시 공간을 정의하는 큰 절두체를 생성합니다. 절두체 바깥에 있는 것은 클립 공간 볼륨에 포함되지 않으므로 잘립니다. 원근 절두체는 모양이 고르지 않은 상자로 시각화할 수 있으며, 이 상자 안의 각 좌표는 클립 공간의 한 점에 매핑됩니다. 원근 절두체의 이미지는 아래에서 볼 수 있습니다.

첫 번째 매개변수는 시야각(FOV, Field of View) 값을 정의하며, 이는 보이는 공간의 범위를 설정합니다. 사실적인 화면을 위해서는 일반적으로 45도로 설정하지만, 둠 스타일의 그래픽을 원한다면 더 높은 값으로 설정할 수 있습니다. 두 번째 매개변수는 화면 비율을 설정하는데, 이는 뷰포트의 너비를 높이로 나눈 값입니다. 세 번째와 네 번째 매개변수는 절두체의 근거리 평면과 원거리 평면을 설정합니다. 일반적으로 근거리 평면은 0.1, 원거리 평면은 100.0으로 설정합니다. 근거리 평면과 원거리 평면 사이, 그리고 절두체 내부에 있는 모든 정점이 렌더링됩니다.

원근 행렬의 근접 값이 너무 높게 설정되면(예: 10.0), OpenGL은 카메라에 가까운 모든 좌표(0.0~10.0 사이)를 잘라냅니다. 이로 인해 게임에서 특정 물체에 너무 가까이 다가가면 물체가 투명하게 보이는 현상이 발생할 수 있습니다.

직교 투영을 사용하면 각 정점 좌표가 복잡한 원근 분할 없이 클립 공간에 직접 매핑됩니다(원근 분할은 여전히 ​​수행되지만 w 구성 요소는 조작되지 않고 1로 유지되므로 아무런 영향을 미치지 않습니다). 직교 투영은 원근 투영을 사용하지 않기 때문에 멀리 있는 물체가 작아 보이지 않아 시각적으로 어색한 결과가 나타납니다. 이러한 이유로 직교 투영은 주로 2D 렌더링이나 원근으로 인한 정점 왜곡이 바람직하지 않은 건축 또는 엔지니어링 응용 분야에서 사용됩니다. Blender와 같은 3D 모델링 프로그램에서는 각 객체의 크기를 더 정확하게 표현하기 위해 직교 투영을 모델링에 사용하는 경우가 있습니다. 아래에서 Blender의 두 투영 방식을 비교해 볼 수 있습니다.

원근 투영에서는 멀리 있는 정점이 훨씬 작게 보이는 반면, 직교 투영에서는 각 정점이 사용자에게서 동일한 거리에 있는 것을 알 수 있습니다.

모든 것을 종합해보면

앞서 언급한 각 단계(모델, 뷰, 투영 행렬)에 대해 변환 행렬을 생성합니다. 그런 다음 정점 좌표는 다음과 같이 클립 좌표로 변환됩니다.

\[ V_{clip} = M_{projection} \cdot M_{view} \cdot M_{model} \cdot V_{local} \]

M은 행렬, V는 벡터(정점)를 뜻합니다.

행렬 곱셈 순서가 반대로 되어 있다는 점에 유의하세요(행렬 곱셈은 오른쪽에서 왼쪽으로 읽어야 합니다). 결과로 나온 정점은 정점 셰이더의 gl_Position에 할당해야 하며, 그러면 OpenGL이 자동으로 원근 분할과 클리핑을 수행합니다.

그런 다음?

정점 셰이더의 출력은 클립 공간 좌표를 필요로 하는데, 이는 방금 변환 행렬을 사용하여 수행한 것입니다. OpenGL은 클립 공간 좌표에 원근분활을 적용하여 정규화된 장치 좌표로 변환합니다. 그런 다음 OpenGL은 glViewPort 함수의 매개변수를 사용하여 정규화된 장치 좌표를 화면 좌표로 매핑합니다. 여기서 각 좌표는 화면상의 한 점(이 예에서는 800x600 화면)에 해당합니다. 이 과정을 뷰포트 변환이라고 합니다.

이 주제는 이해하기 어려울 수 있으므로 각 좌표 공간이 정확히 무엇에 사용되는지 아직 잘 모르시더라도 걱정하지 마세요. 아래에서 이러한 좌표 공간을 실제로 어떻게 유용하게 활용할 수 있는지 보여드리고, 앞으로 나올 장들에서 충분한 예시를 제공할 예정입니다.

첫번째 3D

이제 3D 좌표를 2D 좌표로 변환하는 방법을 알았으니, 지금까지 보여드렸던 밋밋한 2D 평면 대신 실제 3D 객체를 렌더링할 수 있습니다.

3D 드로잉을 시작하려면 먼저 모델 행렬을 만들어야 합니다. 모델 행렬은 객체의 모든 정점을 전역 월드 공간으로 변환하기 위해 적용할 이동, 크기 조정 및/또는 회전으로 구성됩니다. 평면을 바닥에 놓인 것처럼 보이도록 x축을 중심으로 회전시켜 변환해 보겠습니다. 그러면 모델 행렬은 다음과 같습니다.

glm::mat4 model = glm::mat4(1.0f);
model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f)); 

정점 좌표에 이 모델 행렬을 곱하면 정점 좌표가 월드 좌표로 변환됩니다. 따라서 바닥에 약간 닿아 있는 평면은 전역 좌표계에서의 평면을 나타냅니다.

다음으로 뷰 행렬을 생성해야 합니다. 객체가 보이도록 장면에서 약간 뒤로 이동하려고 합니다(월드 공간에서 원점(0,0,0)에 위치할 때). 장면 내에서 이동하는 방법은 다음과 같습니다.

  • 카메라를 뒤로 이동시키는 것은 장면 전체를 앞으로 이동시키는 것과 같습니다.

뷰 매트릭스는 바로 그런 역할을 합니다. 카메라가 이동해야 할 위치에 맞춰 전체 장면을 반전시켜 이동시키는 것이죠.
뒤로 이동하고 싶고 OpenGL은 오른손 좌표계이므로 양의 z축 방향으로 이동해야 합니다. 이를 위해 장면을 음의 z축 방향으로 이동시킵니다. 이렇게 하면 뒤로 이동하는 것처럼 보입니다.

오른손 좌표계

관례적으로 OpenGL은 오른손 좌표계를 사용합니다. 즉, 양의 x축은 오른쪽, 양의 y축은 위쪽, 양의 z축은 뒤쪽을 가리킵니다. 화면을 세 축의 중심이라고 생각하고, 양의 z축이 화면을 통과하여 사용자를 향한다고 생각하면 됩니다. 축은 다음과 같이 그려집니다.

오른손잡이라고 불리는 이유를 이해하려면 다음 과정을 시도해보세요.

  • 오른팔을 양의 y축 방향으로 쭉 뻗고 손을 위로 향하게 하세요.
  • 엄지손가락이 오른쪽을 가리키도록 하세요.
  • 검지손가락을 위로 향하게 하세요.
  • 이제 가운데 손가락을 아래쪽으로 90도 구부리세요.

제대로 했다면 엄지손가락은 양의 x축을, 검지손가락은 양의 y축을, 중지손가락은 양의 z축을 가리킬 것입니다. 만약 왼팔로 똑같이 한다면 z축이 반대로 표시되는 것을 볼 수 있습니다. 이것을 왼손 좌표계라고 하며 DirectX에서 흔히 사용됩니다. 참고로, 정규화된 장치 좌표계에서는 OpenGL도 실제로 왼손 좌표계를 사용합니다(투영 행렬이 좌표계의 방향을 바꿉니다).

번역자 코멘트

마인크래프트는 오른손 좌표계를 사용합니다. 마인크래프트를 즐겨 했다면 오른손 좌표계가 익숙할수도 있겠네요.

또한 블렌더는 왼손 좌표계를 사용합니다.

다음 장에서는 장면을 이동하는 방법에 대해 더 자세히 설명하겠습니다. 지금은 뷰 매트릭스가 다음과 같습니다.

glm::mat4 view = glm::mat4(1.0f);
// 참고로, 우리는 이동하려는 방향과 반대 방향으로 장면을 이동시키고 있습니다.
view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f)); 

마지막으로 정의해야 할 것은 투영 행렬입니다. 장면에 원근 투영을 사용할 것이므로 다음과 같이 투영 행렬을 선언합니다.

glm::mat4 projection;
projection = glm::perspective(glm::radians(45.0f), 800.0f / 600.0f, 0.1f, 100.0f);

이제 변환 행렬을 생성했으니 이를 셰이더에 전달해야 합니다. 먼저 정점 셰이더에서 변환 행렬을 유니폼 변수로 선언하고 정점 좌표와 곱해 보겠습니다.

#version 330 core
layout (location = 0) in vec3 aPos;
...
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    // 행렬 곱셈은 ​​오른쪽에서 왼쪽으로 읽는다는 점에 유의하세요.
    gl_Position = projection * view * model * vec4(aPos, 1.0);
    ...
}

또한 변환 행렬을 셰이더로 보내야 합니다(변환 행렬은 자주 변경되는 경향이 있으므로 일반적으로 매 프레임마다 수행합니다).

int modelLoc = glGetUniformLocation(ourShader.ID, "model");
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
... // 뷰 행렬과 프로젝션 행렬 모두 동일

이제 정점 좌표가 모델, 뷰 및 투영 행렬을 통해 변환되었으므로 최종 객체는 다음과 같아야 합니다.

  • 뒤로 기울어져 바닥에 붙어있어야 합니다.
  • 우리로부터 조금 더 멀리 떨어져 있습니다.
  • 원근감이 표현되어야 합니다. (정점이 멀어질수록 크기가 작아져야 합니다)

결과가 실제로 이러한 요구 사항을 충족하는지 확인해 보겠습니다.

정말 평면이 가상의 바닥 위에 놓여 있는 3차원 평면처럼 보이네요. 만약 같은 결과가 나오지 않는다면, 여러분의 코드와 전체 소스 코드를 비교해 보세요.

더 많은 3D

지금까지는 3D 공간에서도 2D 평면만을 다뤄왔지만, 이제 좀 더 나아가 2D 평면을 3D 정육면체로 확장해 보겠습니다. 정육면체를 렌더링하려면 총 36개의 정점(면 6개 * 삼각형 2개 * 각 삼각형당 정점 3개)이 필요합니다. 36개의 정점은 계산하기 어려우니, 여기에서 필요한 정점들을 가져올 수 있습니다.

재미 삼아 시간이 지남에 따라 큐브가 회전하도록 두겠습니다.

model = glm::rotate(model, (float)glfwGetTime() * glm::radians(50.0f), glm::vec3(0.5f, 1.0f, 0.0f)); 

그다음에는 (인덱스를 지정하지 않았으므로) glDrawArrays를 사용하여 큐브를 그리겠습니다. 이번에는 정점의 개수를 36개로 지정합니다.

glDrawArrays(GL_TRIANGLES, 0, 36);

다음과 비슷한 결과가 나올 것입니다. (클릭해서 확인하세요.)

약간 정육면체처럼 보이긴 하지만 뭔가 이상합니다. 정육면체의 일부 면이 다른 면 위에 겹쳐 그려지고 있습니다. 이는 OpenGL이 삼각형 단위로, 즉 프래그먼트 단위로 정육면체를 그릴 때 이전에 그려진 픽셀 색상을 덮어쓰기 때문에 발생합니다. OpenGL은 (동일한 드로우 콜 내에서) 삼각형 렌더링 순서를 보장하지 않으므로, 분명히 앞에 있어야 할 삼각형이 서로 겹쳐 그려지는 현상이 발생합니다.

다행히 OpenGL은 깊이 정보를 z-버퍼라는 버퍼에 저장하여 픽셀 위에 그림을 그릴지 말지를 결정할 수 있습니다. z-버퍼를 사용하면 OpenGL이 깊이 테스트를 수행하도록 설정할 수 있습니다.

Z-버퍼

OpenGL은 깊이 버퍼라고도 불리는 z-버퍼에 모든 깊이 정보를 저장합니다. GLFW는 출력 이미지의 색상을 저장하는 컬러 버퍼처럼 이러한 버퍼를 자동으로 생성합니다. 깊이 정보는 각 프래그먼트(프래그먼트의 z 값)에 저장되며, 프래그먼트가 색상을 출력하려고 할 때 OpenGL은 해당 프래그먼트의 깊이 값을 z-버퍼와 비교합니다. 현재 프래그먼트가 다른 프래그먼트보다 깊이가 낮으면 해당 값은 버려지고, 그렇지 않으면 덮어쓰기됩니다. 이 과정을 깊이 테스트라고 하며 OpenGL에서 자동으로 수행됩니다.

하지만 OpenGL이 실제로 깊이 테스트를 수행하도록 하려면 먼저 OpenGL에 깊이 테스트를 활성화하도록 알려야 합니다. 깊이 테스트는 기본적으로 비활성화되어 있습니다. glEnable 함수를 사용하여 깊이 테스트를 활성화할 수 있습니다. glEnable 및 glDisable 함수를 사용하면 OpenGL의 특정 기능을 활성화/비활성화할 수 있습니다. 활성화/비활성화된 기능은 다시 비활성화/활성화 함수를 호출할 때까지 유지됩니다. 지금 우리는 GL_DEPTH_TEST를 활성화하여 깊이 테스트를 활성화하려고 합니다.

glEnable(GL_DEPTH_TEST);  

깊이 버퍼를 사용하고 있으므로 각 렌더링 반복 전에 깊이 버퍼를 지워야 합니다(그렇지 않으면 이전 프레임의 깊이 정보가 버퍼에 남아 있게 됩니다). 색상 버퍼를 지우는 것과 마찬가지로 glClear 함수에서 DEPTH_BUFFER_BIT 비트를 지정하여 깊이 버퍼를 지울 수 있습니다.

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

이제 프로그램을 다시 실행해서 OpenGL이 깊이 테스트를 제대로 수행하는지 확인해 보겠습니다. (클릭해서 확인하세요.)

자, 완성입니다! 시간이 지남에 따라 회전하는, 깊이 테스트까지 완벽하게 적용된 텍스처링된 큐브입니다. 소스 코드는 여기에서 확인하세요.

더 많은 상자!!!!

화면에 큐브 10개를 표시한다고 가정해 보겠습니다. 각 큐브는 외형은 동일하지만, 월드 상에서의 위치와 회전 각도만 다릅니다. 큐브의 그래픽 레이아웃은 이미 정의되어 있으므로, 객체를 추가할 때 버퍼나 속성 배열을 변경할 필요가 없습니다. 각 객체에 대해 변경해야 하는 유일한 부분은 큐브를 월드에 맞게 변환하는 모델 행렬입니다.

glm::vec3 cubePositions[] = {
    glm::vec3( 0.0f,  0.0f,  0.0f), 
    glm::vec3( 2.0f,  5.0f, -15.0f), 
    glm::vec3(-1.5f, -2.2f, -2.5f),  
    glm::vec3(-3.8f, -2.0f, -12.3f),  
    glm::vec3( 2.4f, -0.4f, -3.5f),  
    glm::vec3(-1.7f,  3.0f, -7.5f),  
    glm::vec3( 1.3f, -2.0f, -2.5f),  
    glm::vec3( 1.5f,  2.0f, -2.5f), 
    glm::vec3( 1.5f,  0.2f, -1.5f), 
    glm::vec3(-1.3f,  1.0f, -1.5f)  
};

이제 렌더링 루프 내에서 glDrawArrays 함수를 10번 호출하되, 매번 드로우 콜을 보내기 전에 서로 다른 모델 행렬을 정점 셰이더로 전달하려고 합니다. 이를 위해 렌더링 루프 내에 작은 루프를 만들어 매번 다른 모델 행렬을 사용하여 객체를 10번 렌더링합니다. 또한 각 컨테이너에 고유한 회전 값을 추가합니다.

glBindVertexArray(VAO);
for(unsigned int i = 0; i < 10; i++)
{
    glm::mat4 model = glm::mat4(1.0f);
    model = glm::translate(model, cubePositions[i]);
    float angle = 20.0f * i; 
    model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
    ourShader.setMat4("model", model);

    glDrawArrays(GL_TRIANGLES, 0, 36);
}

이 코드 조각은 새로운 큐브가 그려질 때마다 모델 행렬을 업데이트하고, 이 작업을 총 10번 수행합니다. 이제 우리는 이상하게 회전된 큐브 10개로 가득 찬 세상을 보고 있을 것입니다.

완벽해요! 우리 컨테이너가 뜻이 맞는 친구들을 찾은 것 같네요. 혹시 막히는 부분이 있으면 소스 코드와 비교해 보세요.

연습 문제

  • GLM의 투영 함수의 FoV(시야각)와 종횡비 매개변수를 바꿔가며 실험해 보세요. 이러한 매개변수들이 원근 절두체에 어떤 영향을 미치는지 파악해 보세요.
  • 뷰 매트릭스를 여러 방향으로 이동시켜 장면이 어떻게 변하는지 살펴보세요. 뷰 매트릭스를 카메라 객체라고 생각하면 됩니다.
  • 모델 행렬만을 사용하여 첫 번째 컨테이너를 포함하여 3번째 컨테이너마다 시간이 지남에 따라 회전하도록 하고, 나머지 컨테이너는 고정된 상태로 유지하세요. (해결 방법)

Comments