Скелетная анимация в играх. Формат Half-Life SMD.
главная страница статьи файлы о сайте ссылки
Добавляем интерполяцию кватернионами.

Георгий Мошкин
tmtlib@narod.ru

Файлы к статье:
smd-quat-noexe.rar (185kb) - исходники без EXE файла
smd-quat.rar (407kb) - исходники с откомпилированной демкой.

Добавление интерполяции кватернионами в уже написанный проигрыватель скелетной анимации сделать очень просто. Для начала уберём из раздела USES юнит MatrixMaths.pas. Вместо него мы будем использовать новый юнит _math.pas (с подчеркиванием перед буквой m). Сразу же начинаем компилировать наш проект по F9, чтобы увидеть, какие места требуют изменений после отключения модуля MatrixMaths. Компилятор автоматически выдаст строки с ошибками, требующими исправления.

Первое место, на котором остановится компилятор - это описание суставов:

 type sustav=record
              otnosit:Mat;
              absolut:Mat;
              tochka:Vertex;
             end;

Типов "Mat", описывающих матрицы, уже нет, так как мы удалили юнит MatrixMaths. В файле _math.pas этому типу соответствует тип "Matrix". Вместо Vertex нужно написать TVector3s (вектор из трёх координат типа single). Сделаем эти изменения и продолжим компилировать по F9:

 type sustav=record
              otnosit:Matrix;
              absolut:Matrix;
              tochka:TVector3s;
             end;

Теперь компилятор застопорился при виде следующего исходного кода:

    sustavi[i].otnosit:=Xrot(-TempPovorot[0]);
    CConcatMatrix(Yrot(-TempPovorot[1]), sustavi[i].otnosit);
    CConcatMatrix(Zrot(-TempPovorot[2]), sustavi[i].otnosit);

Здесь мы пытались сделать матрицу поворота сустава из интерполированных значений угла повотоа TempPovorot. Как вы помните, от этого у нас при включенной интерполяции идут всякие глюки. Чуть выше виден код, который используется для интерполяции угла:

    TempPovorot[0]:=a[0]+db[0]*timeKadr;
    TempPovorot[1]:=a[1]+db[1]*timeKadr;
    TempPovorot[2]:=a[2]+db[2]*timeKadr;

Итак, для каждого сустава у нас есть углы поворота для текущего и следующего кадров. Это углы A и B. Потом мы пытались посчитать скорректированный DB. Интерполяция заключалась в том, что на быстрых компьютерах в пределах кадра время timeKadr постепенно сменялось с 0 на 1 (что-то вроде 0.03, 0.23, 0.51, 0.77, 9.87). К углу поворота предыдущего кадра мы прибавляли приращение для перевода в угол поворота следующего кадра, но при этом домножали на timeKadr.

Теперь вместо линейной интерполяции между углами мы будем пользоваться интерполяцией между кватернионами. Так как конвертация угла в кватернион занимает определённое время, то в целях оптимизации логично сконвертировать все углы суставов для каждого кадра анимации при загрузке программы. Теперь для каждого кадра анимации мы будем хранить не только повороты и перемещения суставов, но и кватернионы:

 type TKadrAnimacii=record
                     povorot:array of TPovorot;
                     quat:array of Quaternion;                    
                     peremesh:array of TPeremesh;
                    end;

Размерность массивов одинаковая:

   setlength(kadri[j-1].povorot,i);
setlength(kadri[j-1].peremesh,i);
setlength(kadri[j-1].quat,i);

При загрузке положения суставов сразу же делаем кватернион quat[i-1] из свежезагруженного угла povorot[i-1]:

   with kadri[j-1] do
    begin
     peremesh[i-1][0]:=strtofloatdef(StringWordGet(trim(s),' ',2),0);
     peremesh[i-1][1]:=strtofloatdef(StringWordGet(trim(s),' ',3),0);
     peremesh[i-1][2]:=strtofloatdef(StringWordGet(trim(s),' ',4),0);
     povorot[i-1][0]:=strtofloatdef(StringWordGet(trim(s),' ',5),0);
     povorot[i-1][1]:=strtofloatdef(StringWordGet(trim(s),' ',6),0);
     povorot[i-1][2]:=strtofloatdef(StringWordGet(trim(s),' ',7),0);
     quat[i-1].fromAngles(povorot[i-1]);
    end;

Переменную TempPovorot мы полностью удаляем, так как она относится к старой линейной интерполяции между углами. А теперь по аналогии с экспортом анимации из Blender добавляем работу с кватернионами. Рассмотрим что у нас изменится по сравнению со статьёй "Добавляем плавную анимацию". Интерполированные значения углов и перемещений заносились в переменные TempPovorot и TempPeremesh:

TempPovorot[0]:=anim.kadri[kadr].povorot[i][0]+(anim.kadri[sledKadr].povorot[i][0]-anim.kadri[kadr].povorot[i][0])*timeKadr;
TempPovorot[1]:=anim.kadri[kadr].povorot[i][1]+(anim.kadri[sledKadr].povorot[i][0]-anim.kadri[kadr].povorot[i][1])*timeKadr;
TempPovorot[2]:=anim.kadri[kadr].povorot[i][2]+(anim.kadri[sledKadr].povorot[i][0]-anim.kadri[kadr].povorot[i][2])*timeKadr;

TempPeremesh[0]:=anim.kadri[kadr].peremesh[i][0]+(anim.kadri[sledKadr].peremesh[i][0]-anim.kadri[kadr].peremesh[i][0])*timeKadr;
TempPeremesh[1]:=anim.kadri[kadr].peremesh[i][1]+(anim.kadri[sledKadr].peremesh[i][0]-anim.kadri[kadr].peremesh[i][1])*timeKadr;
TempPeremesh[2]:=anim.kadri[kadr].peremesh[i][2]+(anim.kadri[sledKadr].peremesh[i][0]-anim.kadri[kadr].peremesh[i][2])*timeKadr;

Вместо выделенной красным цветом части у нас теперь будет следующий код:

    tempQuat.slerp(anim[ScrollBar5.Position].kadri[kadr].quat[i],
                   anim[ScrollBar5.Position].kadri[sledKadr].quat[i],
                   timeKadr)

Процедура SLERP производит сферическую линейную интерполяцию между кватернионом текущего кадра KADR и следующего кадра SLEDKADR. Если время timeKadr=0, то tempQuat = kadri[kadr].quat[i]. Если же timeKadr=1, то tempQuat = kadri[sledKadr].quat[i]. Ну а когда время находится где-то между нулём и единицей, к примеру timeKadr = 0,764, то мы получим кватернион tempQuat, который находится “в пути" между кватернионами поворота kadri[kadr].quat[i] и kadri[sledKadr].quat[i].

Запись значений в относительные матрицы суставов раньше производилась следующим образом:

 sustavi[i].otnosit:=Xrot(-monster.odinkadr.povorot[i][0]);
 CConcatMatrix(Yrot(-monster.odinkadr.povorot[i][1]), sustavi[i].otnosit);
 CConcatMatrix(Zrot(-monster.odinkadr.povorot[i][2]), sustavi[i].otnosit);
 
 sustavi[i].otnosit[3,0]:=monster.odinkadr.peremesh[i][0];
 sustavi[i].otnosit[3,1]:=monster.odinkadr.peremesh[i][1];
 sustavi[i].otnosit[3,2]:=monster.odinkadr.peremesh[i][2];

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

    sustavi[i].otnosit.setRotationQuaternion(tempQuat);
    sustavi[i].otnosit.SetTranslation(TempPeremesh);

Также упростится код по "сворачиванию модели":

  for i:=0 to length(svernuli)-1 do
   for j:=0 to 2 do
    begin
     sustavi[ svernuli[i].vershini[j].sustav ].absolut.
       InverseTranslateVect(svernuli[i].vershini[j].koordinata);
     sustavi[ svernuli[i].vershini[j].sustav ].absolut.
       InverseRotateVect(svernuli[i].vershini[j].koordinata);
    end;

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

1) Мы загружаем модель обычным образом, загружаем "reference" положение скелета ("reference" - это положение скелета, совпадающее с моделью. Например, это может быть положение стоя с расставлеными в стороны руками). После этого у нас есть модель в "классическом" виде и скелет, соответствующий тому положению, в котором была сделана эта модель.

2) Загружаем повороты и перемещения суставов. Поворот системы координат сустава состоит из трёх чисел. Каждый поворот мы конвертируем в кваретрнион с помощью функции fromAngles. Кватернион состоит из четырёх чисел.

 Procedure Quaternion.fromAngles(angles:TVector3s);
 
 var angle             : single;
     sr,sp,sy,cr,cp,cy : single;
     crcp,srsp         : single;


 begin
      angle:=angles[2]*0.5;
      sy:=sin( angle );
      cy:=cos( angle );
      angle:= angles[1]*0.5;
      sp:= sin( angle );
      cp:= cos( angle );
      angle:= angles[0]*0.5;
      sr:= sin( angle );
      cr:= cos( angle );
 
      crcp:= cr*cp;
      srsp:= sr*sp;
 
      m_quat[0]:=sr*cp*cy-cr*sp*sy;
      m_quat[1]:=cr*sp*cy+sr*cp*sy;
      m_quat[2]:=crcp*sy-srsp*cy;
      m_quat[3]:=crcp*cy+srsp*sy;
 end;

На вход функции fromAngles мы подаём три угла (массив TVector3s:array[0..2]). На выходе из этой функции мы получаем кватернион m_quat:array[0..3]. То есть кватернион не откуда-нибудь с потолка берётся, а вполне конкретный массив из четырёх чисел, рассчитанный по формулам.

3) "Сворачиваем" модель. Это проделывается один раз при загрузке и никак не влияет на производительность. Что значит "сворачиваем"? Модель у нас представлена следующим образом:

 type TModelka=object
                svaz:array of integer;
                odinkadr: TKadrAnimacii;
                poligon:array of TPoligon;
                procedure zagruz(fname:string);
               end;

где odinkadr - это и есть "reference" положение скелета (смотри пункт 1). Зная "reference" положение скелета мы можем перевести отдельные части модели в их локальные системы координат. Если вывести части модели из локальной системы координат в глобальную уже без учёта поворотов и перемещений, то все части модели окажутся в начале координат, поэтому и появился термин "свернуть" модель. Модель как бы "сворачивается" в начало координат. А вот теперь если применить ко всем этим частям модели соответствующие им глобальные матрицы суставов, то модель развернётся в любое анимированное положение скелета. В том числе можно развернуть модели и обратно в "reference" положение скелета.

4) Переключаем кадры анимации один за другим. Но переключение это в каком-то смысле двухуровневое. На первом уровне мы меняем номер кадра. Например, для анимации из пяти кадров: 1,2,3,4,5,1,2,3,4,5,1,2,3,4,5,1,2,3 и т.д. То есть просто переключаем числа. Но вот в промежутках переключения между кадрами мы меняем ещё одну переменную - timeKadr. Эта переменная меняется в пределах от 0 до 1. Рассмотрим как это выглядит на практике:

кадр = 1
timeKadr = 0
timeKadr = 0.2
timeKadr = 0.45
timeKadr = 0.7
timeKadr = 0.9
timeKadr = 1.2 время кадра больше единицы, значит пора делать кадр = кадр + 1
кадр = 2
timeKadr = 0.2
timeKadr = 0.6
timeKadr = 0.81
timeKadr = 1.1 время кадра больше единицы, значит пора делать кадр = кадр + 1
кадр = 3
.... и т.д.

5) Количество изменений timeKadr, умещающихся между двумя кадрами, зависит от быстродействия компьютера. Чем быстрее компьютер, тем больше промежуточных значений будет получено, и тем чаще мы сможем генерировать промежуточный кватернион между кадрами:

    tempQuat.slerp(anim[ScrollBar5.Position].kadri[kadr].quat[i],
                   anim[ScrollBar5.Position].kadri[sledKadr].quat[i],
                   timeKadr)

Интерполированное значение перемещения TempPeremesh между двумя соседними кадрами получается обычной линейной интерполяцией.

6) Из полученныех в результате интерполяции значений поворотов и перемещений создаются относительные матрицы суставов:

  sustavi[i].otnosit.setRotationQuaternion(tempQuat);
  sustavi[i].otnosit.SetTranslation(TempPeremesh);

7) С учётом данных о связях между суставами определяются абсолютные матрицы всех суставов (перемножением абсолютной матрицы предыдущих суставов на относительные матрицы тех суставов, к которым они прикреплены):

       if monster.svaz[i]<>-1 then
        begin
         sustavi[i].absolut:=sustavi[monster.svaz[i]].absolut;
         sustavi[i].absolut.PostMultiply(sustavi[i].otnosit);
        end
         else
          sustavi[i].absolut:=sustavi[i].otnosit;

8) Применяем полученные абсолютные матрицы суставов (костей) к прикреплённым вершинам свёрнутой модели:

  for i:=0 to length(svernuli)-1 do
   for j:=0 to 2 do
    begin
     monster.poligon[i].vershini[j].koordinata:=
      transform(svernuli[i].vershini[j].koordinata,
                sustavi[monster.poligon[i].vershini[j].sustav ].absolut);
    end;

И отображаем модель, прорисовывая все полигоны из массива poligoni[i] на экран. Чтобы увидеть, как выглядит свёрнутая модель, можно вывести полигоны из массива svernuli[i].

Как видите, с добавлением кватернионов всё даже стало несколько проще, так как процедуры и функции нового юнита _math.pas для работы со скелетной анимацией позволяю создавать более компактный исходный код.

Ещё пару слов по кватернионам. Для каждого сустава мы по трём его углам поворота создаём кватернион, состоящий из четырёх чисел. Это проделывается один раз при загрузке моделей. Но что если нам требуется добавить некоторую интерактивность и изменять анимацию персонажей "на лету"? Можно было бы размышлять над вводом всяческих корректировок к кватернионам, но можно поступить и проще: добавить boolean параметр на каждый кватрнион. Если этот параметр true - значит нужно произвести пересчёт, если false - значит пользоваться уже готовым значением. Вообще здесь видится три решения как это сделать: изменение углов, изменение кватерниона, домножение относительной матрицы на дополнительную матрицу поворота. Всё это применимо для создания эффекта, присутствующего в игре Resident Evil 3: там главный герой поворачивает голову и смотрит на те точки, которые были расставлены разработчиками по уровню в процессе создания игры. Это позволило сделать потрясающий эффект: главный герой пробирается по улицам, и смотрит туда, куда нужно, а не только тупо вперёд.