Урок 5. Текстурированный куб


В этом уроке мы научимся следующему:
  • Что такое UV координаты
  • Как самому загружать текстуры
  • Как использовать текстуры в OpenGL.
  • Что такое фильтрация текстур и мипмаппинг, и как этим пользоваться.
  • Как еще лучше загружать текстуры с помощью GLFW
  • Что такое альфа-канал


UV координаты

При текстурировании меша, нужно каким-то образом объяснить OpenGL какая часть текстуры будет использоваться для заливки каждого из треугольников. Это делается с помощью UV координат.
Каждая вершина может иметь не только позицию, а еще цвет и еще, например, два float значения – координаты U и V. Эти координаты используются для доступа к текстуре следующим образом:

Обратите внимание, что текстура растягивается на деформированном треугольнике!!


Загрузка .BMP файла своими руками

Знание устройства bmp формата, это совсем не обязательное знание, так каждая вторая библиотека по работе с графикой умеет загружать bmp. Но так как это очень просто и может помочь нам понять как это работает внутри, мы сделаем этот велосипед. Мы напишем загрузчик BMP файлов с нуля чтобы понять как он работает и забудем про него.

Прототип функции загружающей текстуру:
GLuint loadBMP_custom(const char * imagepath);

И эту функцию можно будет вызывать вот так:
GLuint image = loadBMP_custom("./my_texture.bmp");

Теперь давайте разберем, как читать 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("Image could not be opened\n"); return 0;}

BMP файл начинается с 54 байтного заголовка. Этот заголовок содержит такую информацию как: «Это действительно BMP файл?», размер изображения, количество битов на пиксель, итд. Давайте сначала прочтем заголовок:

if ( fread(header, 1, 54, file)!=54 ){ // У нас проблемы, если не смогли прочитать 54 байта
   printf("Not a correct BMP file\n");
   return false;
}

Каждый BMP файл начинается с заголовка BM. Это можно увидеть открыв BMP файл в HEX редакторе:

И так нам нужно проверить первые два символа:
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 файлах нет полной информации, поэтому мы её добавим сами:

// в некоторых BMP файлах нет полной информации, поэтому мы её добавим сами
if (imageSize==0)    imageSize=width*height*3; // 3 : Один байт на каждую Red, Green, Blue компоненты
if (dataPos==0)      dataPos=54; // Тут заканчивается заголовок, и по идее, должны начаться данные

Теперь когда у нас есть вся необходимая информация, мы можем выделить память под картинку и прочитать в неё данные:
// Создаем буфер
data = new unsigned char [imageSize];

// Читаем данные из файла в буфер
fread(data,1,imageSize,file);
//Теперь все данные в памяти, и можно закрыть файл
fclose(file);

Но это все было не совсем связанное с OpenGL, а теперь будет поинтереснее. Создание текстур, это почти то же самое, что и создание вершинных буферов: создаем текстуру, биндим её, заполняем и конфигурируем.
В функции glTexImage2D,GL_RGB указывает на то, что мы работаем с трехкомпонентным цветом, а GL_BGR указывает на то, как этот цвет хранится в памяти. По какой-то исторической причине в BMP файлах цвет хранится не как Red-Green-Blue, а как Blue-Green-Red, о чем и нужно уведомить 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);

последние две строчки мы разберем позже. А пока, после всего этого, грузить текстуры можно будет так:

GLuint Texture = loadBMP_custom("uvtemplate.bmp");

Еще одно важное замечание: Используйте текстуры со значениями ширины и высоты как степень двойки:
  • хорошо : 128*128*, 256*256, 1024*1024, 2*2…
  • плохо : 127*128, 3*5, …
  • хорошо, но странно : 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() которая возвращает нам цвет в формате (R,G,B,A) vec4. Вскоре мы рассмотрим что такое A компонента.
Вершинный шейдер тоже очень простой:
#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 vec2 vertexUV”? Если забыли, посмотрите в урок 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
};

Координаты которые я привел выше относятся к следующей модели:
Остальное, я думаю, должно быть для вас уже очевидным. Генерируем буфер, биндим его, заполняем и конфигурируем. Обычный буфер, все как всегда. Не забудьте только использовать 2 в качестве параметра size функции glVertexAttribPointer вместо 3.
А вот и результат:

И увеличенная версия:


Фильтрация, Мипмаппинг, и как этим всем пользоваться

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

Но у нас есть в запасе несколько фокусов которые помогут улучшить картинку.

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

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

С такой фильтрацией все становится немножко получше, но есть еще один метод — Анизотропная фильтрация, который дает еще более красивую картинку. Но он значительно медленнее.

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

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


Мипмаппинг(MipMaps)

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

  • Во время загрузки текстуры мы изменяем её размер в два раза, потом результирующую еще в два раза итд пока у нас не получится текстура 1х1 (все промежуточные сохраняем, конечно же).
  • Когда рисуем модель, то видеокарта выбирает наиболее подходящую текстуру среди мипмапов в зависимости от того, каким должен быть тексель по размеру.
  • Делать выборку текселя из подходящего мипмапа можно любым способом, хоть nearest хоть linear хоть anisotropic.
  • Если хочется еще лучшего качества, можно попробовать делать выборку из двух ближайших мипмапов и смешивать полученные цвета.

К нашему счастью все эти алгоритмы нам делать заново не нужно. OpenGL все умеет делать сам если его хорошенько попросить.

// Когда картинка будет увеличиваться(нет большей Мипмапы), используем LINEAR фильтрацию
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);

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

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

GLuint loadTGA_glfw(const char * imagepath){
// Создаем OpenGL текстуру
GLuint textureID;
glGenTextures(1, &textureID); 
// Биндим её
glBindTexture(GL_TEXTURE_2D, textureID); 
// Читаем файл
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); 
// Возвращаем ID нашей новой текстуры
return textureID;
}

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

Я думаю у вас уже возникали мысли, как бы это использовать сжатые JPEG файлы вместо огромных TGA файлов?
Моя рекомендация — не делайте этого. Есть варианты получше.

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

  • Скачайте с сайта ATI программу The Compressonator.
  • Загрузите в него картинку которая имеет размер «степень двойки»
  • Сожмите её с помощью алгоритмов DXT1, DXT3 или DXT5. Если хотите узнать про эти алгоритмы больше, почитайте про них на википедии.
  • Сгенерируйте мипмапы, чтобы не пришлось этого делать в рантайме
  • Экспортируйте в DDS формат.

И после этого у вас получатся текстуры которые прямо совместимы с GPU. Теперь когда вы будете вызывать функцию texture() из GLSL видеокарта автоматически на лету будет распаковывать данные. На первый взгляд это может показаться медленным, но:
·         Значительно меньшее потребление памяти
·         Намного меньше затраты на передачу данных из оперативной памяти в GPU.
·         Распаковка сжатых данных совсем не замедляет обработку так как это происходит аппаратно в GPU.

На практике использование сжатых текстур повышает производительность процентов на 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);

Так как у нас есть три вида сжатых текстур:DXT1, DXT3 и DXT5 нам нужно переконвертировать значение из файла в значение доступное пониманию 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;


Инвертируем UV координаты

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

Выводы

В данном уроке мы научились создавать, загружать и использовать текстуры в OpenGL.
Я рекомендую вам почаще пользоваться сжатыми текстурами, так как они занимают меньше места на диске и в памяти видеокарты, быстрее грузятся и быстрее работают. Одно неудобство — их нужно сжимать с помощью специализированных утилит.


Упражнения

  • Хоть мы и написали загрузчик DDS текстур, но не пофиксили инвертированные текстурные координаты. Исправьте ошибку чтобы куб снова выводился правильно даже со сжатыми текстурами.
  • Попробуйте разные форматы DDS. Какой у вас получится результат? Такой же, или разный? Какие будут размеры сжатых файлов?
  • Попробуйте не генерировать мип уровни в The Compressonator. Какой получился результат? Как это исправить?

4 комментария:

  1. Здравствуйте. Возникли вопросы при изучении dds формата. Можно ли как-то списаться по почте, пролить свет на некоторые моменты? graf_vladislav_iii@mail.ru
    Не совсем разобрался в инете сколько байт из 128 заголовочных относятся к тому или иному параметру.
    Поэтому не получается нормально сделать читалку файла.
    typedef struct {
    DWORD dwSize;
    DWORD dwFlags;
    DWORD dwHeight;
    DWORD dwWidth;
    DWORD dwPitchOrLinearSize;
    DWORD dwDepth;
    DWORD dwMipMapCount;
    DWORD dwReserved1[11];
    DDS_PIXELFORMAT ddspf;
    DWORD dwCaps;
    DWORD dwCaps2;
    DWORD dwCaps3;
    DWORD dwCaps4;
    DWORD dwReserved2;
    } DDS_HEADER;
    Эта структура которую хочу использовать, но не понятно сколько байт в каждой переменной.
    DWORD вроде как 4 байта uint. Но тогда нестыковка получается.
    В общем распределение 128 байт для меня вопрос =(
    Помогите пожалуйста)

    ОтветитьУдалить
  2. Вот бы еще cpp файл к каждому уроку

    ОтветитьУдалить
  3. Здравствуйте. Дело в том, что перед заголовком должно считываться 4 байта в которых должно быть "DDS ", вот пример:
    // read in file marker, make sure its a DDS file
    char filecode[4];
    is.read(filecode, 4);
    if (strncmp(filecode, "DDS ", 4) != 0) {
    throw runtime_error("not a DDS file");
    }
    // read in DDS header
    DDS_HEADER ddsh;
    is.read((char*)&ddsh, sizeof(DDS_HEADER));

    ОтветитьУдалить