Продолжаем изучение OpenGL: работа с моделями
14 октября 2015
В предыдущих заметках об OpenGL мы научились управлять камерой и работать с текстурами. К сожалению, все это время мы кодировали модели (такие, как ящик или покрытый травой кусок земли) вручную. Это не только приводит к распуханию исходного кода, но еще и крайне неудобно. Ящик еще куда не шел, но забить вручную, скажем, модель человека практически нереально. Поэтому прежде, чем двигаться дальше, нам нужно изучить вопрос, не очень-то связанный с самим OpenGL — создание моделей в Blender и загрузку их в коде программы из внешних файлов.
Индексирование VBO
Для начала рассмотрим очень полезную технику под названием VBO indexing.
Если вы внимательно изучите используемую нами модель ящика, то обнаружите, что многие вершины в его VBO повторяются. Другими словами, есть вершины с абсолютно одинаковыми X, Y, Z, U и V. Оказывается, что таким свойством обладают очень многие реальные модели. И что их можно существенно сжать, храня координаты, соответствующие вершинам, без повторов в отдельном VBO, и обращаясь к нему по индексам из другого VBO. Вот как это выглядит.
Заводим индексы (пока что без сжатия):
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:
// ...
GLuint boxIndicesVBO = vboArray[3];
Заполняем его индексами (обратите внимание, что первым аргументом вместо GL_ARRAY_BUFFER передается GL_ELEMENT_ARRAY_BUFFER):
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(globBoxIndices),
globBoxIndices, GL_STATIC_DRAW);
И теперь при рисовании ящика вместо вызова glDrawArrays делаем так:
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, boxIndicesVBO);
glDrawElements(GL_TRIANGLES,
sizeof(globBoxIndices)/sizeof(globBoxIndices[0]),
GL_UNSIGNED_BYTE, nullptr);
Теперь можно убрать все повторяющиеся элементы из globBoxVertexData, не забыв поправить индексы в 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 этой информации, плюс какой-то заголовок, содержащий размеры буферов, возможно, номер версии формата, и что-то еще. После недолгих раздумий описание заголовка у меня получилось таким:
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). Вот и весь формат!
Процедура сохранения модели:
const void *verticesData,
size_t verticesDataSize,
const void *indicesData,
size_t indicesDataSize,
unsigned char indexSize);
Процедура загрузки с использованием отображения файла в память:
GLuint modelVAO,
GLuint modelVBO,
GLuint indicesVBO,
GLsizei* outIndicesNumber,
GLenum* outIndicesType);
При помощи этих процедур мы можем без труда сохранить во внешнем файле уже имеющиеся (захардкоженные) модели, а затем переписать код так, чтобы модели загружались из файлов. Код процедур тривиален, поэтому здесь мы его рассматривать не будем. Заинтересованные читатели могут ознакомиться с ним самостоятельно.
Конвертирование из других форматов
Есть такая замечательная библиотека под названием Assimp. С ее помощью можно читать практически все существующие форматы для хранения моделей. Assimp живет на GitHub, что упрощает его подключение к проекту при помощи сабмодулей Git, использует CMake, и имеет прекрасную документацию на Doxygen. Ну просто не библиотека, а сказка!
А теперь интересный момент. Допустим, мы считали произвольный формат при помощи Assimp (естественно, очень медленно и неэффективно), приготовили его к отображению, построив буферы с координатами и индексами, а потом вызвали modelSave. Что получится в итоге? Правильно, утилита для преобразования любого формата в наш собственный!
Процедура для загрузки модели в произвольном формате:
unsigned int meshNumber,
size_t* outVerticesBufferSize,
unsigned int* outVerticesNumber);
Рассмотрим ее реализацию.
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. Сцена по своей сути представляет множество объектов.
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), вершин и так далее.
std::cerr << "mesh->mTextureCoords[0] == nullptr, fname = " <<
fname << std::endl;
return nullptr;
}
Каждый меш содержит до восьми текстур. Нам нужна хотя бы одна.
/* 3 = vertices per face */
*outVerticesBufferSize = facesNum*sizeof(GLfloat)* 5 * 3;
GLfloat* verticesBuffer = (GLfloat*)malloc(*outVerticesBufferSize);
Выделяем память под буфер, в который мы сложим все координаты.
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. Затем возвращаем указатель на этот буфер.
Процедура сохранения импортированной таким образом модели:
GLfloat* verticesBuffer,
unsigned int verticesNumber);
Очевидно, при сохранении модель оптимизируются и вычисляются индексы. Затем происходит вызов modelSave. Код оптимизации модели тривиален и потому здесь мы его рассматривать не будем. Кому интересно, может изучить этот код самостоятельно.
Итак, теперь можно рисовать модели в Blender, «компилировать» их в наш формат и использовать в программе! Ура!
Заключение
В полной версии исходного кода к этой заметке вы найдете утилиту emdconv, преобразующую модель практически в любом формате в наш собственный формат EMD. Кроме того, в репозитории вы найдете демку, загружающую модели из внешних файлов и использующую их для рисования вот такой сцены:
Все представленные здесь модели были созданы в 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-группе.