01 ноября 2011

glVehicles, комментарии к Луноходу.

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

Итак, я буду параллельно читать свой код и немедленно делать пометку сюда - так, надеюсь, ничего не ускользнет от моего внимания.
Во-первых, стоит немного разделить всю функциональность на несколько моментов:
  • инициализация программы
  • основная физика уровня (создание изначальных препятствий, движения физических коробок)
  • физика лунохода
  • управление луноходом
  • отрисовка всего на уровне
  • дополнительно: текст, фпс
Теперь, разбив всю логику на такие составные части, мне будет проще строить комментарии.
Давайте взглянем на форму - изначально на ней ничего нет. Как же появляются glscene-объекты и все остальное? Все создается run-time. Уметь создавать все элементы программным (а не визуальным) способом зачастую важно:
  • можно легко перенести проект с Delphi на Lazarus
  • в Turbo Delphi есть ограничение на установку DesignTime-компонент
  • становится легко выносить определенные менеджеры наружу, отвязывая их от формы
  • не нужно тыкать по всем компонентам, чтобы узнать их настройки (и уж тем более читать dfm-файл) - все события и свойства указаны в коде
Поэтому мышь можно отложить в сторону - ведь для навигации по коду нужна только клавиатура.
Мы остановились на инициализации приложения, вот оно:

procedure TfrmDemo5.FormCreate(Sender: TObject);
begin
  fFpsTimer := 0;
  CreateDemo(self, fGlScene, fCadencer, fViewer);
  InitDemo(fGlScene, fCamera, fMainDummy, fRenderer);
  fViewer.Camera := fCamera;
  fCadencer.OnProgress := OnCadencerProgress;
  fRenderer.OnRender := OnDirectRender;
  InitFont;
  CreatePhysicsDemo;
end;

Сюда мы "придем" в первую очередь - срабатывает событие на создание формы и мы попадаем в обработчик. 
Читаем по строчкам:
  • выставляем таймер ФПС в нуль
  • создаем основные объекты glscene. Так как из демки в демку этот код дублируется, поэтому я вынес его во внешний модуль uDemoInit, где создал процедуру CreateDemo(), внутри которой создается каденсер (в выключенном состоянии), сцена, viewer (который растягивается на всю клиентскую область)
  • продолжает инициализироваться демка: создается камера, базовый даммикуб и объект класса TGLDirectOpenGL для дебаг-отрисовки.
  • viewer'у выставляется текущая камера
  • устанавливаем обработчик события на срабатывание cadencer'а - по сути игровой update'ер, обычно почти самое важное событие в игре
  • устанавливаем обработчик события на рендер нашего GLDirectOpenGL 'а
  • инициализируем шрифт и хелп-текст
  • создаем физическую основу. Давайте заглянем внутрь CreatePhysicsDemo() и остановимся там подробнее...
Сперва нужно создать мир Box2d, в котором будут жить наши тела, сочленения, силы и скорости! В моем порте это выглядит так:

  fb2World := TBox2d_World.Create(1/100, 20);
  fb2World.InitWorld(VectorMake(0, -10, 0), VectorMake(-50, -50, 50, 50), false);

аргументами при создании объекта класса TBox2d_World являются время, через которые будет проходить физическая симуляция, а также количество итераций, которые будут проведены для достижения стабильности вычисления. Первое значение обычно выставляют в зависимости от значения ФПС, которое будет держаться в игре, второе - в зависимости от предельных сил и скоростей, которые будут. Если в какой-то момент будет видно, что сочленения "рвутся" под напряжением больших сил, или то, что тяжелые тела проваливаются в более легкие - можно попробовать увеличить количество итераций вычислений (второй аргумент) - но знайте, что таким образом добавляется нагрузка на процессор. Нужно искать золотую середину.
Метод же InitWorld() создает сам Box2d-мир, первым вектором задает вектор гравитации, вторым - границы физического мира (кстати, начиная с версии Box2d 2.02 этого делать не нужно, но порт был адаптирован под более ранние версии), третий параметр - стоит ли создавать физические границы-бортики.
Далее задаем обработчик события:
  fb2World.OnBeforeSimulation := OnSimulation;

теперь, перед физической симуляцией, мы сможем выставлять необходимые силы и моторы в нашем методе OnSimulation().
Так как в этой демке появляются сенсоры, при контакте с которыми происходит то или иное событие (поднимается лифт, например), поэтому нам также необходимо "слушать" контакты, чтобы, при соприкосновении с сенсорами, активировать интерактивные события:
  b2_World_SetContactListenerCallBack(fb2World.Box2d_World, OnProceedContactCallBack);

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

  fb2BodyList := TBox2d_ObjectsList.Create(fb2World);
  fb2JointList := TBox2d_JointsList.Create(fb2World);

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

    Function AddSimpleBox(const aFriction, aResitution, aDensity, PosX, PosY, aRotation, Width, Height: Single): TBox2d_CustomBody;
    Function AddSimpleRotationBox(const aFriction, aResitution, aDensity, PosX, PosY, aRotation, Width, Height: Single): TBox2d_CustomBody;
    Function AddSimpleCircle(const aFriction, aResitution, aDensity, PosX, PosY, aRotation, aRadius: Single): TBox2d_CustomBody;
    Function AddSimplePolygon(const aFriction, aResitution, aDensity, PosX, PosY, aRotation: Single; aVertices: array of TVec2d): TBox2d_CustomBody;

Если вернуться внутрь метода CreatePhysicsDemo(), что мы сейчас рассматриваем, то найдем череду вызовов fb2BodyList .AddSimpleBox() - мы создаем параллелепипеды. Назначение аргументов, что мы передаем, легко переводится с английского, если посмотреть на описание функции - коэффициент трения, упругость, плотность, координаты центра, ориентация, ширина и высота. Все предельно просто. Стоит уточнить, что, выставив плотность в 0, мы определим тело как статическое - ни двигаться, ни прыгать оно не будет.
Таким образом мы создаем весь уровней - из прямоугольных коробок.
Двум телам выставляем флаг, указывающий на то, что они будут сенсорами (контакты считаются, но взаимодействия не происходит):
fDoorSensor.IsSensor := true;
fLiftSensor.IsSensor := true;

Для двух тел установим UserData, чтобы при контакте "вытащить" сам объект и сравнить - с каким именно произошло столкновение:
fDoorSensor.SetBodyUserData(fDoorSensor);
fLiftSensor.SetBodyUserData(fLiftSensor);

Далее в цикле всем телам выставляем "трение о воздух" как зачастую их называют. То есть параметры затухания скорости - линейной и угловой.
    for i := 0 to ObjectsCount - 1 do
    begin
      GetObjectByIndex(i).AngularDamping := 1;
      GetObjectByIndex(i).LinearDamping := 0.5;
    end;

Ну и в конце создаем луноход методом RestartCar() (об этом расскажу чуть ниже), устанавливаем время движения лифта в 0, "закрываем" дверь и создаем объект для дебаг-отрисовки:
  RestartCar();
  fLiftTime := 0;
  fShouldOpenDoor := false;
  fb2DebugRenderer := TBox2d_DebugRenderer.Create;
  fb2DebugRenderer.SetBox2dList(fb2BodyList);

Итак, мы вплотную подошли к процессу создания колесного агрегата. Что же происходит в RestartCar()? Мы создаем сам объект лунохода, если он не был создан и чистим его от всех тел и сочленений:
  if fCarVehicle = nil then
    fCarVehicle := TCarVehicle.Create(fb2BodyList, fb2JointList);
  fCarVehicle.ClearVehicle;

Начало уже хорошее, затем, внутри все того же метода RestartCar() вызывается AddCar(-8, 6.5) - то есть мы создаем луноход в заданной позиции. По сути, как раз метод AddCar(x, y: single) и является местом, где создается луноход: определяется прямоугольник-тело, навешиваются колеса, позиционируется как надо:
Procedure TfrmDemo5.AddCar(x, y: single);
begin
  with fCarVehicle do
  begin
    CarBody.AttachShape_Box(0, 0, 0.2, VectorMake(0, 0, 0), VectorMake(1, 0.3, 0));
    CarBody.Position := VectorMake(x, y, 0);
    AddWheel(VectorMake(-1.2, 0, 0), VectorMake(-1.2, -1, 0), 0.3, $0004);
    AddWheel(VectorMake(-0.6, 0, 0), VectorMake(-0.6, -1, 0), 0.3, $0004);
    AddWheel(VectorMake(0, 0, 0),    VectorMake(   0, -1, 0), 0.3, $0004);
    AddWheel(VectorMake( 0.6, 0, 0), VectorMake( 0.6, -1, 0), 0.3, $0004);
    AddWheel(VectorMake( 1.2, 0, 0), VectorMake( 1.2, -1, 0), 0.3, $0004);
    CarBody.SetFilterData($0002, $0001, 0);
    CarBody.SetBodyUserData(CarBody);
  end;
  fb2BodyList.ReFilterAll;
end;

Описание AddWheel() следующее:
Function TCarVehicle.AddWheel(aPrismLocalPoint: TVector; aWheelLocalPoint: TVector; aWheelRadius: Single; aCategory: cardinal): TWheelObject;
то есть сначала задаются точка прикрепления к основе лунохода, затем позиция колеса, радиус колеса и aCategory. Последний аргумент нужен для фильтрации контактов. Об этом можно прочесть в мануале. Нам нужно, чтобы колеса между собой и с телом лунохода не сталкивались, проходя друг через друга. В Box2d это реализуется с помощью масок, категорий и групп.
Как раз строка:
CarBody.SetFilterData($0002, $0001, 0);

и указывает на то, что теперь у "тела" лунохода категория 2, а маска столкновений - 1. У колес же категория 4, маска - 1 (маска зашита внутри AddWheel). При столкновениях берется побитовое пересечение (and) маски одного тела и категории другого. Если результат 0 - значит столкновение не обрабатывается. Если перефразировать и сказать проще, то категория - это цвет тела. Маска - это набор цветов, с которым данное тело может столкнуться. Для тела лунохода мы указали - наш цвет 2, мы сталкиваемся только с телами цвета 1. А колеса цвета 4, которые сталкиваются с цветом 1. Исходя из этого, луноход со своими колесами никогда не столкнется.
Телу лунохода также задаем UserData, чтобы использовать его в слушателе контактов:
CarBody.SetBodyUserData(CarBody);
Ну и выполняем глобальный fb2BodyList.ReFilterAll; чтобы применились категории, маски и группы для тел.
Все! Луноход добавлен, уровень создан, физический мир готов к работе!
И в этот момент выполняется событие FormActivate, которое запускает наш cadencer:
procedure TfrmDemo5.FormActivate(Sender: TObject);
begin
  fCadencer.Enabled := true;
end;
После этого у нас будет крутиться OnCadencerProgress(), посмотрим, что у нас там. Для большей читаемости комментариев, я помещу их сразу в код:
Procedure TfrmDemo5.OnCadencerProgress(Sender : TObject; const deltaTime, newTime : Double);
begin
// считаем время для таймера
  fFpsTimer := fFpsTimer + DeltaTime; // показываем значение ФПС
  UpdateFPS;
// делаем шаг физики - Box2d сам просчитает новые позиции тел, их скорости и т.д.
  fb2World.DoProgress(DeltaTime); // обновим экран
  fViewer.Invalidate;
// если нужно открыть дверь - открываем ее 
  if fShouldOpenDoor then
    OpenDoor;
// если лифт тронулся - продолжить движение
  if fLiftTime > 0 then
  begin
    fLiftTime := fLiftTime + deltaTime;
    if fLiftTime > 4.5 then
      fLiftTime := 4.5;
  end;
// нажали пробел - необходимо перебросить луноход на стартовую позицию
  if IsKeyDown(' ') and not fSpacePressed then
    RestartCar;
// запоминаем текущее состояние клавиши "пробел"
  fSpacePressed := IsKeyDown(' ');
end;

Здесь самым важным вызовом является fb2World.DoProgress(DeltaTime) - обновление физики. После этого физические тела будут находиться на новом месте. Но важно понимать, что графические объекты останутся на прежних позициях. Соединение физики и графики происходит при отрисовке - когда графические тела принимают положение и ориентацию физических. По сути, последующий вызов fViewer.Invalidate() заставит перерисоваться весь viewer и только в этот момент обновится картинка на экране!
Итак, при рендере мы "придем" в наш OnDirectRender(), а оттуда уже вызовется:

Procedure TfrmDemo5.OnDirectRender(Sender: TObject; var rci: TRenderContextInfo);
begin
  fb2DebugRenderer.RenderList(fViewer, rci);
end; 

Таким образом, объект fb2DebugRenderer для дебаг отрисовки нарисует все объекты разом на экране. Рисуется всё: статические, динамические объекты, а также сенсоры. Наверно, стоит также добавить отрисовку сочленений, но пока руки до этого не дошли. После вызова метода
RenderList() на экране видим что-то вроде этого:



Красные - сенсоры, остальное - обычные тела, взаимодействующие между собой по обычным законам физики. Такая дебаг-отрисовка зачастую пригождается для контролирования, что все сделано верно. А то иногда буравят мысли "почему дверь-то не открывается?", включил дебаг-отрисовку всех объектов и все становится ясно. Напомню, что сенсор - это особый вид объектов (включаемый установкой специального флага), с помощью которого можно отлавливать контакты с телами. Но сами сенсоры физически не взаимодействуют с остальными объектами.
Итак, посмотрим, где же происходит управление нашим луноходом. Помните, я говорил, что в OnSimulation() мы можем устанавливать необходимые силы и моторы? Так вот посмотрим, как это происходит в нашем демо:

Procedure TfrmDemo5.OnSimulation(const aFixedDeltaTime: Single);
begin
  if IsKeyDown(VK_LEFT)then
    fCarVehicle.SetWheelsMotors(-20, 400)
  else if IsKeyDown(VK_RIGHT)then
    fCarVehicle.SetWheelsMotors( 20, 400)
  else
    fCarVehicle.SetWheelsMotors(0, 400);


  fLift.Position := VectorMake(-8, -7.4 + fLiftTime, 0);
end;

Как видим, здесь все просто: на нажатие каждой клавиши выставляем моторы колесам нашего Лунохода. Заодно устанавливаем позицию нашего лифта, который поднимается после контакта с сенсором. Ну и напоследок посмотрим, как же мы ловим контакты. Все это происходит в Callback'е физики:

procedure OnProceedContactCallBack(AContactData: Pb2_ContactListenerCallBackData); cdecl;
var
  Body1, Body2: TBox2d_CustomBody;
begin
  with AContactData^ do
  begin
    Body1 := TBox2d_CustomBody(b2_Body_GetUserData(b2_Shape_GetBody(Shape1)));
    Body2 := TBox2d_CustomBody(b2_Body_GetUserData(b2_Shape_GetBody(Shape2)));
    if (Body1 <> nil) and (Body2 <> nil) then
    begin
      if (Body1 = frmDemo5.fDoorSensor) or (Body2 = frmDemo5.fDoorSensor) then
        frmDemo5.fShouldOpenDoor := true;

      if (Body1 = frmDemo5.fLiftSensor) or (Body2 = frmDemo5.fLiftSensor) then
        frmDemo5.StartLift;
    end;
  end;
end;

По сути, сначала мы просто "достаем" ссылку на объект из UserData нашего физического тела. А затем уже проверяем, были ли активированы те или иные сенсоры и какие флаги стоит выставить.

На этом все, господа!

p.s. Это сообщение пролежало у меня в недописанном состоянии порядка полугода и сейчас я с легким сердцем публикую его! Ура, товарищи!

Сообщения, схожие по тематике:

3 коммент.:

  1. О, спасибо, некоторые куски кода нашел очень полезными для себя =)

    ОтветитьУдалить
  2. Отлично :) Познавательно, неплохую аналогию привел для масок и категорий. Сходу я их не очень понял, когда в августе ковырял box2d и его англоязычный мануал.

    ОтветитьУдалить
  3. gltrinix,
    здорово! я очень рад, что пригодилось! постараюсь не забросить glVehicles... хотя идей для продолжения не так много...

    perfect daemon,
    спасибо! приятно, что получилось внятно объяснить! а то у меня зачастую проблемы с доходчивыми аналогиями...

    ОтветитьУдалить