В цьому туторіалі ви навчитесь:

  • Що таке UV координати
  • Як самостійно завантажити текстури
  • Як їх використовувати в OpenGL
  • Що таке фільтрація і mip текстури, і як їх використовувати
  • Як завантажувати текстури надійніше за допомогою GLFW
  • Що таке альфаканал

Про UV координати

Коли текстура накладається на меш, Вам потрібно повідомити OpenGL яка частина зображення використовується для кожного трикутника. Це робиться за допомогою UV координат.

Кожний вертекс крім положення може мати ще пару дійсних чисел, 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;   // = width*height*3
// Фактичні RGB дані
unsigned char * data;

Для початку відкриємо файл

// Відкриваємо файл
FILE * file = fopen(imagepath,"rb");
if (!file){printf("Неможливо відкрити зображення\n"); return 0;}

Перша річ в файлі - це 54байтовий заголовок. Він містить інформацію про те, чи це дійсно BMP файл, його розмір, кількість бітів на піксель і інше. Прочитаємо його:

if ( fread(header, 1, 54, file)!=54 ){ // Якщо не можемо прочитати перші 54 байти, то це проблема
    printf("Не коректний BMP файл\n");
    return false;
}

Заголовок завжди починається з двох літер BM. Ось що Ви побачите, якщо відкриєте такий файл в шістнадцятковому редакторі:

Отже, перевіримо, що перші два байти це дійсно ‘B’ та ‘M’ :

if ( header[0]!='B' || header[1]!='M' ){
    printf("Not a correct BMP file\n");
    return 0;
}

Тепер ми можемо прочитати розмір файлу і позицію даних в файлі:

// Читаємо цілі числа з масиву байтів
dataPos    = *(int*)&(header[0x0A]);
imageSize  = *(int*)&(header[0x22]);
width      = *(int*)&(header[0x12]);
height     = *(int*)&(header[0x16]);

Нам потрібно розрахувати деякі дані, якщо вони відсутні :

// Деякі BMP файли погано сформовані, вгадаємо відсутню інформацію
if (imageSize==0)    imageSize=width*height*3; // 3 : червона, зелена і синя компонента займають по одному байту
if (dataPos==0)      dataPos=54; // Дані розташовані відразу за заголовком

Тепер, коли ми знаємо розмір зображення, ми можемо виділити потрібний розмір пам’яті і прочитати дані:

// Створюємо буфер
data = new unsigned char [imageSize];

// Читаємо дані зображення з файлу в буфер
fread(data,1,imageSize,file);

//Тепер все потрібне в пам'яті, файл можна закрити
fclose(file);

Тепер прийшов час реального OpenGL. Створення текстури дуже схоже на створення вершинного буфера - створити текстуру, прив’язати, заповнити і сконфігурувати.

В glTexImage2D параметр GL_RGB показує, що ми використовуємо трьохкомпонентний колір і GL_BGR показує, як саме компоненти розташовані в файлі. Власне кажучи, BMP не зберігає дані в вигляді червоний-зелений-синій, а не синій-зелений-червоний, отже ми підкажемо 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 незабаром.

Вершинний шейдер також достатньо простий, потрібно просто передати 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(){

    // Вихідна позиція для вершини, в обрізаному просторі (clip space) : MVP * position.
    gl_Position =  MVP * vec4(vertexPosition_modelspace,1);

    // UV координати для цієї вершини. Немає ніяких спеціальний просторів для цього.
    UV = vertexUV;
}

Пам’ятаєте “layout(location = 1) in vec2 vertexUV” з туторіалу 4 ? Ми зробимо точнісінько так само і тут, але замість буферу триплетів (R,G,B), ми будемо використовувати буфер пар (U,V).

// Дві 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 координати вище відповідають наступній моделі:

Все інше очевидно. Сгенерували (створили) буфер, прив’язали, заповнили, сконфігурували і малюємо вершинний буфер як зазвичай. Будьте обережні і використовуйте число 2 в якості другого параметра функції glVertexAttribPointer, а не 3.

Ось результат :

і збільшена версія :

Що таке фільтація та mipmapping, і як їх використовувати

Як Ви маєте змогу побачити на знімку екрана вище, якість текстури не сама краща. Це тому що в loadBMP_custom ми написали наступне:

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

Це значить, що в нашому фрагментному шейдері texture() бере тексель який знаходиться в координатах (U,V) і щаслива.

Є декілька речей, які ми можемо покращити.

Лінійна фільтрація

З використанням лінійної фільтрації, texture() буде дивитись і на сусідні текселі і змішувати кольори відповідно до їхньої відстані від центру. Це допомагає уникнути різких меж, які Ви бачили на малюнку вище.

Це виглядає значно краще, і цей спосіб широко використовується, але якщо Ви хочете кращої якості, ви можете використовувати анізотропну фільтрацію, але вона дещо повільніша.

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

Ця фільтрація апроксимує частину зображення, яка насправді видима через фрагмент. Наприклад, якщо наступна текстура видима тільки збоку і трішки повернута, анізотропна фільтрація розрахує колір, який знаходиться в синьому прямокутнику беручи фіксовану кількість зразків (це називається рівень анізотропної фільтрації - “anisotropic level”) уздовж основного напрямку.

Mip текстури (Mipmaps)

Лінійна і анізотропна фільтрація мають проблеми. Якщо текстуру видно з великої відстані, змішування всього 4 текселів буде недостатньо. Дійсно, якщо Ваша 3Д модель знаходиться так далеко, що займає на екрані один фрагмент, ВСІ текселі зображення повинні бути усереднені для отримання кінцевого кольору. Тому, замість цього ми вводимо Mip текстури:

  • В процесі ініціалізації, Ви зменшуєте своє зображенні в 2 рази, і повторюєте, поки не отримаєте зображення розміром 1х1 (що по факту і є усередненим кольором всіх текселів на зображенні)
  • Коли Ви малюєте меш, Ви обраєте, яку mip текстуру використовувати відповідно до того, наскільки великий тексель повинен бути
  • Ви робите вибірку з цієї mip текстури, використовуючи фільтрацію по найближчому, лінійну чи анізотропну.
  • Для кращої якості Ви маєте змогу обрати текселі з двох mip текстур і змішати результат.

На щастя, це дуже легко зробити, OpenGL зробить все для нас, якщо ми гарненько попросимо:

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

Як завантажити текстуру за допомогою GLFW

Наша функція loadBMP_custom чудова, тому що ми її написали самі, але використання спеціалізованих бібліотек є кращим рішенням. GLFW2 також може справитись з цією задачею (але тільки для TGA файлів і ця можливість була видалена в GLFW3, який ми наразі використовуємо):

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?

Коротка відповідь - ніяк. GPU не розуміє JPEG. Отже, якщо Ваші зображення збережено як JPEG, то Ви повинні розпакувати їх, що б GPU розумів їх. Ви повернулися до сирих зображень, але ще й втратили якість, коли стискали в JPEG.

Є краще рішення.

Створення стиснених текстур

  • Завантажте Compressonator, інструмент від AMD
  • Завантажте текстуру правильного розміру (степінь 2) в нього
  • Згенеруйте mip текстуру, Вам не потрібно це буде робити в процесі виконання програми
  • Стисніть його в 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;
    }

    /* отримаємо опис поверхні (surface) */
    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]);

Після заголовка знаходяться дані - всі рівні mip текстури, одна за іншою. Ми маємо змогу завантажити їх одним викликом:

    unsigned char * buffer;
    unsigned int bufsize;
    /* Як забагато пам'яті потрібно для завантаження всих mip текстур? */
    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);

І тепер, ми просто повинні заповнити кожну mip текстуру по черзі:

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

    /* завантажуємо mip текстури */
    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.

В цілому, краще використовувати тільки стиснені текстури, так як вони займають менше місця, практично моментально завантажуються і швидкі при використанні. Основний мінус - це використання конверторів типу Compressonator (чи чогось подібного)

Вправи

  • Завантажувач DDS реалізований в коді, але без модифікації координат. Змініть код так, що б куб відображався правильно.
  • Поекспериментуйте з різними DDS форматами. Чи дають вони різний результат? Різний коефіцієнт стиснення?
  • Спробуйте не генерувати mip текстури в Compressonator. Який результат? Придумайте три різних способи виправити це.

Посилання