JavaScript для продвинутых: пишем симулятор гравитации
В этот руководстве создадим симуляцию внутренней области нашей Солнечной системы, используя только старый добрый JavaScript.
18К открытий19К показов
Космос трудно понять — или люди склонны так думать. Но в этом уроке мы увидим, что законы, управляющие движением звёзд, планет, астероидов и даже целых галактик, невероятно просты. Если бы наша Вселенная была создана разработчиком, он наверняка был бы обеспокоен написанием чистого кода, который легко поддерживать и масштабировать.
Мы собираемся создать симуляцию внутренней области нашей Солнечной системы, используя только старый добрый JavaScript. Это будет гравитационная N-объектная симуляция, где каждый объект испытывает на себе гравитацию всех других объектов симуляции.
Мы также посмотрим, как позволить пользователям добавлять свои собственные планеты при помощи небольшого движения мыши, и вызывать при этом всевозможный космический беспорядок. Ещё мы разберёмся, как создать несколько необычных траекторий движения тел, в дополнение к некоторым другим фишкам, которые сделают симулятор более увлекательным для обычного пользователя.
Примечание Это руководство предполагает, что у вас есть базовые знания JavaScript, а также синтаксиса и нововведений, представленных в ES6.
Написание алгоритма для N-объектной симуляции
Для написания алгоритма мы будем опираться на численное интегрирование. Оно хорошо подходит для решения гравитационных N-объектных задач, в которых оперируют позициями и скоростями всех объектов в данный момент времени T, вычисляют гравитационную силу, с которой они действуют друг на друга, и изменяют их скорости и позиции во времени (T + dt, dt — изменение во времени). Применяя это, мы можем проследить траектории множества тел в пространстве и времени.
Для моделирования будем использовать декартову систему координат. Преимущество использования такой системы координат для моделирования в том, что Canvas API, с помощью которого мы будем визуализировать моделирование, также её использует.
Для написания алгоритма необходимо иметь представление о том, что подразумевается под скоростью и ускорением. Скорость — это изменение положения объекта во времени, а ускорение — изменение скорости объекта во времени. Первый закон Ньютона гласит, что каждый объект будет оставаться в покое или двигаться равномерно и прямолинейно, если на него не будут воздействовать никакие внешние силы. Земля не движется по прямой линии, а вращается вокруг Солнца, поэтому она ускоряется. Это ускорение вызывают гравитационные силы Солнца и других планет и объектов во Вселенной, действующие на Землю.
Для начала напишем некоторый псевдокод для обновления позиций и скоростей тел в декартовом пространстве. Будем хранить тела как объекты в массиве, где каждый объект представляет собой небесное тело с координатами положения x
, y
и z
и векторами скорости. Векторы скорости имеют префикс v.
Из фрагмента видно, что на каждом временном шаге (dt
) мы обновляем значения скорости моделируемых тел и их позиции. Также видна взаимосвязь между положением и скоростью тела. Изменение положения тела равно произведению вектора скорости на dt
. Точно так же можно разобрать связь между скоростью и ускорением.
Чтобы получить векторы ускорения тела (чтобы можно было рассчитать изменение его векторов скорости) необходимо получить результат взаимодействия massJ
с вектором ускорения massI
. Для этого нужно рассчитать гравитационную силу, которую оказывает massJ
на massI
, а затем, чтобы получить вектор ускорения, например, х
, просто вычислить произведение этой силы на расстояние между двумя массами на оси x. Чтобы получить векторы ускорения y
и z
, нужно проделать то же самое. Теперь необходимо выяснить, как рассчитать гравитационную силу, которую оказывает massJ
на massI
. Формула выглядит следующим образом:
Приведённая выше формула показывает: гравитационное воздействие massJ
на massI
равно произведению гравитационной постоянной g на massJ
(massJ.m
), делённому на произведение суммы квадратов расстояния между massI
и massJ
по осям x, y и z (dSq
) и квадратному корню из (dSq + s)
, где s — это так называемая константа размягчения (softeningConstant
). Включение в гравитационные расчёты константы смягчения предотвращает ситуацию, когда сила гравитации, создаваемая massJ
, становится бесконечно большой, поскольку она слишком близка к massI
. Эта «ошибка» в ньютоновской теории гравитации возникает по той причине, что ньютоновская гравитация рассматривает массы как точечные объекты, которыми они на самом деле не являются. Чтобы получить суммарное ускорение massI
вдоль, например, оси x, мы просто суммируем ускорение, возникающее из-за воздействия других тел в симуляции.
Преобразуем вышесказанное в код для обновления векторов ускорения всех тел.
Мы выполняем проход по всему массиву тел в симуляции и для каждого из них вычисляем влияние на ускорение других тел во вложенном цикле. И соответственно увеличиваем векторы ускорения. Как только выходим из вложенного цикла, обновляем векторы ускорения massI
, которые можно затем использовать для вычисления его новых векторов скорости. Теперь мы умеем обновлять векторы положения, скорости и ускорения N-объектного гравитационного моделирования с использованием численного интегрирования.
В текущих вычислениях не хватает единиц измерения. Логично будет выбрать шкалу, которая подходит для рассматриваемых величин, чтобы избежать неуклюжих длинных чисел. В случае с нашей Солнечной системой учёные склонны использовать астрономические единицы для расстояния, солнечную массу для сравнения с другими телами и года для времени. Принимая этот набор единиц, значение гравитационной постоянной g составляет 39,5. Для определения векторов положения и скорости всех тел обратимся к веб-интерфейсу HORIZONS, созданному NASA JPL, где изменим выходную настройку на векторные таблицы, а единицы — на астрономические единицы и дни. По какой-то причине Horizons не измеряет векторы годами в качестве единицы времени. Поэтому мы должны умножить векторы скорости на 365,25 (количество дней в году), чтобы получить векторы скорости, которые согласуются с нашим выбором единиц измерения времени (годы).
Класс JavaScript кажется отличным способом инкапсуляции методов, которые мы написали выше. Сделаем некоторый рефакторинг:
Так выглядит намного лучше. Создадим экземпляр класса. Для этого нам нужно указать три константы, а именно гравитационную постоянную (g
), шаг по времени моделирования (dt
) и постоянную размягчения (softeningConstant
). Также нужно заполнить массив объектами (небесными телами). Как только мы сделаем всё это, мы можем создать экземпляр класса nBodyProblem
, который назовём innerSolarSystem
, поскольку моделирование будет затрагивать внутреннюю часть Солнечной системы.
Вас может сбить с толку, казалось бы, ненужный парсинг и строковое представление JSON. Мы передаём данные, содержащиеся в массиве masses
, конструктору nBodyProblem
, чтобы пользователи могли сбросить симуляцию к исходной точке. Если же передать сам массив в конструктор при создании экземпляра класса, а затем установить значение свойства masses этого экземпляра равным массиву masses
, когда пользователь нажмет кнопку сброса, симуляция не вернётся к начальным данным. Состояние тел на конец предыдущего запуска симуляции будет прежним, как и любые другие тела, добавленные пользователем. Чтобы решить эту проблему, нужно передать клон массива masses
, когда создаётся экземпляр класса nBodyProblem
. Самый простой способ клонирования — это просто парсить строковую версию массива.
Для продвижения симуляции на следующий шаг вызываем:
Создание визуального отображения небесных тел
Можно было бы представить тела маленькими кружками, созданными с помощью метода arc()
в Canvas API, но визуализация траекторий тел в пространстве и времени была бы сложной. Поэтому напишем класс, который будет шаблоном для визуального отображения тел. Создадим круг, который оставляет за собой заранее определённое количество меньших по размеру и поблёкших кружков и передаёт движение и направление. Чем дальше вы удаляетесь от текущей позиции тела, тем меньшие по размеру и более затухающие будут круги. Таким образом, создадим красивую траекторию движения тел.
Конструктор принимает три аргумента:
- контекст рисования для элемента canvas (
ctx
); - длину траектории движения (
trailLength
), представляющую собой количество предыдущих позиций тела, которые след отображает; - радиус круга (
radius
), который представляет текущее положение тела.
В конструкторе также инициализируем пустой массив positions
, который будет хранить текущие и предыдущие позиции тела, включённые в траекторию движения.
На данном этапе наш класс отображения выглядит так:
Теперь необходимо заполнить массив positions
позициями и убедиться, что их хранится не больше, чем указано в свойстве trailLength
. Для этого нужно добавить в класс метод, который принимает координаты x
и y
в качестве аргументов и сохраняет их в массиве, используя метод push()
, который добавляет элемент в массив. Это означает, что текущая позиция тела будет последним элементом в массиве positions
. Чтобы убедиться, что мы не храним больше позиций, чем указано при создании экземпляра класса, проверяем, превышает ли длина массива positions
значение в свойстве trailLength
. Если превышает, используем метод shift()
, чтобы удалить первый элемент (самая старая сохраненная позиция массива).
Напишем метод, который рисует траекторию движения. Он будет принимать два аргумента, а именно положения x
и y
движущегося тела. Первое, что нужно сделать, это сохранить новую позицию в массиве positions
и отбросить всё лишние, хранящиеся в нём. Затем надо перебрать этот массив и нарисовать круг для каждой позиции. Но это по-прежнему выглядит не очень.
Нам нужен масштабный коэффициент, величина которого зависит от удалённости рисуемой позиции от текущей позиции тела. Хорошим способом будет просто разделить индекс круга i
на длину массива positions
. Например, если количество хранящихся элементов равно 25, элемент с номером 23 в этом массиве получит масштабный коэффициент 23/25 (0,92). Элемент 5 получит масштабный коэффициент 5/25 (0,2). Коэффициент масштабирования уменьшается по мере удаления от текущего положения тела. Обратите внимание, что нам нужно условие, гарантирующее, что масштабный коэффициент текущей позиции устанавливается равным 1 (т. к. этот круг не должен быть блёклым). Учитывая всё это, напишем код для метода draw()
в классе Manifestation
.
Визуализация симуляции
Напишем несколько canvas-шаблонов и свяжем их вместе с гравитационным N-объектным алгоритмом и траекториями движения, чтобы получить анимацию нашей Солнечной системы.
Прежде чем продолжить, вот HTML-код для симулятора:
Теперь перейдём к части JavaScript. Для начала нужна ссылка на canvas-элемент, после чего получаем его контекст рисования. Далее устанавливаем размеры. В данном примере установим свойства width
и height
canvas-элемента равными ширине и высоте окна браузера соответственно.
На данном этапе объявим некоторые константы для анимации:
- радиус круга (
radius
) — представляет текущее положение тела в пикселях; - длина следа движения (
trailLength
) — количество предыдущих позиций, которые включает траектория; - константа масштаба (
scale
) — количество пикселей на астрономическую единицу. Земля — это одна астрономическая единица, а Солнце — другая, поэтому, если не ввести этот масштабный коэффициент, наша внутренняя Солнечная система будет выглядеть слишком сжатой.
Теперь перейдём к визуальному отображению тел. Написанный ранее класс инкапсулирует поведение этих тел, но также нужно создавать экземпляры класса и работать с этими проявлениями в коде. Наиболее удобный и элегантный способ — заполнить каждый элемент массива masses
экземплярами класса Manifestation
. Напишем простой метод, который выполняет итерации по этим телам.
Предполагается, что симулятор должен иметь игровую форму, поэтому вполне можно ожидать, что пользователи будут создавать тела налево и направо и через некоторое время симуляция будет похожа на космический беспорядок. Было бы неплохо предоставить пользователям возможность сбросить симуляцию. Чтобы это сделать, нужно для начала прикрепить обработчик события к кнопке сброса. Затем написать для него callback, который передаёт значение свойства masses объекта innerSolarSystem
в клон массива masses
. Благодаря тому, что все действия проводились с клоном массива masses
, изначальный массив не содержит добавленных после тел. Поэтому вызываем метод populateManifestations()
, чтобы всё вернулось к некоторой исходной точке.
Достаточно настроек. Вдохнём немного жизни в симуляцию, написав метод, который с помощью requestAnimationFrame API будет выполнять 60 шагов моделирования в секунду и анимировать движения тел.
Первое, что делает этот метод — продвигает симуляцию на один шаг. Это происходит путём обновления векторов положения, ускорения и скорости тел. Затем выполняется подготовка элемента canvas для следующего цикла анимации (очистка от нарисованных в предыдущем цикле анимации элементов с помощью метода clearRect()
).
Далее происходит перебор массива и вызов метода draw()
для каждого отображения тела. Если тело имеет имя, также рисуем его в canvas, чтобы пользователь мог видеть, где находятся исходные планеты. Взглянув на код в цикле, вы можете заметить, что значение координаты x
нового добавленного тела не становится равна massI
, умноженному на масштаб. Вместо этого оно становится равно ширине области просмотра, делённой на (2 + massI
), умноженной на масштаб. Это нужно потому, что начало (x = 0, y = 0) системы координат холста задаётся в верхнем левом углу элемента canvas. Поэтому для центрирования моделирования необходимо задавать такое смещение.
После цикла в конце метода animate()
вызываем requestAnimationFrame
с методом animate()
в качестве callback. Затем весь процесс повторяется снова, создавая ещё один кадр. Всё это выполняется очень быстро, создавая видимость движения в симуляции. Но мы кое-что всё-таки упустили. Если запустить приведённый выше код, то вы ничего не увидите. Всё, что нужно сделать, — подтолкнуть Солнечную систему методом animate()
.
Теперь симулятор ожил. Тела представлены маленькими синими кругами с траекториями движения. Но ещё не реализован механизм добавления пользователем собственных тел с помощью мыши.
Добавление небесных тел в симуляцию
Идея заключается в том, чтобы пользователь имел возможность нарисовать с помощью мышки линию, зажав кнопку и переместив курсор. Направление линии и её длина должны определять направление движения и начальную скорость нового небесного тела соответственно. Новый объект симуляции создаётся как только линия начерчена, а пользователь отпускает зажатую клавишу мыши. Разберёмся, как можно шаг за шагом это реализовать. Код для шагов с первого по шестой располагается выше метода animate()
, код для седьмого шага является небольшим дополнением к этому методу.
- Задаём две переменные, которые будут хранить координаты
x
иy
, где пользователь нажимал кнопку мыши на экране.let mousePressX = 0;let mousePressY = 0; - Задаём две переменные, которые хранят координаты
x
иy
текущего положения курсора мыши на экране.let currentMouseX = 0;let currentMouseY = 0; - Задаём одну переменную, которая хранит состояние: перемещается мышь или нет. Мышь перетаскивается за время, прошедшее с момента, когда пользователь нажал кнопку мыши, до момента, когда он отпустил её.let dragging = false;
- Добавляем обработчик
mousedown
к элементу canvas, записывающий координатыx
иy
того места, где была нажата мышь, и устанавливает переменную перемещения в значение true.canvas.addEventListener( "mousedown", e => { mousePressX = e.clientX; mousePressY = e.clientY; dragging = true; }, false); - Добавляем обработчик
mousemove
к элементу canvas, записывающий координатыx
иy
текущего положения курсора мыши.canvas.addEventListener( "mousemove", e => { currentMouseX = e.clientX; currentMouseY = e.clientY; }, false); - Добавляем слушателя
mouseup
к элементу canvas, который устанавливает переменную перетаскивания в false, и помещает новый объект (небесное тело) в массивinnerSolarSystem.masses
. Координаты нажатия клавиши выражаются векторами позиции, разделёнными на значение переменной масштаба.
Если не поделить эти векторы на переменную масштаба, добавленные тела окажутся за пределами Солнечной системы. Вектор положенияz
установлен на ноль, как и вектор скоростиz
. Вектор скоростиx
установлен на координатуx
, где мышь была отпущена, минус координатаx
, где мышь была нажата, а затем всё это разделено на 35. 35 — это магическое число, которое просто присутствует, чтобы скорости добавляемых объектов были разумными. Та же процедура производится и для вектора скоростиy
. Масса тела (m
) задаётся пользователем с помощью элементаselect
, заполненного ранее массами некоторых известных небесных объектов в разметке HTML. И, наконец, заполняем объект (небесное тело) экземпляром классаManifestation
, чтобы пользователь мог видеть его на экране;const massesList = document.querySelector("#masses-list");canvas.addEventListener( "mouseup", e => { const x = (mousePressX - width / 2) / scale; const y = (mousePressY - height / 2) / scale; const z = 0; const vx = (e.clientX - mousePressX) / 35; const vy = (e.clientY - mousePressY) / 35; const vz = 0; innerSolarSystem.masses.push({ m: parseFloat(massesList.value), x, y, z, vx, vy, vz, manifestation: new Manifestation(ctx, trailLength, radius) }); dragging = false; }, false); - В функции
animate()
после цикла рисования и перед вызовомrequestAnimationFrame
проверяем, не перетаскивается ли мышь. Если это так, нарисуем линию между позицией нажатия мыши и текущей позицией курсора.const animate = () => { // Предшествующий код в методе animate() вплоть до цикла, // где рисуются изображения небесных тел if (dragging) { ctx.beginPath(); ctx.moveTo(mousePressX, mousePressY); ctx.lineTo(currentMouseX, currentMouseY); ctx.strokeStyle = "red"; ctx.stroke(); } requestAnimationFrame(animate);};
Ограничение симуляции
Вероятно вы заметили, что добавленные тела имеют тенденцию выходить за пределы моделируемой Солнечной системы, что очень не желательно. Естественное решение этой проблемы — ограничить симуляцию. Когда тело достигнет края холста, оно должно отскочить назад. Звучит довольно сложно, но, к счастью, сделать это легко. В конце цикла, где объекты перебираются и рисуются методом animate()
, нужно добавить два условия:
- проверяем, находится ли тело за пределами области просмотра на оси x;
- такое же условие для оси у.
Если тело находится вне области просмотра на оси x, обращаем его вектор скорости так, чтобы он возвращался обратно в область просмотра. Аналогично для оси y. При этих двух условиях метод animate()
будет выглядеть так:
Заключение
Люди склонны думать об орбитальной механике, как о чём-то, находящемся за пределами их понимания. Правда в том, что эта механика следует очень простому и элегантному набору правил. Немного JavaScript, математики и физики из средней школы, и вы смогли реконструировать внутреннюю часть Солнечной системы с довольно неплохой точностью. С помощью этого симулятора вы можете ответить на вопросы типа: «Что произошло, если бы появилась звезда с массой Солнца в нашей солнечной системе?», — или больше узнать о взаимосвязи между расстоянием тела от Солнца и его скоростью.
Если вам понравился этот материал и вы хотите посмотреть больше проектов по космосу и физике, загляните в этот репозиторий, а также посмотрите проект «Гармония сфер».
18К открытий19К показов