Добро пожаловать на наш пятый урок. В этом уроке вы узнаете:

  • Что такое 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 пути решения этих проблем.

Полезные ссылки