원본: Transformations
변형
수학 잘하세요? 일단 전 못함.
이제 우리는 객체를 생성하고, 색을 입히고, 텍스처를 사용하여 세부적인 모습을 부여하는 방법을 알게 되었습니다. 하지만 이러한 객체들은 모두 정적인 객체이기 때문에 여전히 그다지 흥미롭지 않습니다. 매 프레임마다 정점을 변경하고 버퍼를 재구성하여 객체를 움직이게 만들 수도 있지만, 이는 번거롭고 상당한 처리 능력을 요구합니다. 객체를 변형(transform)하는 훨씬 더 나은 방법이 있는데, 바로 (여러 개의) 행렬 객체를 사용하는 것입니다.
transform
transform은 변형, 변환 등 여러가지 의미로 번역됩니다. 이 번역에서는 변환 또는 변형만 사용하겠습니다.
행렬은 처음에는 다소 어렵게 느껴질 수 있지만, 익숙해지면 매우 유용한 강력한 수학적 개념입니다. 행렬에 대해 이야기하려면 약간의 수학적 개념이 필요하며, 수학에 관심 있는 독자들을 위해 추가 자료를 제공하겠습니다.
하지만 변환을 완전히 이해하려면 행렬을 논하기 전에 벡터에 대해 좀 더 자세히 살펴봐야 합니다. 이 장의 목표는 앞으로 다룰 주제에 대한 기본적인 수학적 배경 지식을 제공하는 것입니다. 내용이 어렵게 느껴지더라도 최대한 이해하려고 노력하고, 필요할 때 언제든 다시 이 장을 참고하여 개념을 복습하세요.
벡터
가장 기본적인 정의로, 벡터는 방향 그 자체입니다. 벡터는 방향(direction)과 크기(magnitude)(또는 강도나 길이)를 가지고 있습니다. 벡터를 보물지도의 방향처럼 생각해 볼 수 있습니다. '왼쪽으로 10걸음, 북쪽으로 3걸음, 오른쪽으로 5걸음'에서 '왼쪽'은 방향이고 '10걸음'은 벡터의 크기입니다. 따라서 보물지도의 방향은 세 개의 벡터로 이루어져 있습니다. 벡터는 어떤 차원이든 가질 수 있지만, 보통 2차원에서 4차원 벡터를 사용합니다. 벡터가 2차원일 때는 평면상의 방향을 나타내고(2D 그래프를 생각해 보세요), 3차원일 때는 3차원 세계의 어떤 방향이든 나타낼 수 있습니다.
아래 그림에서 각 벡터는 2차원 그래프에서 화살표(x, y)로 표현된 세 개의 벡터를 볼 수 있습니다. 벡터는 3차원보다 2차원으로 나타내는 것이 더 직관적이므로, 이 2차원 벡터들을 az 좌표가 0인 3차원 벡터로 생각할 수 있습니다. 벡터는 방향을 나타내므로 벡터의 원점은 벡터의 값을 바꾸지 않습니다. 아래 그래프에서 벡터 \(\textcolor{red}{\vec{v}}\) 와 \(\textcolor{blue}{\vec{w}}\) 는 원점이 다르더라도 크기가 같다는 것을 알 수 있습니다.

벡터를 표현할 때 수학자들은 일반적으로 벡터 머리 위에 작은 막대가 있는 기호( \(\vec{v}\) )로 나타내는 것을 선호합니다. 또한, 공식에서 벡터를 표시할 때는 일반적으로 다음과 같이 나타냅니다.
벡터는 방향을 나타내기 때문에 위치로 시각화하기가 어려울 때가 있습니다. 벡터를 위치로 시각화하려면 방향 벡터의 원점을 (0,0,0)으로 설정하고 특정 방향을 가리키는 지점을 나타내는 위치 벡터를 만들 수 있습니다. (원점을 다르게 지정하고 '이 벡터는 이 원점에서 공간의 저 지점을 가리킨다'라고 표현할 수도 있습니다.) 따라서 위치 벡터 (3,5)는 원점 (0,0)을 기준으로 그래프에서 (3,5)를 가리킵니다. 이처럼 벡터를 사용하면 2차원 및 3차원 공간에서 방향과 위치를 설명할 수 있습니다.
일반 숫자와 마찬가지로 벡터에도 여러 가지 연산을 정의할 수 있습니다(이미 보신 것도 있을 겁니다).
스칼라 벡터 연산
스칼라(scalar)는 한 자리 숫자입니다. 스칼라를 포함하는 벡터를 더하거나 빼거나 곱하거나 나눌 때는 벡터의 각 요소에 스칼라를 더하거나 빼거나 곱하거나 나누면 됩니다. 덧셈의 경우 다음과 같습니다.
여기서 \(+\)는 \(+\), \(-\), \(\cdot\) 또는 \(\div\)가 될 수 있으며, \(\cdot\)는 곱셈 연산자입니다.
벡터 반전
벡터를 반전시키면 방향이 반대인 벡터가 됩니다. 북동쪽을 가리키는 벡터는 반전 후 남서쪽을 가리키게 됩니다. 벡터를 반전시키려면 각 성분에 마이너스 부호를 붙입니다(스칼라 값 -1을 사용하여 스칼라와 벡터의 곱으로 나타낼 수도 있습니다).
덧셈과 뺄셈
두 벡터의 덧셈은 성분별 덧셈으로 정의됩니다. 즉, 한 벡터의 각 성분을 다른 벡터의 동일한 성분에 더하는 것입니다.
시각적으로 보면, 벡터 v=(4,2)와 k=(1,2)에서 두 번째 벡터를 첫 번째 벡터의 끝점 위에 더하여 결과 벡터의 끝점을 찾는 방식(머리-꼬리 방식)과 같습니다.

일반적인 덧셈과 뺄셈처럼, 벡터 뺄셈은 두 번째 벡터의 부호를 반전시킨 덧셈과 같습니다.
두 벡터를 서로 빼면 두 벡터가 가리키는 위치의 차이가 되는 벡터가 나옵니다. 이는 두 점 사이의 차이를 나타내는 벡터를 구해야 하는 특정 경우에 유용합니다.

길이
벡터의 길이/크기를 구하려면 수학 시간에 배웠던 피타고라스 정리를 사용합니다. 벡터의 x축과 y축 성분을 삼각형의 두 변으로 생각하면 벡터는 삼각형을 이룹니다.

두 변의 길이(x, y)가 알려져 있고 기울어진 변의 길이 \(\textcolor{red}{\vec{v}}\)를 알고 싶으므로 피타고라스 정리를 사용하여 다음과 같이 계산할 수 있습니다.
여기서 \(\lVert {\textcolor{red}{\vec{v}}} \rVert\)는 벡터 \(\textcolor{red}{\vec{v}}\)의 길이를 나타냅니다. 이는 \(z^2\)를 추가함으로써 3D로 쉽게 확장할 수 있습니다.
이 경우 벡터 (4, 2)의 길이는 다음과 같습니다.
즉 4.47(근사값)입니다.
벡터에는 특별한 종류가 있는데, 이를 단위 벡터라고 합니다. 단위 벡터는 길이가 정확히 1이라는 추가적인 특징을 가지고 있습니다. 어떤 벡터든 각 성분을 벡터의 길이로 나누면 단위 벡터 \(\hat{n}\)을 구할 수 있습니다.
이를 벡터 정규화(normalizing)라고 합니다. 단위 벡터는 머리 위에 작은 지붕 모양으로 표시되며, 특히 방향만 중요한 경우(벡터의 길이가 바뀌어도 방향은 변하지 않음) 다루기가 훨씬 쉽습니다.
벡터-벡터 곱셈
두 벡터의 곱셈은 다소 특이한 경우입니다. 일반적인 벡터 곱셈은 시각적인 의미가 없기 때문에 명확하게 정의되어 있지 않지만, 곱셈을 할 때 선택할 수 있는 두 가지 특정한 경우가 있습니다. 하나는 내적(\(\vec{v}\cdot\vec{k}\))이고 다른 하나는 외적(\(\vec{v}\times\vec{k}\))입니다.
내적
dot product
원문에서는 내적이 아닌 dot product 라는 단어를 사용하였습니다. 이는 점곱이라고도 불리지만 이 번역에서는 내적으로 통일하겠습니다.
두 벡터의 내적은 두 벡터 길이의 스칼라곱에 두 벡터 사이 각도의 코사인 값을 곱한 것과 같습니다. 만약 이 설명이 어렵게 느껴진다면 공식을 살펴보세요.
두 벡터 사이의 각도는 세타(\(\theta\))로 나타냅니다. 이것이 왜 흥미로울까요? 만약 \(\vec{v}\)와 \(\vec{k}\)가 단위 벡터라면, 그 길이는 1이 됩니다. 그러면 공식은 사실상 다음과 같이 간단해집니다.
내적은 두 벡터 사이의 각도만을 나타냅니다. 코사인 또는 코사인 함수는 각도가 90도일 때 0이 되고, 0도일 때 1이 된다는 것을 기억하실 겁니다. 따라서 내적을 이용하면 두 벡터가 직교하는지 평행한지 쉽게 판별할 수 있습니다 (직교한다는 것은 두 벡터가 서로 직각을 이룬다는 의미입니다). 사인 또는 코사인 함수에 대해 더 자세히 알고 싶으시다면 칸 아카데미의 삼각법 기초 강의 영상을 참고하시기를 추천합니다. (한국어 자막이 있습니다!)
두 개의 단위 벡터가 아닌 벡터 사이의 각도를 계산할 수도 있지만, 그럴 경우 결과에서 두 벡터의 길이를 모두 나누어야 \(\theta\) 값만 남게 됩니다.
그렇다면 내적은 어떻게 계산할까요? 내적은 각 성분의 곱셈 결과를 더하는 것입니다. 두 개의 단위 벡터를 예로 들면 다음과 같습니다(두 벡터의 길이가 모두 정확히 1임을 확인할 수 있습니다).
이 두 단위 벡터 사이의 각도를 계산하기 위해 코사인 함수의 역함수 \(cos^{-1}\)를 사용하면 143.1도가 나옵니다. 이로써 두 벡터 사이의 각도를 계산했습니다. 내적은 나중에 조명 계산을 할 때 매우 유용하게 사용됩니다.
외적
외적은 3차원 공간에서만 정의되며, 평행하지 않은 두 벡터를 입력으로 받아 두 입력 벡터 모두에 직교하는 세 번째 벡터를 생성합니다. 두 입력 벡터 또한 서로 직교하는 경우, 외적은 세 개의 직교 벡터를 생성하게 되는데, 이는 앞으로의 장에서 유용하게 사용될 것입니다. 다음 이미지는 3차원 공간에서 외적의 표현을 보여줍니다.
보시다시피, 그다지 논리적으로 보이지는 않습니다. 그래도 이 방법을 그대로 따라하면 입력 벡터와 직교하는 또 다른 벡터를 얻을 수 있게 됩니다.
행렬
벡터에 대한 거의 모든 내용을 살펴보았으니 이제 행렬에 대해 알아볼 차례입니다! 행렬은 숫자, 기호 및/또는 수학적 표현식으로 이루어진 직사각형 배열입니다. 행렬의 각 항목을 행렬 요소라고 합니다. 아래는 2x3 행렬의 예입니다.
행렬은 (i,j)와 같이 행과 열을 나타내는 인덱스로 사용됩니다. 따라서 위의 행렬은 2x3 행렬(3개의 열과 2개의 행, 즉 행렬의 크기(dimensions))이라고 합니다. 이는 2차원 그래프에서 (x,y)와 같이 인덱스를 사용하는 방식과는 반대입니다. 예를 들어, 값 4를 얻으려면 (2,1) (두 번째 행, 첫 번째 열)과 같이 인덱스를 사용해야 합니다.
행렬은 기본적으로 수학적 표현식의 직사각형 배열일 뿐입니다. 행렬은 매우 훌륭한 수학적 속성을 가지고 있으며, 벡터와 마찬가지로 덧셈, 뺄셈, 곱셈과 같은 여러 연산을 정의할 수 있습니다.
덧셈과 뺄셈
두 행렬 간의 덧셈과 뺄셈은 각 요소별로 수행됩니다. 따라서 일반적인 숫자 계산에 익숙한 규칙이 동일하게 적용되지만, 두 행렬 모두에서 동일한 인덱스를 가진 요소에 적용됩니다. 이는 덧셈과 뺄셈이 같은 크기의 행렬에 대해서만 가능하다는 것을 의미합니다. 3x2 행렬과 2x3 행렬(또는 3x3 행렬과 4x4 행렬)은 서로 더하거나 뺄 수 없습니다. 두 2x2 행렬의 덧셈이 어떻게 작동하는지 살펴보겠습니다.
행렬 뺄셈에도 동일한 규칙이 적용됩니다.
행렬-스칼라 곱셈
행렬-스칼라 곱은 행렬의 각 요소에 스칼라 값을 곱하는 것입니다. 다음 예는 곱셈을 보여줍니다.
이제 왜 그 숫자들을 스칼라라고 부르는지 이해가 될 것입니다. 스칼라는 기본적으로 행렬의 모든 요소를 자신의 값으로 확대/축소합니다. 이 예에서는 모든 요소가 2로 확대/축소되었습니다.
지금까지는 괜찮았습니다. 우리가 다룬 사례들은 그다지 복잡하지 않았거든요. 하지만 행렬-행렬 곱셈을 시작하면서부터는 상황이 달라졌습니다.
행렬-행렬 곱셈
행렬 곱셈은 복잡한 연산이라기보다는 익숙해지기 어려운 연산입니다. 행렬 곱셈은 기본적으로 미리 정의된 규칙들을 따르는 것을 의미합니다. 다만 몇 가지 제약 조건이 있습니다.
- 두 행렬을 곱하려면 왼쪽 행렬의 열 개수와 오른쪽 행렬의 행 개수가 같아야 합니다.
- 행렬 곱셈은 교환법칙이 성립하지 않습니다. 즉, \(A \cdot B \neq B \cdot A\)입니다.
두 개의 2x2 행렬의 곱셈 예시부터 시작해 보겠습니다.
지금쯤이면 방금 무슨 일이 일어났는지 이해하려고 애쓰고 계실 겁니다. 행렬 곱셈은 왼쪽 행렬의 행과 오른쪽 행렬의 열을 이용하여 일반적인 곱셈과 덧셈을 결합한 것입니다. 다음 이미지를 통해 좀 더 자세히 설명해 보겠습니다.

먼저 왼쪽 행렬의 맨 위 행을 가져온 다음 오른쪽 행렬에서 열을 하나 선택합니다. 선택한 행과 열에 따라 결과 2x2 행렬의 어떤 출력값이 계산될지가 결정됩니다. 왼쪽 행렬의 첫 번째 행을 선택하면 결과값은 결과 행렬의 첫 번째 행에 들어가고, 열을 선택했을 때 첫 번째 열을 선택하면 결과값은 결과 행렬의 첫 번째 열에 들어갑니다. 빨간색 사각형이 바로 그런 경우입니다. 오른쪽 아래 결과를 계산하려면 첫 번째 행렬의 맨 아래 행과 두 번째 행렬의 가장 오른쪽 열을 사용합니다.
최종 값을 계산하기 위해 먼저 행과 열의 첫 번째 요소를 일반 곱셈으로 곱하고, 두 번째, 세 번째, 네 번째 요소 등도 같은 방식으로 곱합니다. 그런 다음 각 곱셈 결과를 모두 더하면 최종 결과가 나옵니다. 여기서 왼쪽 행렬의 열 크기와 오른쪽 행렬의 행 크기가 같아야 한다는 조건이 있다는 것도 이해가 될 것입니다. 그렇지 않으면 연산을 완료할 수 없기 때문입니다!
그 결과는 (n,m) 차원의 행렬이 되는데, 여기서 n은 좌변 행렬의 행 수이고 m은 우변 행렬의 열 수입니다.
머릿속으로 곱셈을 떠올리는 데 어려움을 느끼더라도 걱정하지 마세요. 손으로 직접 계산해 보면서 어려움이 있을 때마다 이 페이지로 돌아오세요. 시간이 지나면서 행렬 곱셈은 자연스럽게 익숙해질 것입니다.
좀 더 큰 예제를 통해 행렬 곱셈에 대한 논의를 마무리해 보겠습니다. 색깔을 이용하여 패턴을 시각화해 보세요. 유용한 연습으로, 직접 곱셈 결과를 계산해 보고 결과 행렬과 비교해 보세요. (행렬 곱셈을 직접 손으로 계산해 보면 금방 이해할 수 있을 겁니다.)
보시다시피, 행렬 곱셈은 상당히 번거롭고 오류 발생 가능성이 매우 높습니다(그래서 보통 컴퓨터가 이 작업을 수행합니다). 특히 행렬의 크기가 커질수록 문제는 더욱 심각해집니다. 행렬의 수학적 속성에 대해 더 자세히 알고 싶으시다면 칸 아카데미의 행렬 관련 영상을 시청해 보시기를 강력히 추천합니다.
어쨌든, 이제 행렬 곱셈 방법을 알았으니 본격적인 내용을 살펴볼 수 있겠네요.
행렬-벡터 곱셈
지금까지 우리는 벡터를 꽤 많이 접해왔습니다. 벡터는 위치, 색상, 심지어 텍스처 좌표를 나타내는 데 사용되었죠. 이제 좀 더 깊이 들어가서, 벡터는 기본적으로 Nx1 행렬이며 여기서 N은 벡터의 구성 요소 개수(또는 N차원 벡터)라는 점을 알려드리겠습니다. 생각해 보면 아주 간단합니다. 벡터는 행렬과 마찬가지로 숫자의 배열이지만 열이 하나뿐이라는 점만 다릅니다. 그렇다면 이 새로운 정보는 어떻게 도움이 될까요? MxN 행렬이 있을 때, 행렬의 열 개수가 벡터의 행 개수와 같으므로 행렬 곱셈이 가능합니다.
MxN 행렬은 M개의 행과 N개의 열이 있습니다.
그렇다면 행렬과 벡터를 곱하는 것이 왜 중요할까요? 바로 행렬 안에 다양한 2차원/3차원 변환을 넣을 수 있고, 행렬과 벡터를 곱하면 그 벡터도 변환되기 때문입니다. 아직 잘 이해가 안 되신다면 몇 가지 예시를 통해 알아보겠습니다.
단위 행렬
OpenGL에서는 여러 가지 이유로 보통 4x4 변환 행렬을 사용하는데, 그중 하나는 대부분의 벡터 크기가 4이기 때문입니다. 가장 간단한 변환 행렬은 단위 행렬입니다. 단위 행렬은 대각선을 제외한 모든 요소가 0인 NxN 행렬입니다. 보시다시피, 이 변환 행렬은 사용해도 벡터는 전혀 변하지 않습니다.
벡터는 전혀 변경되지 않았습니다. 이는 곱셈 규칙을 통해 명확해집니다. 첫 번째 결과 요소는 행렬의 첫 번째 행의 각 요소와 벡터의 각 요소를 각각 곱한 값입니다. 첫 번째 요소를 제외한 모든 요소가 0이므로 \(\textcolor{red}1\cdot1 + \textcolor{red}0\cdot2 + \textcolor{red}0\cdot3 + \textcolor{red}0\cdot4 = 1\)이 되고, 벡터의 나머지 세 요소에도 동일하게 적용됩니다.
변환을 수행하지 않는 변환 행렬이 무슨 용도로 쓰이는지 궁금하실 수도 있습니다. 단위 행렬은 일반적으로 다른 변환 행렬을 생성하는 출발점이 되며, 선형 대수학을 더 깊이 파고들면 정리 증명 및 선형 방정식 풀이에 매우 유용한 행렬입니다.
스케일링
벡터를 확대/축소(스케일링)한다는 것은 화살표의 방향을 그대로 유지하면서 확대하려는 양만큼 화살표의 길이를 늘리는 것을 의미합니다. 2차원 또는 3차원 공간에서 작업할 때, 각 축(x, y 또는 z)을 각각 확대/축소하는 2개 또는 3개의 변수로 이루어진 벡터를 사용하여 확대/축소를 정의할 수 있습니다.
벡터 \(\textcolor{red}{\vec{v}} = (3,2)\)를 확대해 보겠습니다. x축 방향으로 0.5배 확대하여 폭을 절반으로 줄이고, y축 방향으로 2배 확대하여 높이를 두 배로 줄입니다. 벡터를 (0.5,2)배 확대했을 때 \(\textcolor{blue}{\vec{s}}\)가 어떻게 변하는지 살펴보겠습니다.

OpenGL은 일반적으로 3D 공간에서 작동하므로 이 2D 경우에는 z축 스케일을 1로 설정해도 아무런 문제가 없다는 점을 기억하세요. 방금 수행한 스케일링 작업은 각 축의 스케일링 계수가 다르기 때문에 비균일(non-uniform) 스케일링입니다. 모든 축의 스케일링 계수가 동일하면 균일 스케일링(uniform scale)이라고 합니다.
이제 스케일링을 수행하는 변환 행렬을 만들어 보겠습니다. 단위 행렬에서 대각선 요소는 각각 해당 벡터 요소와 곱해진다는 것을 알았습니다. 만약 단위 행렬의 1을 3으로 바꾸면 어떻게 될까요? 그러면 각 벡터 요소에 3을 곱하게 되어 벡터 전체가 3배로 균일하게 스케일링됩니다. 스케일링 변수를 \((\textcolor{red}{S_1}, \textcolor{green}{S_2}, \textcolor{blue}{S_3})\)으로 나타내면 임의의 벡터 \((x,y,z)\)에 대한 스케일링 행렬을 다음과 같이 정의할 수 있습니다.
네 번째 스케일링 값은 1로 유지된다는 점에 유의하십시오. w 구성 요소는 나중에 살펴보겠지만 다른 용도로 사용됩니다.
변환
변환(Translation)이란 원래 벡터 위에 다른 벡터를 더하여 위치가 다른 새로운 벡터를 생성하는 과정입니다. 즉, 변환 벡터만큼 벡터를 이동시키는 것입니다. 벡터 덧셈은 이미 다뤘으므로 크게 새로운 내용은 아닐 것입니다.
스케일링 행렬과 마찬가지로 4x4 행렬에는 특정 연산을 수행하는 데 사용할 수 있는 여러 위치가 있으며, 변환의 경우 4번째 열의 상위 3개 값이 이에 해당합니다. 변환 벡터를 \((\textcolor{red}{T_x},\textcolor{green}{T_y},\textcolor{blue}{T_z})\)로 나타내면 변환 행렬은 다음과 같이 정의할 수 있습니다.
이 방법이 가능한 이유는 모든 변환 값이 벡터의 w 열 값과 곱해지고 벡터의 원래 값에 더해지기 때문입니다(행렬 곱셈 규칙을 기억하세요). 3x3 행렬로는 이것이 불가능했을 것입니다.\
동차 좌표
벡터의 w 성분은 동차 좌표(homogeneous coordinates)라고도 합니다. 동차 벡터에서 3D 벡터를 얻으려면 x, y, z 좌표를 해당 벡터의 w 성분으로 나눕니다. w 성분은 대부분 1.0이기 때문에 이 과정을 알아차리기는 어렵습니다. 동차 좌표를 사용하면 여러 가지 장점이 있습니다. 3D 벡터에 행렬 변환을 적용할 수 있고(w 성분이 없으면 벡터를 변환할 수 없음), 다음 장에서는 w 값을 이용하여 3D 원근감을 표현할 것입니다.
또한, 동차 좌표가 0일 때 그 벡터는 특히 방향 벡터(direction vector)라고 불리는데, 이는 w 좌표가 0인 벡터는 변환 될 수 없기 때문입니다.
변환 행렬을 사용하면 객체를 3축 방향(x, y, z) 중 어느 방향으로든 이동시킬 수 있으므로 변형 행렬중에서 매우 유용한 변형 행렬중 하나입니다.
Translation
Translation는 평행 이동으로도 번역됩니다.
회전
앞선 몇 가지 변환은 2D 또는 3D 공간에서 비교적 쉽게 이해하고 시각화할 수 있었지만, 회전은 조금 더 까다롭습니다. 이러한 행렬이 어떻게 구성되는지 정확히 알고 싶다면 칸 아카데미의 선형대수 강의 중 회전 관련 부분을 시청하는 것을 추천합니다.
먼저 벡터의 회전이 무엇인지 정의해 보겠습니다. 2차원 또는 3차원에서의 회전은 각도로 표현됩니다. 각도는 도 또는 라디안으로 나타낼 수 있는데, 원은 360도 또는 2π 라디안입니다. 저는 일반적으로 더 익숙한 도 단위를 사용하여 회전을 설명하는 것을 선호합니다.
대부분의 회전 함수는 라디안 단위의 각도를 필요로 하지만, 다행히 도 단위는 라디안으로 쉽게 변환할 수 있습니다.
angle in degrees = angle in radians * (180 / PI)
angle in radians = angle in degrees * (PI / 180)
반원을 회전하면 360/2 = 180도가 회전하고, 오른쪽으로 1/5을 회전하면 360/5 = 72도가 회전합니다. 이는 기본적인 2차원 벡터 \(\textcolor{red}{\vec{v}}\)가 \(\textcolor{green}{\bar{k}}\)에서 오른쪽으로, 즉 시계 방향으로 72도 회전한 경우를 통해 확인할 수 있습니다.

3D 공간에서의 회전은 각도와 회전축을 이용하여 지정합니다. 지정된 각도는 지정된 회전축을 따라 객체를 회전시킵니다. 머리를 특정 각도로 돌리면서 하나의 회전축을 계속 내려다본다고 상상해 보세요. 예를 들어 2D 벡터를 3D 공간에서 회전시킬 때는 회전축을 z축으로 설정합니다(이를 상상해 보세요).
삼각법을 이용하면 주어진 각도에 따라 벡터를 회전된 벡터로 변환할 수 있습니다. 이는 일반적으로 사인 함수와 코사인 함수(흔히 sin과 cos로 줄여 부름)를 적절히 조합하여 수행됩니다. 회전 행렬의 생성 방법에 대한 자세한 설명은 이 장의 범위를 벗어납니다.
3차원 공간의 각 단위 축에 대해 회전 행렬이 정의되며, 각도는 세타 기호 \(\theta\)로 표현됩니다.
X축을 중심으로 한 회전:
Y축을 중심으로 한 회전:
Z축을 중심으로 한 회전:
회전 행렬을 사용하면 세 개의 단위 축 중 하나를 중심으로 위치 벡터를 변환할 수 있습니다. 임의의 3D 축을 중심으로 회전하려면 먼저 X축을 중심으로 회전한 다음 Y축, 마지막으로 Z축을 중심으로 회전하는 식으로 세 개의 회전 행렬을 모두 결합할 수 있습니다. 그러나 이 방법은 짐벌 록(Gimbal lock)이라는 문제를 야기합니다. 자세한 내용은 생략하겠지만, 회전 행렬을 결합하는 대신 임의의 단위 축(예: (0.662, 0.2, 0.722) (단위 벡터임))을 중심으로 바로 회전하는 것이 더 나은 해결책입니다. 이러한 (상세한) 회전 행렬은 다음과 같습니다. 여기서 \((\textcolor{red}{R_x}, \textcolor{green}{R_y}, \textcolor{blue}{R_z})\)는 임의의 회전 축입니다.
이러한 행렬을 생성하는 수학적 논의는 이 장의 범위를 벗어납니다. 이 행렬조차도 짐벌 록 현상을 완전히 방지하지는 못한다는 점을 기억하십시오(물론 훨씬 더 어려워지긴 합니다). 짐벌 록 현상을 진정으로 방지하려면 쿼터니언을 사용하여 회전을 표현해야 하는데, 이는 더 안전할 뿐만 아니라 계산 효율성도 더 높습니다. 하지만 쿼터니언에 대한 논의는 이 장의 범위를 벗어납니다.
행렬 결합
행렬을 이용한 변환의 진정한 강점은 행렬 곱셈 덕분에 여러 변환을 하나의 행렬로 결합할 수 있다는 점입니다. 여러 변환을 결합한 변환 행렬을 만들어 보겠습니다. 벡터 (x, y, z)를 2배로 확대하고 (1, 2, 3)만큼 이동시킨다고 가정해 봅시다. 이를 위해서는 이동 행렬과 확대/축소 행렬이 필요합니다. 그러면 결과적인 변환 행렬은 다음과 같습니다.
행렬 곱셈을 할 때는 먼저 평행 이동을 하고 그 다음에 크기 조정을 한다는 점에 유의하세요. 행렬 곱셈은 교환 법칙이 성립하지 않으므로 순서가 중요합니다. 행렬 곱셈에서는 가장 오른쪽에 있는 행렬이 먼저 벡터와 곱해지므로 곱셈은 오른쪽에서 왼쪽으로 읽어야 합니다. 행렬을 결합할 때는 크기 조정, 회전, 평행 이동 순으로 하는 것이 좋습니다. 그렇지 않으면 각 연산이 서로 (부정적인) 영향을 미칠 수 있습니다. 예를 들어, 평행 이동을 먼저 하고 그 다음에 평행 이동을 하면 평행 이동 벡터도 함께 확대/축소됩니다!
벡터에 최종 변환 행렬을 적용하면 다음과 같은 벡터가 생성됩니다.
훌륭합니다! 벡터는 먼저 2배로 확대된 다음 (1,2,3)만큼 이동합니다.
코드에서...
이제 변환에 대한 모든 이론적 배경을 설명했으니, 실제로 이 지식을 어떻게 활용할 수 있는지 살펴보겠습니다. OpenGL에는 행렬이나 벡터에 대한 기능이 내장되어 있지 않으므로, 직접 수학 클래스와 함수를 정의해야 합니다. 이 책에서는 세부적인 수학적 계산은 생략하고 미리 만들어진 수학 라이브러리를 사용하는 방식을 택하겠습니다. 다행히 OpenGL에 최적화된 사용하기 쉬운 수학 라이브러리인 GLM이 있습니다.
GLM
GLM은 OpenGL Mathematics의 약자로, 헤더 파일만으로 구성된 라이브러리입니다. 즉, 필요한 헤더 파일만 포함시키면 되고, 링크나 컴파일 과정은 필요하지 않습니다. GLM은 공식 웹사이트에서 다운로드할 수 있습니다. 헤더 파일의 루트 디렉토리를 include 폴더에 복사한 후, 바로 시작해 보겠습니다.
현재는 빌딩이 필요해 진것으로 보입니다. 최초의 창에서 설명했듯이, CMake를 사용해 빌드할 수 있습니다.
또한 리눅스인 경우 glm은 pkg-config로 지정해 주지 않아도 알아서 컴파일을 해주는걸로 보입니다.

GLM에서 필요한 대부분의 기능은 다음과 같이 포함할 3개의 헤더 파일에 있습니다.
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
우리가 가진 변환 지식을 활용하여 (1,0,0) 벡터를 (1,1,0)만큼 이동시켜 보겠습니다. (참고로, 이 벡터는 동차 좌표가 1.0으로 설정된 glm::vec4로 정의됩니다.)
glm::vec4 vec(1.0f, 0.0f, 0.0f, 1.0f);
glm::mat4 trans = glm::mat4(1.0f);
trans = glm::translate(trans, glm::vec3(1.0f, 1.0f, 0.0f));
vec = trans * vec;
std::cout << vec.x << vec.y << vec.z << std::endl;
먼저 GLM의 내장 벡터 클래스를 사용하여 vec이라는 벡터를 정의합니다. 다음으로 mat4를 정의하고 행렬의 대각선 요소를 1.0으로 초기화하여 명시적으로 단위 행렬로 초기화합니다. 단위 행렬로 초기화하지 않으면 행렬이 널 행렬(모든 요소가 0)이 되어 이후의 모든 행렬 연산 결과도 널 행렬이 됩니다.
다음 단계는 변환 행렬을 생성하는 것입니다. 이를 위해 단위 행렬을 glm::translate 함수에 변환 벡터와 함께 전달합니다(주어진 행렬은 변환 행렬과 곱해지고 결과 행렬이 반환됩니다).
다음으로 벡터에 변환 행렬을 곱하고 결과를 출력합니다. 행렬 변환 방식을 기억한다면 결과 벡터는 (1+1, 0+1, 0+0)이 되고, 이는 (2, 1, 0)과 같습니다. 이 코드는 210을 출력하므로 변환 행렬이 제대로 작동한 것입니다.
이제 좀 더 흥미로운 작업을 해 봅시다. 이전 장에서 다룬 컨테이너 객체를 확대/축소하고 회전시켜 보겠습니다.
glm::mat4 trans = glm::mat4(1.0f);
trans = glm::rotate(trans, glm::radians(90.0f), glm::vec3(0.0, 0.0, 1.0));
trans = glm::scale(trans, glm::vec3(0.5, 0.5, 0.5));
먼저 컨테이너를 각 축으로 0.5씩 확대한 다음 Z축을 중심으로 90도 회전합니다. GLM은 각도를 라디안 단위로 입력받기 때문에 glm::radians 함수를 사용하여 도를 라디안으로 변환합니다. 텍스처가 적용된 사각형은 XY 평면에 있으므로 Z축을 중심으로 회전해야 합니다. 회전축은 단위 벡터여야 하므로 X, Y 또는 Z축이 아닌 다른 축을 중심으로 회전하는 경우에는 벡터를 먼저 정규화해야 합니다. GLM의 각 함수에 행렬을 전달하면 GLM은 자동으로 행렬들을 곱하여 모든 변환을 결합한 변환 행렬을 생성합니다.
다음으로 중요한 질문은 변환 행렬을 셰이더로 어떻게 전달하는가입니다. 앞서 GLSL에도 mat4 타입이 있다고 언급했으므로, 정점 셰이더가 mat4 유니폼 변수를 받아들이고 위치 벡터에 해당 행렬 유니폼을 곱하도록 수정하겠습니다.
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;
out vec2 TexCoord;
uniform mat4 transform;
void main()
{
gl_Position = transform * vec4(aPos, 1.0f);
TexCoord = vec2(aTexCoord.x, aTexCoord.y);
}
GLSL에는 벡터처럼 스위즐링과 유사한 연산을 허용하는 mat2 및 mat3 타입이 있습니다. 앞서 언급한 모든 수학 연산(스칼라-행렬 곱셈, 행렬-벡터 곱셈, 행렬-행렬 곱셈 등)은 행렬 타입에서 허용됩니다. 특수한 행렬 연산이 사용되는 경우, 그 의미를 명확히 설명하겠습니다.
우리는 유니폼 변수를 추가하고 위치 벡터에 변환 행렬을 곱한 다음 gl_Position 함수에 전달했습니다. 이제 컨테이너는 크기가 절반으로 줄어들고 90도 회전(왼쪽으로 기울어짐)되었을 것입니다. 하지만 변환 행렬을 셰이더에 전달해야 합니다.
unsigned int transformLoc = glGetUniformLocation(ourShader.ID, "transform");
glUniformMatrix4fv(transformLoc, 1, GL_FALSE, glm::value_ptr(trans));
먼저 유니폼 변수의 위치를 쿼리한 다음, glUniform 함수에 Matrix4fv를 접미사로 붙여 행렬 데이터를 셰이더로 보냅니다. 첫 번째 인수는 유니폼 변수의 위치를 나타내는데, 이는 이미 익숙하실 겁니다. 두 번째 인수는 OpenGL에 보낼 행렬의 개수를 알려주는 것으로, 기본값은 1입니다. 세 번째 인수는 행렬을 전치할지 여부를 묻습니다. 즉, 열과 행의 위치를 바꿀지 묻는 것입니다. OpenGL 개발자는 종종 열 우선 순서라는 내부 행렬 레이아웃을 사용하는데, 이는 GLM의 기본 행렬 레이아웃이므로 행렬을 전치할 필요가 없습니다. 따라서 이 값을 GL_FALSE로 유지하면 됩니다. 마지막 매개변수는 실제 행렬 데이터이지만, GLM은 행렬 데이터를 저장하는 방식이 OpenGL의 예상 방식과 항상 일치하지는 않으므로 GLM의 내장 함수인 value_ptr을 사용하여 먼저 데이터를 변환합니다.
변환 행렬을 생성하고, 정점 셰이더에서 유니폼 변수를 선언한 다음, 해당 행렬을 정점 좌표를 변환하는 셰이더로 전달했습니다. 결과는 다음과 같아야 합니다.

완벽해요! 컨테이너가 왼쪽으로 기울어지고 크기가 절반으로 줄어든 것을 보니 변환이 성공적이네요. 이제 좀 더 재미있게 컨테이너를 시간에 따라 회전시켜 보고, 재미 삼아 창의 오른쪽 아래로 옮겨 보겠습니다. 시간에 따라 컨테이너를 회전시키려면 렌더링 루프에서 변환 행렬을 업데이트해야 합니다. 각 프레임마다 업데이트해야 하기 때문이죠. GLFW의 시간 함수를 사용하여 시간에 따른 각도를 얻습니다.
glm::mat4 trans = glm::mat4(1.0f);
trans = glm::translate(trans, glm::vec3(0.5f, -0.5f, 0.0f));
trans = glm::rotate(trans, (float)glfwGetTime(), glm::vec3(0.0f, 0.0f, 1.0f));
이전 경우에는 변환 행렬을 어디에서든 선언할 수 있었지만, 이제는 회전을 지속적으로 업데이트하기 위해 매 반복마다 변환 행렬을 생성해야 한다는 점을 명심하십시오. 즉, 렌더링 루프의 각 반복마다 변환 행렬을 다시 생성해야 합니다. 일반적으로 장면을 렌더링할 때는 여러 개의 변환 행렬이 있으며, 이 행렬들은 매 프레임마다 새로운 값으로 다시 생성됩니다.
여기서는 먼저 컨테이너를 원점(0,0,0)을 중심으로 회전시킨 다음, 회전된 컨테이너를 화면의 오른쪽 아래 모서리로 이동시킵니다. 실제 변환 순서는 코드의 역순이라는 점을 기억하세요. 코드에서는 먼저 이동시키고 나중에 회전시키지만, 실제 변환은 회전을 먼저 적용한 다음 이동을 적용합니다. 이러한 다양한 변환 조합과 객체에 적용되는 방식을 모두 이해하는 것은 어려울 수 있습니다. 하지만 이러한 변환들을 직접 실험해 보면 금방 이해할 수 있을 것입니다.
만약 모든 과정을 제대로 진행했다면 다음과 같은 결과가 나타날 것입니다. (클릭해서 확인하세요.)
자, 이제 아시겠죠? 시간이 지남에 따라 회전하는 이동된 컨테이너를 단 하나의 변환 행렬로 구현했습니다! 이제 행렬이 그래픽 분야에서 얼마나 강력한 구조인지 이해하실 수 있을 겁니다. 무한한 수의 변환을 정의하고 이들을 모두 하나의 행렬에 결합하여 원하는 만큼 재사용할 수 있습니다. 이처럼 정점 셰이더에서 변환을 사용하면 정점 데이터를 다시 정의하는 수고를 덜고, 데이터를 매번 다시 전송할 필요가 없으므로 처리 시간도 절약할 수 있습니다 (데이터 재전송은 상당히 느립니다). 변환 유니폼만 업데이트하면 되니까요.
원하는 결과를 얻지 못했거나 다른 부분에서 막혔다면 소스 코드와 업데이트된 셰이더 클래스를 살펴보세요.
다음 장에서는 행렬을 사용하여 정점에 대해 서로 다른 좌표 공간을 정의하는 방법을 살펴보겠습니다. 이것이 바로 3D 그래픽스의 첫걸음이 될 것입니다!
추가 자료
- Essence of Linear Algebra: 변환과 선형대수의 기본 수학적 원리를 설명하는 그랜트 샌더슨의 훌륭한 비디오 튜토리얼 시리즈입니다.
- Matrix Multiplication XYZ: 행렬 곱셈을 시각적으로 보여주는 놀라운 대화형 도구를 사용해 보세요. 몇 가지 문제를 풀어보면 이해도를 높이는 데 도움이 될 것입니다.
연습 문제
- 컨테이너에 마지막 변환을 적용한 후, 회전을 먼저 하고 이동을 한 다음 순서를 바꿔 보세요. 어떤 결과가 나오는지 살펴보고 그 이유를 추론해 보세요. (해결 방법)
glDrawElements를 다시 호출하여 두 번째 컨테이너를 그리되, 변환만 사용하여 다른 위치에 배치해 보세요. 이 두 번째 컨테이너가 창의 왼쪽 상단에 위치하도록 하고, 회전하는 대신 시간에 따라 크기를 조정하세요(sin 함수를 사용하면 유용합니다. 단, sin 함수를 사용하면 음수 값을 적용하는 즉시 객체가 반전된다는 점에 유의하세요). (해결 방법)