Вступление и подводка

Каюсь, до сего момента я был веб-разработчиком и ничего тяжелее node в руках не держал. Тем страшнее и загадочнее для меня выглядел мир указателей, ссылок и (о ужас) типизированных массивов, да еще и фиксированной длины. Но сегодня вечером я решился наконец-то исследовать этот мир deep dark fantasies. Я джва года мечтал о своей собственной няшной двумерной симуляции движения небесных тел, и я собрался писать её на крестах!

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

В качестве библиотеки для отрисовки графики я выбрал sfml, просто потому что он выпал в поиске первым. Я еще не совсем хорошо понимаю, как заливать куда-либо c++ проект вместе с его зависимостями, поэтому вам придется самостоятельно устанавливать либу и подключать ее, если вы захотите это потестить.

Математика и логика

Законы моего мира совсем простые - имеется небесное тело, у него есть декартовы координаты x и y, скорости соответствующих компонент vx и vy, а также физические свойства, такие как масса m, радиус r и некий коэффициент "плотности" d. Радиус я считаю как произведение m * d для простоты, а "плотность" в кавычках, потому что это не плотность в прямом ее понимании, просто коэффициент.

Каждое небесное тело влияет на каждое соразмерно расстоянию - его можно посчитать просто по теореме Пифагора, думаю тут объяснения излишни:
dist = sqrt( pow(x1 - x2, 2) + pow(y1 - y2, 2) );

И наконец, сила гравитационного взаимодействия между телами выражается в виде формулы:

 { F = G \cdot \frac{m_{1} \cdot m_{2}}{r^{2}} \cdot  \frac{j_{12}}{r}}

где j12 - радиус-вектор, высчитываемый как x2-x1. Это требуется для векторных подсчетов, типа система координат все такое я сам хз почему, в школе нам такого точно не объясняли, с меня взятки гладки, просто пользуемся формулой (буду благодарен, если в комментариях люди с более старым образованием объяснят). В качестве гравитационной постоянной G будем использовать случайно подобранное значение, поскольку наши тела несоразмерны реальному миру.

Скорости vx и vy изменяются благодаря ускорению, которое в свою очередь является частным полученной выше силы и массы тела 1. Таким образом, одна масса сократится, и формула изменения скорости примет вид:
vx += G * m2 / dist / dist * (x2 - x1) / dist
vy += G * m2 / dist / dist * (y2 - y1) / dist

На этом, пожалуй все, переходим к самому легкому :D

Код

Константы, которые потребуются нам для работы:

const double PI = 3.1415926536;
const double G = 1; //наша гравитационная постоянная
const int boundX = 1200; //размер окна по ширине
const int boundY = 800; //...и высоте

Далее, для небесного тела я создал следующую структуру (зачем нужны структуры если это тоже самое что классы?):

struct Body
{
	float x;
	float y;
	float vx;
	float vy;
	float m;
    float d;
	float r = m * d;
};

Я переживал за производительность, да и не знал как будет работать это все, поэтому на всякий случай обзавелся таймером на миллисекундах, который стопает главный поток, чтобы дать коду отдышаться:

void rest(int ms)
{
	std::this_thread::sleep_for(std::chrono::milliseconds(ms));
}

Вот так выглядит main целиком:

int main() {
	sf::ContextSettings settings;
	settings.antialiasingLevel = 8.0; //уровень сглаживания
	RenderWindow window(VideoMode(boundX, boundY), "Planet Simulator", sf::Style::Close, settings);

	int delay = 50; //50ms отдыха для кода

	while (window.isOpen())
	{
		std::vector<Body> bodiesToAdd = handleEvents(&window); //про обработку событий чуть позже

		window.clear(Color(0, 0, 0, 0)); //очищаем экран

		update(&window); //обновление экрана
		bodies.insert(bodies.end(), bodiesToAdd.begin(), bodiesToAdd.end()); //про это тоже позже :3

		window.display(); //отрисовка экрана

		rest(delay); //отдых
	}

	return 0;
}

Сердцем всего проекта служит метод void update(RenderWindow* window). Он по описанным выше законам обновляет данные тел в векторе bodies, а затем отдает их экрану для отрисовки:

void update(RenderWindow* window)
{
	std::set<int> deleteBodies; //номера небесных тела, которые столкнулись друг с другом и должны умереть
	int size = bodies.size(); //количество небесных тел

    //используем два цикла для фиксации влияния каждого тела на каждое
	for (int i = 0; i < size; i++)
	{
		Body& p0 = bodies[i]; //ссылка на текущее тело
		for (int j = 0; j < size; j++)
		{
            //помним, что под одинаковыми индексами лежит одно и тоже тело, а сами на
            //себя тела не влияют, поэтому пропускаем
			if (i == j)
				continue;

			Body& p = bodies[j]; //ссылка на второе тело
          
			double dist = sqrt(pow(p0.x - p.x, 2) + pow(p0.y - p.y, 2));

            //проверка коллизии тел
			if (dist > p0.r + p.r)
			{
                //собственно, изменение скоростей покомпонентно
				p0.vx += G * p.m / dist / dist * (p.x - p0.x) / dist;
				p0.vy += G * p.m / dist / dist * (p.y - p0.y) / dist;
			}
			else {
				deleteBodies.insert(i);
				deleteBodies.insert(j);
			}
		}

        //изменение координат покомпонентно
		p0.x += p0.vx;
		p0.y += p0.vy;
      
		CircleShape circle = renderBody(p0); //функция, которая на основе структуры просто создает кружок
		window->draw(circle); //рисуем кружок
	}

    //мой способ очистки вектора тел от тех, индексы которых помечены как уничтоженные
    //и содержатся в наборе deleteBodies
	std::vector<Body> copy_bodies;
	for (int i = 0; i < bodies.size(); ++i)
	{
		if (deleteBodies.find(i) == deleteBodies.end()) //если индекса в наборе нет
		{
			copy_bodies.push_back(bodies[i]);
		}
	}
	bodies = copy_bodies;
    //я уверен, что можно сделать это лучшее и быстрее, пока не знаю как.
}

Про проверку коллизии - все достаточно прозаично, достаточно взглянуть на картинку и понять, что если расстояние между радиусами (dist) меньше сумм радиусов, то такие окружности точно пересекутся, а значит убьются.

Базовый функционал готов, но я хотел бы добавить еще создание планет по клику мыши! Для этого мы обратимся к событиям в sfml - тут нам и пригодится функцияstd::vector<Body> handleEvents(RenderWindow* window), возвращающая вектор тел, которые я бы хотел добавить, и которые я затем в main вставляю в вектор bodies. Вот как она устроена:

std::vector<Body> handleEvents(RenderWindow* window) {
	std::vector<Body> newBodies; //вектор с новыми телами
	Event event;

	while (window->pollEvent(event)) //пока в пуле есть новые события
	{
		if (event.type == Event::Closed) //обработка выхода из программы
			window->close();
		if (event.type == Event::MouseButtonPressed) //если случилось нажатие мыши
		{
			sf::Vector2i position = sf::Mouse::getPosition(*window);
			if (event.mouseButton.button == sf::Mouse::Left)
			{
                //по нажатию на левую кнопку добавляем просто планету
				newBodies.push_back(Body{ static_cast<float>(position.x), static_cast<float>(position.y), 0.0, 0.0, 100.0, 0.1});
			}
			else 
			{
                //по нажатию на правую кнопку добавляем целую тяжелую звезду
				newBodies.push_back(Body{ static_cast<float>(position.x), static_cast<float>(position.y), 0.0, 0.0, 1000.0, 0.05});
			}
		}
	}

	return newBodies;
}

Заключение

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

Полный код

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

код
#include <SFML/Graphics.hpp>

#include <chrono>
#include <thread>
#include <vector>
#include <set>

using namespace sf;

const double PI = 3.1415926536;
const double coef = 1;
const int boundX = 1200;
const int boundY = 800;
const float sunMass = 1000;
const float radiusCoef = 0.1;

struct Body
{
	float x;
	float y;
	float vx;
	float vy;
	float m;
	float r = m * radiusCoef;
};

std::vector<Body> bodies = { Body{500.0, 300.0, 0.0, 0.0, 1000.0} };

void sleep(int ms)
{
	std::this_thread::sleep_for(std::chrono::milliseconds(ms));
}

std::vector<Body> handleEvents(RenderWindow* window) {
	std::vector<Body> newBodies;
	Event event;

	while (window->pollEvent(event))
	{
		if (event.type == Event::Closed)
			window->close();
		if (event.type == Event::MouseButtonPressed)
		{
			sf::Vector2i position = sf::Mouse::getPosition(*window);
			if (event.mouseButton.button == sf::Mouse::Left)
			{
				newBodies.push_back(Body{ static_cast<float>(position.x), static_cast<float>(position.y), 0.0, 0.0, 100.0});
			}
			else 
			{
				newBodies.push_back(Body{ static_cast<float>(position.x), static_cast<float>(position.y), 0.0, 0.0, 2000.0});
			}
		}
	}

	return newBodies;
}

CircleShape renderBody(Body& b)
{
	b.r = b.m * radiusCoef;
	CircleShape circle(b.r);
	circle.setOrigin(b.r, b.r);
	circle.setPosition(b.x, b.y);
	if (b.m >= sunMass)
	{
		circle.setFillColor(Color(246, 222, 1));
	}
	return circle;
}

void update(RenderWindow* window)
{
	std::set<int> deleteBodies;
	int size = bodies.size();

	for (int i = 0; i < size; i++)
	{
		Body& p0 = bodies[i];
		for (int j = 0; j < size; j++)
		{
			if (i == j)
				continue;

			Body& p = bodies[j];
			double d = sqrt(pow(p0.x - p.x, 2) + pow(p0.y - p.y, 2));

			if (d > p0.r + p.r)
			{
				p0.vx += coef * p.m / d / d * (p.x - p0.x) / d;
				p0.vy += coef * p.m / d / d * (p.y - p0.y) / d;
			}
			else {
				if (p0.m >= sunMass && p.m >= sunMass)
				{
					deleteBodies.insert(i);
					deleteBodies.insert(j);
				}
				else
				{
					if (p0.m < sunMass)
					{
						deleteBodies.insert(i);
					}
					else {
						p0.m += p.m * 0.1;
					}

					if (p.m < sunMass)
					{
						deleteBodies.insert(j);
					}
					else {
						p.m += p0.m * 0.1;
					}
				}
			}
		}
		p0.x += p0.vx;
		p0.y += p0.vy;

		CircleShape circle = renderBody(p0);
		window->draw(circle);
	}

	std::vector<Body> copy_bodies;

	for (int i = 0; i < bodies.size(); ++i)
	{
		if (deleteBodies.find(i) == deleteBodies.end())
		{
			copy_bodies.push_back(bodies[i]);
		}
	}

	bodies = copy_bodies;
}

int main() {
	sf::ContextSettings settings;
	settings.antialiasingLevel = 8.0;
	RenderWindow window(VideoMode(boundX, boundY), "Planet Simulator", sf::Style::Close, settings);

	int msForFrame = 50;

	while (window.isOpen())
	{
		std::vector<Body> bodiesToAdd = handleEvents(&window);

		window.clear(Color(0, 0, 0, 0));

		update(&window);
		bodies.insert(bodies.end(), bodiesToAdd.begin(), bodiesToAdd.end());
		bodiesToAdd.clear();

		window.display();

		sleep(msForFrame);
	}

	return 0;
}

Комментарии (23)


  1. TheGast
    25.11.2022 20:32
    +2

    1. Прикол с таймером для "отдышаться" лишний. Это точно никому не нужно. Если что, в крайнем случае у тебя ядро процессора будет долбиться в сотку с максимальным фпс. Но лагать ничего не будет.

    2. "Как в Angry Birds" делается тремя событиями (pressed уже обрабатывается в коде): https://www.sfml-dev.org/tutorials/2.5/window-events.php#the-mousebuttonpressed-and-mousebuttonreleased-events и https://www.sfml-dev.org/tutorials/2.5/window-events.php#the-mousemoved-event . На pressed надо запоминать координату нажатия, на moved - рисовать стрелку между координатой нажатия и текущей координатой мыши, а на released -- создавать объект и давать ускорение.


    1. Soft_touch_plastic Автор
      26.11.2022 20:28

      Понял, спасибо за совет)


    1. playermet
      27.11.2022 15:49

      Это точно никому не нужно

      Нужно, но с нулевой задержкой и только если выключен vsync. Как раз потому что грузить одно ядро на 100% приложению которое рисует два кружка это моветон. Люди на ноутбуках спасибо скажут.


  1. Naf2000
    25.11.2022 22:22
    +4

    По сути такой же делал в школе на turbo pascal 6.0


    1. IvanPetrof
      26.11.2022 05:32
      +2

      Аналогично.. Только паскаль был пятый))

      Удивительно, что статьи на эту тему стабильно "лайкаются". Такое уже было на Хабре на разных языках. На JavaScript, например. И судя по комментам, каждый раз это вызывает восхищение, как от магии.

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


  1. a-tk
    25.11.2022 22:27
    +1

    Накопление ошибок интегрирования? Не, не слышал.


    1. yatanai
      25.11.2022 23:40

      Как я знаю вроде нету нормальных алгоритмов для такого? Ты либо энергию теряешь, либо фаза сдвигается постоянно, не?

      Поясню то что "знаю" - когда ты меняешь состояние, ты пишешь типо "+2м/с" к объекту. Потеря энергии означает что сумма всех изменений не будет равна тому, что мы имеем в процессе интегрирования. А "фаза", как я это понял, то что изменения непостоянны/"слегка смещены" по времени, от чего график с интегрированными vs запись в массив, будет постоянно сдвигаться на какой-то шаг, хотя энергия может сохраняться.

      PS за матан вообще не шарю, самоучка


    1. Soft_touch_plastic Автор
      26.11.2022 20:28

      Спасибо за информацию, буду читать что за ошибки интегрирования такие)


  1. Emulyator
    25.11.2022 23:54

    Не уверен, что алгоритм проверки столкновения отработает корректно, если за одну итерацию достаточно большое приращение координат "пропустит" момент, когда "расстояние между радиусами (dist) меньше сумм радиусов". Особенно на встречных, но смещенных от прямой курсах?


  1. brotchen
    25.11.2022 23:56

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


  1. s_f1
    26.11.2022 00:36

    Больше всего мне хотелось бы реализовать силу и направление начального движения планеты через клик и оттягивание мыши (как в энгри бердс).

    По нажатию мыши запоминаешь координаты x1, y1. По отпусканию получаешь координаты x2, y2. Вектор {x1 — x2, y1 — y2} будет и направлением, и скоростью движения, только домножь на нужный коэффициент, чтобы было адекватно остальной картине мира.


    1. a-tk
      26.11.2022 07:59
      +1

      только домножь на нужный коэффициент, чтобы было адекватно остальной картине мира

      так и получаются монстры с урановыми ломами в ртути...


  1. AMDmi3
    26.11.2022 00:39
    +1

    Я еще не совсем хорошо понимаю, как заливать куда-либо c++ проект вместе с его зависимостями, поэтому вам придется самостоятельно устанавливать либу и подключать ее, если вы захотите это потестить.

    Вместе с зависимостями никто проекты не заливает. Достаточно выложить на github репозиторий с двумя файлами - исходником и CMakeLists.text/meson.build/Makefile в зависимости от того чем он собирается. По-хорошему ещё с лицензией и readme.


  1. phenik
    26.11.2022 06:30
    +1

    Может полезно будет для развития проекта — симуляция N-тел.


    1. Soft_touch_plastic Автор
      26.11.2022 20:39

      большое спасибо, я не ожидал если честно такого теплого приема и советов????


  1. AxeFizik
    26.11.2022 07:00
    +3

    Советую ознакомиться с курсом https://classroom.udacity.com/courses/cs222 там рассматриваются методы численного решения диффуров, и в первом же уроке рассматривается пример гравитационного взаимодействия тел. Это поможет вам сделать симуляцию более корректной


    1. Soft_touch_plastic Автор
      26.11.2022 20:27

      Благодарю за наводку!


  1. Un_ka
    26.11.2022 20:31
    +1

    Для полноты картины хотя бы вставили гифку результата симуляции.

    Также может быть интересна двумерная симуляция по другим законам тяготения:


    1. Soft_touch_plastic Автор
      26.11.2022 20:36

      По всей видимости буду доделывать проект, учитывая советы выше и ваш комментарий. Спасибо!


  1. TheCalligrapher
    26.11.2022 22:21

    В данном случае, конечно, не критично, учитывая специфику задачи, но тем не менее: использование pow для возведения в целые степени всегда режет глаз. А уж в квадрат...

    Также: почему float, а не double?


    1. Soft_touch_plastic Автор
      26.11.2022 22:37

      По поводу pow - мне казалось наоборот каким-то оверхедом писать два раза одно и тоже, если можно воспользоваться готовой функцией.

      По поводу float - не уловил разницы между ними, в силу своего нетипизированного пока еще мышления)

      Как я понял, точности float должно хватить.


      1. a-tk
        27.11.2022 13:57
        +2

        Почитайте вот это на досуге, получите удовольствие: https://stihi.ru/diary/datomir/2013-06-01


        1. Soft_touch_plastic Автор
          28.11.2022 14:56

          очень остроумно)