본문으로 바로가기

5장 OpenGL - ④ 텍스처 매핑

OpenGL은 25년 넘게 이어져온 2D/3D 크로스 플랫폼 그래픽을 위한 산업 표준 라이브러리이다. 이번 장에서는 OpenGL의 초기화, 삼각형의 사용, 셰이더 프로그램 작성, 변환을 위한 행렬 사용, 텍스처 지원 추가 등 컴퓨터 그래픽스의 여러 주제를 다룬다.

💡 5장의 목차

  • OpenGL 초기화
  • 삼각형 폴리곤
  • 셰이더
  • 변환과 행렬
  • 텍스처 매핑

텍스처 매핑

텍스처 매핑(texture mapping)은 삼각형의 표면에 텍스처(이미지)를 렌더링 하는 테크닉이다. 텍스처 매핑을 활용하면 삼각형을 그릴 때 단색을 사용하는 대신에 텍스처의 색상을 사용할 수 있다. 텍스처를 적용시키기 위해서는 폴리곤의 각 꼭짓점(버텍스)마다 텍스처 좌표와 매칭 되는 추가적인 정보가 필요하다.

텍스처 좌표는 일반적으로 정규화된 좌표이다. 오브젝트나 세계 좌표와는 구분 짓기 위해서 텍스처 좌표는 U축과 V 축으로 구성된 UV 좌표를 사용한다. 좌측 상단에서 (0,0)으로 시작하는 이유는 많은 이미지 파일 포맷들의 데이터 시작점이 좌측 상단이기 때문이다.

▲ 텍스처의 UV 좌표계

삼각형의 각 버텍스는 자신만의 별도의 UV 좌표를 가진다. 삼각형의 각 버텍스에 대한 UV좌표를 알고 있다면 3개 버텍스 각각으로부터의 거리를 기반으로 텍스처 좌표를 블렌딩(보간)해서 삼각형 내부의 모든 픽셀을 채우는 것이 가능하다.

2D 이미지는 색상이 다른 픽셀들의 격자에 불과하다. 그래서 텍스처 좌표를 얻었다면 UV좌표를 사용해서 텍스처의 특정 픽셀을 구해야 한다. 이 텍스처의 픽셀은 텍스처 픽셀(texture pixel) 또는 텍셀(texel)이라고 부른다. 그래픽 하드웨어는 샘플링이라는 프로세스를 통해서 특정 UV 좌표에 해당하는 텍셀을 선택한다.

최근접 이웃 필터링과 이중 선형 필터링

버텍스가 가지고 있는 텍스처 정보를 활용해서 UV 좌표에서 가장 근접한 텍셀을 선택하고 그 텍셀을 색상으로 사용하는 아이디어를 최근접 이웃 필터링이라고 한다. 하지만 이 최근접 이웃 필터링에는 문제가 있다. 3D 세계에 있는 벽에 텍스처를 매핑한다고 가정해보자. 플레이어가 벽에 더 가까워짐에 따라 벽은 화면상에서 보다 크게 보이게 된다. 이는 페인트 프로그램에서 이미지 파일을 확대한것 같이 보이며 각 개별 텍셀이 화면상에서 매우 커지므로 텍스처는 뭉특해지거나 픽셀레이션 돼 나타난다.

이 문제를 해결하기 위해서 이중 선형 필터링을 사용한다. 이중 선형 필터링을 사용하면 가장 가깝게 인접한 각 텍셀의 블렌딩을 기반으로 색상을 선택한다. 따라서 벽은 픽셀레이션 된 것처럼 보이지 않고 흐리게 보인다. 텍스처 품질을 좀 더 향상하는 추가적인 방법들은 13장 '중급 그래픽스'에서 더 다루고 당분간은 이중 선형 필터링을 활용한다.

▲(a) 원본, (b) 최근접 이웃 필터링, (c) 이중 선형 필터링

텍스처 매핑 구현

OpenGL에서 텍스처 매핑을 사용하려면 3가지 작업이 필요하다.

  • 이미지 파일을 로드하고 OpenGL 텍스처 오브젝트를 생성한다.
  • 버텍스 포맷에 텍스처 좌표를 포함하도록 갱신한다.
  • 텍스처를 사용하도록 셰이더를 갱신한다.

텍스처 로딩

OpenGL에서 사용하는 이미지를 로드하는 방법에는 SDL 이미지 라이브러리를 비롯해 다양한 방법이 존재하지만, 여기서는 SOIL(Simple OpenGL Image Library)를 사용한다. SOIL을 사용하면 다양한 이미지 포맷을 쉽게 읽을 수 있고 OpenGL과 함께 동작하도록 설계가 되어있다.

텍스처 파일을 로딩하고 OpenGL에서 텍스처를 사용하는 복잡한 단계들을 간단하게 활용하기 위해서 아래와 같이 Texture 클래스로 캡슐화 하자.

Texture.h의 선언

class Texture
{
public:
    Texture();
    ~Texture();

    bool Load(const std::string& fileName);
    void Unload();

    void SetActive();
    int GetWidth() const { return mWidth; }
    int GetHeight() const { return mHeight; }
private:
    // 이 텍스처의 OpenGL ID
    unsigned int mTextureID;
    // 텍스처의 너비/높이
    int mWidth;
    int mHeight;
};

구현부는 교재와 아래의 소스코드를 참고하자. 여기에서는 텍스처를 로딩하기 위해 사용된 함수들만 간략하게 정리한다.

Texture.cpp 전체 코드

  • Texture::Load()구현에 사용된 함수
    • SOIL_load_image(fileName.c_str(), &mWidth, &mHeight, &channels, SOIL_LOAD_AUTO): fileName에 해당하는 이미지를 로딩한다. 이미지 타입은 AUTO로 알아서 맞춰준다. 이미지의 너비와 높이 채널 수를 주어진 변수에 각각 입력한다.
    • glGenTextures(1, &mTextureID): 텍스처 오브젝트를 생성해서 mTextureID에 ID값으로 저장한다.
    • glBindTexture(GL_TEXTURE_2D, mTextureID): mTextureID에 해당하는 텍스처를 메모리에 바인딩하여 활성화한다.
    • glTexImage2D(GL_TEXTURE_2D, 0, format, mWidth, mHeight, 0, format, GL_UNSIGNED_BYTE, image): 원본 이미지 데이터를 텍스처 오브젝트에 복사. OpenGL이 사용해야 하는 색상 포맷을 3번째 인자로 넣어주고 너비와 높이 등을 입력한다.
    • SOIL_free_image_data(image): 텍스처로 이미지 복사가 끝난 이미지를 SOIL 메모리상에서 해제한다.
    • glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR): 이중 선형 필터링을 활성 화한다.
    • glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR): 이중 선형 필터링을 활성화한다.
  • Texture::Unload()구현에 사용된 함수
    • glDeleteTextures(1, &mTextureID): mTextureID에 해당하는 텍스처를 해제한다.
  • Texture::SetActive()구현에 사용된 함수
    • glBindTexture(GL_TEXTURE_2D, mTextureID): mTextureID에 해당하는 텍스처를 활성화한다. 매 프레임마다 해당 텍스처를 사용하기 전에 활성화할 것이 요구된다.

버텍스 포맷 갱신

폴리곤에 텍스처를 매핑하기 위해서는 폴리곤의 각 버텍스는 텍스처와 매핑할 수 있는 (u, v) 좌표 값을 추가로 가져야 하므로 mSpriteVerts의 버텍스 배열 값을 다음과 같이 수정한다.

void Game::CreateSpriteVerts()
{
    float vertices[] = {
        //  x,     y,    z,    u,    v  // 버텍스 위치(x,y,z) 텍스처 맵핑(u,v)
        -0.5f,  0.5f,  0.f,  0.f,  0.f, // top left
         0.5f,  0.5f,  0.f,  1.f,  0.f, // top right
         0.5f, -0.5f,  0.f,  1.f,  1.f, // bottom right
        -0.5f, -0.5f,  0.f,  0.f,  1.f  // bottom left
    };

    unsigned int indices[] = {
        0, 1, 2,
        2, 3, 0
    };
    // 스프라이트를 그리기 위한 4각형 스프라이트 VertexArray 생성과정
    // 앞으로 모든 sprite들은 이 멤버변수를 사용한다.
    mSpriteVerts = new VertexArray(vertices, 4, indices, 6);
}

버텍스 하나가 가지는 정보가 3개의 float 에서 5개의 float로 변경됐기 때문에 VertexArray의 생성자도 이에 맞게 수정한다. 그리고 이전에는 버텍스 속성이 0번 ( (x, y, z)에 대한 정보) 하나뿐이었는데 uv좌표를 추가하기 위해서 다음과 같이 1번 속성을 추가한다.

glEnableVertexAttribArray(1);
glVertexAttribPointer(
    1,          // 버텍스 속성 인덱스
    2,          // 요소의 수 u와 v 2개의 컴포넌트 존재
    GL_FLOAT,   // 요소의 타입
    GL_FALSE,   // GL_FLOAT에서는 사용되지 않음
    sizeof(float) * 5,  // 간격(간격은 항상 버텍스의 크기다.)
    reinterpret_cast<void*> (sizeof(float) * 3) // 오프셋 포인터
    );

셰이더 갱신

텍스처를 사용할 수 있도록 버텍스 데이터에 텍스처 좌표를 추가하였다. 버텍스의 양식이 변경되었기 때문에 셰이더도 이에 맞게 새로 작성한다. 아래와 같이 두 개의 셰이더를 만들자.

 

Sprite.vert(설명은 주석으로 대체)

#version 330

// uniform은 in, out 변수와 다르게 셰이더 프로그램의 
// 수많은 호출 사이에서도 동일하게 유지되는 전역 변수이다.
// mat4 는 3차원 동차좌표계 변환 행렬인 4x4 행렬이다.
uniform mat4 uWorldTransform;    
uniform mat4 uViewProj;

// layout 명령어는 속성 슬롯이 어떤 변수에 해당하는지 지정할 수 있다.
// 여기서 속성 슬롯이란 glVertexAttribPointer 함수를 호출했을 때의 슬롯 번호이다.
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec2 inTexCoord;

// 버텍스 셰이더로 입력된 텍스쳐 좌표를 프래그먼트 셰이더로 전달하기 위한 변수
// out 변수를 선언하면 버텍스 셰이더에서 프래그먼트 셰이더로 데이터 전달이 가능하다.
out vec2 fragTexCoord;

void main()
{
    // 위치를 동차 좌표로 변환
    vec4 pos = vec4(inPosition, 1.0);
    // 위치를 세계 공간으로 변환한 뒤 클립 공간으로 변환
    gl_Position = pos * uWorldTransform * uViewProj;
    // 텍스처 좌표를 프래그먼트 셰이더에 전달
    fragTexCoord = inTexCoord;
}

 

Sprite.frag(설명은 주석으로 대체)

#version 330

// 원칙적으로 버텍스 셰이더의 모든 out 변수들은 
// 프래그먼트 셰이더에서 이에 해당하는 in 변수를 갖고 있어야 한다.
// 프래그먼트 셰이더에서 in 변수의 이름과 타입은 버텍스 셰이더에서의 
// out 변수와 일치하는 동일한 이름과 타입을 갖고 있어야 한다.
in vec2 fragTexCoord;

// 제공된 텍스처 좌표로 색상을 얻기 위해 텍스처 샘플러 uniform
// sampler2D 타입은 2D 텍스처를 샘플링할 수 있는 특별한 타입이다.
// sampler uniform은 C++ 코드에서 바인딩이 필요없다.
// 왜냐하면 지금 구조에서는 한 번에 오직 하나의 텍스처만 바인딩하기 때문이다.
// 그래서 OpenGL은 자동으로 셰이더의 텍스처 샘플러가 활성화된 텍스처에 유일하게 대응함을 안다.
uniform sampler2D uTexture;

// 출력 색상을 저장하기 위해 out 변수 지정자를 사용해서 전역 변수를 선언
out vec4 outColor;

void main()
{
    // 텍스처로부터 색상을 샘플링
    outColor = texture(uTexture, fragTexCoord);
}

알파 블렌딩

알파 블렌딩은 픽셀에 투명도를 섞는 방법을 결정한다. 알파 블렌딩은 다음 형태의 방정식을 사용해서 픽셀의 색상을 계산한다.
\[outputColor = srcFactor \cdot sourceColor + dstFactor \cdot destinationColor \]
이 방정식의 각 요소는 다음과 같다.

  • \(outputColor\): 최종 출력되는 색상
  • \(sourceColor\): 프래그먼트 셰이더에서 그리려는 새로운 소스의 색상
  • \(destinationColor\): 해당 픽셀에 이미 존재하던 색상(배경이거나 먼저 그려진 물체)

투명도의 알파 블렌딩 결과를 얻기 위해서는 \(srcFactor\)와 \(dstFactor\)를 다음과 같이 설정한다. (알파 채널의 알파 값은 1보다 작은 실수이다.)

  • \(srcFactor = srcAlpha\)
  • \(dstFactor= 1 - srcAlpha\)

최종적으로 알파 블렌딩을 수식으로 나타내면 다음과 같다.

\[outputColor = srcAlpha \cdot sourceColor + ( 1 - srcAlpha) \cdot destinationColor \]

이를 코드로 구현하면 다음과 같다. 아래의 코드를 Game::GenerateOutput에서 스프라이트를 그리기 전에 부분에 추가하자.

Game.cpp의 Game::GenerateOutput()

// 알파블렌딩을 활성화
glEnable(GL_BLEND);
glBlendFunc(
    GL_SRC_ALPHA,           // srcFactor = srcAlpha
    GL_ONE_MINUS_SRC_ALPHA  // dstFactor = 1 - srcAlpha
);

이상으로 기존 SDL로 작성되었던 2D 게임을 OpenGL로 구현했다. 다음 장에서는 OpenGL을 활용한 3D 그래픽 프로그래밍을 이어서 진행한다.


본 게시글은 에이콘 출판사의 Game Programming in C++ 도서를 학습하며 요약정리한 내용입니다.