Продолжаем изучение OpenGL: работа с моделями

14 октября 2015

В предыдущих заметках об OpenGL мы научились управлять камерой и работать с текстурами. К сожалению, все это время мы кодировали модели (такие, как ящик или покрытый травой кусок земли) вручную. Это не только приводит к распуханию исходного кода, но еще и крайне неудобно. Ящик еще куда не шел, но забить вручную, скажем, модель человека практически нереально. Поэтому прежде, чем двигаться дальше, нам нужно изучить вопрос, не очень-то связанный с самим OpenGL — создание моделей в Blender и загрузку их в коде программы из внешних файлов.

Индексирование VBO

Для начала рассмотрим очень полезную технику под названием VBO indexing.

Если вы внимательно изучите используемую нами модель ящика, то обнаружите, что многие вершины в его VBO повторяются. Другими словами, есть вершины с абсолютно одинаковыми X, Y, Z, U и V. Оказывается, что таким свойством обладают очень многие реальные модели. И что их можно существенно сжать, храня координаты, соответствующие вершинам, без повторов в отдельном VBO, и обращаясь к нему по индексам из другого VBO. Вот как это выглядит.

Заводим индексы (пока что без сжатия):

static const unsigned char globBoxIndices[] = {
  0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,15,16,17,
  18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35
};

Заводим еще один VBO:

glGenBuffers(vbosNum, vboArray);
// ...
GLuint boxIndicesVBO = vboArray[3];

Заполняем его индексами (обратите внимание, что первым аргументом вместо GL_ARRAY_BUFFER передается GL_ELEMENT_ARRAY_BUFFER):

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, boxIndicesVBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(globBoxIndices),
             globBoxIndices, GL_STATIC_DRAW);

И теперь при рисовании ящика вместо вызова glDrawArrays делаем так:

// glDrawArrays(GL_TRIANGLES, 0, 3*12);

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, boxIndicesVBO);
glDrawElements(GL_TRIANGLES,
               sizeof(globBoxIndices)/sizeof(globBoxIndices[0]),
               GL_UNSIGNED_BYTE, nullptr);

Теперь можно убрать все повторяющиеся элементы из globBoxVertexData, не забыв поправить индексы в globBoxIndices:

static const unsigned char globBoxIndices[] = {
  0, 1, 2, 1, 3, 2, 4, 5, 6, 4, 6, 7, 8, 9, 10, 8, 11, 9,
  12, 13, 14, 13, 12, 15, 16, 14, 7, 16, 7, 17, 18, 4, 13, 18, 19, 4
};

До упаковки модель ящика занимала 720 байт, после — 436, то есть 60.5% от оригинального размера. Эксперименты с другими моделями показывали и куда более впечатляющие цифры (от 55% до 39%). В будущем для каждой вершины потребуется хранить еще больше информации (как минимум, нормали), за счет чего VBO indexing позволит запросто сжимать модели раза в 4, если не больше.

Зачем создавать еще один формат

Существует много распространенных форматов для хранения моделей.

Например, большой популярностью пользуется OBJ. Данный формат не поддерживает анимацию, зато он очень простой и является текстовым. Еще заслуживают внимания форматы PLY и COLLADA. Последний основан на XML и поддерживает анимацию. Есть форматы, используемые графическими редакторами, например, 3DS, MAX и BLEND. Кроме того, имеется бесчисленное количество форматов, используемых в играх и других приложениях.

Но мы для хранения моделей будем использовать наш собственный формат.

Большинство программистов скажут, что создавать еще один формат — это бред. Есть уже десятки готовых форматов. Зачем создавать еще один, если можно просто взять готовый, найти библиотеку для работы с ним и загружать модели с ее помощью? Это действительно совершенно оправданный подход в мире мобильной разработки или вебдева. Но если я и узнал что-то об использовании OpenGL, это то, что здесь все устроено несколько иначе.

Рассмотрим для примера игры. Если вы посмотрите на Quake 2, Quake 3, Doom 3 и Half Life, то обнаружите, что все они хранят модели в совершенно разных форматах. Хотя, казалось бы, игры довольны похожи. А ведь помимо FPS есть игры и других жанров, плюс и вовсе не связанные с играми приложения. Где-то используется OpenGL, где-то DirectX, а где-то, возможно, уже и Vulkan. Где-то графика в 3D, где-то в 2D, а где-то и вовсе в 2.5D. Например, карты в Baldur’s Gate двухмерные, но это не мешает персонажам прятаться от камеры за деревьями и домами. Где-то нужно, чтобы модели как можно быстрее загружались, а где-то — чтобы они занимали как можно меньше места на диске. Где-то используется скелетная анимация, где-то per-vertex. Притом, последняя не является такой уж редкостью. Например, она используется в Quake 3, а следовательно и в его формате моделей. Да и для лица, насколько мне известно, скелетная анимация совершенно не подходит. А еще модели могут содержать какие-то дополнительные флаги, информацию для collision detection и так далее. Другими словами, на практике все форматы моделей очень сильно затачиваются под нужды конкретного приложения.

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

Итак, формат должен быть заточен под задачу. Стоящая перед нами задача довольно проста:

  • Моделей не должно быть в коде, их нужно грузить из файлов;
  • Мы хотим редактировать модели в редакторе вроде Blender;
  • Было бы здорово грузить модели быстро, используя уже знакомый нам прием с отображением файлов в память;
  • Но при этом код должен работать под Windows, Linux и MacOS, хотя бы на архитектурах x86 и x64;
  • Формат должен быть расширяемым, так как в будущем мы добавим в него нормали, анимацию и еще что-нибудь;
  • Хотелось бы при этом все-таки писать поменьше кода;

Как показала практика, придумать такой формат и написать код для работы с ним довольно просто. Тем не менее, прежде, чем изобретать свой формат, не помешает ознакомиться с устройством других форматов, например, MD2, MD3 или IQM.

Описание формата, сохранение и загрузка

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

#pragma pack(push, 1)

struct EaxmodHeader {
  char signature[7];
  unsigned char version;
  uint16_t headerSize;
  uint32_t verticesDataSize;
  uint32_t indicesDataSize;
  unsigned char indexSize;
};

#pragma pack(pop)

В заголовке содержится сигнатура, номер версии формата, размер заголовка (он может меняться с изменением версии), сколько байт занимают координаты, сколько байт занимают индексы, размер одного индекса. Следом за заголовком идет verticesDataSize байт с координатами (GLfloat’ами), затем indicesDataSize байт с индексами (char’ов, short’ов или int’ов, в зависимости от indexSize). Вот и весь формат!

Процедура сохранения модели:

bool modelSave(const char *fname,
               const void *verticesData,
               size_t verticesDataSize,
               const void *indicesData,
               size_t indicesDataSize,
               unsigned char indexSize);

Процедура загрузки с использованием отображения файла в память:

bool modelLoad(const char *fname,
               GLuint modelVAO,
               GLuint modelVBO,
               GLuint indicesVBO,
               GLsizei* outIndicesNumber,
               GLenum* outIndicesType);

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

Конвертирование из других форматов

Есть такая замечательная библиотека под названием Assimp. С ее помощью можно читать практически все существующие форматы для хранения моделей. Assimp живет на GitHub, что упрощает его подключение к проекту при помощи сабмодулей Git, использует CMake, и имеет прекрасную документацию на Doxygen. Ну просто не библиотека, а сказка!

А теперь интересный момент. Допустим, мы считали произвольный формат при помощи Assimp (естественно, очень медленно и неэффективно), приготовили его к отображению, построив буферы с координатами и индексами, а потом вызвали modelSave. Что получится в итоге? Правильно, утилита для преобразования любого формата в наш собственный!

Процедура для загрузки модели в произвольном формате:

GLfloat* importedModelCreate(const char* fname,
                             unsigned int meshNumber,
                             size_t* outVerticesBufferSize,
                             unsigned int* outVerticesNumber);

Рассмотрим ее реализацию.

Assimp::Importer importer;
const aiScene* scene = importer.ReadFile(
                         fname,
                         aiProcess_CalcTangentSpace |
                         aiProcess_Triangulate |
                         aiProcess_JoinIdenticalVertices |
                         aiProcess_SortByPType);

if(scene == nullptr) {
  std::cerr << "Failed to load model " << fname << std::endl;
  return nullptr;
}

При загрузке модели возвращается указатель на объект aiScene. Сцена по своей сути представляет множество объектов.

if(scene->mNumMeshes <= meshNumber) {
  std::cerr << "There is no mesh #" << meshNumber << " in model (" <<
    scene->mNumMeshes << " only), fname = " << fname << std::endl;
  return nullptr;
}

aiMesh* mesh = scene->mMeshes[meshNumber];
unsigned int facesNum = mesh->mNumFaces;
// unsigned int verticesNum = mesh->mNumVertices;

*outVerticesNumber = facesNum*3;

Сцена содержит меши, то есть, интересующие нас объекты. У объектов есть ряд свойств, например, количество полигонов (faces), вершин и так далее.

if(mesh->mTextureCoords[0] == nullptr) {
  std::cerr << "mesh->mTextureCoords[0] == nullptr, fname = " <<
    fname << std::endl;
  return nullptr;
}

Каждый меш содержит до восьми текстур. Нам нужна хотя бы одна.

/* 5 = coordinates per vertex */
/* 3 = vertices per face */
*outVerticesBufferSize = facesNum*sizeof(GLfloat)* 5 * 3;
GLfloat* verticesBuffer = (GLfloat*)malloc(*outVerticesBufferSize);

Выделяем память под буфер, в который мы сложим все координаты.

unsigned int verticesBufferIndex = 0;

for(unsigned int i = 0; i < facesNum; ++i) {
  const aiFace& face = mesh->mFaces[i];
  if(face.mNumIndices != 3) {
    std::cerr << "face.numIndices = " << face.mNumIndices <<
      " (3 expected), i = " << i << ", fname = " << fname << std::endl;
    free(verticesBuffer);
    return nullptr;
  }

  for(unsigned int j = 0; j < face.mNumIndices; ++j) {
    unsigned int index = face.mIndices[j];
    aiVector3D pos = mesh->mVertices[index];
    aiVector3D uv = mesh->mTextureCoords[0][index];
    // aiVector3D normal = mesh->mNormals[index];

    verticesBuffer[verticesBufferIndex++] = pos.x;
    verticesBuffer[verticesBufferIndex++] = pos.y;
    verticesBuffer[verticesBufferIndex++] = pos.z;
    verticesBuffer[verticesBufferIndex++] = uv.x;
    verticesBuffer[verticesBufferIndex++] = 1.0f - uv.y;
  }
}

return verticesBuffer;

Идем по всем полигонам, и для каждой вершины пишем в verticesBuffer координаты X, Y, Z, U и V. Затем возвращаем указатель на этот буфер.

Процедура сохранения импортированной таким образом модели:

bool importedModelSave(const char* fname,
                       GLfloat* verticesBuffer,
                       unsigned int verticesNumber);

Очевидно, при сохранении модель оптимизируются и вычисляются индексы. Затем происходит вызов modelSave. Код оптимизации модели тривиален и потому здесь мы его рассматривать не будем. Кому интересно, может изучить этот код самостоятельно.

Итак, теперь можно рисовать модели в Blender, «компилировать» их в наш формат и использовать в программе! Ура!

Заключение

В полной версии исходного кода к этой заметке вы найдете утилиту emdconv, преобразующую модель практически в любом формате в наш собственный формат EMD. Кроме того, в репозитории вы найдете демку, загружающую модели из внешних файлов и использующую их для рисования вот такой сцены:

OpenGL и загрузка моделей

Все представленные здесь модели были созданы в Blender. Как видите, это позволило использовать куда более сложные модели. Skybox и остров (теперь уже круглый, из 32 полигонов!) я сделал в Blender своими руками, используя готовые текстуры. Модель башни была найдена на blendswap.com. Код, как обычно, был проверен на трех разных компьютерах с тремя разными GPU и ОС (Windows, MacOS, Linux).

К сожалению, объяснение работы с Blender выходит за рамки данной статьи, да и вообще таким вещам проще учиться по видеоурокам. Я лично учился по этой серии уроков. Правда, я даже не досмотрел до конца вторую часть — полученных знаний оказалось достаточно. Кроме того, здесь можно посмотреть, как в Blender накладываются текстуры, а здесь описывается очень классная схема раскраски моделей. Других обучающих видео я не использовал. В целом, пользоваться Blender не сложно. Основам работы с ним можно научиться за пару вечеров.

При экспорте моделей из Blender нужно иметь в виду одну тонкость. Следует убедиться, что в свойствах модели Transform → Rotation по всем осям нулевой. Иначе модель будет выглядеть правильно в Blender, но Assimp импортирует его без этого rotation, из-за чего модель будет расположена в пространстве неверно. Решить проблему можно простым применением Ctr+A → Apply Rotation перед сохранением модели.

Дополнение: Продолжаем изучение OpenGL: освещение по Фонгу

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.