Day 14 텍스쳐 매핑과 알파 블랜딩
텍스처 매핑(texture mapping)은 삼각형 표면에 텍스쳐(이미지)를 렌더링하는 것이다. 텍스쳐 매핑은 삼각형을 그릴 때 단색을 사용하는 대신에 텍스쳐의 색상을 사용 가능하게 해준다. 텍스처 매핑을 사용하기 위해서는 우선 이미지 파일이 필요하다. 다음으로 각 삼각형에 텍스처를 적용하는 방법을 결정해야 한다. 단순한 스프라이트 사각형만 있다면 사각형의 횐쪽 상단 모서리는 텍스처의 왼쪽 상단 모서리와 일치시키면된다. 하지만 텍스처는 게임에서 임의의 3D 오브젝트에 사용한다. 예를 들어 텍스처를 캐릭터 얼굴에 올바르게 적용하려면 텍스처의 어떤 부분이 어떤 삼각형에 해당하는지를 알아야한다. 이를 지원하려면 버텍스 버퍼의 모든 버텍스에 대해 추가적인 버텍스 속성이 필요하다. 지금까지는 버텍스 속성으로 각 버텍스에 3D 위치만을 저장했었다. 텍스처 매핑을 위해 각 버텍스는 이제 해당 버텍스에 해당하는 텍스처 위치를 지정하는 텍스처 좌표가 필요하다. 텍스처 좌표는 일반적으로 정규화된 좌표이다.
OpenGL에서 좌표는 위 그림에서처럼 왼쪽 하단이 (0, 0)이고 오른쪽 상단이 (1, 1)이다. U 컴포넌트는 텍스처의 오른쪽 방향을 정의하고 V 컴포넌트는 텍스처의 위쪽 방향을 정의한다. 그래서 많은 곳에서 텍스처 좌표를 UV 좌표라고도 한다. OpenGL은 텍스처의 왼쪽 하단을 원점으로 사용하므로 하단의 행에서 시작하는 이미지 픽셀 데이터 포맷을 기대한다. 그러나 대부분의 이미지 파일 포맷은 자신의 데이터 시작을 상단 행에서부터 시작해서 저장한다. 이 차이를 고려하지 않으면 거꾸로된 텍스처가 나타나는 결과를 초래한다. 이 문제를 해결하는 데는 여러 가지 방법이 있다.
간단한 방법은 V 컴포넌트를 뒤집는다. 즉 왼쪽 상단 구석이 (0, 0)이라고 가정한다. 이 형식은 DirectX가 사용하는 텍스처 좌표 체계에 해당한다. 삼각형의 각 버텍스는 자신만의 별도의 UV 좌표를 갖는다. 삼각형의 각 버텍스에 대한 UV 좌표를 알고 있다면 3개의 버텍스 각각으로부터의 거리를 기반으로 텍스처 좌표를 블렌딩(보간, interpolating)해서 삼각형 내부의 모든 픽셀을 채우는 것이 가능하다.
예를 들어 삼각형의 정확한 중심에 있는 픽셀은 위 그림처럼 세 버텍스의 UV 좌표의 평균값에 해당한다. 2D 이미지는 색상이 다른 픽셀들의 격자에 불과하다. 그래서 텍스처 좌표를 얻었다면 이 UV 좌표를 사용해서 텍스처의 특정 픽셀을 구해야 한다. 이 텍스처의 픽셀은 텍스처 픽셀(texture pixel) 또는 텍셀(texel)이라 부른다. 그래픽 하드웨어는 샘플링(sampling)이라는 프로세스를 통해서 특정 UV 좌표에 해당하는 텍셀을 선택한다. 정규화된 UV 텍스처를 사용할 시 한 가지 문제점은 약간의 차이만 있는 두 UV 좌표의 경우 이미지 파일에서 같은 텍셀을 선택할 수 있다는 점이다. UV 좌표에서가장 근접한 텍셀을 선택하고 그 텍셀을 색상으로 사용하는 아이디어를 최근접 이웃 필터링(nearset-neighbor filtering)이라고 부른다. 그러나 최근접 이웃 필터링에는 몇 가지 문제가 있다. 3D 세계에 있는 벽에 텍스처를 매핑한다고 하면 플레이어가 벽에 더 가까워짐에 따라 벽은 화면상에서 보다 크게 보이게 된다. 이는 페인트 프로그램에서 이미지 파일을 확대한 것 같이 보이며, 각 개별 텍셀이 화면상에서 매우 커지므로 텍스처는 뭉툭해지거나 픽셀레이션(pixelation, 각각의 픽셀들이 보이는 것) 이 픽셀레이션을 해결하고자 이중 선형 필터링(bilinear filtering)을 사용한다. 이중 선형 필터링을 사용하면 가장 가깝게 인접한 각 텍셀의 블렌딩을 기반으로 색상을 선택한다. 벽을 예를 들면 이중 선형 필터링을 사용하면 플레이어가 벽에 가까워짐에 따라 벽은 픽셀레이션된 것처럼 보이지 않고 흐리게 보인다. OpenGL에서 텍스처 매핑을 사용하려면 3가지 작업이 필요하다.
텍스처 로딩
OpenGL에서 사용하는 이미지는 SDL 이미지 라이브러리를 사용해서 로드할 수도 있지만 Simple OpenGL Image Library(SOIL)이 좀 더 사용하기 쉽다. SOIL은 PNG, BMP, JPG, TGA 그리고 DDS를 포함한 다양한 파일 포맷을 읽을 수 있따. SOIL은 OpenGL과 함께 동작하도록 설계됐으므로 텍스처 오브젝트를 생성한느 OpenGL 코드와 쉽게 연결된다.
위 코드는 Texture 클래스의 선언이다. Texture 클래스에서는 텍스처 파일을 로딩하고 OpenGL로 텍스처를 사용한다. Texture::Load의 구현은 Texture 클래스 코드의 대부분을 포함한다.
먼저 채널 수를 저장하기 위한 지역 변수를 선언하고 SOIL_load_image 함수를 호출해서 텍스처를 로다한다. SOIL이 이미지를 로드하는데 실패하면 SOIL_load_image는 nullptr을 반환한다. 따라서 이미지가 제대로 로드됐는지를 보증하는 체크 코드를 추가해야한다. 그런 다음 이미지가 RGB인지 또는 RGBA인지를 알아내야한다. 이것은 채널의 수를 토대로 알아낼 수 있다. 채널의 수가 3이면 RGB를 의미하고 채널 수가 4면 RGBA를 뜻한다. 그리고 glTextures를 사용해서 OpenGL 텍스처 오브젝트(mTextureID에 ID를 저장)를 생성한다. glBindTexture에 전달된 GL_TEXTURE_2D 타겟은 가장 일반적인 텍스처 타겟이지만 고급 텍스처 유형을 위한 다른 타겟도 존재한다. OpenGL 텍스처 오브젝트를 얻은 후에 할 일은 glTexImage2D 함수로 원본 이미지 데이터를 텍스처 오브젝트에 복사하는 일인데 glTexImage2D 함수는 꽤 많은 파라미터를 사용한다. OpenGL에 이미지 데이터를 복사한 후에는 SOIL에 메모리상의 이미지 데이터 해제를 알린다. 그리고 마지막으로 glTexParameteri 함수를 사용해서 이중 선형 필터링을 활성화한다. Texture::Unload는 텍스처 오브젝트를 삭제하며 Texture::SetActive는 glBindTexture를 호출한다.
그리고 Gaem의 맵에 텍스처를 로드한다. Game::GetTexture함수는 요청된 텍스처에 대한 Texture를 리턴한다. 그리고 SpriteComponent는 SDL_Texture 대신에 Texture* 멤버 변수를 사용한다. 마지막으로 SpriteComponent::Draw에서 버텍스를 그리지 직전에 mTexture의 SetActive를 호출한느 코드를 추가한다. 이는 그리려는 각 스프라이트 컴포넌트마다 다른 활성화된 텍스처를 설정할 수 있다는 걸 뜻한다.
// SpirteComponent::Draw에서
// 현재 텍스처를 설정
mTexture->SetActive();
// 사각형을 그린다
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);
버텍스 포맷 갱신
텍스처 매핑을 사용하기 위해 버텍스는 텍스처 좌표를 가져야 하므로 스프라이트 VertexArray를 업데이트해야한다.
V 텍스쳐 좌표는 이미지 데이터를 얻는 방법에 있어서 OpenGL의 특이한 방식 때문에 플립됐다. 각 버텍스에서 처음 3개의 부동 소수점 값은 텍스처 좌표다. 버텍스 레이아웃을 변경했으므로 VertexArray 생성자의 코드도 변경해야한다. 간경함을 위해 모든 버텍스를 3D 위치와 2D 텍스처 좌표를 가진다. 버텍스의 크기가 바뀌었으므로 각 버텍스가 이제는 버텍스마다 5개의 실수값을 갖고 있다는 걸 지정하고자 glBuffeData 호출을 수정해야한다.
glBufferData(GL_ARRAY_BUFFER, numVerts * 5 * sizeof(float), verts, GL_STATIC_DRAW);
인덱스 버퍼는 여전히 동일하므로 인덱스 버퍼에 대한 glBufferData의 호출은 바뀌지 않는다. 그러나 버텍스의 간격이 이제는 5개의 실수 크기라는 걸 지정하기 위해 버텍스 속성 0을 변경해야한다.
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
sizeof(float) * 5, // 버텍스 간격은 이제 5개의 float
0); // 버텍스 위치는 여전히 오프셋 0
위 코드는 위치 버텍스 속성만 수정한다. 그러나 이제는 두 번째 버텍스 속성인 텍스처 좌표가 존재하므로 버텍스 속성 1을 활성화하고 그 포맷을 지정해야한다.
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, // 버텍스 속성 인덱스
2, // 컴포넌트의 수 (U,V 2개)
GL_FLOAT, // 각 컴포넌트의 타입
GL_FALSE, // GL_FLOAT에서는 사용되지 않는다
sizeof(float) * 5, // 간격 (간격은 항상 버텍스의 크기)
reinterpret_cast(sizeof(float)* 3) // 오프셋 포인터
);
이 glVertexAttribPointer 호출의 마지막 파라미터는 조금 복잡하다. OpenGL은 버텍스의 시작에서 이 속성까지의 바이트 수를 알아야한다. 이 속성까지의 바이트 수는 sizeof(float) 3이다. 하지만 OpenGL은 오프셋 포인터를 원한다. 그래서 reinterpret_cast를 사용해서 타입을 void로 강제 변환해야한다.
셰이더 갱신
이제 버텍스 포맷은 텍스처 좌표를 사용하므로 새로운 2개의 셰이더를 작성해야한다.
Sprite.vert 셰이더
이전에는 오직 하나의 버텍스 속성만 있었으므로 변수로 위치 하나만을 선언한다. 하지만 이제는 여러 개의 버텍스 속성이 있으므로 어떤 속성 슬롯이 어떤 변수에 해당하는지를 구체적으로 지정해야한다. 이 때문에 변수 선언을 layout을 정해서 해야한다. layout 명령어는 속성 슬롯이 어떤 변수를 해당하는지를 지정한다.
layout(location=0) in vec3 inPosition;
layout(location=1) in vec2 inTexCoord;
여기서는 버텍스 속성 슬롯 0에 float 3개를 가진 3D 벡터를 지정했고, 버텍스 속성 슬롯 1에는 2개의 2D 벡터를 지정했다. 이 슬롯 번호는 glVertexAttribPointer 함수를 호출했을 때의 번호에 해당한다. 다음으로 텍스처의 좌표가 버텍스 셰이더로의 입력이긴 하지만 프래그먼트 셰이더 또한 텍스처의 좌표를 알아야한다. 왜냐하면 프래그먼트 셰이더는 픽셀의 색상을 결정하기 위해 텍스처 좌표를 알아야하기 때문이다. 버텍스 셰이더에서 전역 out 변수를 선언하면 버텍스 셰이더에서 프래그먼트 셰이더로 데이터를 전달하는 것이 가능하다.
out vec2 fragTexCoord;
그런 다음 버텍스 셰이더의 메인함수에서 버텍스 입력 변수로부터 출력 변수로 텍스처 좌표를 직접 복사하는 라인을 추가한다.
fragTexCoord = inTexCoord;
이러한 코드가 잘 작동하는 이유는 OpenGL이 자동적으로 삼각형의 면에 걸쳐 버텍스 셰이더 출력을 자동으로 보간해주기 때문이다. 따라서 삼각형이 오직 3개의 버텍스만 있다하더라도 삼각형 면의 모든 임의의 픽셀은 프래그먼트 세이더에서 자신에 해당하는 텍스처 좌표를 알 수 있다.
Sprite.frag 셰이더
원칙적으로 버텍스 셰이더의 모든 out 변수들은 프래그먼트 셰이더에서 이에 해당하는 in 변수를 갖고 있어야한다. 프래그먼트 셰이더에서 in 변수의 이름과 타입은 버텍스 셰이더의 out 변수와 일치하는 동일한 이름과 타입을 갖고 있어야한다.
in vec4 fragTexCoord;
그리고 제공된 텍스처 좌표로 색상을 얻기 위해 텍스처 샘플러 uniform을 추가해야한다.
uniform sampler2D uTexture;
sampler2D 타입은 2D 텍스처를 샘플링할 수 있는 특별한 타입이다. 버텍스 셰이더에서 사용된 세계 변환 행렬이나 뷰-투영 uniform과는 다르게 이 샘플러 uniform은 C++ 코드에서 바인딩이 필요없다. 왜냐하면 지금 구조에서는 한 번에 오직 하나의 텍스처만 바인딩하기 때문이다. 그래서 OpenGL은 자동으로 셰이더의 텍스처 샘플러가 활성화된 텍스처에 유일하게 대응됨을 안다. 마지막으로 다음과 같이 메인 함수에서 최종 픽셀에 해당하는 outColor에 텍셀을 할당한다.
outColor = texture(uTexture, fragTexCoord);
위 코드는 버텍스 셰이더로 부터 넘겨받은 텍스처 좌표(텍스처 좌표는 삼각형의 면에 걸쳐 보간됐다)를 사용해서 텍스처로부터 색상을 샘플링한다. 그리고 Game::LoadShaders의 코드를 변경해서 Sprite.vert와 Sprite.frag를 로드한다. SpriteComponents에 텍스처를 설정했던 액터의 이전 코드는 이제 SOIL을 사용해서 텍스처를 문제없이 로드한다.
하지만 위 실행 화면을 보면 투명해야 될 픽셀을 검정색으로 그리고 있다.
알파 블랜딩
알파 블랜딩(alpha blending)은 픽셀에 투명도를 섞는 방법을 결정한다.(알파 채널의 값은 1보다 작다). 알파 블랜딩은 다음 형태의 방정식을 사용해서 픽셀의 색상을 계산한다.
이 방정식에서 소스 색상(source color)은 프래그먼트 셰이더에서 그리려는 새로운 소스의 색상이며, 대상 색상은 색상 버퍼에 이미 존재하는 색상이다. 팩트 파라미터를 지정해서 알파 블랜딩 함수를 커스터마이징하는 것이 가능하다. 원하는 투명도의 알파 블랜딩 결과를 얻으려면 픽셀의 알파에 소스 팩터(소스 알파)를 설정하고, 대상 팩터는 (1소스 알파)로 설정한다.
예를 들어 색상당 비트가 8비트이고 일부 픽셀의 색상 버퍼에는 빨간색이 저장돼 있다고 가정하면 대상 색상은 다음과 같다.
다음으로 파란색 픽셀을 그린다. 소스 색상은 다음과 같다.
이제 소스 알파가 0이라고 하면 이는 픽셀이 완전히 투명하다는 것을 뜻한다. 이 경우 방정식은 다음과 같이 최종 색상을 평가한다.
이 결과는 완전히 투명한 픽셀을 원한 결과다 알파가 0이면 소스 색상은 완전히 무시되며 색상 버퍼에 이미 있는 색상만을 사용한다. 알파 블랜딩을 활성화하기 위해 Game::GenerateOutput에 다음 코드를 추가한다. 모든 스프라이트를 그리는 코드 앞에 추가한다.
glEnable(GL_BLEND);
glBlendFunc(
GL_SRC_ALPHA, // srcFactor = srcAlpha
GL_ONE_MINUS_SRC_ALPHA // dstFactor = 1 - srcAlpha
);
glEnable을 호출해서 색상 버퍼 블랜딩 기능을 켠다(기본적으로는 비활성화돼 있다). 그런 다음 glBlendFunc 함수를 사용해서 원하는 srcFactor 값과 dstFactor값을 지정한다.