게임 프로그래머의 만담, 게임코디 2nd
+5 투표
2.1k views 만담 (1.9kp)
수정됨 , 2.1k views

지난 강좌

1. OpenGL 윈도우 만들기

2. 삼각형 그리기

3. 알록달록한 삼각형 그리기

4. 텍스쳐 입히기

 


전체 소스 코드 : https://github.com/sunduk/opengl-3d-examples

 

5. 변환(Transform)

지금까지 해본 삼각형 그리기, 텍스쳐 입히기 등은 여전히 2D형태의 결과물이었습니다. 이제 본격적인 3D 게임 프로그래밍을 시작해 볼 것인데 여기서 부터는 수학과 관련이 깊어지게 됩니다. 특히 벡터, 행렬, 삼각함수와는 친해지는 것이 좋습니다. 수학을 몰라도 3D 게임 프로그래밍을 할 수는 있지만 어떤 부분에서는 겉핥기에 그칠 수 있습니다. 이 글에서는 행렬에 대한 개념부터 배워보면서 3D 프로그래밍에서 이동, 회전, 스케일 변환을 처리하는 원리에 대해 알아 보겠습니다.

 

이동

2D 게임에서 캐릭터의 좌표를 이동 시키는 것은 매우 직관적 입니다. x,y 좌표에 이동하려는 값 만큼 더하면 되는 것이죠. 물론 3D 게임에서도 마찬가지로 적용할 수 있습니다. 간단하게 의사 코드로 표현하면 다음과 같습니다.

// 현재 위치.
x = 0
y = 0
z = 0

// 이동 하려는 값.
dx = 1
dy = 2
dz = 3

// 좌표 이동.
x = x + dx
y = y + dy
z = z + dz

// 결과.
// x = 1
// y = 2
// z = 3

아주 직관적이고 쉬운 방법 이지만 3D게임 프로그래밍에서 사용하기에는 그리 적합하지 않습니다. 우리는 행렬이라는 수학적 도구를 통해 이동을 표현 하는 방법을 배워 볼 것입니다.  행렬은 행과 열로 이루어진 사각형의 배열 같은 개념 입니다. 프로그래밍 언어에서 2차원 배열을 생각하면 됩니다.

// 이동 행렬.
float matrix[4][4] = 
{
 // 1열,  2열, 3열,  4열
    {1,   0,   0,   dx},    // 1행
    {0,   1,   0,   dy},    // 2행
    {0,   0,   1,   dz},    // 3행
    {0,   0,   0,   1},     // 4행
};

4행 4열을 가진 이동 행렬의 모습 입니다. 행은 가로, 열은 세로를 의미 하며 C++에서 배열 첨자의 순서에 따라 [행][열]로 이해하시면 됩니다. dx, dy, dz의 자리에 이동 하려는 만큼의 값을 넣어주면 이동 행렬이 완성 됩니다. 이 행렬과 벡터를 곱해서 새로운 좌표를 계산해 낼 수 있는데 다음과 같이 코드로 구현 할 수 있습니다.

 

이동 행렬의 구현

void Matrix4_Vec4(float matrix[][4], float vector4[4], float* result)
{
    // 행렬의 행의 수 만큼 반복 합니다.
    for (int row = 0; row < 4; ++row)
    {
        float sum = 0;
        // 행의 모든 요소와 벡터의 모든 요소를 각각 곱한 뒤 모두 더합니다.
        for (int col = 0; col < 4; ++col)
        {
            sum += matrix[row][col] * vector4[col];
        }
        
        // 하나의 행에 대한 연산이 끝나면 결과값을 저장 해 줍니다.
        result[row] = sum;
    }
}

행렬과 벡터의 곱셈을 계산하는 함수입니다. 파라미터로 4x4행렬과 4차원 벡터를 받고 결과값을 result에 넣어 줍니다. 앞서 언급한 대로 벡터는 [x,y,z,1]로 구성되어 있어야 합니다. 마지막 요소가 반드시 1로 들어가야 하는데 이것은 손으로 행렬과 벡터의 곱셈을 직접 계산 해 보면 왜 그래야 하는지 알 수 있을겁니다. 만약 마지막 요소를 0으로 놓고 계산 해 보면 벡터에 아무런 변화도 일어나지 않습니다. 사실 벡터의 이동은 한차원 높여 밀기변환을 수행하고 여기서 원래 차원의 값들만 취하면 그 결과가 이동인 셈입니다. 이것에 대해서는 이득우님의 게임 수학 블로그에 자세히 나와 있습니다.(참고 : https://blog.naver.com/destiny9720/221408760120)

이 함수를 갖고 이동변환을 적용 해 보겠습니다.

void CreateRectangle()
{
    ...
    
    // 이동할 양.
    float dx = 1.0f;
    float dy = 0.0f;
    float dz = 0.0f;
    
    // 이동 행렬.
    float translationMatrix[4][4] =
    {
        {1, 0, 0, dx},
        {0, 1, 0, dy},
        {0, 0, 1, dz},
        {0, 0, 0, 1},
    };

    // 각 버텍스에 이동 행렬을 적용 한다.
    for (auto& v : mVertices)
    {
        float position[4] = { 
            v.mPosition.x, 
            v.mPosition.y, 
            v.mPosition.z, 
            1.0f
        };
        float result[4]{};
        Matrix4_Vec4(translationMatrix, position, result);

        v.mPosition = glm::vec3(result[0], result[1], result[2]);
    }
}

오른쪽으로 1만큼 이동 시키기 위해 dx를 1.0으로 입력하고 이동 행렬을 구성 한 뒤 모든 버텍스(여기서는 사각형이므로 총 4개의 버텍스가 있습니다)에 이 행렬을 곱해 줍니다. 코드를 실행 해 보면 사각형이 화면 오른쪽으로 1만큼 이동한 모습이 보입니다.

y좌표도 1로 올려보면 아래와 같이 오른쪽 위로 올라간 형태가 됩니다.

// 이동할 양.
float dx = 1.0f;
float dy = 1.0f;
float dz = 0.0f;

 

 

스케일

스케일은 확대/축소를 뜻합니다. 앞서 이동 행렬을 구현 하면서 행렬에 조금은 익숙해 지셨을 테니 바로 스케일 행렬 코드를 작성 해 보겠습니다.

// 스케일 값.
float scaleX = 0.2f;
float scaleY = 0.2f;
float scaleZ = 1.0f;

// 스케일 행렬.
float scaleMatrix[4][4] =
{
    {scaleX, 0,      0,      0},
    {0,      scaleY, 0,      0},
    {0,      0,      scaleZ, 0},
    {0,      0,      0,      1},
};

// 버텍스 좌표에 스케일 행렬 적용.
for (auto& v : mVertices)
{
    float position[4] = { v.mPosition.x, v.mPosition.y, v.mPosition.z, 1.0f };
    float result[4]{};
    Matrix4_Vec4(scaleMatrix, position, result);

    v.mPosition = glm::vec3(result[0], result[1], result[2]);
}

행렬의 대각선 부분에 각 좌표축에 대한 비율을 넣어주면 스케일 행렬이 완성 됩니다. 1보다 작으면 축소, 1보다 크면 확대, 1이면 현재 상태 그대로 유지 됩니다. 변수가 들어가는 위치만 다를 뿐 계산 공식은 이동 행렬에서 한 것과 동일합니다. 재미 있는 것은 -값을 입력하면 결과물이 뒤집혀진다는 것입니다.

// 스케일 값.
float scaleX = -0.2f;    // -값을 입력.
float scaleY = 0.2f;
float scaleZ = 1.0f;

 

 

회전

회전은 물체를 돌리는 것을 의미 합니다. 이동 행렬과 스케일 행렬은 원하는 값을 직접 넣어서 행렬을 구성했기 때문에 직관적이고 이해하기 쉬운 편이었습니다. 하지만 회전 행렬은 x,y,z 좌표 축에 따라 총 세 개가 존재 하며 삼각함수까지 사용되어서 앞의 두 행렬 보다는 조금 복잡하다고 느낄 수 있습니다. 삼각함수에 대한 자세한 설명은 나중에 하기로 하고 지금은 회전 행렬을 어떻게 구성하고 적용 하는지 알아보겠습니다.

// 회전 값.
float rotationX = 0.0f;
float rotationY = 0.0f;
float rotationZ = glm::radians(45.0f);

// 각 축 별로 코사인, 사인 값을 구한다.
float cosX = glm::cos(rotationX);
float sinX = glm::sin(rotationX);

float cosY = glm::cos(rotationY);
float sinY = glm::sin(rotationY);

float cosZ = glm::cos(rotationZ);
float sinZ = glm::sin(rotationZ);

// X축 회전 행렬.
float rotationMatrixX[4][4] =
{
    {1,   0,      0,    0},
    {0,  cosX,  -sinX,  0},
    {0,  sinX,  cosX,   0},
    {0,   0,      0,    1},
};
// Y축 회전 행렬.
float rotationMatrixY[4][4] =
{
    {cosY,   0,  sinY,  0},
    {  0,    1,   0,    0},
    {-sinY,  0,  cosY,  0},
    {  0,    0,   0,    1},
};
// Z축 회전 행렬.
float rotationMatrixZ[4][4] =
{
    {cosZ,  -sinZ,  0,  0},
    {sinZ,  cosZ,   0,  0},
    { 0,     0,     1,  0},
    { 0,     0,     0,  1},
};

// X,Y,Z축의 행렬을 하나의 행렬로 만든다.
float rotationMatrix[4][4]{};
Matrix4_Multiply3(rotationMatrixY, rotationMatrixX, rotationMatrixZ, rotationMatrix);

// 버텍스 좌표에 회전 행렬 적용.
for (auto& v : mVertices)
{
    float position[4] = { v.mPosition.x, v.mPosition.y, v.mPosition.z, 1.0f };
    float result[4]{};
    Matrix4_Vec4(rotationMatrix, position, result);

    v.mPosition = glm::vec3(result[0], result[1], result[2]);
}

glm::radians는 도를 라디안으로 변환 해 주는 함수 입니다. 각을 표현 하는 방법에는 우리가 일상 생활에서 흔히 사용하는 도(degree) 표기 방식이 있고 수학 연산에 주로 사용하는 라디안(radian) 방식이 있습니다. 호도법 이라고도 부르는데 이것 역시 추후 자세히 알아보도록 하겠습니다. 단, 프로그래밍에서 수학 함수를 사용할 때는 거의 대부분 라디안 방식을 사용한다는 것을 꼭 기억해 주시기 바랍니다. 무심코 라디안 대신 도를 입력하면 전혀 다른 결과가 나와 버리기 때문이죠.

다음으로 각 축 별로 입력된 값을 기준으로 사인, 코사인 값을 구합니다. 사인, 코사인은 삼각함수를 말하는데 이것도 지금 설명하기에는 범위가 꽤 큽니다. 3D 프로그래밍을 하려면 이렇게 꼬리에 꼬리를 물며 알아야 할 지식이 늘어나는데 이런 사전지식들을 초반에 다 배우려고 하면 맛을 보기도 전에 지칠 수 있습니다. 지금은 회전 행렬을 구성하는데 집중하기로 했으므로 삼각함수에 대한 내용은 추후 별도의 주제를 파서 배워보도록 하겠습니다.

이제 드디어 회전 행렬을 만드는 코드입니다. 회전 행렬은 x,y,z 세 축 마다 모두 만들어 줘야 합니다. 그리고 이 행렬들을 각 버텍스에 곱해주면 회전된 새로운 좌표가 계산되어 나옵니다. 여기서 행렬의 특성을 활용해 최적화 할 수 있는 부분이 존재 합니다. 세 개의 회전 행렬을 하나의 행렬로 만들어 연산량을 줄일 수 있다는 것이죠. 각 회전 행렬을 따로 따로 적용 하면 버텍스당 총 세 번의 행렬 곱셈이 발생 하는데, 하나의 행렬로 합쳤을 경우 버텍스당 한 번씩의 행렬 곱셈으로 줄어들게 됩니다.

// 비효율적인 방법 : X,Y,Z축을 따로 따로 계산 하기 때문에 연산량이 증가 된다.
// 버텍스 좌표에 회전 행렬 적용.
for (auto& v : mVertices)
{
    float position[4] = { v.mPosition.x, v.mPosition.y, v.mPosition.z, 1.0f };
    float result[4]{};
    Matrix4_Vec4(rotationMatrixZ, position, result);
    Matrix4_Vec4(rotationMatrixX, result, result);
    Matrix4_Vec4(rotationMatrixY, result, result);

    v.mPosition = glm::vec3(result[0], result[1], result[2]);
}

이런 코드를 아래와 같이 최적화 할 수 있습니다.

// X,Y,Z축의 행렬을 하나의 행렬로 만든다.
float rotationMatrix[4][4]{};
Matrix4_Multiply3(rotationMatrixY, rotationMatrixX, rotationMatrixZ, rotationMatrix);

for (auto& v : mVertices)
{
    float position[4] = { v.mPosition.x, v.mPosition.y, v.mPosition.z, 1.0f };
    float result[4]{};
    // x,y,z축의 회전 행렬들이 rotationMatrix하나로 통합되었다.
    Matrix4_Vec4(rotationMatrix, position, result);

    v.mPosition = glm::vec3(result[0], result[1], result[2]);
}

 

Matrix4_Multiply3함수는 세 개의 행렬을 곱해서 하나의 행렬로 만들어 주는 함수입니다. 이렇게 구현해볼 수 있습니다.

void Matrix4_Multiply3(float matrixA[][4], float matrixB[][4], float matrixC[][4], float(*result)[4])
{
    // ab = matrixA * matrixB
    float ab[4][4]{};
    for (int row = 0; row < 4; ++row)
    {
        for (int col = 0; col < 4; ++col)
        {
            float sum = 0;
            for (int i = 0; i < 4; ++i)
            {
                sum += matrixA[row][i] * matrixB[i][col];
            }
            ab[row][col] = sum;
        }
    }

    // ab * matrixC
    for (int row = 0; row < 4; ++row)
    {
        for (int col = 0; col < 4; ++col)
        {
            float sum = 0;
            for (int i = 0; i < 4; ++i)
            {
                sum += ab[row][i] * matrixC[i][col];
            }
            result[row][col] = sum;
        }
    }
}

 

회전 행렬을 적용해본 모습입니다.

전체 소스 코드 : Example06.h / cpp (https://github.com/sunduk/opengl-3d-examples)

이번 시간에는 행렬이라는 생소한 수학적 개념을 통해 3D환경에서 어떻게 물체에 변환을 부여하는지 알아 봤습니다. 조금 많은 내용이었는데 한번 정리해 보면 다음과 같습니다.

  • 이동, 회전, 스케일 행렬을 만들고 물체에 적용하는 방법
  • x,y,z 회전 행렬을 하나로 합쳐 최적화 하는 방법

다음번에는 여기서 조금 더 최적화를 해 보고 GPU가속을 받는 셰이더 프로그래밍에 대해 배워보도록 하겠습니다.

>>> 다음 강좌 : 6. 셰이더 프로그래밍 기초 - 기반코드 작성

감사합니다.

 

만담에 대한 답글을 하려면 로그인 또는 가입해야합니다.

게임 프로그래머의 만담 커뮤니티, 게임코디 입니다

온종일 코드와 씨름하는 우리들의 외로움을 삭이고자 합니다