До цього часу ми прописували координати куба безпосередньо в в коді. Я думаю, Ви погодитесь, що це трішки громіздко та незручно.

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

Що б цей туторіал був якомога простіше, ми будемо використовувати файл формату OBJ, який достатньо простий та широковживаний. І, що б все було дійсно просто, ми будемо мати справу тільки з OBJ файлами, що містять одну UV координату та одну нормаль на вершину (ми ще не говорили про те, що таке нормаль).

Завантаження OBJ

Наша функція розташована в common/objloader.cpp і об’явлена в common/objloader.hpp з наступною сигнатурою:

bool loadOBJ(
    const char * path,
    std::vector < glm::vec3 > & out_vertices,
    std::vector < glm::vec2 > & out_uvs,
    std::vector < glm::vec3 > & out_normals
)

Ми хочемо, що б loadOBJ читала файл path, записувала дані в out_vertices/out_uvs/out_normals і повертає false, якщо щось пішло не так. std::vector це тип, який в С++ використовується для роботи з масивами змінної довжини. Він не має жодного відношення до математичних векторів. Але працює як масив. І накінець, символ & означає, що функція буде мати змогу модифікувати вміст масиву.

Приклад OBJ файлу

Зазвичай OBJ файл виглядає десь так:

# Blender3D v249 OBJ File: untitled.blend
# www.blender3d.org
mtllib cube.mtl
v 1.000000 -1.000000 -1.000000
v 1.000000 -1.000000 1.000000
v -1.000000 -1.000000 1.000000
v -1.000000 -1.000000 -1.000000
v 1.000000 1.000000 -1.000000
v 0.999999 1.000000 1.000001
v -1.000000 1.000000 1.000000
v -1.000000 1.000000 -1.000000
vt 0.748573 0.750412
vt 0.749279 0.501284
vt 0.999110 0.501077
vt 0.999455 0.750380
vt 0.250471 0.500702
vt 0.249682 0.749677
vt 0.001085 0.750380
vt 0.001517 0.499994
vt 0.499422 0.500239
vt 0.500149 0.750166
vt 0.748355 0.998230
vt 0.500193 0.998728
vt 0.498993 0.250415
vt 0.748953 0.250920
vn 0.000000 0.000000 -1.000000
vn -1.000000 -0.000000 -0.000000
vn -0.000000 -0.000000 1.000000
vn -0.000001 0.000000 1.000000
vn 1.000000 -0.000000 0.000000
vn 1.000000 0.000000 0.000001
vn 0.000000 1.000000 -0.000000
vn -0.000000 -1.000000 0.000000
usemtl Material_ray.png
s off
f 5/1/1 1/2/1 4/3/1
f 5/1/1 4/3/1 8/4/1
f 3/5/2 7/6/2 8/7/2
f 3/5/2 8/7/2 4/8/2
f 2/9/3 6/10/3 3/5/3
f 6/10/4 7/6/4 3/5/4
f 1/2/5 5/1/5 2/9/5
f 5/1/6 6/10/6 2/9/6
f 5/1/7 8/11/7 6/10/7
f 8/11/7 7/12/7 6/10/7
f 1/2/8 2/9/8 3/13/8
f 1/2/8 3/13/8 4/14/8

Отже:

  • # - коментар, як // в C++
  • usemtl та mtllib описує вигляд моделі. Ми не будемо використовувати їх в цьому туторіалі.
  • v - вершина
  • vt - текстурна координата вершини
  • vn - нормаль вершини
  • f - грань (face)

v, vt та vn легко зрозуміти. f є трішки складнішим. Отже, такий запис f 8/11/7 7/12/7 6/10/7 означає:

  • 8/11/7 описує першу вершину трикутника
  • 7/12/7 описує другу вершину трикутника
  • 6/10/7 описує третю вершину трикутника (ох)
  • Для першої вершини, 8 - це яку саме вершину використовувати, в нашому випадку це -1.000000 1.000000 -1.000000 (увага - індекси починаються з 1, а не з нуля, як в С++)
  • 11 - це яку текстурну координату використовувати. В нашому випадку 0.748355 0.998230
  • 7 - це яку нормаль використовувати, тобто 0.000000 1.000000 -0.000000

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

Погана новина в тому, що OpenGL не може використовувати один індекс для позиції, один для текстури і один для нормалі. Тому зараз я вирішив використовувати неіндексований меш, а індекси відкласти на потім. В туторіалі 9 ми поговоримо про них.

Створення OBJ файлу в Blender

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

Читання файлу

Тепер давайте подивимось на реальний код. Нам потрібні деякі тимчасові змінні, в яких ми будемо зберігати вміст obj файлу:

std::vector< unsigned int > vertexIndices, uvIndices, normalIndices;
std::vector< glm::vec3 > temp_vertices;
std::vector< glm::vec2 > temp_uvs;
std::vector< glm::vec3 > temp_normals;

З туторіалу 5 “текстурований куб” Ви знаєте, як відкрити файл:

FILE * file = fopen(path, "r");
if( file == NULL ){
    printf("Неможливо відкрити файл!\n");
    return false;
}

Давайте будемо читати файл до кінця :

while( 1 ){

    char lineHeader[128];
    // читаємо перше слово першого рядка
    int res = fscanf(file, "%s", lineHeader);
    if (res == EOF)
        break; // EOF = End Of File - кінець файлу. Виходимо з циклу

    // else : розбираємо lineHeader

(майте на увазі, що ми припустили, що перше слово першого рядку менше 128 символів, що є не дуже гарним припущенням. Але для нашого парсера підійде)

Давайте спочатку попрацюємо з вершинами:

if ( strcmp( lineHeader, "v" ) == 0 ){
    glm::vec3 vertex;
    fscanf(file, "%f %f %f\n", &vertex.x, &vertex.y, &vertex.z );
    temp_vertices.push_back(vertex);

Тобто, якщо перше слово в рядку є v, то далі буде 3 дійсних числа. Ми створимо glm::vec3 з них і додамо в вектор (масив) вершин.

}else if ( strcmp( lineHeader, "vt" ) == 0 ){
    glm::vec2 uv;
    fscanf(file, "%f %f\n", &uv.x, &uv.y );
    temp_uvs.push_back(uv);

якщо ж перше слово це vt, то далі буде 2 дійсних числа. З них створимо glm::vec2 і додамо до вектора текстурних координат.

і дуже схоже для нормаль:

}else if ( strcmp( lineHeader, "vn" ) == 0 ){
    glm::vec3 normal;
    fscanf(file, "%f %f %f\n", &normal.x, &normal.y, &normal.z );
    temp_normals.push_back(normal);

А тепер трішки складніше - грані

}else if ( strcmp( lineHeader, "f" ) == 0 ){
    std::string vertex1, vertex2, vertex3;
    unsigned int vertexIndex[3], uvIndex[3], normalIndex[3];
    int matches = fscanf(file, "%d/%d/%d %d/%d/%d %d/%d/%d\n", &vertexIndex[0], &uvIndex[0], &normalIndex[0], &vertexIndex[1], &uvIndex[1], &normalIndex[1], &vertexIndex[2], &uvIndex[2], &normalIndex[2] );
    if (matches != 9){
        printf("Наш простий файл не може прочитати цей файл : ( Спробуйте зберегти з іншими опціями\n");
        return false;
    }
    vertexIndices.push_back(vertexIndex[0]);
    vertexIndices.push_back(vertexIndex[1]);
    vertexIndices.push_back(vertexIndex[2]);
    uvIndices    .push_back(uvIndex[0]);
    uvIndices    .push_back(uvIndex[1]);
    uvIndices    .push_back(uvIndex[2]);
    normalIndices.push_back(normalIndex[0]);
    normalIndices.push_back(normalIndex[1]);
    normalIndices.push_back(normalIndex[2]);

Цей код подібний до попереднього, тільки потрібно трішки більше читати.

Обробка даних

Все що ми робимо, це змінюємо “форму” даних. У нас був рядок символів, тепер декілька std::vector. Але цього не достатньо, нам потрібно перетворити дані в форму, яка підходить OpenGL. Ми видаляємо індекси і робимо масив glm::vec3. Ця операція називається індексуванням.

Пройдемося по кожній вершині (кожному v/vt/vn) кожного трикутника (кожний рядочок з f):

    // Для кожної вершини кожного трикутника
    for( unsigned int i=0; i<vertexIndices.size(); i++ ){

індекс позиції вершини в vertexIndices[i]:

unsigned int vertexIndex = vertexIndices[i];

отже позиція є temp_vertices[vertexIndex-1] (пам’ятаєте про -1, тому що с++ починає індексування з 0, а OBJ використовує ідексування з 1):

glm::vec3 vertex = temp_vertices[ vertexIndex-1 ];

І це дає нам позицію нової вершини

out_vertices.push_back(vertex);

Зробімо це саме для UV та нормалей і все готово!

Використання завантажених даних

Як тільки у нас є всі ці дані, практично нічого більше не потрібно змінювати. Замість нашого статичного масиву static const GLfloat g_vertex_buffer_data[] = {...} ми використовуємо std::vector, що містить вершини (і те ж саме для UV та нормалей). Просто викличемо loadOBJ з правильними параметрами:

// Читаємо наш .obj файл
std::vector< glm::vec3 > vertices;
std::vector< glm::vec2 > uvs;
std::vector< glm::vec3 > normals; // Поки не використовуємо
bool res = loadOBJ("cube.obj", vertices, uvs, normals);

І передамо ці вектори до OpenGL замість масивів:

glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(glm::vec3), &vertices[0], GL_STATIC_DRAW);

І це все!

Результати

Вибачте за цю дивну текстуру - я не дуже гарних художник :( Будь-який внесок вітається!

Інші формати/завантажувачі

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