17 марта 2011

Сплайны: целый воз практики и чуточку теории...

Загадочное название в заголовке этого сообщения не должно никого настораживать. Дело в том, что недавно я решил смастерить класс-навигатор для движения объекта по заданной траектории. Казалось бы все просто - меняй координаты вдоль траектории и радуйся передвижениям тела по экрану! На деле же появилось несколько интересных моментов, которые я и решил описать здесь - поделиться так сказать.
Центральная вещь класса-навигатора, конечно, состоит в движении объекта по гладкой кривой, то есть нам необходимо по заданным основным точкам траектории, восстанавливать всю сглаженную кривую, чтобы движения тела были плавными, без угловатых поворотов. Сейчас этим и займемся!

Чуточку теории.
Сплайном как раз и зовут гладкую кривую, проходящую по заданным точкам. Обычно строится кривая определенного порядка вроде "кубического сплайна", "сплайна четвертой степени", а также известны всякие "кривые Безье", получившие популярность из-за своей простоты и хорошего результата. Также сплайны различаются по способу использования контрольных точек. В каком-то случае кривая проходит через них, в другом - контрольные точки используются для задания базового направления, но итоговая кривая через них может и не проходить (по научному это интерполирующий и аппроксимирующий сплайны):


На практике широко применяют и те, и другие... Вообще, формулы для построения сплайнов довольно "увесистые", требующие хорошей математической подготовки.
Но я сразу скажу одну приятную вещь: в glscene уже имеется класс для построения упомянутого кубического сплайна (interpolating-spline, кривая проходит через заданные точки). Есть у него ряд недостатков, но в целом съедобно. Им и воспользуемся...

Воз практики.
Дальше будет код демки, так что советую сначала скачать ее исходники к себе на компьютер и сверяться параллельно в Delphi. А вот и видео результата...

Приступим! Создадим основной класс для движения объектов в модуле uSplineNavigator.pas:

TSplineNavigator = class
end;

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

TSplineNavigator = class protected fList: TVectorList; fCubicSpline: TCubicSpline; function GetBasePointsCount: Integer; public property BasePointsCount: Integer read GetBasePointsCount; Function Add3d(const x, y, z: Single): Integer; Function Add2d(const x, y: Single): Integer; // z = 0 Function GetBasePointByIndex(const aIndex: Integer): TVector; // base points Function GetPointByUnit(const aUnit: Single): TVector; Function GetSlopeByUnit(const aUnit: Single): TVector; Procedure DeletePoints; Procedure CreateCubicSpline; Constructor Create; Destructor Destroy; override; end;

где нас по сути интересуют два метода:

Function Add2d(const x, y: Single): Integer;
Function GetPointByUnit(const aUnit: Single): TVector;

Первый метод добавляет контрольную точку для построения сплайна, второй - выдает точку сплайна по указанному значению.
Объявляем fNavigator: TSplineNavigator; и вперед!
Вот все и готово, теперь этот класс нужно использовать по назначению...

Для примера я выбрал простую задачу: плыть корабликом к указанному порту на карте. Маршрут корабля должен быть сглаженным, красивым, для этого и воспользуемся TSplineNavigator'ом.
А вот и фоновая картинка, используемая нами для отображения карты местности с отмеченными портами:


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

В общем, что мы имеем: карта, кораблик, порты. Кликаем по изображению порта на карте - наше маленькое суденышко отправляется к нему по заданному маршруту. Удерживая пробел, можно увидеть контрольные точки, по которым строится сплайн и движется корабль.

Итак, добавляем пути в каком-нибудь графическом редакторе (я делал все в FireWorks'е), потом вытаскиваем координаты точек-направляющих сплайна и забиваем в наш метод TfrmMain.StartShip():

Procedure TfrmMain.StartShip(aTradePath: Integer); begin fForwardDir := true; fTime := 0; fShiping := true; fNavigator.DeletePoints; with fNavigator do case aTradePath of 1:begin Add2d(0, 600); Add2d(100, 589); Add2d(175, 574); Add2d(275, 544); Add2d(343, 503); Add2d(403, 443); Add2d(441, 362); Add2d(443, 272); Add2d(413, 189); Add2d(371, 124); Add2d(332, 85); end; 2:begin Add2d(0, 600); Add2d(155, 559); Add2d(263, 507); Add2d(376, 471); Add2d(459, 513); Add2d(558, 573); Add2d(679, 530); Add2d(722, 447); Add2d(778, 461); end; 3:begin Add2d(0, 600); Add2d(139, 559); Add2d(277, 520); Add2d(364, 576); Add2d(486, 545); Add2d(518, 447); Add2d(463, 376); Add2d(471, 279); Add2d(574, 293); end; 4:begin Add2d(0, 600); Add2d(121, 568); Add2d(230, 517); Add2d(339, 523); Add2d(425, 582); Add2d(485, 510); Add2d(492, 415); Add2d(448, 334); Add2d(498, 253); Add2d(438, 200); Add2d(351, 174); Add2d(320, 255); end; 5:begin Add2d(0, 600); Add2d(98, 567); Add2d(205, 565); Add2d(302, 573); Add2d(345, 495); Add2d(354, 399); Add2d(427, 355); Add2d(515, 431); Add2d(488, 512); Add2d(422, 563); Add2d(345, 526); Add2d(300, 463); end; end; fNavigator.CreateCubicSpline; end;

В этом методе мы как раз сначала обнуляем все значения, чистим текущий сплайн и генерируем новый. Да, бесчисленные вывозы Add2d() делают код некрасивым, это можно обойти несколькими путями, например:
1). использовать скрипты (к примеру, Lua) и вынести все эти числа во внешние файлы
2). также, как я сделал в текущей демке для координат портов, а именно объявление константы-массива, как показано ниже

const
  PortsCount = 5;
  PortPos  : array [1..PortsCount] of TVector = ((332, 82, 0, 1), (782, 466, 0, 1), (577, 294, 0, 1), (319, 258, 0, 1), (300, 464, 0, 1));

Теперь, построив сплайн для движения корбалика, мы "считаем" прошедшее время и достаем необходимые нам положение корабля и его направление движения в TfrmMain.OnDirectRender():

Pos := fNavigator.GetPointByUnit(fTime / fDuration); Dir := VectorNormalize(fNavigator.GetSlopeByUnit(fTime / fDuration)); fShip.Position.SetPoint(Pos); if not fForwardDir then fShip.Rotation := ArcTan2(Dir[1], -Dir[0]) * 180 / pi else fShip.Rotation := ArcTan2(-Dir[1], Dir[0]) * 180 / pi; fShip.Render(rci);

Также для красоты я решил добавить анимацию при наведении на эмблемку-кружок порта, куда мы хотим отправить наш корабль. Для реализации этого пришлось "захламить" код, добавив такие проверки, как:
  • метод CheckPortsForSelect() - проверяет, где находится мышь и выставляет fPortSelected, fPortAnimate, fAnimationTime, fAnimateShow для обозначения подсвеченного порта, порта с анимацией (если мы увели мышь, то порт остается на некоторое время анимированным с уползающим с экрана названием, но уже не подсвеченным), время с начала анимации и тип анимации (проявляется или гаснет имя порта)
  • в OnDirectRender() добавился увесистый код, создающий анимацию имени порта:

for i := 1 to PortsCount do if (fPortSelected = i) or (fPortAnimate = i)then begin fPortName.Text := PortNames[i]; fPortName.ModulateColor.SetColor(0, 0, 0, fAnimationTime / fAnimationDuration); Pos := NamesPos[i]; Pos[1] := Pos[1] + 20 * (-1 + sin(fAnimationTime / fAnimationDuration * pi /2)); fPortName.Position.SetPoint(Pos); fPortName.Render(rci); end;

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

// имеется ли подсвеченный порт?
  if fPortAnimate > -1 then
  begin
   // анимация появления?
    if fAnimateShow then
    begin
     // имя появляется, увеличиваем анимационное время
      fAnimationTime := fAnimationTime + DeltaTime;
      if fAnimationTime >= fAnimationDuration then
        fAnimationTime := fAnimationDuration;
    end
    else
    // анимация пропадания имени
    begin
     // имя пропадает, уменьшаем анимационное время
      fAnimationTime := fAnimationTime - DeltaTime;
      if fAnimationTime < 0 then
      begin
        fAnimationTime := 0;
        fPortAnimate := -1;
      end;
    end;
  end;

А вот и сам метод CheckPortsForSelect(), комментарии опять помещаю внутрь:

Procedure TfrmMain.CheckPortsForSelect;
var
  i: Integer;
  MPos: TPoint;
  dx, dy: integer;
begin
  dx := 20;
  dy := 20;
  // узнаем координаты мыши
  GetCursorPos(MPos);
  // переводим в систему относительно viewer'а
  MPos := fViewer.ScreenToClient(MPos);

  // сначала устанавливаем подсвеченный порт в -1
  fPortSelected := -1;
  for i := 1 to PortsCount do
    if (MPos.x > PortPos[i][0] - dx) and (MPos.x < PortPos[i][0] + dx)
    and(MPos.y > PortPos[i][1] - dy) and (MPos.y < PortPos[i][1] + dy)then
    begin
      // курсор над портом - помечаем его как подсвеченный и анимированный
      fPortSelected := i;
      fPortAnimate := i;
      // если до этого был подсвечен другой порт, тогда сбрасываем время
      if fPortSelected <> i then
        fAnimationTime := 0;
      fAnimateShow := true;
      break;
    end;

  // если была анимация появления, а теперь порт не подсвечен, тогда указываем
  // что теперь анимация пропадания имени порта
  if fAnimateShow and (fPortSelected = -1) then
    fAnimateShow := false;
end;

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



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

  • построение сплайна,
  • движение объекта по кривой, 
  • анимационные эффекты надписей, 
  • реагирование эмблемок на мышь. 

Да, дело оказалось нетрудным, но, надеюсь, кому-нибудь окажется полезным... 

До новых встреч!

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

3 коммент.:

  1. Клёво, уникальный пример!

    ОтветитьУдалить
  2. Хех, я недавно написал NURBS для дельфи. Только не догнал - как сделать, чтобы аппроксимация превратилась в интерполяцию. Если интересно - могу поделиться - допилим вместе.

    Ast (GLScene.ru)

    ОтветитьУдалить
  3. Yarov, благодарю! надеюсь, пригодится!

    Странник, здорово! насчет "допилить вместе" сейчас просто немного другим занимаюсь... времени совсем нет. огромное спасибо за доверие и предложение! надеюсь, скоро разгребу все у себя и постараюсь пригодиться в этом деле!

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