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

  • Что такое UV-координаты
  • Как самостоятельно загружать текстуры
  • Как использовать их в OpenGL
  • Что такое фильтрация и мип-маппинг и как их использовать
  • Как загружать текстуры с помощью GLFW
  • Что такое Alpha-канал

UV-координаты

Когда вы текстурируете какой-то объект, то вам необходимо как-то сообщить OpenGL, какая часть изображения прикрепляется к каждому треугольнику. Именно для этого и используются UV-координаты

Каждая вершина помимо позиции имеет несколько дополнительных полей, а также U и V. Эти координаты используются применительно к текстуре, как показано на рисунке:

Обратите внимание, как текстура искажается на треугольнике.

Загрузка Bitmap-изображений

Знание формата файлов BMP не является критичным, так как многие библиотеки могут сделать загрузку за вас. Однако, чтобы лучше понимать то, что происходит в таких библиотеках мы разберем ручную загрузку.

Объявляем функцию для загрузки изображений: ```

GLuint loadBMP_custom(const char * imagepath); ```

Вызываться она будет так: ```

GLuint image = loadBMP_custom(“./my_texture.bmp”); ```

Теперь перейдем непосредственно к чтению файла.

Для начала, нам необходимы некоторые данные. Эти переменные будут установлены когда мы будем читать файл: ```

// Данные, прочитанные из заголовка BMP-файла unsigned char header[54]; // Каждый BMP-файл начинается с заголовка, длиной в 54 байта unsigned int dataPos; // Смещение данных в файле (позиция данных) unsigned int width, height; unsigned int imageSize; // Размер изображения = Ширина * Высота * 3 // RGB-данные, полученные из файла unsigned char * data; ```

Открываем файл: ```

FILE * file = fopen(imagepath,”rb”); if (!file) { printf(“Изображение не может быть открытоn”); return 0; } ```

Первым, в BMP-файлах идет заголовок, размером в 54 байта. Он содержит информацию о том, что файл действительно является файлом BMP, размер изображение, количество бит на пиксель и т. п., поэтому читаем его: ```

if ( fread(header, 1, 54, file) != 54 ) { // Если мы прочитали меньше 54 байт, значит возникла проблема printf(“Некорректный BMP-файлn”); return false; } ```

Заголовок всегда начинается с букв BM. Вы можете открыть файл в HEX-редакторе и убедиться в этом самостоятельно, а можете посмотреть на наш скриншот:

Итак, мы проверяем первые два байта и если они не являются буквами “BM”, то файл не является BMP-файлом или испорчен: ```

if ( header[0]!=’B’ || header[1]!=’M’ ){ printf(“Некорректный BMP-файлn”); return 0; } ```

Теперь мы читаем размер изображения, смещение данных изображения в файле и т. п.: ```

// Читаем необходимые данные dataPos = (int)&(header[0x0A]); // Смещение данных изображения в файле imageSize = (int)&(header[0x22]); // Размер изображения в байтах width = (int)&(header[0x12]); // Ширина height = (int)&(header[0x16]); // Высота ```

Проверим и исправим полученные значения: ```

// Некоторые BMP-файлы имеют нулевые поля imageSize и dataPos, поэтому исправим их if (imageSize==0) imageSize=widthheight3; // Ширину * Высоту * 3, где 3 - 3 компоненты цвета (RGB) if (dataPos==0) dataPos=54; // В таком случае, данные будут следовать сразу за заголовком ```

Теперь, так как мы знаем размер изображения, то можем выделить область памяти, в которую поместим данные: ```

// Создаем буфер data = new unsigned char [imageSize];

// Считываем данные из файла в буфер fread(data,1,imageSize,file);

// Закрываем файл, так как больше он нам не нужен fclose(file); ```

*Примечание переводчика: *

Следует отметить, что приведенный код может быть использован только для загрузки 24-битных изображений (т. е. где на каждый пиксель изображения отводится 3 байта). С другими форматами BMP-файла вам следует познакомиться самостоятельно.

Мы вплотную подошли к части, касающейся OpenGL. Создание текстур очень похоже на создание вершинных буферов:

  • Создайте текстуру
  • Привяжите ее
  • Заполните
  • Сконфигурируйте

GL_RGB в glTextImage2D указывает на то, что мы работает с 3х компонентным цветом. А GL_BGR указывает на то, как данные представлены в памяти. На самом деле в BMP-файлах цветовые данные хранятся не в RGB, а в BGR (если быть точным, то это связано с тем, как хранятся числа в памяти), поэтому необходимо сообщить об этом OpenGL: ```

// Создадим одну текстуру OpenGL GLuint textureID; glGenTextures(1, &textureID);

// Сделаем созданную текстуру текущий, таким образом все следующие функции будут работать именно с этой текстурой 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”); ```

**Очень важное замечание: **используйте текстуры с шириной и высотой степени двойки! То есть:

  • Хорошие: 128128, 256256, 10241024, 2*2…
  • Плохие: 127128, 35, …
  • Приемлемые: 128*256

Использование текстуры в OpenGL

Что же, давайте посмотрим на наш Фрагментный шейдер: ```

#version 330 core

// Интерполированные значения из вершинного шейдера in vec2 UV;

// Выходные данные out vec3 color;

// Значения, которые остаются неизменными для объекта. uniform sampler2D myTextureSampler;

void main(){

// Выходной цвет = цвету текстуры в указанных UV-координатах
color = texture( myTextureSampler, UV ).rgb; } ```

Три замечания:

  • Фрагментному шейдеру требуются UV-координаты. Это понятно.
  • Также, ему необходим “sampler2D”, чтобы знать, с какой текстурой работать (вы можете получить доступ к нескольким текстурам в одном шейдере т. н. мультитекстурирование)
  • И наконец, доступ к текстуре завершается вызовом texture(), который возвращает vec4 (R, G, B, A). A-компоненту мы разберем немного позднее.

Вершинный шейдер также прост. Все, что мы делаем - это передаем полученные UV-координаты в фрагментный шейдер: ```

#version 330 core

// Входные данные вершин, различные для всех запусков этого шейдера layout(location = 0) in vec3 vertexPosition_modelspace; layout(location = 1) in vec2 vertexUV;

// Выходные данные, которые будут интерполированы для каждого фрагмента out vec2 UV;

// Значения, которые останутся неизменными для всего объекта uniform mat4 MVP;

void main(){

// Выходная позиция вершины
gl_Position =  MVP * vec4(vertexPosition_modelspace,1);

// UV-координаты вершины.
UV = vertexUV; } ```

Помните “layout(location = 1) in vec3 vertexColor” из Урока 4? Здесь мы делаем абсолютно тоже самое, только вместо передачи буфера с цветом каждой вершины мы будем передавать буфер с UV-координатами каждой вершины: ```

// Две 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-координаты относятся к такой модели:

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

И вот такой результат мы получим:

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

Фильтрация и мип-маппинг.

Как вы можете видеть на скриншоте выше, качество текстуры не очень хорошее. Это потому, что в нашей процедуре загрузки BMP-изображения (loadBMP_custom) мы указали: ```

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); ```

Это означает, что в нашем фрагментном шейдере, texture() возвращает строго тексель, который находится по указанным текстурным координатам:

Есть несколько решений, которые позволят улучшить ситуацию.

##Линейная фильтрация

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

Это будет выглядить значительно лучше и используется часто, но если вы хотите очень высокого качества, то вам понадобится анизотропная фильтрация, которая работает несколько медленнее.

##Анизотропная фильтрация

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

##Мип-маппинг

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

  • При инициализации вы уменьшаете масштаб текстуры до тех пор, пока не получите изображение 1х1 (которое по сути будет являться средним значением всех текселей текстуры)
  • Когда вы выводите объект, то вы выбираете тот мип-мап, который наиболее приемлем в данной ситуации.
  • Вы применяете к этому мип-мапу фильтрацию
  • А для большего качества вы можете использовать 2 мип-мапа и смешать результат.

К счастью для нас, все это делается очень просто с помощью OpenGL: ```

// Когда изображение увеличивается, то мы используем обычную линейную фильтрацию glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // Когда изображение уменьшается, то мы используем линейной смешивание 2х мипмапов, к которым также применяется линейная фильтрация glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); // И генерируем мипмап glGenerateMipmap(GL_TEXTURE_2D); ```

Загрузка текстур с помощью GLFW

Наша процедура loadBMP_custom великолепна, так как мы сделали ее сами, но использование специальных библиотек может быть предпочтительнее (в конечном итоге мы в своей процедуре многое не учли). GLFW может сделать это лучше (но только для TGA-файлов): ```

GLuint loadTGA_glfw(const char * imagepath){

// Создаем одну OpenGL текстуру
GLuint textureID;
glGenTextures(1, &textureID);

// "Привязываем" только что созданную текстуру и таким образом все последующие операции будут производиться с ней
glBindTexture(GL_TEXTURE_2D, textureID);

// Читаем файл и вызываем glTexImage2D с необходимыми параметрами
glfwLoadTexture2D(imagepath, 0);

// Трилинейная фильтрация.
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);

// Возвращаем идентификатор текстуры который мы создали
return textureID; } ```

Сжатые текстуры

На этом шаге вы наверное хотите узнать, как же все-таки загружать JPEG файлы вместо TGA?

Короткий ответ: даже не думайте об этом. Есть идея получше.

##Создание сжатых текстур

  • Скачайте The Compressonator, утилита от ATI
  • Загрузите в нее текстуру, размер которой является степенью двойки
  • Сожмите ее в DXT1, DXT3 или в DXT5 (о разнице между форматами можете почитать на Wikipedia)

  • Создайте мипмапы, чтобы не создавать их во время выполнения программы.
  • Экспортируйте это как .DDS файл

После этих шагов вы имеете сжатое изображение, которое прямо совместимо с GPU. И когда вы вызовите texture() в шейдере, то текстура будет распакована на лету. Это может показаться более медленным, однако это требует гораздо меньше памяти, а значит пересылаемых данных будет меньше. Пересылка данных всегда будет дорогой операцией, в то время как декомпрессия является практически бесплатной. Как правило, использование сжатия текстур повышает быстродействие на 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 о формате данных. ```

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;
} ```

Создание текстуры выполняется как обычно: ```

// Создаем одну OpenGL текстуру
GLuint textureID;
glGenTextures(1, &textureID);

// "Привязываем" текстуру.
glBindTexture(GL_TEXTURE_2D, textureID); ```

Следующим шагом мы загружаем мип-мапы: ```

unsigned int blockSize = (format == GL_COMPRESSED_RGBA_S3TC_DXT1_EXT) ? 8 : 16;
unsigned int offset = 0;

/* загрузка мип-мапов */
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; ```

##Инверсия V-координаты

DXT компрессия пришла к нам из DirectX, где координатная текстура V является инвертированной по сравнению с OpenGL. Поэтому, если вы используете сжатые текстуры, то вам необходимо использовать (coord.u, 1.0 - coord.v), чтобы исправить тексель. Вы можете выполнять это как при экспорте текстуры, так и в загрузчике или в шейдере.

Заключение

В данном уроке вы узнали как создавать, загружать и использовать текстуры в OpenGL.

Стоит отметить, что в своих проектах мы настоятельно рекомендуем вам использовать только сжатые текстуры, так как они занимают меньше места, быстрее загружаются и используются. Для этих целей можете также использовать The Compressonator.

Упражнения

  • В исходный код к урокам включен загрузчик DDS, но без исправления текстурных координат. Модифицируйте код так, чтобы корректно выводить куб.
  • Поэкспериментируйте с разными DDS форматами. Дают ли они разный результат или разную степень сжатия?
  • Попробуйте не создавать мип-мапы в The Compressonator. Каков результат? Создайте 3 пути решения этих проблем.

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