Верле: разрешаем коллизии (часть 1)

Всех приветствую! Сегодня мы попробуем написать некое подобие простейшего физического движка.

Введение:

Из жизни мы знаем:

\vec{x}(t+\Delta t) = \vec{x}(t) + \vec{v}(t)\Delta t

Другими словами:

\vec{x}_{n+1} = \vec{x}_{n} +  \vec{v}_{n} \Delta t

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

\vec{x}_{n+1} = \vec{x}_n + (\vec{v}_{n-1} + \vec{a}_n \Delta t )\Delta t =\vec{x}_n + \vec{v}_{n-1} \Delta t + \vec{a}_n \Delta t^2 = \vec{x}_n + \vec{x}_{n} - \vec{x}_{n-1} + \vec{a}_n \Delta t^2

Получаем такую формулу:

\vec{x}_{n+1} = 2\vec{x}_n - \vec{x}_{n-1} + \vec{a}_n \Delta t^2

Если интересно, можно ознакомиться со статьей на Википедии.

Начинаем писать код:

Для начала напишем простенький класс для вектора (x; y):

#pragma once
#include 

namespace eng {
template  struct Vec2 {
  T x, y;

  Vec2() : x{0}, y{0} {};
  Vec2(T _x, T _y) : x{_x}, y{_y} {};

  T length() const { return std::sqrt(x * x + y * y); }

  Vec2 &operator=(const Vec2 &other) {
    x = other.x;
    y = other.y;
    return *this;
  }

  Vec2 operator+(const Vec2 &other) const {
    return Vec2{x + other.x, y + other.y};
  }
  Vec2 operator-(const Vec2 &other) const {
    return Vec2{x - other.x, y - other.y};
  }

  void operator+=(const Vec2 &other) {
    x += other.x;
    y += other.y;
  }
  void operator-=(const Vec2 &other) {
    x -= other.x;
    y -= other.y;
  }

  Vec2 operator*(const T value) const { return Vec2{x * value, y * value}; }
  Vec2 operator/(const T value) const { return Vec2{x / value, y / value}; }
};
}

Такого функционала нам будет достаточно; думаю пояснять здесь ничего не нужно. Вообще, даже не нужно было делать класс шаблонным.

Теперь пропишем константы:

#pragma once
#include 

namespace constants {
const inline eng::Vec2 gravity = {0.0f, 1000.0f};
const inline int screenWidth = 1280;
const inline int screenHeight = 720;

// область, которую нельзя покидать нашим объектам
const inline float areaRadius = 300.f;
const inline float areaX = constants::screenWidth / 2.f;
const inline float areaY = constants::screenHeight / 2.f;
} // namespace constants

Перейдем к написанию класса для сущностей нашего движка:

#pragma once
#include "Constants.hpp"
#include "Vector2.hpp"
#include 
#include 
#include 

namespace eng {
struct VerletObject {
  // вектора из формулы
  Vec2 positionCurrent;
  Vec2 positionOld;
  Vec2 acceleration;

  // в качестве графической библиотеки будем использовать СФМЛ
  sf::CircleShape sfShape;
  float radius;

  // не забываем сделать центр окружности центром шейпа, по умолчанию 
  // им является левый верхний угол
  VerletObject(float xPos, float yPos, float _radius, sf::Color color)
      : positionCurrent{xPos, yPos}, positionOld{xPos, yPos}, radius{_radius} {
    sfShape.setRadius(radius);
    sfShape.setOrigin(radius, radius);
    sfShape.setPosition(xPos, yPos);
    sfShape.setFillColor(color);
  }
  

  // все в соответствии с формулой
  void updatePosition(float dt) {
    Vec2 velocity = positionCurrent - positionOld;
    positionOld = positionCurrent;

    positionCurrent += velocity + constants::gravity * dt * dt;

    sfShape.setPosition(positionCurrent.x, positionCurrent.y);
  }

};
}
  

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

#pragma once
#include "Constants.hpp"
#include "Vector2.hpp"
#include "VerletObject.hpp"
#include 
#include 

namespace eng{
class Game {
private:
  //в массиве храним объекты движка
  std::vector objects;
  sf::RenderWindow *window;

  // так обновляем позицию им всем 
  void updatePositions(float dt) {
    for (auto *object : objects) {
      object->updatePosition(dt);
    }
  }

  // так не даем объекту покидать разрешенную область
  void applyConstraint() {
    const Vec2 centerPosition{constants::areaX, constants::areaY};
    
    // для каждого объекта
    for (auto *object : objects) {
      // считаем радиус-вектор от центра допустимой области к объекту
      // ищем его модуль
      const Vec2 vecToObj = object->positionCurrent - centerPosition;
      const float distToObj = vecToObj.length();

      // если объект выходит за границы области
      if (distToObj > constants::areaRadius - object->radius) {

        // берем единичный вектор (направление от ц. области к ц. объекта) 
        const Vec2 normalized = vecToObj / distToObj;
        // обновляем позицию так, чтобы наш объект был внутри области
        // по-сути, мы двигаем его ближе к центру области по прямой, проходящей
        // через центр объекта и центр области
        object->positionCurrent =
            centerPosition +
            normalized * (constants::areaRadius - object->radius);
      }
    }
  }
  }
}

Чек-поинт:

Пора посмотреть, что у нас получается.

Для этого напишем конструктор и два метода: один для добавления объектов, второй вызвающий два предыдущих, рисующий все шейпы:

//...
public:
  Game(sf::RenderWindow *_window) : window{_window} {};

  void addObject(float xPos, float yPos, float radius,
                 sf::Color color = sf::Color(sf::Color::Blue)) {
    VerletObject *obj = new VerletObject(xPos, yPos, radius, color);
    objects.push_back(obj);
  }

  void update(float dt) {
    applyConstraint();
    updatePositions(dt);

    for (auto *object : objects) {
      window->draw(object->sfShape);
    }
  }
//...

И, наконец, main.cpp:

#include "Constants.hpp"
#include "Game.hpp"
#include "Random.hpp"
#include "Vector2.hpp"
#include 


int main() {
  sf::RenderWindow window(
      sf::VideoMode(constants::screenWidth, constants::screenHeight), "Verlet");
  window.setVerticalSyncEnabled(1);
  eng::Game game(&window);

  // та самая область
  sf::CircleShape area;
  area.setOrigin(constants::areaRadius, constants::areaRadius);
  area.setPosition(constants::areaX, constants::areaY);
  area.setRadius(constants::areaRadius);
  area.setFillColor(sf::Color::White);
  area.setPointCount(200);


  sf::Clock deltaClock;
  sf::Time dt;
  while (window.isOpen()) {
    sf::Event event;
    while (window.pollEvent(event)) {
      if (event.type == sf::Event::Closed)
        window.close();
    }

    // будем генерировать объекты по нажатию ПКМ
    if (sf::Mouse::isButtonPressed(sf::Mouse::Right)) {
      sf::Vector2i position = sf::Mouse::getPosition(window);
      game.addObject(position.x, position.y, eng::getRandomInt(5, 30),
                     sf::Color(eng::getRandomInt(0, 255),
                               eng::getRandomInt(0, 255),
                               eng::getRandomInt(0, 255)));
    }


    window.clear();
    // сначала рисуем область, потом уже объекты, иначе мы их не увидим
    window.draw(area);
    game.update(dt.asSeconds());
    // отображаем что получилось
    window.display();

    // записываем время итерации
    dt = deltaClock.restart();
  }
  return 0;
}

Забыл еще одну вспомогательную функцию:

int getRandomInt(int l, int r) {
  std::random_device rd;
  std::uniform_int_distribution gen(l, r);
  return gen(rd);
}

Результат следующий:

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

//...
void solveCollisions() {
    // перебираем все пары объектов
    for (int i = 0; i < objects.size(); ++i) {
      for (int j = 0; j < objects.size(); ++j) {
        // самому с собой столкнуться невозможно
        if (j == i)
          continue;

        // вектор от центра первой окр. к центру второй
        Vec2 collisionAxis =
            objects[i]->positionCurrent - objects[j]->positionCurrent;

        // если расстояние между ними больше, чем сумма радиусов
        // то они не контактируют
        const float dist = collisionAxis.length();
        if (dist > objects[i]->radius + objects[j]->radius)
          continue;

        // единичная версия нашего вектора от ц. первой окр. к ц. второй окр
        Vec2 normalized = collisionAxis / dist;
        // расстояние, на которое нам нужно отодвинуть друг от друга окружности
        // чтобы одна не была в другой
        const float delta = objects[i]->radius + objects[j]->radius - dist;
        
        // рассталкиваем их вдоль пряиой 
        // соблюдая некое подобие закона сохранения импульса
         float weightDiff =
            objects[j]->radius / (objects[i]->radius + objects[j]->radius);
        objects[i]->positionCurrent += normalized * delta * weightDiff;
        objects[j]->positionCurrent -= normalized * delta * (1 - weightDiff);
      }
    }
  }
//...

Осталось добавить вызов этого метода в update.

И запускаем!

Получилось просто, хоть и не идеально.

Во второй части попробуем стать к идеалу чуть ближе. Спасибо тем, кому хватило терпения дочитать

© Habrahabr.ru