Урок 7. Загрузка моделей

Во всех предыдущих уроках мы просто хардкодили координаты вершин прямо в коде. Думаю вы согласны, что это не сильно удобно и довольно трудоемко.
В этом уроке мы научимся загружать 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
)

Пусть эта функция просто читает файл по пути и записывает прочитанные данные в массивы 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

Рассмотрим поподробнее:
·         # это комментарий(как // в С++)
·         usemtl  и mtlib описывают библиотеку материалов нашей модели. Мы не будем пользоваться этой директивой в нашем загрузчике.
·         v — вершина
·         vt — текстурная координата на одну вершину
·         vn — нормаль вершины
·         f — грань

Думаю 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, а не с 0, как в С++).
·         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::vec3 > temp_uvs;
std::vector< glm::vec3 > temp_normals;

в уроке 5(текстурированный куб) мы учились читать файлы

FILE * file = fopen(path, "r");
if( file == NULL ){    printf("Impossible to open the file !\n");    return false;
}

Теперь прочтем файл до самого конца:

while( 1 ){
   char lineHeader[128];
   // читаем первый символ в файле
   int res = fscanf(file, "%s", lineHeader);
   if (res == EOF)
       break; // EOF = Конец файла. Заканчиваем цикл чтения
   // else : парсим строку

Заметьте, мы предполагаем, что первое слово в строке не может быть длиннее 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 float числа. Поэтому мы создаем 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);

Если не «v», а «vt», то после них должно быть 2 float числа, поэтому мы создаем 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);

А вот с «f» все немного посложнее:
}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("File can't be read by our simple parser : ( Try exporting with other options\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]);

Код, в целом очень похож, просто данных читается немного больше.

Обработка данных

Только что мы просто загрузили данные из строк в массивы. Однако, к сожалению, этого не достаточно. OpenGL не умеет работать с нашими индексами. Сейчас нам нужно убрать индексы и сделать просто массивы glm::vec3.
Пробегаем по каждой вершине каждого треугольника(каждая строка «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 координатами и с нормалями, и все!

Использование загруженных данных

После того как у нас есть на руках данные, почти ничего не меняется в коде рендеринга. Лишь вместо объявления статического массива GLfloat g_vertex_buffer_data[] = {…} мы объявляем std::vector с вершинами(то же и с UV координатами и нормалями).
Вызываем loadOBJ:

// Читаем наш OBJ файл
std::vector< glm::vec3 > vertices;
std::vector< glm::vec3 > uvs;
std::vector< glm::vec3 > normals; // Сейчас мы еще не работаем с нормалями
bool res = loadOBJ("cube.obj", vertices, uvs, normals);

И дать OpenGL ссылку на наш массив вершин(то, что это std::vector а не просто статический массив, не меняет ничего):

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

Вот и все!

Результат



Загрузка других форматов

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

6 комментариев:

  1. А где эти полезные ссылки и Инструменты?

    ОтветитьУдалить
    Ответы
    1. Данная статья это просто перевод вот этого тутора: http://www.opengl-tutorial.org/ru/beginners-tutorials/tutorial-7-model-loading/
      Там в самом конце есть ссылка

      Удалить
  2. Может мне показалось но вроде тут опечатка
    std::vector< glm::vec3 > temp_uvs; должно быть
    std::vector< glm::vec2 > temp_uvs;

    ОтветитьУдалить
  3. А что с нормалями делать и куда записывать свойства материала? Например, в 3ds max'e на модельку налаживается текстура и модификатором "UVW MAP" уменьшается/увеличивается

    ОтветитьУдалить
  4. В коде вместо f 5/1/1 1/2/1 4/3/1, ставится двойной слеш f 5//1//1 1//2//1 4//3//1

    ОтветитьУдалить
  5. чтобы использовать glBufferData потребуется скачать glext

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