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

  • Что такое 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=width*height*3; // Ширину * Высоту * 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 пути решения этих проблем.

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