В предыдущих заметках об 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: освещение по Фонгу