Загрузка и проигрывание скелетной анимации в формате Half-Life SMD.
главная страница статьи файлы о сайте ссылки
Загрузка и проигрывание скелетной анимации в формате Half-Life SMD.

Обратите внимание - самая новая версия загрузчика моделей Half-Life smd: текущая версия загрузчика SMD.

Файлы к статье:
primerexe.rar (208kb) - откомпилированный пример (простой загрузчик *.SMD)
primersrc.rar (49kb) - исходники примера на Delphi (простой загрузчик *.SMD)
Это исходники, которые описываются в данной статье.

КАК ДОБАВИТЬ ПЛАВНУЮ АНИМАЦИЮ читайте здесь (маленькая заметка ПЛЮС пример с исходниками, переделанный под плавную анимацию).


Файлы по теме статьи:
hlmvpascal.zip (19kb) - исходники загрузки моделей Half-Life SMD с плавной анимацией (кватернионы) и смешиванием анимаций (animation blending) на TMT Pascal.
milkshape.zip (8kb) - исходники загрузчика MilkShape анимаций на TMT Pascal.
страница скачивания smdview1.zip (122kb) - старая версия загрузки Half-Life SMD с исходниками (Delphi, OpenGL);
страница скачивания smdview2.zip (584kb) - старая версия загрузки Half-Life SMD с исходниками (Delphi, DirectX);
mdldec12.zip (134kb) - декомпилятор моделей в формате MDL (распаковывает много SMD файлов из одного MDL).


Скриншот:


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

Пишем свой загрузчик скелетной анимации Half-Life на Borland Delphi. Без использования кватернионов (для простоты).

Итак, долго вдаваться в подробности что такое smd файлы я не буду. Скажу лишь, что когда делали игру Half-Life, то модели к ней создавались в 3D Studio MAX. К 3DS MAX существовал плагин (plugin), который записывал модели и скелетную анимацию в текстовые файлы с расширением SMD. Потом кучу таких файлов собирали ("компилировали") в один бинарный с расширением MDL.

Формат SMD файлов очень прост и немного похож на формат MilkShape 3D ASCII. Так как смысл в записанных данных в SMD и MilkShape одинаков, то легко написать проигрыватель и для MS3D-совских файлов (милкшейпа).

Для начала распотрошим какой-нибудь MDL файл, сделанный для халфы. Для этого можно использовать программу MilkShape 3D (в меню tools выбрать пункт decompile half-life mdl). После этого получится куча текстовых файлов с расширением SMD, среди которых есть один файлов с мешем (mesh/модель), а остальные - с анимацией (sequence). Хотя мне больше нравится утилита MDLDEC.EXE: её нужно просто запустить, указав в коммандной строке в качестве параметра имя какого-нибудь MDL файла. Или можно сделать свою модель и анимацию в программе MilkShape 3D с нуля и через меню file/export записать half-life smd.

Ну вот, есть у нас куча SMD файлов, и их надо как-то грузить в программу. Поэтому сначала мы опишем массивы, куда всё это будет загружено. Выше я уже говорил, что есть два типа SMD фалов. Первый тип - это модель. Второй тип - это анимация. Файлов второго типа больше, так как для каждого вида анимации существует отдельный SMD файл: бег, ходьба, стрельба, подпрыгивание, ползание, плавание и т.д.. Таким образом у нас есть множество SMD с анимацией, а модель одна и хранится в одном SMD файле.

Раз у нас есть два типа файлов, то целесообразно завести два типа данных: модель и анимация. Назовём эти типы так, чтобы сразу было понятно что это: TModelka и TAnimacia. Ну а в будущем в программе весьма приятно будет написать Models:array of TModelka и Animation:array of TAnimacia. Как видите, здесь всё просто. Начнём с описания типа TModelka.

Итак, если мы заглянем в SMD-файл, в котором хранится модель, то вначале увидим следующее:

version 1
nodes
0 "Bip01" -1
1 "Bip01 Bacino" 0
2 "Bip01 SX Coscia" 1
3 "Bip01 SX Polpaccio" 2
4 "Bip01 SX Piede" 3
5 "Bip01 DX Coscia" 1
6 "Bip01 DX Polpaccio" 5
7 "Bip01 DX Piede" 6
8 "Bip01 Dorso" 1
9 "Bip01 Dorso1" 8
10 "Bip01 Dorso2" 9
11 "Bip01 Dorso3" 10
12 "Bip01 Collo" 11
13 "Bip01 Testa" 12
14 "Bip01 SX Clavicola" 12
15 "Bip01 SX Braccio superiore" 14
16 "Bip01 SX Avambraccio" 15
17 "Bip01 SX Mano" 16
18 "Bip01 DX Clavicola" 12
19 "Bip01 DX Braccio superiore" 18
20 "Bip01 DX Avambraccio" 19
21 "Bip01 DX Mano" 20
end

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

0,-1
1,0
2,1
3,2
4,3
5,1
6,5
7,6
8,1
9,8
10,9
11,10
12,11
13,12
14,12
15,14
16,15
17,16
18,12
19,18
20,19
21,20

Ну и тут сразу видно, что отлично для хранения этих данных подойдёт одномерный массив. Простой одномерный массив. Делаем юнит modelki.pas. И пишем:

unit modelki;
interface
implementation
end.

Теперь добавляем тип TModelka:

type TModelka = object
end;

Выше мы выяснили, что для хранения взаимосвязей в скелете подойдёт одномерный массив. Зададим этот одномерный массив внутри нашей модельки:

type TModelka=object
svaz:array of integer;
end;

Смысл здесь таков. Номер элемента массива - это номер сустава, а значение элемента массива - это куда сустав прикреплён (костью, но о костях позже). Посмотрим немножко данных из файла:

0 "Bip01" -1
1 "Bip01 Bacino" 0
2 "Bip01 SX Coscia" 1
3 "Bip01 SX Polpaccio" 2
4 "Bip01 SX Piede" 3

0,-1
1,0
2,1
3,2
4,3

Смысл массива будет такой:

svaz[0]:=-1;
svaz[1]:=0;
svaz[2]:=1;
svaz[3]:=2;
svaz[4]:=3;

Нулевой сустав присоединён с помощью кости к мифическому минус-первому суставу (пространство). Первый сустав присоединён к нулевому и т.д. Не обязательно так складно, кости присоединяются по-разному. Например, вот так:

5 "Bip01 DX Coscia" 1
6 "Bip01 DX Polpaccio" 5
7 "Bip01 DX Piede" 6
8 "Bip01 Dorso" 1

5,1
6,5
7,6
8,1

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

svaz[5]:=1;
svaz[6]:=5;
svaz[7]:=6;
svaz[8]:=1;

С этими данными мы разобрались. Теперь идём дальше по тексту SMD-файла с моделью. Идём-идём и тут бац! Вот что мы видим:

skeleton
time 0
0 0.254895 0.401299 43.083469 0.000000 0.000000 -1.570795
1 -0.000002 0.000000 0.000000 -1.570795 -1.570451 0.000000
2 -0.000005 0.000008 4.047447 -3.139539 0.026088 3.120120
3 19.669840 0.000000 0.000000 0.000000 0.000000 -0.121501
4 19.181383 0.000000 0.000000 -0.002613 0.026036 0.099967
5 0.000005 -0.000003 -4.047447 3.139069 -0.017355 3.117144
6 19.669836 0.000000 0.000000 0.000000 0.000000 -0.127551
7 19.181381 0.000000 0.000000 0.002950 -0.017289 0.103055
8 1.410100 0.746081 -0.000001 -0.000004 -0.000001 0.114185
9 5.787557 -0.004179 0.000000 0.000000 0.000000 -0.069778
10 5.261201 -0.005004 0.000000 0.000000 0.000000 -0.095944
11 6.314116 -0.005781 0.000000 0.000000 0.000000 0.017444
12 7.261931 -0.001584 0.000000 0.000000 -0.000001 0.471000
13 2.231210 0.000001 0.000000 0.000000 0.000000 -0.157798
14 -3.392111 1.653810 4.732682 -0.000013 -1.370798 2.669809
15 5.261682 0.000000 0.000001 0.112066 1.260128 0.113677
16 12.142342 0.000000 0.000000 0.000000 0.000000 -0.200000
17 11.242911 0.000001 -0.000001 -1.570000 0.000000 -0.069778
18 -3.128193 1.525636 -4.513032 -0.000014 1.370795 2.669783
19 5.419531 0.000000 0.000000 -0.115193 -1.268795 0.116957
20 12.628036 0.000000 0.000001 0.000000 0.000000 -0.200000
21 11.038492 0.000000 0.000000 1.569752 0.000000 -0.069778
end

Какой-то вылез скелетон (skeleton). Что за скелетон? Сразу отметим одну особенность: выше мы видели блок такого вида:

nodes
... тут всякая фигня ...
end

Ну а здесь мы нашли блок, который выглядит так:

skeleton
... тут много цифр понаписано ...
end

Особенность заключается в том, что блоки данных имеют что-то вроде begin-а и end-a. В первом случае begin-ом блока является слово nodes, а во втором - skeleton. Блок nodes - информация о взаимосвязях в скелете: какой сустав с каким соединён. А блок skeleton - это информация о том, как перемещены и повёрнуты друг относительно друга суставы скелета!

Тут нужно врубиться в одну вещь. Длина костей не задаётся в явном виде. В каком-то смысле костей нет вообще. Вместо этого задаются проекции смещений и поворотов между суставами. Но никто нам не мешает потом нарисовать между суставами линии, чтобы визуально увидить как выглядит скелет модели.

Итак, в блоке skeleton строк столько же, сколько и в блоке nodes. Но есть ещё одна строчка, которая называется "time 0". Эта строчка означает номер кадра анимации! Обратите внимание, в файле с моделью есть один кадр анимации. А анимацию из одного кадра не сделаешь. Весь секрет в том, что это начальное положение суставов скелета, в котором записана модель.

Теперь, если вы посмотрите остальные SMD-фалы (с анимацией), то в них помимо "time 0" будет ещё много кадров: "time 1", "time 2", "time 3", "time 4" и т.д.. Кадры представляют собой положение суставов скелета в пространстве. Вернёмся к разбору SMD-файла с моделью. Взглянем на структуру данных, хранящуюся в блоке skeleton под номером кадра "time 0":

0 0.254895 0.401299 43.083469 0.000000 0.000000 -1.570795
1 -0.000002 0.000000 0.000000 -1.570795 -1.570451 0.000000
2 -0.000005 0.000008 4.047447 -3.139539 0.026088 3.120120
3 19.669840 0.000000 0.000000 0.000000 0.000000 -0.121501
4 19.181383 0.000000 0.000000 -0.002613 0.026036 0.099967
5 0.000005 -0.000003 -4.047447 3.139069 -0.017355 3.117144
6 19.669836 0.000000 0.000000 0.000000 0.000000 -0.127551
7 19.181381 0.000000 0.000000 0.002950 -0.017289 0.103055
8 1.410100 0.746081 -0.000001 -0.000004 -0.000001 0.114185
9 5.787557 -0.004179 0.000000 0.000000 0.000000 -0.069778
10 5.261201 -0.005004 0.000000 0.000000 0.000000 -0.095944
11 6.314116 -0.005781 0.000000 0.000000 0.000000 0.017444
12 7.261931 -0.001584 0.000000 0.000000 -0.000001 0.471000
13 2.231210 0.000001 0.000000 0.000000 0.000000 -0.157798
14 -3.392111 1.653810 4.732682 -0.000013 -1.370798 2.669809
15 5.261682 0.000000 0.000001 0.112066 1.260128 0.113677
16 12.142342 0.000000 0.000000 0.000000 0.000000 -0.200000
17 11.242911 0.000001 -0.000001 -1.570000 0.000000 -0.069778
18 -3.128193 1.525636 -4.513032 -0.000014 1.370795 2.669783
19 5.419531 0.000000 0.000000 -0.115193 -1.268795 0.116957
20 12.628036 0.000000 0.000001 0.000000 0.000000 -0.200000
21 11.038492 0.000000 0.000000 1.569752 0.000000 -0.069778

Здесь в первом столбце - номер сустава, все остальные столбцы - это "ротации" (rotation) и "трансляции" (translation). То есть здесь записаны повороты и перемещения суставов друг относительно друга. Введём соответствующие типы данных:

type TPovorot=array[0..2] of single;
type TPeremesh=array[0..2] of single;

И добавим в тип нашей модельки массивы с поворотами и перемещениями суставов:

type TModelka=object
svaz:array of integer;
povorot:array of TPovorot;
peremesh:array of TPeremesh;
end;

Например, для нулевого сустава смысл заполнения массивов с данными будет такой:

0 0.254895 0.401299 43.083469 0.000000 0.000000 -1.570795

povorot[0][0]:=0.0;
povorot[0][1]:=0.0;
povorot[0][2]:=-1.570795;

peremesh[0][0]:=0.254895;
peremesh[0][1]:=0.401299;
peremesh[0][2]:=43.083469;

Как видно, здесь всё очень просто. Три проекции перемещения и три проекции поворота. Блоки данных после ключевых слов nodes и skeleton мы разобрали. Перемещаемся теперь дальше по SMD-файлу с моделью и находим следующий блок с данными:

triangles
ammo_pack_blk.bmp
11 -7.665082 -4.687413 65.324814 -0.292812 0.513422 0.806634 0.040323 0.250000
11 -7.645082 -4.706727 65.345474 -0.458699 -0.327099 0.826197 0.108871 0.241667
11 -4.395079 -3.913551 66.019089 -0.271609 0.350712 0.896231 0.040323 0.783333
ammo_pack_blk.bmp
11 -4.395079 -3.913551 66.019089 -0.271609 0.350712 0.896231 0.040323 0.783333
11 -7.645082 -4.706727 65.345474 -0.458699 -0.327099 0.826197 0.108871 0.241667
11 -4.685081 -5.198767 66.182549 -0.180698 -0.654792 0.733891 0.108871 0.791667
ammo_pack_blk.bmp
11 -7.645082 -4.706727 65.345474 -0.458699 -0.327099 0.826197 0.108871 0.241667
11 -6.015085 -5.911491 64.335548 -0.245507 -0.927924 0.280507 0.245968 0.516667
... здесь много-много похожих данных ...
13 2.794936 -2.218915 72.218056 0.071099 -0.968586 0.238297 0.670940 0.620000
13 2.794935 -2.298234 71.914398 0.071099 -0.968586 0.238297 0.681624 0.612000
SM_1pNEW.bmp
13 2.774935 -1.784279 71.718567 -0.821175 0.135396 -0.554383 0.905983 0.468000
13 2.814935 -2.273493 71.900688 -0.844126 -0.379811 0.378411 0.882479 0.486000
13 2.814936 -2.194175 72.204346 -0.496412 -0.864620 0.077502 0.893162 0.496000
SM_1pNEW.bmp
13 2.814935 -2.273493 71.900688 -0.844126 -0.379811 0.378411 0.882479 0.486000
13 2.744931 -3.630459 70.574966 -0.311995 -0.804386 0.505591 0.811966 0.478000
13 2.914936 -2.318795 71.877289 -0.216301 -0.829202 0.515401 0.871795 0.494000
end

Это блок triangles: начинается со слова triangles, и заканчивается end-ом. Из названия ясно, что это блок с треугольниками. Проще говоря это и есть сама модель. В этом блоке последовательно перечислены данные всех треугольников, из которых состоит модель. Чередование выглядит так:

название текстуры, наложенной на треугольник . bmp
даннные первой вершины
данные второй вершины
данные третьей вершины
название текстуры, наложенной на треугольник . bmp
даннные первой вершины
данные второй вершины
данные третьей вершины
название текстуры, наложенной на треугольник . bmp
даннные первой вершины
данные второй вершины
данные третьей вершины

И так много-много раз. Вся модель записана в текстовом файле после ключевого слова triangles. Нам нужно сделать какой-нибудь тип данных, в котором мы будем хранить это огромное количество треугольников Посмотрим на первый треугольник:

ammo_pack_blk.bmp
11 -7.665082 -4.687413 65.324814 -0.292812 0.513422 0.806634 0.040323 0.250000
11 -7.645082 -4.706727 65.345474 -0.458699 -0.327099 0.826197 0.108871 0.241667
11 -4.395079 -3.913551 66.019089 -0.271609 0.350712 0.896231 0.040323 0.783333

Каждый треугольник состоит из трёх точек. Вначале указывается текстура, которую следует наложить на этот треугольник. В данном случае текстура называется ammo_pack_blk.bmp. Далее следует три строчки, в которых описываются вершины полигона. Теперь посмотрим на данные одной вершины:

11 -7.665082 -4.687413 65.324814 -0.292812 0.513422 0.806634 0.040323 0.250000

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

11 / -7.665082 / -4.687413 / 65.324814 / -0.292812 / 0.513422 / 0.806634 / 0.040323 / 0.250000

сустав / Xвершины / Yвершины / Zвершины / Xнормали / Yнормали / Zнормали / Uтекстуры / Vтекстуры

Как видно, здесь нет ничего сложного. Данные представлены в классическом виде: даны координаты вершины, вектор нормали и текстурные координаты. Единственный дополнительный параметр - это номер сустава, которому принадлежит данная вершина. Суставы ещё называют узлами скелета (nodes), а номер сустава называют "родительским" идентификатором (parent id). То есть у узлов есть "parent" узлы и у вершин (вертексов) есть "parent" узлы.

Осталось создать типы данных, в котором мы будем хранить вершины и треугольники. Для начала опишем вершину:

type TVershina=record
sustav:integer;
koordinata:array[0..2] of single;
normal:array[0..2] of single;
textkoordinata:array[0..1] of single;
end;

Как видно, здесь есть место для всех данных вершины:

sustav - номер сустава;
koordinata[0], koordinata[1], koordinata[2] - координата вершины (x,y,z);
normal[0], normal[1], normal[2] - нормаль вершины (x,y,z);
textkoordinata[0], textkoordinata[1] - текстурные координаты (u,v).

Вернёмся к нашему формату данных:

ammo_pack_blk.bmp
11 -7.665082 -4.687413 65.324814 -0.292812 0.513422 0.806634 0.040323 0.250000
11 -7.645082 -4.706727 65.345474 -0.458699 -0.327099 0.826197 0.108871 0.241667
11 -4.395079 -3.913551 66.019089 -0.271609 0.350712 0.896231 0.040323 0.783333

У каждого треугольника есть название текстуры и три вершины. Введём соответствующий тип данных:

type TPoligon=record
tekstura:string;
vershini:array[0..2] of TVershina;
end;

Здесь tekstura - это название текстуры, а vershini - массив из трёх вершин. В итоге тип TModelka будет выглядеть так:

type TModelka=object
svaz:array of integer;
povorot:array of TPovorot;
peremesh:array of TPeremesh;
poligon:array of TPoligon
end;

Итак, мы описали все структуры данных, необходимые для загрузки SMD-файла модели. Теперь ясно видно, что SMD-файл модели имеет следующую структуру:

version 1
nodes
...
end
skeleton
...
end
triangles
...
end

Напишем загрузку SMD-файлов с моделями. Так как файл текстовый, то возникает вопрос как из этих строк перевести данные в наши массивы. Удобнее всего в этом случае загружать строки этого файла по очереди, и добывать из этих строк данные с помощью готового юнита _strman.pas. Добавим этот юнит в uses модуля modelki.pas. Процедуру загрузки начнём писать с определения блоков:

procedure TModelka.zagruz(fname:string);
var f:text;
s:string;
begin
assignFile(f,fname);
reset(f);

repeat
readln(f,s);
until s='nodes';

repeat
readln(f,s);
until s='end';

repeat
readln(f,s);
until s='time 0';

repeat
readln(f,s);
until s='end';

repeat
readln(f,s);
until s='triangles';

repeat
readln(f,s);
until s='end';

closeFile(f);
end;

Как видно, пока процедура ничего не загружает, а является лишь заготовкой для написания загрузчика SMD-фалов. В юните _strman есть отличная функция StringWordGet. Её описание вы можете найти в комментариях к исходникам этого юнита. Если коротко, то смысл этой функции такой:

что:=StringWordGet (откуда,'разделитель',номер);

Начнём с загрузки блока nodes. Нужные цифры находятся вне кавычек, а цифра слева меняется от нуля до некоторой цифры (всё время плюс один). Поэтому есть смысл выдирать только цифру, находящуюся справа от названия в кавычках:

svaz[i]:=StrToInt(StringWordGet (s, '"', 3));

Теперь перейдём к загрузке блока skeleton. Здесь есть не только целые числа (integer), но и вещественные (single). Чтобы конвертировать их из текста в числа, воспользуемся процедурой StrToFloatDef из юнита _util.pas:

peremesh[i][0]:=strtofloatdef(StringWordGet(trim(s),' ',2),0);
peremesh[i][1]:=strtofloatdef(StringWordGet(trim(s),' ',3),0);
peremesh[i][2]:=strtofloatdef(StringWordGet(trim(s),' ',4),0);
povorot[i][0]:=strtofloatdef(StringWordGet(trim(s),' ',5),0);
povorot[i][1]:=strtofloatdef(StringWordGet(trim(s),' ',6),0);
povorot[i][2]:=strtofloatdef(StringWordGet(trim(s),' ',7),0);

И, наконец, блок triangles:

poligon[i].vershini[j].sustav:=strToInt(StringWordGet(trim(s),' ',1));
poligon[i].vershini[j].koordinata[0]:=strtofloatdef(StringWordGet(trim(s),' ',2),0);
poligon[i].vershini[j].koordinata[1]:=strtofloatdef(StringWordGet(trim(s),' ',3),0);
poligon[i].vershini[j].koordinata[2]:=strtofloatdef(StringWordGet(trim(s),' ',4),0);
poligon[i].vershini[j].normal[0]:=strtofloatdef(StringWordGet(trim(s),' ',5),0);
poligon[i].vershini[j].normal[1]:=strtofloatdef(StringWordGet(trim(s),' ',6),0);
poligon[i].vershini[j].normal[2]:=strtofloatdef(StringWordGet(trim(s),' ',7),0);
poligon[i].vershini[j].textkoordinata[0]:=strtofloatdef(StringWordGet(trim(s),' ',8),0);
poligon[i].vershini[j].textkoordinata[1]:=strtofloatdef(StringWordGet(trim(s),' ',9),0);

Загрузчик данных из SMD-файлов с моделью полностью готов, и вызывается следующим образом:

var monster:TModelka;
begin

monster.zagruz('c:\tutorial\smd\wesker.smd');

end;

Теперь напишем загрузчик SMD-файлов с анимацией. Если посмотреть внутрь любого такого фала, то его структура будет выглядеть следующим образом:

version1
nodes
...
end
skeleton
time 0
...
time 1
...
time 2
...
time 3
...
time ...
...
end

Теперь становится ясно в чём отличие SMD-файлов с анимацией от SMD-файлов с моделью. В SMD-файлах с анимацией нет раздела triangles, в котором описывается полигональная 3d модель. Но зато там есть много кадров положения костей скелета (time 0, time1, time 2, time 3 и т.д.). А выше я уже писал, что в SMD-файлах с моделью имеется только одно положение скелета (time 0), в котором сделан mesh (полигональная модель).

Написать загрузчик SMD-анимаций достаточно просто, так как выше уже были описаны нужные нам структуры данных для загрузки кадра time-0 из SMD-модели. Опишем один кадр анимации скелета:

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

И зададим тип TAnimacia, в котором будет хранится массив кадров анимации:

type TAnimacia=object
kadri:array of TKadrAnimacii;
end;

Добавим к этому объекту процедуру загрузки анимации из SMD-файла:

type TAnimacia=object
kadri:array of TKadrAnimacii;
procedure zagruz(fname:string);
end;

Вернёмся к нашему загрузчику SMD-файлов с моделью и немножко изменим его, чтобы он укладывался в стиль написания этой программы. Заменим вот эти строки:

type TModelka=object
svaz:array of integer;
povorot:array of TPovorot;
peremesh:array of TPeremesh;

poligon:array of TPoligon;
procedure zagruz(fname:string);
end;

На следующие:

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

Это отразится в тексте программы на самом загрузчике SMD-модели. Вместо povorot и peremesh будут использоваться odinkadr.povorot и odinkadr.peremesh. Приступим непосредственно к написанию загрузчика SMD-анимации:

procedure TAnimacia.zagruz(fname:string);
begin
end
;

Из файла с анимацией нас будет интересовать только раздел skeleton, в котором записаны кадры анимации:

skeleton
time 0
...
time 1
...
time 2
...
time 3
...
time ...
...
end

Поэтому загрузку будем делать так: считываем данные, находящиеся между строчками "time 0", "time 1", "time 2" и "time 3" т.д., где встречается слово "time". Для поиска этого слова сделаем будем использовать слудующую конструкцию:

if StringWordGet (s, ' ', 1) = 'time' then

Итак, мы написали загрузку всех данных из файла SMD. Теперь у нас есть два типа переменных и две процедуры для их загрузки. Начнём писать программу для проигрывания анимации. Сначала зададим переменные для 3d модели главого героя игры и для его анимации:

monster:TModelka;
anim:TAnimacia;

Теперь, используя описанные выше процедуры, загрузим данные из SMD-файлов:

monster.zagruz('wesker.smd');
anim.zagruz('run.smd');

Чтобы посмотреть, правильно ли всё загрузилось, выведем данные о загруженных моделях:

listbox1.Items.Add('Количество полигонов: '+IntToStr(length(monster.poligon)));
listbox1.Items.Add('Количество суставов: '+IntToStr(length(monster.svaz)));
listbox1.Items.Add('Количество кадров анимации: '+IntToStr(length(anim.kadri)))

Если посмотреть внутрь SMD файлов, то нетрудно убедиться, что количество полигонов, суставов и кадров анимации вывелось правильно. Было бы неплохо взглянуть на модель, которую мы загрузили. Воспользуемся средствами OpenGL и выведем модель без текстуры:

glBegin(GL_TRIANGLES);

for i:=0 to length(monster.poligon)-1 do
for j:=0 to 2 do
glVertex3f(monster.poligon[i].vershini[j].koordinata[0],
monster.poligon[i].vershini[j].koordinata[1],
monster.poligon[i].vershini[j].koordinata[2]);

glEnd;

Теперь мы видим неподвижную модель. Приступим к самой интересной части. Нам нужно используя кадры анимации скелета заставить эту модель двигаться. Для этого понадобятся процедуры, с помощью которых можно производить перемещения и повороты точек. Воспользуемся юнитом MatrixMaths, в котором очень удобно реализована работа с матрицами. Сначала попробуем вывести сам скелет: для этого зададим тип sustavi (суставы):

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

Otnosit и absolut - это матрицы суставов. Местная (относительная) и абсолютная. Tochka - это переменная, где мы будем хранить положения суставов. Так как скелет состоит из нескольких суставов, зададим массив суставов:

sustavi:array of sustav;

Вычислим положение суставов скелета. Для этого пройдёмся по всем узлам скелета:

for i:=0 to length(monster.svaz)-1 do

Сначала все суставы будут в начале координат:

sustavi[i].tochka[0]:=0.0;
sustavi[i].tochka[1]:=0.0;
sustavi[i].tochka[2]:=0.0;

Создаём матрицу поворота пространства суставов (у каждого сустава есть своя система координат, которая повёрнута матрицей otnosit):

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);

С помощью CConcatMatrix мы просто перемножали матрицы. Помимо поворотов скелет описывается взаимным перемещением суставов. Поэтому получившуюся матрицу i-го сустава нужно немного изменить:

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];

Как видно, мы сначала с помощью функций Xrot, Yrot и Zrot получили матрицы поворота относительно осей X,Y и Z. Потом мы перемножили эти матрицы процедурой CConcatMatrix. Ну а потом в получивешйся матрице i-го узла (сустава) мы поправили три элемента в соответствии с данными о перемещениях monster.odinkadr.peremesh[i].

Дальше мы смотрим, прикреплён ли сустав к другому суставу или нет:

if monster.svaz[i]<>-1 then

Если сустав связан с любым другим суставом (номера суставов не могут быть отрицательными), то мы попадаем в систему координат этого сустава. Другими словами можно сказать так: i-тый сустав попадает в пространство сустава monster.svaz[i]. Поэтому мы берём абсолютную матрицу "родительского" сустава и перемножаем её на относительную матрицу текущего сустава:

sustavi[i].absolut:=ConcatMatrix(sustavi[monster.svaz[i]].absolut, sustavi[i].otnosit)

То есть абсолютная матрица текущего сустава получена по формуле:

абсолютная_матрица_сустава_i:=Перемножить(
абсолютная_матрица_сустава_monster.svaz[i],
относительная_матрица_сустава_i)

Если же номер сустава, к которому прикреплён текущей равен минус еденице:

if monster.svaz[i]=-1 then

то мы приравниваем абсолютную матрицу i-того сустава его относительной матрице:

sustavi[i].absolut:=sustavi[i].otnosit;

Таким образом, у нас в цикле происходит создание матриц:

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

Цель этих преобразований заключается в следующем: мы хотим получить абсолютные матрицы суставов. Почему абсолютные? Потому что перемножив точку(вектор) с координатами (0,0,0) на абсолютную матрицу сустава мы сразу получим координату этого сустава. Сначала мы воспользовались данными о связях в скелете (monster.svaz[i]) и о поворотах (monster.odinkadr.povorot[i]) и перемещениях (monster.odinkadr.peremesh[i]) i-тых суставов.

Итак, что же здесь происходит. Напомню вам структуру SMD-файла:

version 1
nodes
0 "Bip01" -1
1 "Bip01 Bacino" 0
2 "Bip01 SX Coscia" 1
3 "Bip01 SX Polpaccio" 2
4 "Bip01 SX Piede" 3
5 "Bip01 DX Coscia" 1
... и т.д. ...

Эту структуру (nodes) мы загнали в массив monster.svaz[i]. Далее следовал такой кусок:

skeleton
time 0
0 0.254895 0.401299 43.083469 0.000000 0.000000 -1.570795
1 -0.000002 0.000000 0.000000 -1.570795 -1.570451 0.000000
2 -0.000005 0.000008 4.047447 -3.139539 0.026088 3.120120
3 19.669840 0.000000 0.000000 0.000000 0.000000 -0.121501
4 19.181383 0.000000 0.000000 -0.002613 0.026036 0.099967
5 0.000005 -0.000003 -4.047447 3.139069 -0.017355 3.117144
... и т.д. ...

Этот блок (skeleton) мы распихали по двум массивам: monster.odinkadr.peremesh[i] и monster.odinkadr.povorot[i]. Потом мы взяли юнит MatrixMaths.pas и воспользовались процедурами для работы с матрицами. Сначала мы сгенерировали относительные матрицы суставов (relative matrices): sustavi[i].otnosit. Потом, учитывая связи между суставами (monster.svaz[i]), мы перемножали относительные матрицы до тех пор, пока не "развернули" весь скелет. Результатом этой "развёртки" скелета являются абсолютные матрицы всех узлов (суставов): sustavi[i].absolut. Нулевой сустав не прикреплён ни к одному суставу (индекс минус один) 0 "Bip01" -1 и поэтому его относительная матрица стала абсолютной: sustavi[i].absolut:=sustavi[i].otnosit;.

Выведем получившийся скелет. Для этого применим абсолютные матрицы ко всем точкам сустава:

for i:=0 to length(monster.svaz)-1 do
ApplyMatrix(sustavi[i].tochka,
sustavi[i].absolut);

Сначала все суставы у нас находились в точке (0,0,0), так как мы их обнулили вначале цикла. Здесь же мы процедурой ApplyMatrix перемножили абсолютные матрицы i-тых суставов sustavi[i].absolut на координаты этих суставов sustavi[i].tochka. В результате мы получили ненулевые координаты sustavi[i].tochka.

Вывод скелета на экран сделать совсем просто. Суставы мы выведем жирными точками белого цвета:

glColor3f(1,1,1);

glPointSize(3);

glBegin(GL_POINTS);
for i:=0 to length(monster.svaz)-1 do
begin
glVertex3f(sustavi[i].tochka[0],
sustavi[i].tochka[1],
sustavi[i].tochka[2]);
end;

glEnd;

Кости соединяют суставы. Для вывода костей достаточно провести линии между суставами. Зададим красный цвет и воспользуемся данными о связях между суставами, чтобы вывести кости скелета:

glColor3f(1,0,0);

glBegin(GL_LINES);

for i:=0 to length(monster.svaz)-1 do
if monster.svaz[i]<>-1 then
begin
glVertex3f(sustavi[i].tochka[0],
sustavi[i].tochka[1],
sustavi[i].tochka[2]);
glVertex3f(sustavi[monster.svaz[i]].tochka[0],
sustavi[monster.svaz[i]].tochka[1],
sustavi[monster.svaz[i]].tochka[2]);
end;

glEnd;

Статичный скелет выводится без проблем. Теперь попробуем вывести кадры анимации скелета. Сделать это окажется просто. Для этого достаточно немного видоизменить код, написанный выше. Данные о скелете мы брали из модели, т.е. из переменной monster. В этой переменной у нас есть только один кадр анимации, перемещения и повороты которого находятся в записях monster.odinkadr.peremesh[i] и monster.odinkadr.povorot[i]. Чтобы вывести кадры анимации скелета достаточно использовать вместо этих данных записи из переменной anim:

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

sustavi[i].otnosit[3,0]:=anim.kadri[kadr].peremesh[i][0];
sustavi[i].otnosit[3,1]:=anim.kadri[kadr].peremesh[i][1];
sustavi[i].otnosit[3,2]:=anim.kadri[kadr].peremesh[i][2];

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

inc(kadr);

Как только номер кадра превысит общее число кадров анимации, то мы его обнулим:

if kadr>length(anim.kadri)-1 then kadr:=0;

Итак, теперь мы можем выводить кадры анимации. Возможно, что вас смущает одна вещь: модель почему-то куда-то уходит, а потом опять начинает свой путь из начала координат. В этом нет ничего странного: так создаются модели к Half-Life. Вы можете не учитывать peremesh[0][0] при создании абсолютной матрицы нулевого сустава, тогда модель будет стоять на месте. В своём 3d движке, который разрабатывался для создания игры в стиле Resident Evil, я использовал изменение данных из peremesh[0][0] для проецирования на наравление движения игрока. Эти данные могут понадобиться в том случае, если в модели заложено неравномерное движение (хромой зомби, который при прихрамывании резко сдвигается вперёд). Задавать такие движения вручную неудобно, для этого и существуют данные нулевого сустава.

Никто не мешает нам вручную менять кадры анимации (вращать головой, руками и т.д.). Для этого мы меняем у сустава его параметры вручную тёмя скроллбарами (ползунками):

for i:=0 to length(anim.kadri)-1 do
begin
anim.kadri[i].povorot[10][0]:=ScrollBar1.Position*3.14/180;
anim.kadri[i].povorot[10][1]:=ScrollBar2.Position*3.14/180;
anim.kadri[i].povorot[10][2]:=ScrollBar3.Position*3.14/180;
end;

Во всех кадрах анимации разом поменяли повороты десятого сустава. Здесь можно сделать эффект, аналогичный Resident Evil 3: главный герой может смотреть куда-нибудь при ходьбе. При этом нужно учесть максимально возможный угол поворота головы. Например, главный герой может поворачивать голову на важные места, как в Silent Hill 3.

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

triangles
ammo_pack_blk.bmp
11 -7.665082 -4.687413 65.324814 -0.292812 0.513422 0.806634 0.040323 0.250000
11 -7.645082 -4.706727 65.345474 -0.458699 -0.327099 0.826197 0.108871 0.241667
11 -4.395079 -3.913551 66.019089 -0.271609 0.350712 0.896231 0.040323 0.783333
... и т.д. ...

Для нас здесь очень важным является первое число (11). Это число показывает номер сустава, в пространстве которого находится данная вершина. И мы приходим к одному очень интересному месту. В SMD-файле с моделью была не в случайной позе. И нулевой кадр анимации для этой модели дан не случайно. Нулевой кадр анимации соответствует этой позе. И здесь в скелетной анимации применяется следующий трюк: мы как бы "сворачиваем" модель, делая скелетные преобразования наоборот. Скукоженная таким образом модель готова к "разворачиванию" любыми кадрами анимации. Сделаем массив полигонов, которые будем "сварачивать":

setlength(svernuli,length(monster.poligon));
for i:=0 to length(monster.poligon)-1 do
svernuli[i]:=monster.poligon[i];

Далее пользуемся алгоритмом для создания абсолютных (глобальных) матриц скелета, но с небольшими изменениями. Эти небольшие изменения как раз служат для "сворачивания". Когда модель свернётся (все вершины её полигонов), то можно будет приступить к скелетной анимации этой модели. Я взял процедуру ApplyMatrix(Var Vert: Vertex; const M: Mat) из юнита MatrixMaths.pas и немного видоизменил её. Получилась новая процедура:

Procedure InvApplyMatrix(Var Vert: Vertex; const M: Mat);

А теперь более корректное объяснение. Мы применяем вместо ApplyMatrix процедуру InvApplyMatrix. Это некое "инверсное преобразование". А смысл "сворачивания" модели состоит в том, что мы определяем локальные координаты всех точек модели. Итак, для всех вершин в массиве svernuli мы выполняем следующие манипуляции:

for i:=0 to length(svernuli)-1 do
for j:=0 to 2 do
InvApplyMatrix(Vertex(svernuli[i].vershini[j].koordinata), sustavi[ svernuli[i].vershini[j].sustav ].absolut);

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

for i:=0 to length(monster.poligon)-1 do
monster.poligon[i]:=svernuli[i];

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

Перед началом "разворачивания" каждого кадра анимации мы записываем рассчитанную выше "свёрнутую" модель:

for i:=0 to length(monster.poligon)-1 do
monster.poligon[i]:=svernuli[i];

Свёртку модели нужно проводить только один раз. После этого они хранятся в svernuli[i]; и перед разворачиванием загоняются в monster.poligon[i].

Вспомним как мы получали изображение скелета. Сначала все суставы были в начале координат. Потом мы стали "разворачивать" скелет, получая таким образом глобальные матрицы. Потом перемножили все точки из начала координат на соответствующие им глобальные матрицы и получили коодинаты точек в глобальном пространстве.

В случае с моделью приходится делать всё наоборот: по данным о глобальных матрицах мы сворачиваем все "полигоны" модели в начало координат. Модель из нормальной превращается в "скукоженную". Но это только на первый взгляд. На самом деле если вывести только те полигоны, которые принадлежан одному суставу, то этот кусочек модели будет выглядеть нормально. Короче говоря, при "сворачивании" мы повернули и переместили все части модели так, что они оказались в начале координат. А при анимации мы "разворачиваем" всё это хозяйство, основываясь на глобальных матрицах текущего кадра скелета.

Я пытался описать всё как можно проще и понятнее. Изменяя и дорабатывая эти исходники вы можете написать свой загрузчик моделей Half-Life как с использованием OpenGL, так и DirectX. Также я надеюсь, что это описание поможет тем, кто хочет написать загрузчик на других языках (Visual Basic). В заключении скажу несколько слов о загрузке текстур, создании плавной анимации и прикреплении предметов к скелету. Если у вас будут какие-нибудь вопросы, пожелания или замечания, пишите мне на e-mail tmtlib@narod.ru.

Загрузку текстур реализовать весьма просто. Для этого достаточно пройтись по массиву monster.poligon[i] и составить список неповторяющихся имён файлов с текстурами: перебор всех полигонов и анализ строки monster.poligon[i].tekstura. После этого загружаете текстуры какими-нибудь средствами и уже можно вывести текстурированную модель. Теперь у вас есть загруженные текстуры.

При выводе j-той точки i-того полигона:

monster.poligon[i].vershini[j].koordinata[0]
monster.poligon[i].vershini[j].koordinata[1]
monster.poligon[i].vershini[j].koordinata[2]

У вас всегда есть идентификатор его текстуры

monster.poligon[i].tekstura

и две текстурные координаты для этой точки:

monster.poligon[i].vershini[j].textkoordinata[0]
monster.poligon[i].vershini[j].textkoordinata[1]

Всё это было уже описано в типе "вершина":

type TVershina=record
sustav:integer;
koordinata:array[0..2] of single;
normal:array[0..2] of single;
textkoordinata:array[0..1] of single;
end;

Перейдём к созданию плавной анимации. Видно, что в моём примере анимация "дёргается". Сделать плавную анимацию можно двумя способами. Первый способ - это использование кватернионов (quaternion). В этом случае можно получить глобальные матрицы, плавно изменяющиеся между двумя кадрами глобальных матриц. Второй способ - это интерполяция исходных данных (povorot, peremesh). Кватернионы используются для плавной анимации вращения. Для перемещения - обычная интерполяция. Для хорошей работы с FPS (скоростью) анимации удобно использовать функцию timeGetTime из mmsystem winApi. Эта функция позволит адаптироваться к разному быстродействию компьютеров. С помощью этой функции можно очень точно измерить время между кадрами и, основываясь на этом, влиять на ход плавной интерполяции. У вас есть "ключевые" положения скелета, а ваша задача сделать промежуточные положения скелета между этими "ключевыми" положениями.

Одна из интереснейших вещей в скелетной анимации - это возможность прикрепления предметов (ружья, ножи, дубинки и т.п.) к скелету. Вы также можете прикреплять части тела. Или откреплять, если, например, у зомби снесёт голову, как в Resident Evil. Кстати, мне известно, что в Resident Evil, начиная с первой части, используется скелетная анимация. Так вот, как же прикреплять эти предметы к модели? Да очень просто! Достаточно для всех вершин этого предмета применить абсолютную матрицу сустава скелета:

ApplyMatrix(вершина предмета , sustavi[ номер сустава куда прикреплять ].absolut);

То есть просто в цикле все вершины предмета трансформируем с помощью абсолютной матрицы. Номер сустава - может быть любое число. В это место прикрепится предмет. К суставу на голове можно прикрепить солнцезащитные очки. К суставу в руке - какой-нибудь вид оружия. Можно сделать ещё интереснее. Никто не мешает вам взять базовый (нулевой) сустав другой склетно анимированной модели и прикрепить к суставу в другой модели. Это реализуется простым приравниванием абсолютных матриц. Ну а вся прелесть состоит в том, что и солнечные очки, и дубинка в руке будут отлично двигаться вместе с вашей моделью, не смотря на свою статичность! Да, изначально у этих объектов не было анимации, но подвергая их процедуре ApplyMatrix мы можем анимировать их с помощью абсолютных матриц, входящих в скелетно анимированную модель!