Форматы 3D моделей, часть I: Quake II MD2 формат
Этот материал является переводом статьи Дэвида Генри. Оригинал публикации доступен здесь http://tfc.duke.free.fr/old/models/md2.htm
Вступление
Некоторые могут сказать «Вот, опять урок по MD2!!!» (имеется в виду здесь метод криптозащиты MD2) — да, но в этом уроке я покажу как производить рендер MD2:) А если серьезно, то это урок по другому MD2, то есть о трехмерном формате файлов 3d-моделей, использовавшимся компанией IdSoftware в знаменитой QuakeII. И я покажу, как загружать и отображать на экране этот формат, используя OpenGL.
Вы наверное подумали, «%^, да у него ностальгия по 1997!», однако есть несколько причин, использовать формат. Во первых, формат MD2 это неплохой формат 3D моделей, и имеет простую структуру, несложную для изучения, (если вы начинающий разработчик, то вы можете не понять это, но познакомившись с другими 3D форматами, вы поймете о чем я). И далее, этот формат абсолютно бесплатен! (Хотя если вы захотите использовать собственно модели в этом формате, то конечно же вы должны знать, используете ли вы свободно распространяемую модель, или принадлежащую кому-то). Давайте посмотрим на содержание данной статьи:
- Формат файла MD2
- Разработка класса CMD2MODEL
- Чтение и запись MD2 модели
- Отображение модели на экране
- Анимация
Также можно добавить, что исходный код к данному уроку полностью свободный и вы можете его скачать по ссылке в конце данного документа. Прежде чем начинать, я хотел бы отметить, что вы должны быть немного знакомы с OpenGLAPI. Теперь давайте начнем с небольшой теории по формату MD2.
Формат MD2
Как и большинство файловых форматов, MD2 состоит из двух частей: заголовков и данных. Заголовки содержат некоторые важные переменные, используемые при загрузке из файла, такие как размер данных, номер версии. Размер заголовка всегда одинаков. Так что заголовок можно загрузить непосредственно в struct. И напротив, размер самих данных может меняться, и содержит множество структур вершин, полигонов, координат текстуры и прочее… На рис. 1. представлена архитектура файла:
Далее идет описание структуры MD2. (здесьmd2_t):
// md2 header typedef struct { int ident; // magic number. must be equal to "IDP2" int version; // md2 version. must be equal to 8 int skinwidth; // width of the texture int skinheight; // height of the texture int framesize; // size of one frame in bytes int num_skins; // number of textures int num_xyz; // number of vertices int num_st; // number of texture coordinates int num_tris; // number of triangles int num_glcmds; // number of opengl commands int num_frames; // total number of frames int ofs_skins; // offset to skin names (64 bytes each) int ofs_st; // offset to s-t texture coordinates int ofs_tris; // offset to triangles int ofs_frames; // offset to frame data int ofs_glcmds; // offset to opengl commands int ofs_end; // offset to end of file } md2_t; |
Хорошо, давайте посмотрим на переменные.
Первое, что вы видите, это «Магический номер».При загрузке файла, убедитесь, что ident равен IPD2. Если равенство не соблюдается, вы не должны загружать такой файл, это не MD2. Следующая переменная: version; это номер версии, равный 8.
Затем идет разрешение текстуры(соответственно, ширина и высота). Мы не станем использовать эти переменные, так как текстуры md2 хранятся в других файлах, в основном PCX или TGA, и мы получим разрешения текстуры из этих файлов.
Переменная framesize определяет размер в байтах каждого фрейма. Хорошо. Но, черт возьми, а что такое фрейм? Фрейм в md2 это наподобие кадра в кинофильме. Проигрывая зацикленные последовательности фреймов с определенной скоростью, мы получаем анимацию. Так что один кадр хранит информацию о вершинах и полигонах модели в определенном их положении (для текущего кадра). Например, обычный файл md2 может состоять из 199 фреймов, разбросанных по 21 анимациям. Фрейм содержит список вершин для всех полигонов фрейма(каждый фрейм имеет одно и тоже количество полигонов). На текущий момент переменная framesize нам нужна для того, чтобы знать, сколько памяти распределить для каждого фрейма. Дальнейшие переменные несколько похожи друг на друга.
- Переменная num_skins определяет количество текстур для модели. Например, у вас может быть текстура для игроков красной команды и для синей команды, если вы делаете спортивный симулятор. Имя каждой текстуры, имеющее длину 64 байта, хранится в файле и начинается с позиции ofs_skins. Однако, надо быть осторожными в чтении имени текстуры из этой переменной, так как она может указывать на путь директории QuakeII, например “player/ogro/igdosh.pcx”.
- Переменная num_xyz хранит общее количество вершин модели. Это число соответствует количеству вершин в каждом фрейме.
- Переменная num_st определяет количество координат текстур, хранящихся в файле с позиции ofs_st. Обратите внимание, что это число не обязательно эквивалентно количеству вершин. В нашем коде мы не будем загружать координаты текстур из массива файла md2, а определим текстурные координаты другим способом.
- Переменная num_tris определяет общее количество полигонов (треугольников) модели.
- Переменная num_glcmds определяет количество OpenGL команд. Список GL команд представляет из себя массив чисел типа integer, позволяющий производить рендер модели с помощью типов примитив trianglefan или trianglestrip (GL_TRIANGLE_STRIP и GL_TRIANGLE_FAN), вместо обычных индексированных полигонов (GL_TRIANGLES). С помощью этих GL команд можно увеличить производительность рендера на 10 или 15fps.
И наконец, мы подошли к переменной num_frames. Эта переменная определяет общее количество фреймов(кадров) для модели. Фактически, каждый фрейм представляет из себяkeyframe (ключевой кадр). При этом, анимация получается дискретной (на самом деле, чтобы сделать непрерывную анимацию, нужно fps в пределах 200 или 300 кадров в секунду, но такие большие показатели не используются при создании анимации). Так что реальная частота кадров может быть 20 fps, и если необходимо сделать более плавную анимацию, то мы просто интерполируем дискретные данные, получая более непрерывный переход между кадрами. На рисунке 2 показан пример. В нем представлена упрощенная модель с одной анимацией, которая требует 20 кадров для плавного показа, однако анимация содержит только 5 ключевых кадров. Промежуточные кадры, такие как 4,от 6 до 9, 11 до 14, 16 до 19. должны быть предварительно рассчитаны, чтобы получить плавную анимацию.
Последний блок переменных в заголовке содержит смещения к различным данным модели. При этом:
- Параметр ofs_skins указывает на имена текстур
- Параметр ofs_st указывает на координаты текстур
- Параметр ofs_tris указывает на вершины
- Параметр ofs_frames указывает на данные первого кадра модели
- Параметр ofs_glcmds указывает на список команд OpenGL
- Параметра ofs_end указывает на конец файла
Теперь, наконец, мы разобрались со структурой заголовка файла! Давайте взглянем на структуры, представляющие данные модели. Также как и для заголовка, мы будем использовать структуры для хранения фреймов, вершин и команд OpenGL. Первый тип данных очень полезен в большинстве 3d приложений, это тип данных для векторов. Мы не будем использовать усложненный класс Vector, так что мы определим вектор просто как структуру, содержащую три float-а:
typedef float vec3_t[3]; |
Каждая модель состоит из (num_frame * num_xyz) вершин. Так что создадим несложнуюструтуру для хранения вершин:
// vertex typedef struct { unsigned char v[3]; // compressed vertex (x, y, z) coordinates unsigned char lightnormalindex; // index to a normal vector for the lighting } vertex_t; |
Вы можете заметить, что v[3] содержит координаты (x,y,z) и так как тип данных unsigned, то они могут принимать значения от 0 до 255. Фактически, это потому, что координаты подвергнуты компрессии, так что мы имеем 3 байта вместо 12 (для типа данных float). Чтобы осуществить декомпрессию, мы будем использовать другие данные, свойственные отдельным фреймам. Переменная lightnormalindex указывает на одно из предварительно рассчитанных значений в таблице нормалей. Нормали пригодятся для освещения.
Последняя часть информации для вершины это текстурные координаты. Онирасположенывструктуре:
// texture coordinates typedef struct { short s; short t; } texCoord_t; |
Также как и для вершин, данные сжаты. Будет использоваться тип данных short(2 байта) вместо float(4 байта). При преобразовании этих данных во float необходимо учитывать, что результат должен находиться в промежутке от 0.0f до 1.0f, таким образом расчет преобразования будет таким:
RealST[i].s = (float)texCoord[i].s / header.skinwidth; RealST[i].t = (float)texCoord[i].t / header.skinheight; |
Здесь предполагается, что RealST это объект структуры типа texCoord_t, но в ней вместо short используется float, а texCoord это массив, загруженный из файла MD2. Каждый фрейм (или ключевой кадр) модели хранится в структуре такого типа:
// frame typedef struct { float scale[3]; // scale values float translate[3]; // translation vector char name[16]; // frame name vertex_t verts[1]; // first vertex of this frame } frame_t; |
Каждый отдельный кадр сохраняется в экземпляре такой структуры. Так, обычная модель с анимацией будет хранить 199 экземпляров структуры frame_t. Минуту назад я говорил, что мы будем осуществлять декомпрессию вершин с помощью данных фреймов. Чтобы осуществить декомпрессию вершин, мы масштабируем значения, умножив их на scale[3], а потом переместим, прибавив translate[3].
Массив name[16] представляет из себя просто имя фрейма. И, наконец, массив vert[1] представляет из себя первую вершину фрейма. Остальные вершины следуют за ней, так что, на самом деле массив больше чем [1]. Получитьдоступкостальнымвершинамможнотак:
frame.verts[ 2 ] // get the second vertex of the frame frame.verts[ i ] // get the i th vertex of the frame frame.verts[ num_xyz - 1 ] // get the last vertex of this frame |
А таким образом рассчитываются координаты вершин в формате float:
vertex.x = (frame.verts[i].v[0] * frame.scale[0]) + frame.translate[0] vertex.y = (frame.verts[i].v[1] * frame.scale[1]) + frame.translate[1] vertex.z = (frame.verts[i].v[2] * frame.scale[2]) + frame.translate[2] |
При этом i лежит в диапазоне от 0 до (num_xyz– 1). Давайте взглянем на рисунок 3, для получения представления о связях между анимациями, фреймами и вершинами:
Таким образом, каждая анимация содержит n фреймов, при этом каждый из фреймов содержит num_xyz вершин.
Теперь нам нужно прилинковать каждую вершину к паре её текстурных координат. Однако вместо того, чтобы линковать отдельные вершины, здесь линкуются целые группы вершин (по 3), таким образом, линкуются целые полигоны:
// triangle typedef struct { short index_xyz[3]; // indexes to triangle's vertices short index_st[3]; // indexes to vertices' texture coorinates } triangle_t; |
Таким образом, мы получили представление об общей архитектуре файла. Обратите внимание, что index_xyz и index_st это только индексы к данным, но не сами данные! Данные должны быть помещены отдельно в массивы vertex_t и texCoord_t. Второй случай, texCoord_t понадобится, если вы предпочитаете декомпрессировать данные по мере загрузки модели в данные типа float. Здесь предполагается, что Vertices[] это массив типа vertex_t, Texcoord[] это массив типа texCoord_t, и Meshes[] это массив типа triangle_t и anorms[] – массив типа vec3_t, хранящий все предварительно рассчитанные вектора нормалей. Вы можете осуществить рисование модели используя такой метод:
glBegin( GL_TRIANGLES ); // draw each triangle for( int i = 0; i < header.num_tris; i++ ) { // draw triangle #i for( int j = 0; j < 3; j++ ) { // k is the frame to draw // i is the current triangle of the frame // j is the current vertex of the triangle glTexCoord2f( (float)TexCoord[ Meshes[i].index_st[j] ].s / header.skinwidth, (float)TexCoord[ Meshes[i].index_st[j] ].t / header.skinheight ); glNormal3fv( anorms[ Vertices[ Meshes[i].index_xyz[j] ].lightnormalindex ] ); glVertex3f( (Vertices[ Meshes[i].index_xyz[j] ].v[0] * frame[k].scale[0]) + frame[k].translate[0], (Vertices[ Meshes[i].index_xyz[j] ].v[1] * frame[k].scale[1]) + frame[k].translate[1], (Vertices[ Meshes[i].index_xyz[j] ].v[2] * frame[k].scale[2]) + frame[k].translate[2] ); } } glEnd(); |
Хорошо, но данный метод использует GL_TRIANGLES. Также, в качестве альтернативы, можно использовать GL_TRIANGLE_STRIP и GL_TRIANGLE_FAN, если раздел OpenGL команд в файле содержит указания на именно эти типы примитив.
Это вся информация о формате данных. Для того, чтобы было более наглядно, можно здесь привести структуру файла MD2 в виде таблицы:
Разработка класса CMD2Model
Благодаря разделу OpenGL команд, мы не будем использовать структуры triangle_t и texCoord_t, так как они включены в список OpenGL команд, оттуда мы и будем получать данные. В случае, если вы не будете пользоваться OpenGL командами, или например, осуществляете рендер не через OpenGL вы можете получать данные через структуры triangle_t и texCoord_t, по описаниям в предыдущем разделе.
Сейчас же мы займемся разработкой базового объекта (класса), который будет представлять загруженную модель MD2. Прототипклассабудетследующим:
// ============================================ // CMD2Model - MD2 model class object. // ============================================ class CMD2Model { public: // constructor/destructor CMD2Model( void ); ~CMD2Model( void ); // functions bool LoadModel( const char *filename ); bool LoadSkin( const char *filename ); void DrawModel( float time ); void DrawFrame( int frame ); void SetAnim( int type ); void ScaleModel( float s ) { m_scale = s; } private: void Animate( float time ); void ProcessLighting( void ); void Interpolate( vec3_t *vertlist ); void RenderFrame( void ); public: // member variables static vec3_t anorms[ NUMVERTEXNORMALS ]; static float anorms_dots[ SHADEDOT_QUANT ][256]; static anim_t animlist[21]; // animation list private: int num_frames; // number of frames int num_xyz; // number of vertices int num_glcmds; // number of opengl commands vec3_t *m_vertices; // vertex array int *m_glcmds; // opengl command array int *m_lightnormals; // normal index array unsigned int m_texid; // texture id animState_t m_anim; // animation float m_scale; // scale value }; |
Каждая загруженная модель из MD2 будет загружаться во вновь созданный экземпляр объекта типа CMD2Model. Однако, если внимательно присмотреться к объявлению класса, то можно заметить, что здесь нет ни frame_t, ни vertex_t! И еще, а где собственно, хранятся текстурные координаты? Что же, давайте посмотрим…
Для начала мы определим классический конструктор и деструктор, при этом все переменные класса будут установлены в 0 (за исключением m_scale). Также, при загрузке данных, распределим память.
Что насчет функций: методов класса? Функция LoadModel() будет загружать модель из файла и LoadSkin(), будет загружать текстуры и инициализировать переменную m_texid. Разберем остальные методы класса CMD2Model:
- Метод DrawModel() осуществляет рендер модели. При этом устанавливаются все необходимые матрицы трансформаций. Параметр time нужен для вычисления фрейма, с которого начинать анимацию.
- Метод DrawFrame() осуществляет рендер отдельного кадра.
- Методы SetAnim() и ScaleModel() используются для установки текущей анимации и масштаба.
- Методы Animate(), ProcessLighting(), Interpolate() и RenderFrame() являются приватными методами класса и вызываются из функции DrawModel(). Эти методы производят все вычисления, требуемые для правильного интерполирования и освещения фрейма.
Теперь поговорим о переменных класса. Массив anorms это массив предварительно рассчитанных векторов нормалей. Каждая вершина имеет индекс, расположенный в массиве m_lightnormals, указывающий на вектор нормали. Переменная anorms_dots похожа на anorms, однако она хранит предварительно рассчитанные скалярно умноженные вектора. Эти вектора понадобятся для расчета освещения. Массив animlist это массив с анимациями. Здесьпредставленпрототипструктурыanim_t:
// animation typedef struct { int first_frame; // first frame of the animation int last_frame; // number of frames int fps; // number of frames per second } anim_t; |
Вы можете обратить внимание, что последние три переменные статические. Это потому, что они одинаковы для каждой копии модели MD2.
Далее идут переменные: num_frames, хранящую общее количество фреймов, num_xyz, хранящую количество вершин для отдельного кадра и num_glcmds, для количества команд OpenGL.
Переменная m_vertices хранит 3d координаты в значениях float для каждой вершины. Массив m_glcmds хранит список OpenGL команд. На текущий момент, мы не будем беспокоиться об этих странных “OpenGL командах”, будем предполагать, что это некоторые магические числа. Мы рассмотрим эти команды далее, когда будем изучать метод рендера меша. Для этих трех массивов с префиксами m мы будем распределять память динамически.
Переменная m_texid хранит ID для текстур OpenGL. Переменная m_anim хранит номер воспроизводимой в текущий момент анимаци. Она указывает на объект типа animState_t (см. описание переменных структуры в комментариях):
// animation state typedef struct { int startframe; // first frame int endframe; // last frame int fps; // frame per second for this animation float curr_time; // current time float old_time; // old time float interpol; // percent of interpolation int type; // animation type int curr_frame; // current frame int next_frame; // next frame } animState_t; |
И в завершении рассмотрения переменных идет m_scale. Масштабирование объекта лучше выполнять именно при помощи этой переменной, в отличии от glScalef(), так как эта функция также масштабирует и вектора нормалей, что приведет к непредсказуемым эффектам в освещении.
Я уже говорил, что мы не будем использовать ни структуру triangle_t, ни texCoord_t. Но что насчет vertex_t и frame_t? Мы будем использовать их только при загрузке модели функцией LoadModel(), и преобразовывать данные фреймов, сохраненные в массивах m_vertices и m_lightnormals.
Перед тем как завершить этот раздел, я хотел быть дать объявления конструктора и деструкторов:
// ---------------------------------------------- // constructor - reset all data. // ---------------------------------------------- CMD2Model::CMD2Model( void ) { m_vertices = 0; m_glcmds = 0; m_lightnormals = 0; num_frames = 0; num_xyz = 0; num_glcmds = 0; m_texid = 0; m_scale = 1.0; SetAnim( 0 ); } // ---------------------------------------------- // destructor - free allocated memory. // ---------------------------------------------- CMD2Model::~CMD2Model( void ) { delete [] m_vertices; delete [] m_glcmds; delete [] m_lightnormals; } |
В конструкторе, мы установим все переменные (за исключением m_scale) в 0. Мы инициализируем эту переменную значением 1.0, так как если бы она была равна 0, мы бы ничего не увидели. В деструкторе мы просто освободим память.
Ок, теперь мы готовы начать рендер и мы переходим к следующему разделу, где загрузим файл MD2.
Загрузка и сохранение моделей в формате MD2
Чтобы загрузить модель, необходимо вызвать функцию LoadModel(), передав ей в параметрах имя файла. Функция возвращает true, при успешной загрузке, и false если произошла ошибка. Давайте посмотрим на первую часть функции:
// ---------------------------------------------- // LoadModel() - load model from file. // ---------------------------------------------- bool CMD2Model::LoadModel( const char *filename ) { std::ifstream file; // file stream md2_t header; // md2 header char *buffer; // buffer storing frame data frame_t *frame; // temporary variable vec3_t *ptrverts; // pointer on m_vertices int *ptrnormals; // pointer on m_lightnormals // try to open filename file.open( filename, std::ios::in | std::ios::binary ); if( file.fail() ) return false; // read header file file.read( (char *)&header, sizeof( md2_t ) ); ///////////////////////////////////////////// // verify that this is a MD2 file // check for the ident and the version number if( (header.ident != MD2_IDENT) && (header.version != MD2_VERSION) ) { // this is not a MD2 model file.close(); return false; } ///////////////////////////////////////////// |
Сначала определим некоторые локальные переменные, требующиеся в течении загрузки модели. Переменная file типа filestream предназначена для получения данных из файла. Переменная header типа md2_t хранит данные о заголовке файла. Далее расположена переменная buffer: это большой буфер для данных фреймов. Три последних переменных это различные указатели для доступа к данным из buffer.
Загрузку мы начнем, открыв файл в режиме чтения. При этом если произойдет ошибка, возвращается false. Файл открыт, теперь мы можем загрузить заголовок модели. Здесь мы протестируем магическое число (идентификатор), а также версию файла, и убедимся, что файл имеет тип MD2. Идентификатор должен всегда иметь значение “IDP2”, и номер версии должен быть 8. Так что мы определим константы MD2_IDENT и MD2_VERSION:
// magic number "IDP2" or 844121161 #define MD2_IDENT (('2'<<24) + ('P'<<16) + ('D'<<8) + 'I') // model version #define MD2_VERSION 8 |
Обратите внимание, что мы можем проверить магический номер, сравнивая идентификатор со значением 844121161 или используя функцию strcmp(). Идентификатор должен быть определен как char[4].
Теперь, убедившись, что файл имеет тип MD2, можно продолжить загрузку.
// initialize member variables num_frames = header.num_frames; num_xyz = header.num_xyz; num_glcmds = header.num_glcmds; // allocate memory m_vertices = new vec3_t[ num_xyz * num_frames ]; m_glcmds = new int[ num_glcmds ]; m_lightnormals = new int[ num_xyz * num_frames ]; buffer = new char[ num_frames * header.framesize ]; ///////////////////////////////////////////// // reading file data // read frame data... file.seekg( header.ofs_frames, std::ios::beg ); file.read( (char *)buffer, num_frames * header.framesize ); // read opengl commands... file.seekg( header.ofs_glcmds, std::ios::beg ); file.read( (char *)m_glcmds, num_glcmds * sizeof( int ) ); ///////////////////////////////////////////// |
Здесь мы сначала инициализируем наши переменные, прочитав их из заголовка. Затем мы можем распределить необходимую память для наших переменных m_vertices, m_glcmds, m_lightnormals, а также массивов. Обратите внимание, что m_vertices и m_lightnormals имеют одинаковое количество элементов. Поэтому для того, чтобы адресовать 3D координаты и их нормали можно использовать один и тот же индекс. Его мы получим из массива m_glcmds.
Память выделена, теперь можно считывать данные из файла. Перед тем как осуществить чтение данных, необходимо переместится на позицию, указанную в смещениях заголовка (переменных заголовка с префиксом ofs). Мы будем считывать только данные фреймов и команды OpenGL. Затем мы инициализируем m_vertices и m_lightnormals значениями буфера:
// vertex array initialization for( int j = 0; j < num_frames; j++ ) { // adjust pointers frame = (frame_t *)&buffer[ header.framesize * j ]; ptrverts = &m_vertices[ num_xyz * j ]; ptrnormals = &m_lightnormals[ num_xyz * j ]; for( int i = 0; i < num_xyz; i++ ) { ptrverts[i][0] = (frame->verts[i].v[0] * frame->scale[0]) + frame->translate[0]; ptrverts[i][1] = (frame->verts[i].v[1] * frame->scale[1]) + frame->translate[1]; ptrverts[i][2] = (frame->verts[i].v[2] * frame->scale[2]) + frame->translate[2]; ptrnormals[i] = frame->verts[i].lightnormalindex; } } |
Данный код требует небольших разъяснений. Сначала устанавливается цикл с проходом по каждому фрейму. Для каждого фрейма, мы извлечем из буфера данные фрейма, используюя указатель frame_t*, объявленный в начале функции. Также, мы установим переменные *m_vertices и *m_lightnormals так, чтобы они указывали на начало данных текущего фрейма.
Затем мы осуществим в цикле проход по каждой вершине текущего фрейма. Мы инициализируем 3D координаты формулой, о которой я недавно рассказывал – в разделе, посвященном формату данных MD2. Мы также инициализируем индекс нормалей, записанный в структуре vertex_t.
Теперь мы инициализировали три числовых переменные и три массива данных, и заполнили их значениями из файла, так что на этом чтение файла MD2 завершено! Вот, это ведь получилось несложно? Теперь мы можем просто закрыть файл, освободив buffer и возвратив true:
// free buffer's memory delete [] buffer; // close the file and return file.close(); return true; } |
А что насчет текстуры? Для текстуры необходимо только ID, для этого есть m_texid. Текстуры MD2 хрянятся в обычных файлах формата TGA или PCX. Загрузка текстур из файла не является предметом данной статьи, так что я не буду здесь приводить описание. Я предполагаю, что у вас есть функция загрузки текстуры из файла, и эта функция фозвращает правильный ID. В исходном коде данной статьи вы можете найти менеджер текстур, который загружает и инициализирует текстуру из изображений формата bitmap, targa или pcx. Далее приведен пример загрузки текстуры:
// ---------------------------------------------- // LoadSkin() - load model texture. // ---------------------------------------------- bool CMD2Model::LoadSkin( const char *filename ) { m_texid = LoadTexture( filename ); return (m_texid != LoadTexture( "default" )); } |
Пару слов о моем менеджере текстур. Для начала, я написал inline функцию LoadTexture(), предназначенную для более простого восприятия кода. Эта функция получает доступ к функции LoadTexture() менеджера. Менеджер текстур является модулем, реализующим единственную функцию LoadTexture(). При инициализации модуль создает пустую текстуру по умолчанию (либо заполненную белым цветом, либо шахматной клеткой). При загрузке текстуры из файла, модуль сначала проверяет, не была ли она уже предварительно загружена. Если да, то модуль просто возвращает ID, если нет, то модуль загружает текстуру. Если загрузка неудачная, модуль возвращает ID пустой текстуры по умолчанию. Так что, если вызвать texmgr.LoadTexture(“defult”), загрузки не произойдет и возвратится ID по умолчанию. Сам метод CMD2Model::LoadSkin возвращает значение bool, устанавливаемое в true если текстура загружена и false, если нет.
Мы подошли к завершению раздела о загрузке данных из файла. Теперь все необходимые данные загружены.
Отображение модели
Настало время осуществить рендер предварительно загруженной модели. Главная функция рендера это DrawModel(). Однако, эта функция не осуществляет рендер непосредственно модели, но осуществляет препроцесс, устанавливая трансформации и производя некоторые вычисления, чтобы потом сделать с этими подготовленными данными вызов функции RenderFrame(). Посмотримнаобъявлениефункции:
// ---------------------------------------------- // DrawModel() - draw the model. // ---------------------------------------------- void CMD2Model::DrawModel( float time ) { glPushMatrix(); // rotate the model glRotatef( -90.0, 1.0, 0.0, 0.0 ); glRotatef( -90.0, 0.0, 0.0, 1.0 ); // render it on the screen RenderFrame(); glPopMatrix(); } |
Вы можете видеть, что перед вызовом RenderFrame() осуществляется два простых вращения. При этом параметр floattime не используется. Мы дополним эту функцию далее, когда будем осуществлять анимацию. Вращения по X и Z необходимы так как модель MD2 записана в таком формате, в котором оси координат установлены не по стандарту OpenGL. Вы можете закомментировать эти две строки и посмотреть на результатJ
Вы можете помнить, что ранее упоминалось значение m_scale а также функция ScaleModel(). Чтобы избежать огромной модели на экране после рендера, мы масштабируем вершины. Операция масштабирования выполняется в функции Interpolate(), вызываемой из RenderFrame(). Обычно интерполяция не имеет дела с масштабированием, но так как мы еще не производим анимацию, то мы можем заполнить функцию Interpolate() кодом масштабирования. Далее, мы дополним функцию так, чтобы она осуществляла интерполяцию вершин между двумя кадрами анимации. Воткодфункции:
// ---------------------------------------------- // Interpolate() - interpolate and scale vertices // from the current and the next frame. // ---------------------------------------------- void CMD2Model::Interpolate( vec3_t *vertlist ) { for( int i = 0; i < num_xyz ; i++ ) { vertlist[i][0] = m_vertices[ i + (num_xyz * m_anim.curr_frame) ][0] * m_scale; vertlist[i][1] = m_vertices[ i + (num_xyz * m_anim.curr_frame) ][1] * m_scale; vertlist[i][2] = m_vertices[ i + (num_xyz * m_anim.curr_frame) ][2] * m_scale; } } |
Данная функция инициализирует массив вершин масштабированными значениями. Так что в функции RenderFrame() мы будем использовать массив, переданный как параметр и не станем использовать оригинальный m_vertices напрямую. С такими данными удобнее работать, в отличие от m_vertices.
Теперь, давайте немного поговорим об освещении. Существует два способа освещения модели. Первый из них, использовать функции освещения OpenGL. Для этого, необходимо сначала установить нормали для каждой вершины. Это не сложно; выборку нормалей можно осуществить с помощью индексов из m_lightnormals, при этом нормали будут выбираться из таблицы anorms.
Второй способ освещения модели – использовать glColor() для освещения и затенения каждой вершины. Этот способ используется в движке QuakeII. Второй способ требует определенной проработки. Всю реализацию способа мы поместим в функцию ProcessLighting(), вызываемую из RenderFrame() вместе с функцией Interpolate(). Но до этого, нам необходимо создаст некоторые глобальные переменные и произвести инициализации:
// number of precalculated normals #define NUMVERTEXNORMALS 162 // number of precalculated dot product results (for lighting) #define SHADEDOT_QUANT 16 // precalculated normal vectors vec3_t CMD2Model::anorms[ NUMVERTEXNORMALS ] = { #include "anorms.h" }; // precalculated dot product results float CMD2Model::anorms_dots[ SHADEDOT_QUANT ][256] = { #include "anormtab.h" }; static float *shadedots = CMD2Model::anorms_dots[0]; static vec3_t lcolor; ///////////////////////////////////////////////// vec3_t g_lightcolor = { 1.0, 1.0, 1.0 }; int g_ambientlight = 32; float g_shadelight = 128; float g_angle = 0.0; ///////////////////////////////////////////////// |
Предварительно рассчитанные нормали и результаты скалярных умножений слишком большие, так что они расположены в файлах заголовков, которые мы просто подключим, тем самым инициализируя статические массивы. Указатель shadedots устанавливается в функции ProcessLighting(). Он указывает на элемент массива anorms_dots. Переменная lcolor хранит значения RGB финального цвета.
И в заключении, взглянем на три последние глобальные переменные для значения окружающего освещения (ambient, от 0 до 255), значения затенения (shadelight, от 0 до 255), а также значения угла источника освещения (от 0.0 до 360.0).
Далее приведено описание функции ProcessLighting():
// ---------------------------------------------- // ProcessLighting() - process all lighting calculus. // ---------------------------------------------- void CMD2Model::ProcessLighting( void ) { float lightvar = (float)((g_shadelight + g_ambientlight)/256.0); lcolor[0] = g_lightcolor[0] * lightvar; lcolor[1] = g_lightcolor[1] * lightvar; lcolor[2] = g_lightcolor[2] * lightvar; shadedots = anorms_dots[ ((int)(g_angle * (SHADEDOT_QUANT / 360.0))) & (SHADEDOT_QUANT - 1) ]; } |
Сначала мы создадим локальные переменные, которые мы будем использовать для инициализации финального цвета источника освещения lcolor; затем установим указатель shadedots. Формула для расчета может показаться непонятной, но не беспокойтесь, она работает, и это как раз то, что нужно. Эта строка взята из исходного кода Quake II.
Теперь мы можем приступить к рисованию полигонов! Помните, что в начале статьи я дал небольшой код рендера полигонов(треугольников) текущего кадра. Проблема в том, что тот метод использует GL_TRIANGLES, и для каждого полигона нужно указать три вершины. Методы GL_TRIANGLE_STRIP и GL_TRIANGLE_LIST требуют меньшего количества вершин. На рисунке 5 это показано:
Так что лучше, если рендер будет делаться через GL_TRIANGLE_STRIP и GL_TRIANGLE_FAN. Для этого и сделаны GL команды. Список команд OpenGL в файле MD2 это список значений типа integer. Мы инициализируем указатель, установив его в начало списка, и будем его читать пока значение не достигнет 0. Это значение завершает список. Давайте посмотрим на алгоритм:
- Сначала мы считываем первое значение. Это число определяет, будет ли список типа GL_TRIANGLE_STRIP если число положительное или GL_TRIANGLE_FAN, если отрицательное. Абсолютное значение числа (без знака) определяет количество n вершин для рендера.
- Далее следует n*3 вершин для рисования.
- Первые два числа это текстурные координаты, а третье — это индекс вершины для рисования.
- После того, как все вершины группы пройдены, получаем следующее значение и начинаем новую группу вершин, и так далее до значения 0.
Сначала это покажется не очень то простым, но попрактиковавшись, можно сделать вывод, что не все так уж и запутанноJ Давайте взглянем на рисунок 6, который дает представление об оперировании со списком OpenGL команд. При этом каждый прямоугольник в рисунке обозначает отдельное число типа integer.
Ок, теория завершена, рассмотрим код:
// ---------------------------------------------- // RenderFrame() - draw the current model frame // using OpenGL commands. // ---------------------------------------------- void CMD2Model::RenderFrame( void ) { static vec3_t vertlist[ MAX_MD2_VERTS ]; // interpolated vertices int *ptricmds = m_glcmds; // pointer on gl commands // reverse the orientation of front-facing // polygons because gl command list's triangles // have clockwise winding glPushAttrib( GL_POLYGON_BIT ); glFrontFace( GL_CW ); // enable backface culling glEnable( GL_CULL_FACE ); glCullFace( GL_BACK ); // process lighting ProcessLighting(); // interpolate Interpolate( vertlist ); // bind model's texture glBindTexture( GL_TEXTURE_2D, m_texid ); // draw each triangle! while( int i = *(ptricmds++) ) { if( i < 0 ) { glBegin( GL_TRIANGLE_FAN ); i = -i; } else { glBegin( GL_TRIANGLE_STRIP ); } for( /* nothing */; i > 0; i--, ptricmds += 3 ) { // ptricmds[0] : texture coordinate s // ptricmds[1] : texture coordinate t // ptricmds[2] : vertex index to render float l = shadedots[ m_lightnormals[ ptricmds[2] ] ]; // set the lighting color glColor3f( l * lcolor[0], l * lcolor[1], l * lcolor[2] ); // parse texture coordinates glTexCoord2f( ((float *)ptricmds)[0], ((float *)ptricmds)[1] ); // parse triangle's normal (for the lighting) // >>> only needed if using OpenGL lighting glNormal3fv( anorms[ m_lightnormals[ ptricmds[2] ] ] ); // draw the vertex glVertex3fv( vertlist[ ptricmds[2] ] ); } glEnd(); } glDisable( GL_CULL_FACE ); glPopAttrib(); } |
Для начала, создадим две локальные переменные. Массив vertlist[] хранит 3D координаты, значениями float. Эти значения предварительно интерполированы и масштабированы перед рендером текущего фрейма. Массив статический, так что объявляется один раз. Это лучше для производительности, в отличие от распределения памяти каждый раз при вызове функции. Размер массива — константа, определяющая максимальное количество вершин в модели.
Следующая переменная — ptricmds. Эта переменная указывает на список OpenGL команд.
Далее идут атрибуты полигонов; они перевернуты, в отличии от front-facing, так как мы используем OpenGL команды, и включаем опцию backfaceculling. Мы осуществляем все вычисления для освещения, интерполируем вершины и затем масштабируем их, затем осуществляем привязку текстур.
Рендер осуществляется в цикле while. В этом цикле сначала определяется тип полигона и количество вершин для рендера. В цикле for осуществляется рендер отдельных вершин. Так как каждая вершина в списке GL команд состоит из трех значений integer, то в цикле for осуществляется инкременент на 3.
Для каждой вершины, мы рассчитываем цвет, используя указатель на таблицу скалярных умножений для угла источника света shadedots[]. Эти значения рассчитаны в функции ProcesssLighting(). Текстурные координаты преобразуются из int во float. Мы получаем вектора нормалей из таблицы anorms и осуществляем рендер вершин, используя только что рассчитанные значения массива.
Обратите внимание, что если вы не используете освещение OpenGL, вызов glNormal3fv() вам не понадобится, а также вызов glColor3f().
Анимация
Трехмерные модели выглядят значительно лучше, если они анимированы. Давайте приступим к анимации модели MD2.
Вы можете помнить массив animlist. Этот массив предназначен для хранения всех минимальных данных для анимации: индекс первого и последнего фрейма, а также переменную fps. Все эти данные перегруппированы в структуру anim_t, уже рассматриваемую ранее. Вотинициализацияэтойструктуры:
// ---------------------------------------------- // initialize the 21 MD2 model animations. // ---------------------------------------------- anim_t CMD2Model::animlist[ 21 ] = { // first, last, fps { 0, 39, 9 }, // STAND { 40, 45, 10 }, // RUN { 46, 53, 10 }, // ATTACK { 54, 57, 7 }, // PAIN_A { 58, 61, 7 }, // PAIN_B { 62, 65, 7 }, // PAIN_C { 66, 71, 7 }, // JUMP { 72, 83, 7 }, // FLIP { 84, 94, 7 }, // SALUTE { 95, 111, 10 }, // FALLBACK { 112, 122, 7 }, // WAVE { 123, 134, 6 }, // POINT { 135, 153, 10 }, // CROUCH_STAND { 154, 159, 7 }, // CROUCH_WALK { 160, 168, 10 }, // CROUCH_ATTACK { 196, 172, 7 }, // CROUCH_PAIN { 173, 177, 5 }, // CROUCH_DEATH { 178, 183, 7 }, // DEATH_FALLBACK { 184, 189, 7 }, // DEATH_FALLFORWARD { 190, 197, 7 }, // DEATH_FALLBACKSLOW { 198, 198, 5 }, // BOOM }; |
Мы будем использовать индекс для доступа к данным анимации, но лучше определить макрос для каждого индекса, для более простой восприимчивости кода:
// animation list typedef enum { STAND, RUN, ATTACK, PAIN_A, PAIN_B, PAIN_C, JUMP, FLIP, SALUTE, FALLBACK, WAVE, POINT, CROUCH_STAND, CROUCH_WALK, CROUCH_ATTACK, CROUCH_PAIN, CROUCH_DEATH, DEATH_FALLBACK, DEATH_FALLFORWARD, DEATH_FALLBACKSLOW, BOOM, MAX_ANIMATIONS } animType_t; |
Текущие данные анимации хранятся в переменной m_anim, немного отличающейся от структуры anim_t. Так что чтобы установить анимацию, мы должны получить данные анимации и инициализировать текущую анимацию этими данными. Для этого предназначена функция SetAnim():
// ---------------------------------------------- // SetAnim() - initialize m_anim from the specified // animation. // ---------------------------------------------- void CMD2Model::SetAnim( int type ) { if( (type < 0) || (type > MAX_ANIMATIONS) ) type = 0; m_anim.startframe = animlist[ type ].first_frame; m_anim.endframe = animlist[ type ].last_frame; m_anim.next_frame = animlist[ type ].first_frame + 1; m_anim.fps = animlist[ type ].fps; m_anim.type = type; } |
Сначала в функции определяется правильность значения type. Затем инициализируется переменные структуры m_anim. В качестве значения type можно устанавливать любые значения, определенные предыдущим макросом.
Теперь посмотрим на функцию Animate(). Функция вызывается из DrawModel(), так что сначала нужно дополнить DrawModel() вызовом функции:
// ---------------------------------------------- // DrawModel() - draw the model. // ---------------------------------------------- void CMD2Model::DrawModel( float time ) { // animate. calculate current frame and next frame if( time > 0.0 ) Animate( time ); glPushMatrix(); // rotate the model glRotatef( -90.0, 1.0, 0.0, 0.0 ); glRotatef( -90.0, 0.0, 0.0, 1.0 ); // render it on the screen RenderFrame(); glPopMatrix(); } |
Мы будем осуществлять анимацию, если параметр time больше 0.0. В противном случае, анимации не происходит и модель статична. Теперь разберем функцию Animate():
// ---------------------------------------------- // Animate() - calculate the current frame, next // frame and interpolation percent. // ---------------------------------------------- void CMD2Model::Animate( float time ) { m_anim.curr_time = time; // calculate current and next frames if( m_anim.curr_time - m_anim.old_time > (1.0 / m_anim.fps) ) { m_anim.curr_frame = m_anim.next_frame; m_anim.next_frame++; if( m_anim.next_frame > m_anim.endframe ) m_anim.next_frame = m_anim.startframe; m_anim.old_time = m_anim.curr_time; } // prevent having a current/next frame greater // than the total number of frames... if( m_anim.curr_frame > (num_frames - 1) ) m_anim.curr_frame = 0; if( m_anim.next_frame > (num_frames - 1) ) m_anim.next_frame = 0; m_anim.interpol = m_anim.fps * (m_anim.curr_time - m_anim.old_time); } |
Сначала функция вычисляет первый и последний кадры используя счетчик fps, указанный в текущей анимации. Затем значения проверяются на правильность (они должны быть больше общего количества кадров, которые хранит модель). И в заключение, рассчитывается процент интерполяции. Это значение получается из переменных time и fps.
Теперь мы перейдем к функции Interpolate(). В этот раз мы дополним функцию кодом интерполяции. Если не производить интерполяцию, анимация будет дискретной, поэтому будет отображаться рывками. Используя интерполяцию, мы теоретически можем получить бесконечное значение fps. Формула для расчета коэффициента интерполяции довольно простая:
Xinterpolated = Xinital+ InterpolationPercent * (Xfinal — Xinital)
Так что давайте произведем интерполяцию вершин для текущего и следующего фреймов, дополнив функцию:
// ---------------------------------------------- // Interpolate() - interpolate and scale vertices // from the current and the next frame. // ---------------------------------------------- void CMD2Model::Interpolate( vec3_t *vertlist ) { vec3_t *curr_v; // pointeur to current frame vertices vec3_t *next_v; // pointeur to next frame vertices // create current frame and next frame's vertex list // from the whole vertex list curr_v = &m_vertices[ num_xyz * m_anim.curr_frame ]; next_v = &m_vertices[ num_xyz * m_anim.next_frame ]; // interpolate and scale vertices to avoid ugly animation for( int i = 0; i < num_xyz ; i++ ) { vertlist[i][0] = (curr_v[i][0] + m_anim.interpol * (next_v[i][0] - curr_v[i][0])) * m_scale; vertlist[i][1] = (curr_v[i][1] + m_anim.interpol * (next_v[i][1] - curr_v[i][1])) * m_scale; vertlist[i][2] = (curr_v[i][2] + m_anim.interpol * (next_v[i][2] - curr_v[i][2])) * m_scale; } } |
Одновременно мы масштабируем интерполируемые вершины. И это всё! Вам осталось только вызвать один раз SetAnim() и ScaleModel(), с такими параметрами, какие вам подойдут, а затем вызвать метод DrawModel(), указав текущий кадр в секундах в качестве параметра. Повторяя эти действия в цикле вашей функции Render вы запустите анимацию. Замечательно!
Перед тем как завершить можно показать также, как произвести рендер статичной модели(например, статуи):
// ---------------------------------------------- // RenderFrame() - draw one frame of the model // using gl commands. // ---------------------------------------------- void CMD2Model::DrawFrame( int frame ) { // set new animation parameters... m_anim.startframe = frame; m_anim.endframe = frame; m_anim.next_frame = frame; m_anim.fps = 1; m_anim.type = -1; // draw the model DrawModel( 1.0 ); } |
Эта функция устанавливает переменные для анимации, а затем осуществляет вызов DrawModel(). При этом осуществляется рендер выбранного кадра модели.
Заключение
Наконец, мы подошли к завершению урока.
Данная статья возможно, далека от идеала, так как может быть дополнена поддержкой клонирования для разделения данных модели (списков вершин и нормалей) на основе параметров текущего кадра и текущей анимации, чтобы избежать дублирования одних и тех же данных модели для множественных экземпляров класса CMD2Model. Однако довольно сложно разработать универсальный класс, который бы подходил для любых применений.
Я надеюсь, этот урок помог вам в изучении формата MD2, и вообще, послужил хорошим дополнением к изучению 3D форматов.
Вы можете скачать исходный код к данной статье а также бинарные данные модели монстра и его экипировки. Исходный код подойдет для компилятора VisualC++ 6.0. Исходный код распространяется свободно без каких-либо гарантий. Файл для загрузки: q2md2_us.zip
Примечание: Исходный код загрузчика MD2 был полностью обновлен с момента первой публикации, так что последняя версия загрузчика здесь: md2loader.zip
