Форматы 3D моделей, часть I: Quake II MD2 формат

24.09.2012

Этот материал является переводом статьи Дэвида Генри. Оригинал публикации доступен здесь 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
 
    {   0399 },   // STAND
    {  4045, 10 },   // RUN
    {  4653, 10 },   // ATTACK
    {  54577 },   // PAIN_A
    {  58617 },   // PAIN_B
    {  62657 },   // PAIN_C
    {  66717 },   // JUMP
    {  72837 },   // FLIP
    {  84947 },   // SALUTE
    {  95, 111, 10 },   // FALLBACK
    { 112, 1227 },   // WAVE
    { 123, 1346 },   // POINT
    { 135, 153, 10 },   // CROUCH_STAND
    { 154, 1597 },   // CROUCH_WALK
    { 160, 168, 10 },   // CROUCH_ATTACK
    { 196, 1727 },   // CROUCH_PAIN
    { 173, 1775 },   // CROUCH_DEATH
    { 178, 1837 },   // DEATH_FALLBACK
    { 184, 1897 },   // DEATH_FALLFORWARD
    { 190, 1977 },   // DEATH_FALLBACKSLOW
    { 198, 1985 },   // 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

Заполнен: OpenGL4Туториалы
Присвоен тэг:

avatar

Об Авторе ()

Планета назначения, JO - 8703 - IV, обозначается светлой точкой на объемном экране корабля. К тому времени, как Робоид нанялся на юпитерианский круговой рейс, ему следовало бы стать главным инженером, но после Суховодного пионерского его вышибли, внеся в черный список, и высадили в Луна-Сити за то, что вместо слежения за приборами он провел время, программируя синтезатор пищи корабля строжайше запрещенной корабельными инструкциями последовательностью синтеза пива.

Комментирование закрыто.

Наверх