(UnityShader) 08 Fragment Advanced : Rotation Matrix
이 포스트는 오즈라엘님의 수포자를 위한 게임수학 (https://youtu.be/xCr1pU9l1A8)에서 많은 지식을 참고하였습니다.
-
(영화 매트릭스의 한 장면)
이번 Advanced 포스트에서 다룰 주제는 행렬(Matrix)입니다. 고등학교 수학 정규 교과 과정에 편성되어 있는 수준의 난이도지만 저희 같이 수학을 포기한.. 수포자 아티스트들에게는 쉐이더에서 제일 어려운 개념중 하나입니다. 최대한 쉽게 설명하려고 노력하겠습니다.
(Transform)
행렬 연산은 게임 엔진의 기본적인 틀을 구성하는데 필수적이고 유용한 도구입니다. 수학은 우주를 구성하는 법칙이자 인간에게 자연의 본질을 정의하고 설명 할 수 있는 언어입니다. 현실을 모방한 게임 엔진은 수학을 제외하고는 성립 할 수 없는 것입니다.
유니티 엔진에서 가장 기본적인 오브젝트의 포지션/로테이션/스케일 조작이 행렬 연산으로 수행 되는 대표적인 기능입니다.
(OpenGL Rendering Pipeline)
렌더링 파이프라인에서 버텍스 데이터를 이용해 그래픽카드가 삼각형 폴리곤 데이터를 만들고 로컬 좌표를 월드 좌표로 변환하는 과정, 그리고 카메라를 통해 모니터로 보여주는 과정에서도 행렬 연산이 사용됩니다.
(m×n Matrix)
행렬은 가로의 행(Raw)과 세로의 열(Column)으로 구성된 표(table) 데이터 입니다. 그리고 행렬 내부를 구성하는 변수들을 성분(Component)이라고 부릅니다. 예를 들어 a(1,2)는 1행 2열에 있는 성분을 말하는 것입니다.
행렬의 크기는 (행의 개수)×(열의 개수)로 나타냅니다. 예를 들어 행의 개수가 m이고 열의 개수가 n이면 m×n(차원) 행렬이라고 합니다.
(행렬의 덧셈)
여기 두개의 2x2차원 행렬이 있습니다. 두 행렬을 더하는 방법은 각 행렬과 똑같은 위치에 있는 성분끼리 더하는 것입니다. 거꾸로 해도 결과 값은 똑같습니다. 따라서 행렬의 덧셈은 교환법칙이 성립합니다.
(행렬의 곱셈)
하지만 행렬의 곱셈은 쉽지 않습니다. 전제조건으로 좌측 행렬의 열 수와 우측 행렬의 행 수가 같아야 두 행렬을 곱할 수 있습니다. 공식도 언뜻 보기에는 매우 복잡해 보이는데요. 행렬의 방향성을 생각하면 쉽게 이해 할 수 있습니다.
2x2 차원 행렬을 각각 행 벡터로 이루어진 행렬과 열 벡터로 이루어진 행렬이라고 생각하면 문제는 훨씬 쉬워집니다.
(Dot Product)
성분을 같은 행과 열으로 분해해서 곱한뒤 더하는 것이 놀랍게도 벡터의 내적(Dot) 공식과 완전히 똑같다는 것을 눈치 챌 수 있습니다.
따라서 원점(0,0)에서 출발하는 벡터 float2(2,1)은 행렬 float1x2(2,1)로 표기 해줄 수도 있습니다.
주의할 점은 행렬을 곱하는 순서에 따라 값이 완전히 바뀌게 됩니다. 따라서 교환법칙이 성립하지 않습니다. 계산 순서를 잘못하면 원하는 값이 나오지 않기 때문에 순서가 중요합니다.
그래서 OpenGL을 사용하는 유니티 엔진의 경우 행렬M에 벡터V를 곱할 때 M × V 순서로 계산해야 하며, 벡터를 열 방향으로 계산하는 열 기준 행렬(Column matrix)으로 정해두었습니다.
그렇다면 서로 다른 크기의 행렬곱은 어떻게 해야 할까요? 기호가 아닌 실제 숫자를 사용하여 열 기준으로 계산해보도록 합시다.
(float3x3 × float3x1)
이제 계산 방법을 모두 숙지했으니 위에서 서술한 내용대로 트랜스폼(trasform)의 행렬 연산을 직접 따라 해볼 것입니다. 가장 쉬운 스케일(Scale) 부터 해봅시다.
float2x2(1,0,0,1)에 float2(1,2)를 곱할 경우 계산 결과는 float(1,2)로 기존 벡터와 똑같습니다. 그렇다면 행렬의 성분을 두배로 하면 어떻게 될까요?
놀랍게도 벡터의 크기가 두배가 되었습니다.
(단위행렬 혹은 항등행렬, Identity Matrix, 줄여서 'I' 라고 표기)
이렇게 n x m 차원의 행렬에서 n과 m의 값이 똑같고(n=m), 성분의 i,j값이 같은 부분(i=j)이 1인 행렬을 단위행렬(identity Matrix)이라고 합니다.
단위행렬의 성분을 n배 해주면 벡터 역시 n배만큼 크기가 커집니다. 스케일 뿐 만 아니라 벡터의 회전과 이동 역시 단위행렬을 기준으로 임의의 값을 더하고 곱하여 계산 됩니다.
또한 벡터와 단위행렬의 차원을 실제보다 +1만큼 늘려주는 것이 계산하기 훨씬 수월하고 직관적입니다. 트랜스폼에 쓰이는 단위행렬도 유니티 엔진의 공간이 3차원이기 때문에 4x4 차원 행렬과 벡터를 사용합니다.
단위행렬과 2차원 벡터를 3차원으로 늘리고, 2차원 벡터인 float2(1,1)을 가로와 세로로 각각 1만큼 이동시켜 보겠습니다.
벡터가 1만큼 이동 하였습니다. 그런데 곰곰히 생각해보면 이것은 단위행렬 float3x3(1,0,0/0,1,0/0,0,1)에 float3x3(0,0,1/0,0,1/0,0,0)를 더한 것과 같습니다.
여기서 뭔가 감이 와야 합니다.
우리가 쉐이더를 공부 하면서 가장 많이 다룬 float2 데이터는 무엇일까요? 그렇습니다... 바로 texcoord입니다. UV에 변수를 더하면 오프셋 이동이고, 곱하면 타일링이 되었습니다.
트랜스폼 뿐만 아니라 UV 역시 행렬 연산으로 조작 하는 것을 깨닫게 된 순간입니다. 단순한 곱셈 덧셈으로도 이루어지는 타일링과 오프셋은 기본 중의 기본기였지만 UV를 회전시키는 방법은 몰랐습니다. 가장 어려운 대망의 회전입니다.
크기가 1인 단위벡터 float2(0,1)이 있습니다.
(30도 회전)
계산하기 편하게 30도 정도로 회전시켰습니다. 앞으로는 이 회전한 각도를 세타(θ) 라고 부르겠습니다. 저희는 UV를 세타만큼 회전시키고 싶습니다. 이제 벡터의 방향은 float2(0,1)이 아니지만 크기는 1으로 똑같습니다.
(삼각 함수, Trigonometric functions)
이제 피타고라스 아저씨가 저희를 도와주실 차례입니다. 상술했듯이 단위벡터의 크기는 1이고, 따라서 삼각형 빗변의 길이도 1입니다. 그러니 a값에 1을 대입하면?
단위벡터의 머리(head)의 좌표는 float2(b,c). 즉 float2(sinθ,cosθ)가 됩니다.
이제 거꾸로 생각해보면 말이죠. 단위벡터 float2(0,1)은 사인 세타 값이 0이고, 코사인 세타 값이 1인 벡터라고 이야기 할 수 있는 것입니다.
따라서 계산해볼 필요도 없이 float2(0,1)을 세타 값만큼 회전 시키면 벡터의 머리 좌표는 float2(-sinθ,cosθ)입니다.
빗변 길이는 항상 1으로 일정하며, 세타 만큼 회전하면서 float2(x,y)의 x값만 그래프의 음수 부분으로 값이 넘어가기 때문에 float2(-x,y)가 됩니다. 필요한 값은 모두 구했으니 이제 UV 데이터에 어떻게 생긴 행렬을 곱해야 될지 회전 행렬 공식을 유도 해봅시다.
선형 변환(Linear transformation)
저희같은 아티스트가 알아 듣기에는 너무 어렵게 정의되어 있으니 간단히 풀어서 설명드리겠습니다.
회전의 특성을 생각해봅시다. 왼쪽은 10도 회전했다가 -20도 회전한 오브젝트입니다. 그리고 오른쪽은 -20도 회전했다가 10도 회전한 오브젝트 입니다.
회전하는 순서가 달라도 최종 각도는 '-10도'로 둘 다 똑같습니다. θ1만큼 회전하고 θ2만큼 회전해도, θ2만큼 회전하고 θ1만큼 회전해도 결과는 θ1 + θ2입니다.
이렇게 변환 순서와 상관 없이 결과가 같은 변환을 선형 변환이라고 합니다.
따라서 회전 행렬은 선형 변환이며 행렬의 덧셈을 통해 위와 같은 식이 성립 한다는 것을 이미 저희는 알고 있습니다.
그리고 저희는 열 단위벡터 float2(0,1)과 float2(1,0)은 곧 float2(-sinθ,cosθ) 와 float2(cosθ,sinθ)라는 것을 삼각함수를 이용해 증명하였습니다.
위의 공식대로 양변의 열 벡터 [x,y]를 소거하면 회전행렬 R이 나옵니다.
다음 포스트에서는 UV 회전을 쉐이더 코딩으로 구현하는 파트를 다루도록 하겠습니다.
Comentarios