Форматы 3D моделей, часть II: Doom III MD5Mesh и MD5Anim

01.10.2012

Этот материал является переводом статьи Дэвида Генри. Оригинал публикации доступен здесь http://tfc.duke.free.fr/coding/md5-specs-en.html

Вступление

Формат моделей MD5 был разработан компанией idSoftware для DoomIII, выдающегося шутера с видом от первого лица, выпущенном в августе 2004 года. В этом формате данные меша и анимации находятся в раздельных файлах. Эти файлы ASCII, поэтому могут легко восприниматься. Есть несколько особенностей формата MD5:

  • Геометрические данные модели хранятся в файлах *.md5
  • Анимации хранятся в файлах *.md5anim
  • Поддерживается скелетная анимация
  • Поддерживается скин вершин
  • Для преобразований используются кватернионы

Текстуры для этого формата, как обычно, хранятся в отдельных файлах (TGA, DDS или любой другой формат). В Doom 3 текстуры указываются в файлах *.mtr папки /materials, содержащейся в архивах *.pk4. Файлы MTR здесь мы затрагивать не будем.

Операции с кватернионами

Файлы MD5 и MD5 Anim часто используют кватернионы. Кватернион представляет из себя математический объект, с помощью которого можно описать ориентацию объекта в трехмерном пространстве. Кватернионы являются расширением комплексных чисел. Если вы только что узнали о них, то вам следует взглянуть на литературу, посвященную компьютерной графике, или поискать информацию о них в интернете.

Кватернионы это альтернативный способ осуществить операцию вращения. Кватернионы, в отличие от матриц 4×4, не хранят информацию о трансляции (перемещении вершин), они хранят только информацию о вращении. В этом смысле они похожи на матрицы 3×3.

Однако, вам не требуется изучать полностью всю обширную информацию о кватернионах, достаточно знать следующее:

  • Умножение кватернионов (QuatxQuat)
  • Вращение точек 3d пространства с использованием квартеронов
  • Операцию инверсии кватерниона
  • Операцию нормализации кватерниона
  • Операцию интерполяции кватерниона (SLERP), использующуюся для плавной анимации

Кватернион имеет четыре скалярные компоненты, это: x, y, z и w. Кватернионы для вращений всегда являются нормализованными.

Вычисление w-компоненты

Так как мы будем иметь дело только с нормализованными кватернионами, модуль которых равен 1, то компонента w вычисляется так:

floatt = 1.0f - (q.x * q.x) - (q.y * q.y) - (q.z * q.z);
if (t < 0.0f)
{
    q.w = 0.0f;
}
else
{
    q.w = -sqrt (t);
}

Другие операции с кватернионами

Для того, чтобы работать с кватернионами, потребуется небольшой обзор формул, использующихся для них. Более подробную информацию можно почерпнуть из книги 3DMath, онлайнфорумах и Википедии. Умножение кватернионов преобразует два вращение в одно. Продукт умножения двух кватернионов Qa и Qb определяется так:

Qa.Qb = (wa, va)(wb, vb) = (wawb - va·vb, wavb + wbva + wa×wb)

После того, раскрытия скобок и упрощения выражения, имеем следующий результат:

r.w = (qa.w * qb.w) - (qa.x * qb.x) - (qa.y * qb.y) - (qa.z * qb.z);
r.x = (qa.x * qb.w) + (qa.w * qb.x) + (qa.y * qb.z) - (qa.z * qb.y);
r.y = (qa.y * qb.w) + (qa.w * qb.y) + (qa.z * qb.x) - (qa.x * qb.z);
r.z = (qa.z * qb.w) + (qa.w * qb.z) + (qa.x * qb.y) - (qa.y * qb.x);

Будте внимательны, так как операция умножения кватерниона некоммутативна, то есть Qa×Qb≠Qb×Qa.

Вращение некоторой точки через кватернион делается так:

R = Q·P·Q*

При этом R является результатом, Q это кватернион для вращения, Q* является сопряженным кватернионом исходномуQ, и P это координаты вершины, преобразованные в кватернион. Чтобы осуществить преобразование вершины в кватернион, просто скопируем x, y и z, установив при этом компоненту w в 0. Тоже самое относится к обратному преобразованию, из R в вершину, просто скопируем три первые компоненты и отбросим последнюю.

В приведенной выше формуле точка обозначает операцию умножения кватернионов. Операции инверсии кватернионов (но только для нормализованных кватернионов) аналогична операции сопряжения кватернионов, и вычисляется по формуле:

inverse(<w, x, y, z>) = conjugate(<w, x, y, z>) = <w, -x, -y, -z>

Операция нормализации кватернионов похожа на нормализацию векторов, только нужно учитывать, что у кватерниона четыре компоненты.

Мы не будем затрагивать здесь интерполяцию кватернионов (SLERP), но вы можете взглянуть на исходный код (в конце этой статьи), чтобы получить представление о ней; также вы можете обратиться к литературе или веб. Для интерполяции двух кватернионов используется сферическая линейная интерполяция. Это полезно для скелетной анимации.

Формат MD5 Mesh

Файлы этого формата имеют расширение *.md5mesh. Они содержат геометрические данные модели:

  • Скелет модели bind-pose
  • Один или несколько мешей Каждый меш соответственно содержит:
    • Вершины
    • Полигоны
    • Весовые коэффициенты вершин
    • Имя шейдера

Чтение файла md5mesh

При проходе файла MD5 Mesh используются комментарии. Они начинаются с “//” и распространяются до конца строки. Комментарии не затрагивают никакие данные, поэтому они просто для дополнительной информации.

Перед загрузкой данных геометрии, нужно проверить несколько переменных, чтобы определить, что файл является корректным файлом формата md5mesh. Также с помощью переменных заголовка вы можете распределить память. Заголовок MD5 такой:

MD5Version 10
commandline "<string>"
 
numJoints <int>
numMeshes <int>

Первая строка заголовка, это номер версии. Это число типа integer. Формат моделей Doom 3 MD5 имеет версию 10. Эта статья также именно об этой версии формата MD5. Другие версии формата могут немного отличатся.

Далее идет строка commandline, использующаяся в консоли Doom3 при команде exportmodels. Команды консоли здесь мы рассматривать не будем.

Значение numJoints это число узлов скелета модели. Значение numMeshes является количеством мешей модели. Далее идут узловые точки (joints) скелета модели:

joints {
    "name" parent ( pos.xpos.ypos.z ) ( orient.xorient.yorient.z )
    ...
}

Значение “name” типа string это имя узловой точки. Значение parent типа int это номер родительской узловой точки. Если индекс -1, то узловая точка не имеет родительской и поэтому является корневой (root) узловой точкой. Переменные pos.x, pos.y, pos.z типа float являются координатами узловых точек. Переменные orient.x, orient.y, orient.z типа float являются компонентами x, y, z кватерниона ориентации.

После раздела скелета модели в файле идет меш. Каждый меш в файле имеет такой вид:

mesh {
    shader "<string>"
 
    numverts <int>
    vert vertIndex ( s t ) startWeight countWeight
    vert ...
 
    numtris <int>
    tri triIndex vertIndex[0] vertIndex[1] vertIndex[2]
    tri ...
 
    numweights <int>
    weight weightIndex joint bias ( pos.x pos.y pos.z )
    weight ...
}

Строка shader связана с файлом MTR (директория /materials) из Doom 3 и указывает на метериал а также на текстуры, используемые для меша.

Переменная numverts типа int определяет количество вершин меша. После этой переменной следует список вершин. Переменная vertIndex (int) это индекс вершины. Переменные s и t (float) являются текстурными координатами (еще одно название:UV координаты). В формате MD5 Mesh вершина не имеет определенной позиции. Позиция вершины определяется на основе весовых коэффициентов (weight) вершин (разъяснение этого будет дальше). Переменная countWeitght (int) хранит количество весовых коэффициентов, при этом первый весовой коэффициент – startWeight (int), эта переменная предназначенный для расчета финальных координат вершины.

Переменная numtris определяет количество полигонов меша. Переменная triIndex (int) является индексом полигона. Каждый полигон определяется тремя вершинами, их индексы хранятся в переменных vertIndex[0], [1] и [2] типа int.

Переменная numweights (int) определяет количество весовых коэффициентов меша. Переменная weightIndex (int) является индексом весовогокоэфициента. Переменная joint (int) является узловой точкой, эта переменная зависит от bias (float) – значение bias лежит в диапазоне от 0.0 до 1.0, — и определяет распределение веса при вычислении координат вершины. Переменные pos.x, pos.y, pos.z (float) определяют пространственное расположение весового коэффициента.

Скелет модели и bind-поза

Данные скелета модели расположены в файле MD5 Mesh и называются «bind-поза», еще одно название «T-поза». Поза bind является позой скелета по умолчанию.

При этом узловые точки находятся в начальном положении, таким образом, что к ним не нужно применять никакие преобразования, чтобы получить и отобразить bind-позу, наподобие преобразований вращения или добавления координат родительской узловой точки. Координаты узловых точек лежат в пространстве объекта и не зависят от других узловых точек.

Вычисление координат вершин

Как я уже говорил ранее, координаты вершин вычисляются на основе весовых коэффициентов. Каждая вершина имеет один или более весов, каждый весовой коэффициент имеет координаты, зависящие от узловой точки, при этом координаты веса лежат в локальном координатном пространстве узловой точки. Также вершина имеет фактор bias, определяющий распределение координаты. Сумма всех весовых факторов для вершины должна быть 1.0. Такая техника называется скин вершин (vertexskinning) и позволяет координате вершины зависеть более чем от одной узловой точки скелета. Эта техника позволяет добиться хороших результатов анимации и рендера.

Теперь, для начала определим позицию вершины преобразовав ей из локального пространства узловой точки в локальное координатное пространство объекта. Затем, сумма всех весовых коэффициентов умножается на значение bias:

finalPos = (weight[0].pos * weight[0].bias) + ... + (weight[N].pos * weight[N].bias)

Данные вершин расположенные в файле MD5 Mesh имеют индекс start и количество count. Индекс start это номер первого весового коэффициента используемого вершиной. После этого первого весового коэффициента идут остальные. Значение count обозначает количество весовых коэффициентов. В листинге ниже приведен код расчета финальных координат вершин (в координатном пространстве объекта) на основе их весовых коэффициентов:

/* Setup vertices */
for (i = 0; i < mesh->num_verts; ++i)
  {
    vec3_t finalVertex = { 0.0f, 0.0f, 0.0f };
 
    /* Calculate final vertex to draw with weights */
    for (j = 0; j < mesh->vertices[i].count; ++j)
      {
        const struct md5_weight_t *weight = &mesh->weights[mesh->vertices[i].start + j];
        const struct md5_joint_t *joint = &joints[weight->joint];
 
        /* Calculate transformed vertex for this weight */
        vec3_t wv;
        Quat_rotatePoint (joint->orient, weight->pos, wv);
 
        /* the sum of all weight->bias should be 1.0 */
        finalVertex[0] += (joint->pos[0] + wv[0]) * weight->bias;
        finalVertex[1] += (joint->pos[1] + wv[1]) * weight->bias;
        finalVertex[2] += (joint->pos[2] + wv[2]) * weight->bias;
      }
 
    ...
  }

Текстурные координаты

Каждая вершина имеет текстурные координаты. Текстурные координаты ST(или UV) в левом верхнем угле имеют значения (0.0 и 0.0). Текстурные координаты нижнего правого угла имеют значения (1.0 и 1.0).

В стандарте OpenGL вертикальное направление имеет инверсию для координаты V. Этот метод также используется в DirectDrawSurface. При загрузке текстуры, отличной от DDS, вы должны перевернуть её вертикально, либо обратить координату V в вершинах MD5 Mesh (т.е. 1-V).

Предварительный расчет нормалей

Вам, скорее всего, потребуется вычислить нормали, например для освещения. Далее будет описан метод расчета нормалей для получения нормалей, интерполированных по весовым коэффициентам (weightnormals), точно также, как мы ранее интерполировали координаты на основе весовых коэффициентов. Этот метод также хорошо будет работать для интерполяций тангентов(tangents) и бинормалей(bi-normals).

Сначала для этого нужно рассчитать все позиции вершин для bind-позы (используя скелет bind-позы).

Затем рассчитываются нормали к вершинам. После этого вы будете иметь нормали в координатном пространстве объекта для bind-позы. Затем для каждого весового коэффициента вершины, преобразовываются нормаль вершины, с помощью инвертированного кватерниона узловой точки и весового коэффициента. После этого вы будете иметь нормали в локальном координатном пространстве узловой точки.

Затем, при вычислении координат вершин, вы сможете сделать тоже самое для нормалей, за исключением того, что вам не надо будет перемещать координаты из координат узловой точки при конвертации из координатного пространства узла в пространство объекта.

Формат MD5 Anim

Файлы с анимацией MD5 имеют расширения *.md5anim. Эти файлы хранят информацию о скелетной анимации для моделей MD5 Mesh:

  • Иерархию скелета модели с флагами состояний для узловых точек для анимации
  • Ограничительный куб для каждого кадра анимации
  • Базовый скелет основного кадра (baseframeskeleton), из которого вычисляются скелеты отдельных кадров анимации
  • Последовательность кадров, каждый из которых содержит данные для вычисления скелета отдельных кадров на основе базового

Чтение файла типа md5anim

Файлы MD5 Anim имеют синтаксис, подобный файлам MD5 Mesh. Комментарии начинаются с “//” и распространяются до конца строки. Также используется заголовок с номеров версии, параметром commandline а также переменными, на основе которых можно рассчитать количество выделяемой памяти:

MD5Version 10
commandline "<string>"
 
numFrames <int>
numJoints <int>
frameRate <int>
numAnimatedComponents <int>

Номер версии одинаков для всех файлов MD5, это значение 10. Параметр commandline это внутренная команда Doom3.

  • Параметр numFrames (int) определяет количество кадров анимации. Анимация, конечно состоит из множества кадров, каждый из которых является копией скелета, но в определенной позе, меняющейся от кадра к кадру.
  • Параметр numJoints (int) определяет количество узлов в скелете. Он должен быть такимже, как и в MD5 Mesh.
  • Параметр frameRate (int) определят количество кадров в секунду для анимации. Продолжительность кадра можно вычислить соответственно как 1/frameRate.
  • Параметр numAnimatedComponents (int) это количество параметров для кадров используемых для вычисления скелетов отдельных кадров. Эти параметры, вместе со скелетом базового кадра определяют файл MD5 Anim, благодаря этому можно рассчитать скелет для каждого кадра.

После заголовка идет иерархия скелета. Она содержит информацию об узловых точках (joints) для построения отдельных кадров скелета на основе базового кадра:

hierarchy {
    "name"   parent flags startIndex
    ...
}

Параметр name (string) это имя узла. Параметра parent (int) это индекс родительской узловой точки. Если значение -1, то узел скелета является корневым. На основе этой информации о иерархии и количества узлов, можно сравнить скелет MD5 Mesh и MD5 Anim, чтобы убедиться, что они совпадают. Параметр flags (int) содержит набор битов флагов указывающих, как рассчитывать скелет для отдельного кадра и узла. Параметр startIndex (int) обозначает индекс начала параметров, использующийся для расчета скелетов для отдельных кадров.

После раздела иерархии идет ограничительные кубы (boundingbox) отдельных кадров. Они имеют такой вид:

bounds {
    ( min.xmin.ymin.z ) ( max.xmax.ymax.z )
    ...
}

Параметры min.x, min.y, min.z (float) определяют минимальные 3D координаты куба, а max.x, max.y, max.z (float) – максимальные. Они находятся в координатном пространстве объекта. Они пригодятся для вычисления AABB или OBB для метода frustumculling или для несложного алгоритма расчета столкновений.

После раздела bounds следует раздел со скелетом базового кадра. Он содержит позицию и ориентацию каждого узла скелета. На основе этих данных потом будут рассчитываться скелеты отдельных кадров. Строка для каждого узла скелета базового кадра такая:

baseframe {
    ( pos.x pos.y pos.z ) ( orient.x orient.y orient.z )
    ...
}

Параметры pos.x, pos.y, pos.z (float) этокоординатыузловойточки. Параметры orient.x, orient.y, orient.z (float) являются кватернионами для ориентации узлов. После данных для базового кадра идут данные для кадров. Для каждого кадра есть набор данных. Они используются для расчета скелетов отдельных кадров анимации:

frameframeIndex {
    <float> <float> <float> ...
}

Параметр frameIndex (int) это номер кадра. В скобках заключен массив значений. Количество этих значений определяется переменной numAnimatedComponents. Когда вы соберете все данные для отдельного кадра, вы можете построить скелет для кадра.

Построение скелетов отдельных кадров

На основе данных скелета базового кадра, информации об иерархии, и данных для отдельных кадров вы можете построить скелет для отдельного кадра. Вот как это работает для каждого узла: Нужно начать с данных базового кадра (положения и ориентации). Затем некоторые данные положения и ориентации будут заменяться данными из раздела frame. Флаги для узловых точек (расположенные в иерархии скелета) будут указывать какие именно координаты базового кадра должны быть замены координатами отдельных кадров.

Переменная flags имеет такой формат: начиная справа, первые три бита для вектора позиции и следующие три для кватерниона ориентации. Если бит установлен, вы должны заменить соответствующий (x, y, z) компонент значением из данных frame. Но какие значения? Они определяются переменной startIndex. Вы начинаете со startIndex массива frame и увеличиваете индекс массива каждый раз когда заменяете значение компоненты.

После того, как вы рассчитали «анимированные» значения узловых точек позиции и ориентации, вы должны рассчитать позицию узловой точки и ориентацию в координатном пространстве объекта. Перед этим, не забудьте также рассчитать w компонент «анимированного» кватерниона ориентации. Для позиции (координаты), если узел имеет родительский узел, вы должны преобразовать «анимированный» узел с помощью кватерниона ориентации родительского узла, и сложить координату положения с координатой родительского узла.

Для ориентации(вращения), если узел имеет родительский, вы должны соединить два кватерниона ориентации: родительский кватернион ориентации и «анимированный» кватернион ориентации. Просто умножьте кватернионы (формула дана в начале статьи), родительский кватернион на «анимированный», и затем заново выполните нормализацию кватернионов (кватернионы вращения всегда должны быть нормализованы). Если узел не имеет родительского, просто скопируйте «анимированные» данные ориентации.

Вот код для построения скелетов отдельных кадров анимации:

for (i = 0; i < num_joints; ++i)
  {
    const struct baseframe_joint_t *baseJoint = &baseFrame[i];
    vec3_t animatedPos;
    quat4_t animatedOrient;
    int j = 0;
 
    memcpy (animatedPos, baseJoint->pos, sizeof (vec3_t));
    memcpy (animatedOrient, baseJoint->orient, sizeof (quat4_t));
 
    if (jointInfos[i].flags & 1) /* Tx */
      {
        animatedPos[0] = animFrameData[jointInfos[i].startIndex + j];
        ++j;
      }
 
    if (jointInfos[i].flags & 2) /* Ty */
      {
        animatedPos[1] = animFrameData[jointInfos[i].startIndex + j];
        ++j;
      }
 
    if (jointInfos[i].flags & 4) /* Tz */
      {
        animatedPos[2] = animFrameData[jointInfos[i].startIndex + j];
        ++j;
      }
 
    if (jointInfos[i].flags & 8) /* Qx */
      {
        animatedOrient[0] = animFrameData[jointInfos[i].startIndex + j];
        ++j;
      }
 
    if (jointInfos[i].flags & 16) /* Qy */
      {
        animatedOrient[1] = animFrameData[jointInfos[i].startIndex + j];
        ++j;
      }
 
    if (jointInfos[i].flags & 32) /* Qz */
      {
        animatedOrient[2] = animFrameData[jointInfos[i].startIndex + j];
        ++j;
      }
 
    /* Compute orient quaternion's w value */
    Quat_computeW (animatedOrient);
 
    /* NOTE: we assume that this joint's parent has
       already been calculated, i.e. joint's ID should
       never be smaller than its parent ID. */
    struct md5_joint_t *thisJoint = &skelFrame[i];
 
    int parent = jointInfos[i].parent;
    thisJoint->parent = parent;
    strcpy (thisJoint->name, jointInfos[i].name);
 
    /* Has parent? */
    if (thisJoint->parent < 0)
      {
        memcpy (thisJoint->pos, animatedPos, sizeof (vec3_t));
        memcpy (thisJoint->orient, animatedOrient, sizeof (quat4_t));
      }
    else
      {
        struct md5_joint_t *parentJoint = &skelFrame[parent];
        vec3_t rpos; /* rotated position */
 
        /* Add positions */
        Quat_rotatePoint (parentJoint->orient, animatedPos, rpos);
        thisJoint->pos[0] = rpos[0] + parentJoint->pos[0];
        thisJoint->pos[1] = rpos[1] + parentJoint->pos[1];
        thisJoint->pos[2] = rpos[2] + parentJoint->pos[2];
 
        /* Concatenate rotations */
        Quat_multQuat(parentJoint->orient, animatedOrient, thisJoint->orient);
        Quat_normalize (thisJoint->orient);
      }
  }

Переменная jointInfos содержит информацию об иерархии. Переменная animFrameData содержит массив, содержащий данные кадра. Также не забудьте скопировать родительский индекс из информации иерархии в вашу новую структуру узлов. Иногда также могут оказаться полезны и текстовые имена узловых точек.

Вы должны повторять процедуру расчета скелета для каждого кадра. Если модель статичная, то по крайней мере для одного кадра.

Анимация модели

Чтобы анимировать модель, необходимо рассчитать данные текущего кадра и подготовить данные для следующего.

Значение времени текущего кадра увеличивается до тех пор, пока не достигнет значения maxtime. После этого можно переключаться на следующий кадр. Таким образом, процесс анимации состоит в интерполяции данных вершин между двумя кадрами. Максимальный интервал времени между двумя кадрами maxtime равен 1/frameRate.

После того, как вы подготовили данные текущего кадра и следующего (то есть, нужно подготовить скелеты этих двух кадров), вы можете интерполировать их. Процент интерполяции получается умножением количества времени, прошедшего с текущего кадра на значение frameRate.

Интерполяция узловых точек скелета

Для интерполяции двух скелетов состоящих из узловых точек, вы конечно, должны интерполировать каждую из узловых точек. После интерполяции двух узлов вы также интерполируете координаты и ориентацию.

Для позиции (координат) подойдет обычная линейная интерполяция:

finalJoint->pos.x = jointA->pos.x + interp * (jointB->pos.x - jointA->pos.x);
finalJoint->pos.y = jointA->pos.y + interp * (jointB->pos.y - jointA->pos.y);
finalJoint->pos.z = jointA->pos.z + interp * (jointB->pos.z - jointA->pos.z);

Для ориентации, будет лучше выполнить интерполяцию методом сферической линейной интерполяции. Линейную интерполяцию вращений можно выполнять только при малых углах вращений. Формула сферической интерполяции SLERP приводится в специальной литературе, в нашем коде мы воспользуемся функцией для этого:

Quat_slerp (jointA->orient, jointB->orient, interp, finalJoint->orient);

Заключение

Исходный код к данной статье можно скачать тут; md5.c, 14KB, упрощенный вариант, только MD5 Mesh, без текстур, освещения и анимации; демо содержит около 600 строк кода. А также тут; md5mesh.c, 15KB, md5anim.c, 13KB, md5model.h2.8KB, более полный вариант, включая MD5 Mesh и MD5 Anim; без текстур и освещения; демо содержит около 1300 строк кода.

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

avatar

Об Авторе ()

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

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

Наверх