opengl-models/

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

EnglishRussianUkrainian