Tutorial 5 : 텍스쳐가 입혀진 큐브
이 튜토리얼에서는 이걸 배울거에요! :
- UV 좌표계가 뭘까요?
- 어떻게 텍스처를 로딩할까요?
- 그리고 그것들을 OpenGL에서 어떻게 쓸까요?
- 필터링과 밉맵핑이 뭔지, 그리고 그걸 어떻게 쓸까요?
- 어떻게 GLFW를 이용해서 텍스처를 더 편하게 로드할 수 있을까요?
- 알파 채널이 도대체 뭘까요?
UV 좌표계에 대해서
Mesh에 텍스쳐링(텍스처를 붙이는 작업)하려면, 여러분은 각 삼각형(폴리곤)에 사용할 이미지의 일부를 OpenGL에게 전해줘야 하는 방법이 필요할거에요. 그 때 필요한 것이 UV 좌표계이랍니다.
각각의 정점은 위치의 제일 위에서 부터 2개의 float을 가질 수 있어요. 바로 U와 V죠. 이 좌표들은 텍스처에 접근할때 필요한데, 한번 보시죠!
삼각형에서 텍스처를 씌우면, 어떻게 왜곡되는지 살펴보세요.
혼자서 .BMP 이미지들 로딩해보기
사실 BMP 파일 형식을 아는 건 그리 중요한게 아니에요. 많은 라이브러리들은 BMP 파일을 끝장나게 로드해 줄 수 있거든요. 그래도 아주 간단하고, 새련된 인터페이스 밑에서 얼마나 추악한 짓이 일어나고 있는지 이해하는데 도움이 될거에요. 자. 그러면 BMP 파일 로더를 처음부터 만들어서. 작동 방식을 알고. 그다음에 영원히 코드를 잠재워버립시다. 이 세상에는 더 가치 있는 코드가 많으니까요!
로딩 함수의 정의부터 살펴봐요!:
GLuint loadBMP_custom(const char * imagepath);
그러면, 이렇게 쓰겠죠? :
GLuint image = loadBMP_custom("./my_texture.bmp");
자. 그러면 어떻게 BMP 파일을 읽나 봅시다. 먼저, 자료가 필요할 건데. 이 변수들은 파일을 읅을 때 설정될거에요.
// Data read from the header of the BMP file
unsigned char header[54]; // Each BMP file begins by a 54-bytes header
unsigned int dataPos; // Position in the file where the actual data begins
unsigned int width, height;
unsigned int imageSize; // = width*height*3
// Actual RGB data
unsigned char * data;
우리는 이제 ‘진짜로’ 파일을 열고 있어요!
// Open the file
FILE * file = fopen(imagepath,"rb");
if (!file){printf("Image could not be opened\n"); return 0;}
파일의 처음 부분은 54-바이트 헤더일거에요. 여기엔 “이거, 진짜 BMP파일이야?”와 같은 정보가 저장되어 있어요. 그러니까 이미지의 크기, 픽셀당 비트수 같은 거 말이에요. 자. 한번 읽어볼까요?:
if ( fread(header, 1, 54, file)!=54 ){ // If not 54 bytes read : problem
printf("Not a correct BMP file\n");
return false;
}
헤더는 언제나 BM으로 시작할 거에요. 진짜냐고요? 그럼 16진수 편집기로 한번 까보죠! :
자. 그래서 우리는 첫 두바이트가 진짜 ‘B’와 ‘M’인지 체크해야 해요. :
if ( header[0]!='B' || header[1]!='M' ){
printf("Not a correct BMP file\n");
return 0;
}
그럼 이제 우리는 이미지의 크기를 읽을 수 있고, 파일의 위치나.. 그런 것들을 읽을 수 있어요. :
// 바이트 배열에서 int 변수를 읽습니다.
dataPos = *(int*)&(header[0x0A]);
imageSize = *(int*)&(header[0x22]);
width = *(int*)&(header[0x12]);
height = *(int*)&(header[0x16]);
몇몇 정보가 날아갔을 때도 대비해야죠! :
// 몇몇 BMP 파일들은 포맷이 잘못되었습니다. 정보가 누락됬는지 확인해봅니다.
// Some BMP files are misformatted, guess missing information
if (imageSize==0) imageSize=width*height*3; // 3 : one byte for each Red, Green and Blue component
if (dataPos==0) dataPos=54; // The BMP header is done that way
자, 그러면 우리는 이제 이미지의 크기를 알고 있으니까 - 이미지를 저장할 메모리를 할당한 다음 - 파일을 읽을 수 있겠네요! :
// 버퍼 생성
data = new unsigned char [imageSize];
// 파일에서 버퍼로 실제 데이터 넣기.
fread(data,1,imageSize,file);
//이제 모두 메모리 안에 있으니까, 파일을 닫습니다.
//Everything is in memory now, the file can be closed
fclose(file);
자. 드디어 ‘진짜’ OpenGL 파트에 도착했어요. 텍스처 생성은 정점 버퍼 생성이랑 아주 비슷해요. 또 텍스처를 만들고, 또 바인딩 하고, 또 채우고, 또 구성하는거죠!
glTexImage2D에선. GL_RGB는 3가지 색상이라고 알려주는거고요, 그리고 GL_BGR은 RAM에 정확히 어떻게 표현되는지 알려주는거에요. 실제론, BMP는 빨강->초록->파랑 순이 아니라 파랑->초록->빨강으로 저장해요. 그래서. 우리는 그걸 OpenGL에게 알려줘야해요.
// OpenGL Texture를 생성합니다.
GLuint textureID;
glGenTextures(1, &textureID);
// 새 텍스처에 "Bind" 합니다 : 이제 모든 텍스처 함수들은 이 텍스처를 수정합니다.
// "Bind" the newly created texture : all future texture functions will modify this texture
glBindTexture(GL_TEXTURE_2D, textureID);
// OpenGL에게 이미지를 넘겨줍니다.
glTexImage2D(GL_TEXTURE_2D, 0,GL_RGB, width, height, 0, GL_BGR, GL_UNSIGNED_BYTE, data);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
마지막 두 줄은 나중에 설명할게요. 어쨌든, C++에선 이제 새 기능을 이용해 텍스처를 로드할 수 있어요! :
GLuint Texture = loadBMP_custom("uvtemplate.bmp");
아주 아주 중요한 점이에요! :** 2배수의 텍스처를 쓰세요! !**
- 좋음 : 128*128, 256*256, 1024*1024, 2*2…
- 나쁨 : 127*128, 3*5, …
- 괜찮은데, 이상해요. : 128*256
OpenGL에서 텍스처 쓰기
자. Frgament Shader를 먼저 봅시다. 간단해요! :
#version 330 core
// 정점 셰이더에서 넘겨준 보간 값.
in vec2 UV;
// 출력 데이터
out vec3 color;
// 한 메쉬를 그리는 동안 일정하게 유지되는 값.
uniform sampler2D myTextureSampler;
void main(){
// Output color = 지정된 UV에서 텍스처의 색.
color = texture( myTextureSampler, UV ).rgb;
}
자. 세가지가 필요합니다! :
- Fragment Shader는 UV 좌표가 필요한 것 같네요. 좋아요.
- 그리고, 접근할 텍스처를 확인하려면 “Sampler” 2D가 필요한 것 같고요. (동일한 셰이더로, 여러 텍스처에 접근할 수 있어요.)
- 마지막으론, texture() 함수를 통해 텍스쳐에 접근하면, (R,G,B,A) vec4를 돌려주네요. 곧 A에 대해 알아 볼거에요.
Vertex Shader도 간단해요, 그냥 UV 좌표들을 Fragment Shader에 전해주기만 하면 되죠! :
#version 330 core
// 입력 정점 데이터, 이 셰이더가 실행할 때마다 달라집니다. (각 정점마다 셰이더가 한번씩 실행되요.)
layout(location = 0) in vec3 vertexPosition_modelspace;
layout(location = 1) in vec2 vertexUV;
// 출력 데이터 ; 각 픽셀마다 알아서 보간될거에요.
out vec2 UV;
// 이 변수는 '매쉬당' 상수에요.
// Values that stay constant for the whole mesh.
uniform mat4 MVP;
void main(){
// 정점의 출력 위치 = MVP(Model View Projection) * position;
gl_Position = MVP * vec4(vertexPosition_modelspace,1);
// 정점의 UV. 특별한 건 없음.
UV = vertexUV;
}
튜토리얼 4에 나왔던 “layout(location = 1) in vec2 vertexUV”를 기억하세요? 뭐, 여기서도 똑같이 해야겠지만. (R,G,B) 세 개를 주는 것보단 (U,V) 를 던져주죠! 그 편이 더 예쁠꺼니까요.
// 정점 당 두개의 UV 좌표. Blender에서 생성되었습니다. 곧 이걸 스스로 하는 법을 배울겁니다.
static const GLfloat g_uv_buffer_data[] = {
0.000059f, 1.0f-0.000004f,
0.000103f, 1.0f-0.336048f,
0.335973f, 1.0f-0.335903f,
1.000023f, 1.0f-0.000013f,
0.667979f, 1.0f-0.335851f,
0.999958f, 1.0f-0.336064f,
0.667979f, 1.0f-0.335851f,
0.336024f, 1.0f-0.671877f,
0.667969f, 1.0f-0.671889f,
1.000023f, 1.0f-0.000013f,
0.668104f, 1.0f-0.000013f,
0.667979f, 1.0f-0.335851f,
0.000059f, 1.0f-0.000004f,
0.335973f, 1.0f-0.335903f,
0.336098f, 1.0f-0.000071f,
0.667979f, 1.0f-0.335851f,
0.335973f, 1.0f-0.335903f,
0.336024f, 1.0f-0.671877f,
1.000004f, 1.0f-0.671847f,
0.999958f, 1.0f-0.336064f,
0.667979f, 1.0f-0.335851f,
0.668104f, 1.0f-0.000013f,
0.335973f, 1.0f-0.335903f,
0.667979f, 1.0f-0.335851f,
0.335973f, 1.0f-0.335903f,
0.668104f, 1.0f-0.000013f,
0.336098f, 1.0f-0.000071f,
0.000103f, 1.0f-0.336048f,
0.000004f, 1.0f-0.671870f,
0.336024f, 1.0f-0.671877f,
0.000103f, 1.0f-0.336048f,
0.336024f, 1.0f-0.671877f,
0.335973f, 1.0f-0.335903f,
0.667969f, 1.0f-0.671889f,
1.000004f, 1.0f-0.671847f,
0.667979f, 1.0f-0.335851f
};
위 UV 좌표는 다음 모델에 해당되요 :
자. 2번이나 했던 걸 또 해봐요. 버퍼 생성, 바인딩, 채우고, 구성하고, 그리고 평소대로 Vertex Buffer를 그리는데. 조심할 점은 glVertexAttribPointer 함수에서 두 번째 매게변수(size, 크기.)는 3이 아니라 2에요! 중요하니 다시 말할께요. glVertexAttribPointer 함수에서 두 번째 매게변수(size, 크기.)를 3에서 2로 바꿔요!
이제 결과를 볼까요? :
그리고 - 줌인 버전! :
밉맵과 필터링은 무엇이고, 어떻게 쓸까
아래 스크린 샷에 볼 수 있듯. 텍스처 품질이 구리네요. 왜일까요? 우리가 loadBMP_custom에서 이렇게 써서 그래요. :
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
그러니까, 우리의 Fragment Shader에서, texture() 함수가 대충 U,V 좌표에 있는 texel(역주: Tex + Pixel)을 취하고. 계속 대충 때려박는거죠. 왜냐면 화면은 아-주 크지만. 텍스처는 아-주 크지는 않으니. 한 Texel에서 계속 똑같은 색만 들고 오는 거에요.
자. 이대로 두고 볼수만은 없겠죠?
선형 필터링
선형 필터링을 쓰면, texutre() 함수는 주변의 다른 텍셀을 보기 시작하고. 각 텍셀까지의 거리에 따라 색상을 섞어요. 그렇게 하면- 아까 보이던 도트게임 같던 비주얼은 사라지겠죠!
어휴, 굉장한 진전이기는 한데, 그치만 여전히 구려보인다면. 이것보단 조금 더 느린 비등방성 필터링이라는 걸 쓸 수 있어요.
비등방성 필터링
이 부분은 조각을 통해 실제로 보이는 이미지 부분을 근사해요. 예를 들어, 다음 텍스처가 측면에서 보이고 조금 회전한다면, 이방성 필터링이 주 방향에 따라 고정된 수의 샘플(“비등방성 레벨”)을 취해 파란색 직사각형에 포함된 색깔을 계산해요.
밉맵
선형, 비등방성 필터링은 둘 다 문제를 가지고 있어요. 만약에 텍스처가 멀리서도 잘 보이려면, 4개의 텍셀을 섞는 것만으론 충분하지 않겠죠. 실제로, 만약 우리의 3D 모델이 화면의 한 픽셀(fragment를 Pixel로 번역했습니다.) 만 차지 한다면. 이미지의 모든 텍셀들의 평균을 내서 최종 색깔을 결정해야해요. 점 하나 그리는데 말이에요! 그걸 누가 해요?! 아무도 안하죠! 똑똑한 사람들은 모두 MipMaps를 쓰거든요!
- 초기화 때, 이미지를 2배씩 계속 축소면서 1x1 이미지(사실상 이미지 내 모든 Texel의 평균)까지 생성해요.
- 메쉬를 그릴 때, 텍셀이 얼마나 커야된지 생각하며 어느 밉맵을 쓸지 정해요.
- 가장 가까운 밉맵을 선형이나 이방성 필터링을 써서 샘플링해요.
- 더 괜찮은 결과를 얻으려면, 두개의 밉맵을 샘플링하고 섞어서 결과를 얻을 수도 있겠네요!
다행히도, 위 과정은 아주 간단하게 할 수 있어요! OpenGL은 우리들을 잘 돌봐주고 있거든요. :
// 이미지를 확대할땐(영어로 확대는 MAGnifying. 앞글자 MAG를 따왔네요.), 선형 필터링을 사용합니다.
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 이미지를 축소할땐(영어로 축소는 MINifying. 앞글자 MIN을 따왔네요.), 두개의 밉맵을 선형으로 블랜드하고, 선형으로 필터링합니다.
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
// 그리고- 밉맵 생성.
glGenerateMipmap(GL_TEXTURE_2D);
GLFW로 텍스처 로드하기
우리의 loadBMP_custom 함수는 괜찮아요. 물론 우리가 스스로 만든 건 치곤요. 하지만 역시 전용 라이브러리를 쓰는 게 더 좋아요. GLFW2는 훌륭한 역활을 해줄거에요!(하지만 TGA 파일 밖에 안되고. 게다가 GLFW3에선 그 기능마저 사라졌어요. 우리가 ‘지금’ 쓰는 버전 말이에요. ) (역주: 그럼 도대체 왜 이게 있는거지;) :
GLuint loadTGA_glfw(const char * imagepath){
// OpenGL 텍스처 생성!
GLuint textureID;
glGenTextures(1, &textureID);
// 새롭게 생성된 텍스처를 "Bind"합니다. : 이제 앞으로 모든 Texutre 관련 함수는 이 친구를 건듭니다.
glBindTexture(GL_TEXTURE_2D, textureID);
// 파일을 읽고, 매개 변수로 glTexImage2D를 호출해요.
glfwLoadTexture2D(imagepath, 0);
// 괜찮은 3중 필터링.
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glGenerateMipmap(GL_TEXTURE_2D);
// 우리가 만든 Texture의 ID를 돌려줍니다!
return textureID;
}
압축 텍스쳐
자. 이제 TGA나, BMP처럼 멸종당한 생명체보단 JPEG 파일을 로드하는 게 궁금 할 거에요.
짧은 답을 드리죠 : 하지마요. GPU들은 JPEG를 이해하지 못해요. 그래서 원본 이미지를 JPEG로 압축하고, 또 압축을 풀고 GPU가 이해할 수 있도록 해야하는데요. 그럼 결국 원시 이미지로 돌아왔지만. JEPG로 돌아오는 동안 화질은 떨어졌죠. 램은 그대로 먹고요.
더 괜찮은 방법이 있어요.
압축 택스쳐 만들기
- The Compressonator를 다운로드 받으세요, AMD 툴입니다.
- 2의 제곱 텍스처를 넣어요!
- Runtime에 밉맵들을 만들고 싶지 않으면, 지금 만들 수 있어요.
- DXT1, DXT3나 DXT5로 압축하세요.(더 자세한 정보는 Wikipedia) :
- .DDS 파일로 뽑습니다.
- Export it as a .DDS file.
이 시점에서, 우리의 이미지는 GPU에 바로 박을 수 있는 포맷으로 압축되었어요. shader 안에서 texutre() 함수를 부를 때, 즉석해서 압축을 풀거에요. 압축 푸는게 느려보일 수 있지만 - 엄청나게 적은 메모리를 먹어서 적은 양의 데이터만 전송하면 되기에 - 오히려 더 빠른데요. 왜냐면 텍스처 압축해제는 무료지만(전용 하드웨어가 있어요.), 데이터 전송이 비싸기 때문이죠! 일반적으로, 텍스처 압축을 사용하면 성능이 무려 20%나 증가해요! 성능과 메모리를 절약할 수 있죠. 조금 텍스처가 구려보이겠지만.
압축 텍스처 사용하기
그럼 이미지를 어떻게 불러오는 지 보자고요! BMP 로드 코드랑 유사하지만, 헤더가 다르다는게 차이점이에요. :
GLuint loadDDS(const char * imagepath){
unsigned char header[124];
FILE *fp;
/* 파일 열기 시도 */
fp = fopen(imagepath, "rb");
if (fp == NULL)
return 0;
/* 파일 타입 체크 */
char filecode[4];
fread(filecode, 1, 4, fp);
if (strncmp(filecode, "DDS ", 4) != 0) {
fclose(fp);
return 0;
}
/* 이미지의 정보를 긁어옵니다. */
fread(&header, 124, 1, fp);
unsigned int height = *(unsigned int*)&(header[8 ]);
unsigned int width = *(unsigned int*)&(header[12]);
unsigned int linearSize = *(unsigned int*)&(header[16]);
unsigned int mipMapCount = *(unsigned int*)&(header[24]);
unsigned int fourCC = *(unsigned int*)&(header[80]);
헤더 뒤에 진짜 데이터가 있는데요. 모든 밉맵 레벨들이 포함되어 있어요! 그리고 우린 이걸 한번에 읽을 수 있고요 :
unsigned char * buffer;
unsigned int bufsize;
/* 모든 밉맵을 포함하면 얼마나 크나요? */
bufsize = mipMapCount > 1 ? linearSize * 2 : linearSize;
buffer = (unsigned char*)malloc(bufsize * sizeof(unsigned char));
fread(buffer, 1, bufsize, fp);
/* 파일 포인터 닫기 */
fclose(fp);
우리는 - 머리가 복잡해지기 싫으니 - 3가지 포맷들만 다룰게요 : DXT1, DXT3, 그리고 DXT5. 위에서 얻은 “fourCC” 플래그는 OpenGL이 이해할 수 없으니. OpenGL이 이해할 수 있는 값으로 바꿀게요.
unsigned int components = (fourCC == FOURCC_DXT1) ? 3 : 4;
unsigned int format;
switch(fourCC)
{
case FOURCC_DXT1:
format = GL_COMPRESSED_RGBA_S3TC_DXT1_EXT;
break;
case FOURCC_DXT3:
format = GL_COMPRESSED_RGBA_S3TC_DXT3_EXT;
break;
case FOURCC_DXT5:
format = GL_COMPRESSED_RGBA_S3TC_DXT5_EXT;
break;
default:
free(buffer);
return 0;
}
텍스처 생성은 평소 같이! :
// Create one OpenGL texture
GLuint textureID;
glGenTextures(1, &textureID);
// 새롭게 생성된 텍스처를 "Bind"합니다. : 이제 앞으로 모든 Texutre 관련 함수는 이 친구를 건듭니다.
glBindTexture(GL_TEXTURE_2D, textureID);
그리고, 우리는 이제 각 밉맵을 하나씩 채워 넣기만 하면 되죠. :
unsigned int blockSize = (format == GL_COMPRESSED_RGBA_S3TC_DXT1_EXT) ? 8 : 16;
unsigned int offset = 0;
/* load the mipmaps */
for (unsigned int level = 0; level < mipMapCount && (width || height); ++level)
{
unsigned int size = ((width+3)/4)*((height+3)/4)*blockSize;
glCompressedTexImage2D(GL_TEXTURE_2D, level, format, width, height,
0, size, buffer + offset);
offset += size;
width /= 2;
height /= 2;
}
free(buffer);
return textureID;
UV 좌표 반전하기
DXT 압축은 OpenGL과 V 좌표계가 반대인 DirectX에서 건너왔어요. 그래서 압축된 텍스처를 사용할 때엔 올바른 Texel을 가져 오기 위해 이 공식을 써야해요.( coord.u, 1.0 - coord.v ) 뭐, 언제 하든 상관 없어요. : 로더에서 하든, 쉐이더에서 하든… 방법은 많겠죠. 그냥 까먹지나 마요.
결론
박수! 이제 OpenGL 텍스처를 만들고, 불러오고 사용하는 법을 배우셨어요!
일반적으로 압축 된 텍스처가 저장하기에도 작고, 불러오는 데도 엄청 빠르고, 사용하기도 엄청 빨라서 사용해야해요. 뭐. 단점이 있다면 Compressonator를 이용해서 변환해야 한다는 걸까요? 귀찮죠. (아니면 다른 툴을 쓰던가요.)
연습문제들
- DDS 로더는 소스코드에 구현은 되어있는데, 텍스처 좌표는 수정하지 않아요. 적절하게 코드를 변경해서 큐브를 멋지게 출력해봐요!
- 다양한 DDS 형식으로 실험해보세요. 다른 결과를 주던가요? 다른 압축 비율이라던가?
- Compressonator에서 밉맵을 생성하지 마세요. 어떻게 되던가요? 터졌나요? 터졌으면 해결하는 3가지 다른 방법을 만들어봐요!
참고문헌들
- Using texture compression in OpenGL , Sébastien Domine, NVIDIA
참고
역주 : 알파 채널 내용은 원본에서도 안 나와있습니다.