Урок 5: Текстурированный куб
Добро пожаловать на наш пятый урок. В этом уроке вы узнаете:
- Что такое UV-координаты
- Как самостоятельно загружать текстуры
- Как использовать их в OpenGL
- Что такое фильтрация и мип-маппинг и как их использовать
- Как загружать текстуры с помощью GLFW
- Что такое Alpha-канал
UV-координаты
Когда вы текстурируете какой-то объект, то вам необходимо как-то сообщить OpenGL, какая часть изображения прикрепляется к каждому треугольнику. Именно для этого и используются UV-координаты
Каждая вершина помимо позиции имеет несколько дополнительных полей, а также U и V. Эти координаты используются применительно к текстуре, как показано на рисунке:

Обратите внимание, как текстура искажается на треугольнике.
Загрузка Bitmap-изображений
Знание формата файлов BMP не является критичным, так как многие библиотеки могут сделать загрузку за вас. Однако, чтобы лучше понимать то, что происходит в таких библиотеках мы разберем ручную загрузку.
Объявляем функцию для загрузки изображений:
1 GLuint loadBMP_custom(const char * imagepath);Вызываться она будет так:
1 GLuint image = loadBMP_custom("./my_texture.bmp");Теперь перейдем непосредственно к чтению файла.
Для начала, нам необходимы некоторые данные. Эти переменные будут установлены когда мы будем читать файл:
1 // Данные, прочитанные из заголовка BMP-файла
2 unsigned char header[54]; // Каждый BMP-файл начинается с заголовка, длиной в 54 байта
3 unsigned int dataPos; // Смещение данных в файле (позиция данных)
4 unsigned int width, height;
5 unsigned int imageSize; // Размер изображения = Ширина * Высота * 3
6 // RGB-данные, полученные из файла
7 unsigned char * data;Открываем файл:
1 FILE * file = fopen(imagepath,"rb");
2 if (!file) {
3 printf("Изображение не может быть открытоn");
4 return 0;
5 }Первым, в BMP-файлах идет заголовок, размером в 54 байта. Он содержит информацию о том, что файл действительно является файлом BMP, размер изображение, количество бит на пиксель и т. п., поэтому читаем его:
1 if ( fread(header, 1, 54, file) != 54 ) { // Если мы прочитали меньше 54 байт, значит возникла проблема
2 printf("Некорректный BMP-файлn");
3 return false;
4 }Заголовок всегда начинается с букв BM. Вы можете открыть файл в HEX-редакторе и убедиться в этом самостоятельно, а можете посмотреть на наш скриншот:

Итак, мы проверяем первые два байта и если они не являются буквами “BM”, то файл не является BMP-файлом или испорчен:
1 if ( header[0]!='B' || header[1]!='M' ){
2 printf("Некорректный BMP-файлn");
3 return 0;
4 }Теперь мы читаем размер изображения, смещение данных изображения в файле и т. п.:
1 // Читаем необходимые данные
2 dataPos = *(int*)&(header[0x0A]); // Смещение данных изображения в файле
3 imageSize = *(int*)&(header[0x22]); // Размер изображения в байтах
4 width = *(int*)&(header[0x12]); // Ширина
5 height = *(int*)&(header[0x16]); // ВысотаПроверим и исправим полученные значения:
1 // Некоторые BMP-файлы имеют нулевые поля imageSize и dataPos, поэтому исправим их
2 if (imageSize==0) imageSize=width*height*3; // Ширину * Высоту * 3, где 3 - 3 компоненты цвета (RGB)
3 if (dataPos==0) dataPos=54; // В таком случае, данные будут следовать сразу за заголовкомТеперь, так как мы знаем размер изображения, то можем выделить область памяти, в которую поместим данные:
1 // Создаем буфер
2 data = new unsigned char [imageSize];
3
4 // Считываем данные из файла в буфер
5 fread(data,1,imageSize,file);
6
7 // Закрываем файл, так как больше он нам не нужен
8 fclose(file);*Примечание переводчика: *
Следует отметить, что приведенный код может быть использован только для загрузки 24-битных изображений (т. е. где на каждый пиксель изображения отводится 3 байта). С другими форматами BMP-файла вам следует познакомиться самостоятельно.
Мы вплотную подошли к части, касающейся OpenGL. Создание текстур очень похоже на создание вершинных буферов:
- Создайте текстуру
- Привяжите ее
- Заполните
- Сконфигурируйте
GL_RGB в glTextImage2D указывает на то, что мы работает с 3х компонентным цветом. А GL_BGR указывает на то, как данные представлены в памяти. На самом деле в BMP-файлах цветовые данные хранятся не в RGB, а в BGR (если быть точным, то это связано с тем, как хранятся числа в памяти), поэтому необходимо сообщить об этом OpenGL:
1 // Создадим одну текстуру OpenGL
2 GLuint textureID;
3 glGenTextures(1, &textureID);
4
5 // Сделаем созданную текстуру текущий, таким образом все следующие функции будут работать именно с этой текстурой
6 glBindTexture(GL_TEXTURE_2D, textureID);
7
8 // Передадим изображение OpenGL
9 glTexImage2D(GL_TEXTURE_2D, 0,GL_RGB, width, height, 0, GL_BGR, GL_UNSIGNED_BYTE, data);
10
11 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
12 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);Последние две строки мы поясним позднее, а пока в части C++ мы должны использовать нашу функцию для загрузки текстуры:
1 GLuint Texture = loadBMP_custom("uvtemplate.bmp");**Очень важное замечание: **используйте текстуры с шириной и высотой степени двойки! То есть:
- Хорошие: 128128, 256256, 10241024, 2*2…
- Плохие: 127128, 35, …
- Приемлемые: 128*256
Использование текстуры в OpenGL
Что же, давайте посмотрим на наш Фрагментный шейдер:
1 #version 330 core
2
3 // Интерполированные значения из вершинного шейдера
4 in vec2 UV;
5
6 // Выходные данные
7 out vec3 color;
8
9 // Значения, которые остаются неизменными для объекта.
10 uniform sampler2D myTextureSampler;
11
12 void main(){
13
14 // Выходной цвет = цвету текстуры в указанных UV-координатах
15 color = texture( myTextureSampler, UV ).rgb;
16 }Три замечания:
- Фрагментному шейдеру требуются UV-координаты. Это понятно.
- Также, ему необходим “sampler2D”, чтобы знать, с какой текстурой работать (вы можете получить доступ к нескольким текстурам в одном шейдере т. н. мультитекстурирование)
- И наконец, доступ к текстуре завершается вызовом texture(), который возвращает vec4 (R, G, B, A). A-компоненту мы разберем немного позднее.
Вершинный шейдер также прост. Все, что мы делаем - это передаем полученные UV-координаты в фрагментный шейдер:
1 #version 330 core
2
3 // Входные данные вершин, различные для всех запусков этого шейдера
4 layout(location = 0) in vec3 vertexPosition_modelspace;
5 layout(location = 1) in vec2 vertexUV;
6
7 // Выходные данные, которые будут интерполированы для каждого фрагмента
8 out vec2 UV;
9
10 // Значения, которые останутся неизменными для всего объекта
11 uniform mat4 MVP;
12
13 void main(){
14
15 // Выходная позиция вершины
16 gl_Position = MVP * vec4(vertexPosition_modelspace,1);
17
18 // UV-координаты вершины.
19 UV = vertexUV;
20 }Помните “layout(location = 1) in vec3 vertexColor” из Урока 4? Здесь мы делаем абсолютно тоже самое, только вместо передачи буфера с цветом каждой вершины мы будем передавать буфер с UV-координатами каждой вершины:
1 // Две UV-координаты для каждой вершины. Они были созданы с помощью Blender. Мы коротко расскажем о том, как сделать это самостоятельно.
2 static const GLfloat g_uv_buffer_data[] = {
3 0.000059f, 1.0f-0.000004f,
4 0.000103f, 1.0f-0.336048f,
5 0.335973f, 1.0f-0.335903f,
6 1.000023f, 1.0f-0.000013f,
7 0.667979f, 1.0f-0.335851f,
8 0.999958f, 1.0f-0.336064f,
9 0.667979f, 1.0f-0.335851f,
10 0.336024f, 1.0f-0.671877f,
11 0.667969f, 1.0f-0.671889f,
12 1.000023f, 1.0f-0.000013f,
13 0.668104f, 1.0f-0.000013f,
14 0.667979f, 1.0f-0.335851f,
15 0.000059f, 1.0f-0.000004f,
16 0.335973f, 1.0f-0.335903f,
17 0.336098f, 1.0f-0.000071f,
18 0.667979f, 1.0f-0.335851f,
19 0.335973f, 1.0f-0.335903f,
20 0.336024f, 1.0f-0.671877f,
21 1.000004f, 1.0f-0.671847f,
22 0.999958f, 1.0f-0.336064f,
23 0.667979f, 1.0f-0.335851f,
24 0.668104f, 1.0f-0.000013f,
25 0.335973f, 1.0f-0.335903f,
26 0.667979f, 1.0f-0.335851f,
27 0.335973f, 1.0f-0.335903f,
28 0.668104f, 1.0f-0.000013f,
29 0.336098f, 1.0f-0.000071f,
30 0.000103f, 1.0f-0.336048f,
31 0.000004f, 1.0f-0.671870f,
32 0.336024f, 1.0f-0.671877f,
33 0.000103f, 1.0f-0.336048f,
34 0.336024f, 1.0f-0.671877f,
35 0.335973f, 1.0f-0.335903f,
36 0.667969f, 1.0f-0.671889f,
37 1.000004f, 1.0f-0.671847f,
38 0.667979f, 1.0f-0.335851f
39 };Указанные UV-координаты относятся к такой модели:

Остальное очевидно. Мы создаем буфер, привязываем его, заполняем, настраиваем и выводим Буфер Вершин как обычно. Только будьте осторожны, так как в glVertexAttribPointer для буфера текстурных координат второй параметр (размер) будет не 3, а 2.
И вот такой результат мы получим:

в увеличенном варианте:

Фильтрация и мип-маппинг.
Как вы можете видеть на скриншоте выше, качество текстуры не очень хорошее. Это потому, что в нашей процедуре загрузки BMP-изображения (loadBMP_custom) мы указали:
1 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
2 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);Это означает, что в нашем фрагментном шейдере, texture() возвращает строго тексель, который находится по указанным текстурным координатам:

Есть несколько решений, которые позволят улучшить ситуацию.
Линейная фильтрация
При помощи линейной фильтрации texture() будет смешивать цвета находящихся рядом текселей в зависимости от дистанции до их центра, что позволит предотвратить резкие границы, которые вы видели выше:

Это будет выглядить значительно лучше и используется часто, но если вы хотите очень высокого качества, то вам понадобится анизотропная фильтрация, которая работает несколько медленнее.
Анизотропная фильтрация
Аппроксимирует часть изображения, которая действительно видна через фрагмент. К примеру, если указанная текстура просматривается сбоку и немного повернута, то анизотропная фильтрация будет вычислять цвет, который находится в синем прямоугольнике, с помощью фиксированного количество сэмплов (Уровень анизотропии) вдоль его направления:

Мип-маппинг
И линейная, и анизотропная фильтрация имеют недостаток. Если текстура просматривается с большого расстояния, то смешивать 4 текселя будет недостаточно. То есть, если ваша 3D модель находится так далеко, что занимает на экране всего 1 фрагмент, то фильный цвет фрагмента будет являться средним всех текселей текстуры. Естественно, это не реализовано из-за соображений производительности. Для этой цели существует так называемый мип-маппинг:

- При инициализации вы уменьшаете масштаб текстуры до тех пор, пока не получите изображение 1х1 (которое по сути будет являться средним значением всех текселей текстуры)
- Когда вы выводите объект, то вы выбираете тот мип-мап, который наиболее приемлем в данной ситуации.
- Вы применяете к этому мип-мапу фильтрацию
- А для большего качества вы можете использовать 2 мип-мапа и смешать результат.
К счастью для нас, все это делается очень просто с помощью OpenGL:
1 // Когда изображение увеличивается, то мы используем обычную линейную фильтрацию
2 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
3 // Когда изображение уменьшается, то мы используем линейной смешивание 2х мипмапов, к которым также применяется линейная фильтрация
4 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
5 // И генерируем мипмап
6 glGenerateMipmap(GL_TEXTURE_2D);Загрузка текстур с помощью GLFW
Наша процедура loadBMP_custom великолепна, так как мы сделали ее сами, но использование специальных библиотек может быть предпочтительнее (в конечном итоге мы в своей процедуре многое не учли). GLFW может сделать это лучше (но только для TGA-файлов):
1 GLuint loadTGA_glfw(const char * imagepath){
2
3 // Создаем одну OpenGL текстуру
4 GLuint textureID;
5 glGenTextures(1, &textureID);
6
7 // "Привязываем" только что созданную текстуру и таким образом все последующие операции будут производиться с ней
8 glBindTexture(GL_TEXTURE_2D, textureID);
9
10 // Читаем файл и вызываем glTexImage2D с необходимыми параметрами
11 glfwLoadTexture2D(imagepath, 0);
12
13 // Трилинейная фильтрация.
14 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
15 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
16 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
17 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
18 glGenerateMipmap(GL_TEXTURE_2D);
19
20 // Возвращаем идентификатор текстуры который мы создали
21 return textureID;
22 }Сжатые текстуры
На этом шаге вы наверное хотите узнать, как же все-таки загружать JPEG файлы вместо TGA?
Короткий ответ: даже не думайте об этом. Есть идея получше.
Создание сжатых текстур
- Скачайте The Compressonator, утилита от ATI
- Загрузите в нее текстуру, размер которой является степенью двойки
- Сожмите ее в DXT1, DXT3 или в DXT5 (о разнице между форматами можете почитать на Wikipedia)

- Создайте мипмапы, чтобы не создавать их во время выполнения программы.
- Экспортируйте это как .DDS файл
После этих шагов вы имеете сжатое изображение, которое прямо совместимо с GPU. И когда вы вызовите texture() в шейдере, то текстура будет распакована на лету. Это может показаться более медленным, однако это требует гораздо меньше памяти, а значит пересылаемых данных будет меньше. Пересылка данных всегда будет дорогой операцией, в то время как декомпрессия является практически бесплатной. Как правило, использование сжатия текстур повышает быстродействие на 20%.
Использование сжатой текстуры
Теперь перейдем непосредственно к загрузке нашей сжатой текстуры. Процедура будет очень похожа на загрузку BMP, с тем исключением, что заголовок файла будет организован немного иначе:
1 GLuint loadDDS(const char * imagepath){
2
3 unsigned char header[124];
4
5 FILE *fp;
6
7 /* пробуем открыть файл */
8 fp = fopen(imagepath, "rb");
9 if (fp == NULL)
10 return 0;
11
12 /* проверим тип файла */
13 char filecode[4];
14 fread(filecode, 1, 4, fp);
15 if (strncmp(filecode, "DDS ", 4) != 0) {
16 fclose(fp);
17 return 0;
18 }
19
20 /* читаем заголовок */
21 fread(&header, 124, 1, fp);
22
23 unsigned int height = *(unsigned int*)&(header[8 ]);
24 unsigned int width = *(unsigned int*)&(header[12]);
25 unsigned int linearSize = *(unsigned int*)&(header[16]);
26 unsigned int mipMapCount = *(unsigned int*)&(header[24]);
27 unsigned int fourCC = *(unsigned int*)&(header[80]);После заголовку идут данные, в которые входят все уровни мип-мап. К слову, мы можем прочитать их все сразу:
1 unsigned char * buffer;
2 unsigned int bufsize;
3 /* вычисляем размер буфера */
4 bufsize = mipMapCount > 1 ? linearSize * 2 : linearSize;
5 buffer = (unsigned char*)malloc(bufsize * sizeof(unsigned char));
6 fread(buffer, 1, bufsize, fp);
7 /* закрываем файл */
8 fclose(fp);Сделано. Так как мы можем использовать 3 разных формата (DXT1, DXT3, DXT5), то необходимо в зависимости от флага “fourCC”, сказать OpenGL о формате данных.
1 unsigned int components = (fourCC == FOURCC_DXT1) ? 3 : 4;
2 unsigned int format;
3 switch(fourCC)
4 {
5 case FOURCC_DXT1:
6 format = GL_COMPRESSED_RGBA_S3TC_DXT1_EXT;
7 break;
8 case FOURCC_DXT3:
9 format = GL_COMPRESSED_RGBA_S3TC_DXT3_EXT;
10 break;
11 case FOURCC_DXT5:
12 format = GL_COMPRESSED_RGBA_S3TC_DXT5_EXT;
13 break;
14 default:
15 free(buffer);
16 return 0;
17 }Создание текстуры выполняется как обычно:
1 // Создаем одну OpenGL текстуру
2 GLuint textureID;
3 glGenTextures(1, &textureID);
4
5 // "Привязываем" текстуру.
6 glBindTexture(GL_TEXTURE_2D, textureID);Следующим шагом мы загружаем мип-мапы:
1 unsigned int blockSize = (format == GL_COMPRESSED_RGBA_S3TC_DXT1_EXT) ? 8 : 16;
2 unsigned int offset = 0;
3
4 /* загрузка мип-мапов */
5 for (unsigned int level = 0; level < mipMapCount && (width || height); ++level)
6 {
7 unsigned int size = ((width+3)/4)*((height+3)/4)*blockSize;
8 glCompressedTexImage2D(GL_TEXTURE_2D, level, format, width, height,
9 0, size, buffer + offset);
10
11 offset += size;
12 width /= 2;
13 height /= 2;
14 }
15 free(buffer);
16
17 return textureID;Инверсия V-координаты
DXT компрессия пришла к нам из DirectX, где координатная текстура V является инвертированной по сравнению с OpenGL. Поэтому, если вы используете сжатые текстуры, то вам необходимо использовать (coord.u, 1.0 - coord.v), чтобы исправить тексель. Вы можете выполнять это как при экспорте текстуры, так и в загрузчике или в шейдере.
Заключение
В данном уроке вы узнали как создавать, загружать и использовать текстуры в OpenGL.
Стоит отметить, что в своих проектах мы настоятельно рекомендуем вам использовать только сжатые текстуры, так как они занимают меньше места, быстрее загружаются и используются. Для этих целей можете также использовать The Compressonator.
Упражнения
- В исходный код к урокам включен загрузчик DDS, но без исправления текстурных координат. Модифицируйте код так, чтобы корректно выводить куб.
- Поэкспериментируйте с разными DDS форматами. Дают ли они разный результат или разную степень сжатия?
- Попробуйте не создавать мип-мапы в The Compressonator. Каков результат? Создайте 3 пути решения этих проблем.
Полезные ссылки
- Using texture compression in OpenGL , Sébastien Domine, NVIDIA