Интерактивная визуализация физики и математики на C++

Курс погружает в создание интерактивных физико-математических симуляций реального времени с использованием C++, SFML и OpenGL. Вы научитесь переносить сложные формулы в эффективный код, моделировать кинематику, динамику и разрабатывать собственные графические движки.

1. Особенности C++ для графики и симуляций

Особенности C++ для графики и симуляций

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

Переход от базового понимания синтаксиса к созданию физических движков реального времени требует смены парадигмы мышления. В скриптовых языках, таких как Python или JavaScript, разработчик часто отдалён от того, как данные хранятся в памяти компьютера. В C++ память — это ваш главный инструмент и ваша главная ответственность.

Для создания плавной симуляции, которая обновляется 60 раз в секунду (каждые 16 миллисекунд), нам необходимо понимать три фундаментальных аспекта: как эффективно управлять памятью, как переносить математические абстракции в код и как работает непрерывный цикл обновления состояния системы.

Управление памятью: Стек, Куча и Указатели

В физической симуляции количество объектов может меняться динамически. Например, при взрыве астероида один объект мгновенно превращается в тысячу мелких осколков. Чтобы компьютер справился с этой задачей, нужно понимать разницу между двумя областями памяти: стеком и кучей.

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

Куча (Heap) — это огромное пространство оперативной памяти, доступное вашей программе. Здесь вы можете запрашивать гигабайты памяти для хранения миллионов частиц. Но выделение и освобождение памяти в куче происходит медленнее и требует ручного управления (или использования умных обёрток).

> Управление памятью в C++ похоже на работу склада. Стек — это ваш рабочий стол: вы можете быстро положить на него чертёж и так же быстро убрать, но места мало. Куча — это огромный ангар: места хватит для тысяч ящиков, но чтобы найти нужный, вам потребуется записать его точные координаты.

Этими «координатами» в C++ выступают указатели — переменные, которые хранят не само значение (например, число 5), а адрес в памяти, где это значение находится.

Рассмотрим пример с числами. Допустим, у нас есть частица, и мы хотим хранить её массу. Если мы создадим миллион таких частиц неправильно, оперативная память быстро закончится, а программа аварийно завершится (произойдёт переполнение стека).

В современном C++ для безопасной работы с динамической памятью (кучей) используются стандартные контейнеры, такие как std::vector. Это динамический массив, который сам берёт на себя работу с указателями.

В этом примере std::vector выделяет память для 100 000 частиц в куче, но сам объект simulation (управляющий этой памятью) находится на стеке. Когда функция main завершится, память в куче будет автоматически и безопасно очищена.

Архитектура данных: ООП против DOD

Когда мы переходим к симуляции физики, классическое объектно-ориентированное программирование (ООП) может стать нашим врагом в борьбе за производительность. В ООП принято объединять все свойства и методы объекта в одну сущность. Это называется Массив структур (Array of Structures, AoS).

Представьте класс Asteroid, который содержит свои координаты, скорость, массу, текстуру для отрисовки, звуковой файл для столкновения и логику искусственного интеллекта. Если у нас 10 000 астероидов, они выстраиваются в памяти друг за другом со всем этим багажом.

Проблема кроется в том, как работает кэш процессора. Процессор читает данные из оперативной памяти не по одному байту, а блоками (обычно по 64 байта). Если физическому движку нужно обновить только координаты всех астероидов, он загружает в сверхбыстрый кэш весь блок данных первого астероида (включая текстуры и звуки, которые сейчас не нужны), обновляет координаты, отбрасывает блок и берёт следующий. Это приводит к постоянным промахам кэша (Cache miss) — процессор простаивает, ожидая загрузки новых данных из медленной оперативной памяти.

Для высокопроизводительных симуляций используется Data-Oriented Design (DOD) — проектирование, ориентированное на данные. Вместо массива структур используется Структура массивов (Structure of Arrays, SoA).

| Характеристика | Массив структур (AoS / ООП) | Структура массивов (SoA / DOD) | | :--- | :--- | :--- | | Организация | [{x, y, color}, {x, y, color}] | {x: [], y: [], color: []} | | Чтение памяти | Прерывистое (много лишних данных) | Непрерывное (только нужные данные) | | Скорость обработки | Медленная при массовых обновлениях | Экстремально быстрая (векторизация) | | Применение | UI, бизнес-логика, одиночные объекты | Системы частиц, физика жидкостей, стаи |

При подходе DOD мы создаём один объект AsteroidSystem, внутри которого есть отдельные массивы: массив всех координат X, массив всех координат Y, массив всех масс. Когда физический движок обновляет позиции, процессор загружает в кэш только координаты — плотным потоком, без лишнего мусора. Производительность симуляции может вырасти в 5–10 раз только за счёт правильного расположения данных в памяти.

Математика в коде: Перегрузка операторов

Физика описывается математическими формулами, в основе которых лежат векторы. Вектор в физике — это направленный отрезок, имеющий длину и направление. В 2D-пространстве он описывается двумя числами: и .

Второй закон Ньютона гласит, что сила равна массе, умноженной на ускорение:

Где — вектор силы (в Ньютонах), — масса тела (в килограммах, скалярная величина), а — вектор ускорения (в метрах на секунду в квадрате). Жирным шрифтом в физике традиционно обозначаются векторы.

Если бы мы писали это на базовом уровне без использования возможностей C++, код выглядел бы громоздко:

А теперь представьте формулу гравитационного притяжения между двумя телами. Код превратится в нечитаемую простыню. Здесь на помощь приходит перегрузка операторов — мощная возможность C++, позволяющая научить стандартные математические знаки (+, -, *, /) работать с вашими собственными типами данных.

Мы можем создать структуру Vector2f (вектор из двух чисел с плавающей точкой) и объяснить компилятору, что значит умножить вектор на число:

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

Такой подход делает код самодокументируемым. Библиотека SFML, которую мы будем использовать в дальнейшем, уже имеет встроенный класс sf::Vector2f с перегруженными операторами, что невероятно упростит нам расчёт кинематики.

Игровой цикл и Дельта-время

Любая интерактивная визуализация или игра работает на основе бесконечного цикла, который называется Игровым циклом (Game Loop). Это сердцебиение вашей программы. Пока окно открыто, цикл повторяется десятки или сотни раз в секунду.

Классический игровой цикл состоит из трёх этапов:

  • Обработка ввода (Input): считывание нажатий клавиш, движения мыши, закрытия окна.
  • Обновление состояния (Update): расчёт физики, перемещение объектов, проверка столкновений.
  • Отрисовка (Render): очистка экрана и рисование объектов в их новых позициях.
  • !Схема классического игрового цикла: от обработки ввода до отрисовки кадра на экране

    Самая большая ловушка для начинающих разработчиков симуляций — привязка скорости движения к частоте кадров (FPS).

    Допустим, на этапе Update вы пишете: x = x + 5. Это означает, что каждый кадр объект сдвигается на 5 пикселей вправо. Если у вас мощный компьютер, выдающий 120 кадров в секунду, объект пролетит 600 пикселей за секунду. Но если вы запустите ту же программу на старом ноутбуке, который выдаёт 30 кадров в секунду, объект пролетит всего 150 пикселей. Симуляция будет вести себя абсолютно по-разному на разных устройствах.

    Решение этой проблемы — использование Дельта-времени ().

    Дельта-время — это время, прошедшее между предыдущим и текущим кадром. Вместо того чтобы двигать объект на фиксированное расстояние за кадр, мы задаём ему скорость в пикселях (или метрах) в секунду, а затем умножаем на время, прошедшее с прошлого кадра.

    Формула равномерного прямолинейного движения выглядит так:

    Где — новая позиция объекта, — текущая позиция, — скорость объекта (например, 100 пикселей в секунду), а — дельта-время (доля секунды, например 0.016 с).

    !Попробуйте изменить частоту кадров (FPS) и посмотрите, как дельта-время спасает симуляцию от рассинхронизации

    Если компьютер работает быстро (100 FPS), будет маленьким (0.01 с). За один кадр объект сдвинется на пиксель. За 100 кадров (одну секунду) он пройдёт 100 пикселей. Если компьютер тормозит (10 FPS), будет большим (0.1 с). За один кадр объект сдвинется на пикселей. За 10 кадров (одну секунду) он всё равно пройдёт ровно 100 пикселей!

    Использование отвязывает физику от скорости рендеринга графики, делая симуляцию детерминированной и плавной на любом оборудовании.

    Связь с графическими библиотеками

    Сам по себе C++ не умеет рисовать на экране. Стандартная библиотека языка знает только о тексте в консоли. Чтобы визуализировать наши векторы и частицы, нам нужен мост между математикой процессора и пикселями видеокарты.

    В этом курсе мы будем опираться на SFML (Simple and Fast Multimedia Library). Это объектно-ориентированная библиотека, написанная на C++, которая берёт на себя самую сложную низкоуровневую работу: создание окна операционной системы, захват контекста OpenGL и передачу команд видеокарте.

    SFML идеально подходит для изучения физики, так как она предоставляет готовые классы для векторов (sf::Vector2f), управления временем (sf::Clock для получения ) и базовых геометрических фигур (sf::CircleShape, sf::ConvexShape). Вам не придётся писать сотни строк кода для инициализации графического конвейера, как это было бы в чистом OpenGL или Vulkan. Вы сможете сосредоточиться на главном — на математике и физике процессов.

    В следующей статье мы настроим рабочее окружение, подключим SFML к нашему проекту на C++ и напишем нашу первую программу, в которой реализуем правильный игровой цикл с дельта-временем и заставим объект двигаться по экрану по законам кинематики.

    10. Гравитация и орбитальные механики

    Гравитация и орбитальные механики

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

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

    Закон всемирного тяготения

    В 1687 году Исаак Ньютон сформулировал закон, который навсегда изменил наше понимание мира. Он понял, что яблоко, падающее на землю, и Луна, вращающаяся вокруг Земли, подчиняются одному и тому же математическому правилу.

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

    Математически это записывается так:

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

    Обратите внимание на знаменатель . Это проявление закона обратных квадратов.

    Представьте себе лампочку, висящую в центре комнаты. Свет от неё распространяется во все стороны, образуя расширяющуюся сферу. Чем дальше вы от лампочки, тем по большей площади размазывается одно и то же количество света. Поскольку площадь сферы растет пропорционально квадрату радиуса, интенсивность света падает в квадрате. Гравитация ведет себя точно так же: если вы увеличите расстояние между планетами в 2 раза, сила притяжения между ними упадет в 4 раза. Увеличите в 3 раза — сила упадет в 9 раз.

    Перенос формулы в код на C++

    Как программистам, нам нужно перевести эту элегантную формулу в эффективный код. У нас есть два объекта с позициями и массами. Нам нужно вычислить вектор силы и добавить его в аккумулятор сил каждого объекта.

    Наивная реализация «в лоб» могла бы выглядеть так:

    Этот код математически верен, но он содержит избыточные вычисления. Мы сначала извлекаем квадратный корень std::sqrt, чтобы получить distance, а затем в следующей же строке возводим его обратно в квадрат (distance * distance). Операция извлечения корня — одна из самых медленных математических инструкций для процессора. Когда мы будем симулировать тысячи частиц, это станет узким местом.

    Давайте оптимизируем математику. Мы знаем, что нормализованный вектор равен вектору смещения , деленному на его длину :

    Подставим это в исходную формулу Ньютона:

    Теперь мы можем переписать наш код более эффективно, вычисляя куб расстояния:

    Обратите внимание на последние две строки. Мы используем Третий закон Ньютона: сила, с которой тело 2 притягивает тело 1, равна по модулю и противоположна по направлению силе, с которой тело 1 притягивает тело 2. Это позволяет нам вычислять гравитацию один раз для пары объектов, сокращая количество вычислений ровно вдвое!

    Проблема сингулярности и гравитационное смягчение

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

    В математике это называется сингулярностью. В коде это приводит к делению на ноль (или на микроскопическое число), в результате чего forceMagnitude становится NaN (Not a Number) или Infinity. В следующем кадре эти значения попадут в скорость и позицию, и ваши планеты навсегда исчезнут с экрана.

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

    Для этого применяется техника гравитационного смягчения (Gravitational softening). Мы добавляем небольшое константное значение (эпсилон) к квадрату расстояния:

    В коде это выглядит как крошечная правка:

    Теперь, даже если центры объектов идеально совпадут (), знаменатель будет равен softeningSq, и сила останется конечной. Объекты плавно пролетят друг сквозь друга, как призраки, совершив красивый гравитационный маневр.

    Орбитальная механика: искусство промахиваться мимо земли

    Почему Луна не падает на Землю? На самом деле, она падает. Каждую секунду. Просто она движется вбок с такой огромной скоростью, что поверхность Земли успевает «закруглиться» и уйти из-под неё.

    Эту концепцию блестяще проиллюстрировал сам Ньютон с помощью мысленного эксперимента, известного как «Пушка Ньютона».

    !Схема мысленного эксперимента 'Пушка Ньютона'. На вершине земного шара стоит пушка. Показаны 4 траектории: A и B (снаряд падает на землю), C (снаряд огибает Землю по круговой орбите), D (снаряд улетает по эллипсу) и E (снаряд покидает гравитационное поле).

    Если выстрелить из пушки с небольшой скоростью, ядро пролетит немного и упадет по параболе. Если увеличить скорость, ядро улетит дальше. Но Земля круглая. Если придать ядру строго определенную тангенциальную скорость (скорость, направленную по касательной к поверхности), то кривизна траектории падения ядра в точности совпадет с кривизной планеты. Ядро будет вечно падать, но так и не достигнет земли. Это и есть орбита.

    Вычисление первой космической скорости

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

    Для круговой орбиты центростремительная сила должна быть в точности равна силе гравитации:

    Где — масса центрального тела (звезды), а — масса вращающегося тела (планеты). Масса планеты сокращается с обеих сторон. Решая уравнение для скорости , получаем:

    Это скалярное значение скорости. Но в C++ нам нужен вектор sf::Vector2f. Как его получить?

    Мы знаем вектор направления от звезды к планете. Тангенциальная скорость должна быть перпендикулярна этому радиус-вектору. Как мы помним из главы по векторной алгебре, чтобы повернуть 2D-вектор на 90 градусов, нужно поменять его компоненты местами и инвертировать один знак: или , в зависимости от того, по часовой или против часовой стрелки мы хотим запустить планету.

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

    !Попробуйте изменить начальную скорость планеты. При 100% от расчетной скорости орбита круговая. Уменьшите до 70% — получите вытянутый эллипс. Увеличьте до 150% — и планета навсегда покинет звездную систему.

    Проблема N-тел (N-Body Problem)

    Пока мы рассматриваем систему из двух тел (звезда и планета), их движение можно описать точными аналитическими формулами (законами Кеплера). Но стоит нам добавить в систему всего одно дополнительное тело — вторую планету, — как математика ломается.

    Гравитация действует на всё. Звезда притягивает планету 1, звезда притягивает планету 2, но и планета 1 притягивает планету 2, слегка искажая её орбиту. Это знаменитая Проблема трех тел, которая не имеет общего аналитического решения. Траектории становятся хаотичными и непредсказуемыми на больших промежутках времени.

    Именно поэтому мы используем численное интегрирование (Эйлера или Верле). Наш движок не пытается предсказать будущее по формуле, он просто считает силы в текущий момент времени и делает крошечный шаг вперед.

    Но когда мы переходим от трех тел к симуляции целой галактики (например, 10 000 звезд), возникает вычислительная проблема. Каждая звезда притягивает каждую другую звезду.

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

    | Количество тел () | Количество вычислений гравитации за 1 кадр | При 60 FPS (вычислений в секунду) | | :--- | :--- | :--- | | 2 | 1 | 60 | | 10 | 45 | 2 700 | | 100 | 4 950 | 297 000 | | 1 000 | 499 500 | ~30 миллионов | | 10 000 | 49 995 000 | ~3 миллиарда (CPU начнет тормозить) |

    Квадратичный рост — злейший враг программиста. Если вы попытаетесь запустить двойной цикл for для 100 000 частиц, ваша программа зависнет.

    Алгоритм Барнса-Хата (Barnes-Hut)

    Как физические движки справляются с симуляцией миллионов частиц? Они используют хитрые структуры данных для аппроксимации (приближения) гравитации. Самый известный метод — Алгоритм Барнса-Хата.

    Идея гениальна в своей простоте. Представьте, что вы находитесь на Земле и смотрите на галактику Андромеды, состоящую из триллиона звезд. Будет ли ваш компьютер вычислять силу притяжения между вами и каждой отдельной звездой в Андромеде? Нет. Поскольку Андромеда находится невероятно далеко, мы можем математически «схлопнуть» весь этот триллион звезд в одну гигантскую мега-звезду, расположенную в центре масс галактики, и посчитать гравитацию только один раз.

    Алгоритм Барнса-Хата рекурсивно делит 2D-пространство на квадранты (используя структуру данных Quadtree — Квадродерево). Если группа звезд находится достаточно далеко от текущей частицы, алгоритм перестает спускаться по дереву и считает всю группу за один массивный объект. Это снижает сложность с до , позволяя симулировать сотни тысяч тел в реальном времени.

    Реализация Квадродерева выходит за рамки этой статьи, но это именно тот путь, по которому вам предстоит пойти, если вы захотите превратить свою пет-симуляцию в масштабный проект уровня Universe Sandbox.

    В следующей главе мы отойдем от космических масштабов и вернемся на землю, чтобы разобраться с самой сложной частью любой физической симуляции — обнаружением и разрешением столкновений (Collision Detection). Мы узнаем, почему объекты проваливаются сквозь пол и как математика проекций помогает выталкивать их обратно.

    11. Обнаружение столкновений простых фигур

    Обнаружение столкновений простых фигур

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

    Процесс обработки столкновений в физических движках всегда делится на два независимых этапа: обнаружение столкновений (Collision Detection) — математический ответ на вопрос «пересекаются ли эти два объекта прямо сейчас?», и разрешение столкновений (Collision Resolution) — физический ответ на вопрос «как изменить их координаты и скорости, чтобы они перестали пересекаться?».

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

    AABB: Выровненный по осям ограничивающий объем

    Самый простой и быстрый способ проверить пересечение двух объектов — использовать AABB (Axis-Aligned Bounding Box). Это прямоугольник, стороны которого строго параллельны осям координат X и Y. Он не может вращаться.

    Представьте, что вы смотрите на план комнаты сверху. У вас есть два прямоугольных ковра, лежащих строго параллельно стенам. Как понять, что один ковер налез на другой? Вам не нужно измерять углы или сложные диагонали. Достаточно проверить их координаты по ширине комнаты (ось X) и по длине (ось Y).

    Математически AABB описывается всего двумя точками: минимальной (левый верхний угол в экранных координатах) и максимальной (правый нижний угол).

    Два AABB пересекаются тогда и только тогда, когда их проекции пересекаются одновременно на обеих осях.

    Условие пересечения по оси X: Левый край первого прямоугольника должен быть левее правого края второго, А правый край первого — правее левого края второго.

    В коде на C++ эта логика выглядит невероятно лаконично:

    Почему AABB так популярен? Из-за скорости. Процессору нужно выполнить всего 4 быстрых операции сравнения (<=, >=) и логическое И (&&). Никаких корней, тригонометрии или умножений. В играх вроде Minecraft или классическом Mario почти вся физика построена исключительно на AABB.

    Столкновение окружностей

    Если AABB идеален для платформ и стен, то для симуляции частиц, бильярдных шаров или планет лучше всего подходят окружности.

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

    Где — расстояние между центрами, а и — радиусы первой и второй окружности соответственно.

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

    Реализация на C++:

    Проблема проникновения и нормаль столкновения

    Функции, написанные выше, возвращают true или false. Этого достаточно, чтобы запустить звук удара или перекрасить объект в красный цвет. Но для физики этого мало.

    В дискретной симуляции (работающей по кадрам) объекты не останавливаются точно в момент касания. За время одного кадра (dt) быстрый объект успевает глубоко «вонзиться» в другой. Это называется проникновением (Penetration).

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

  • Нормаль столкновения (Collision Normal) — единичный вектор, указывающий направление, в котором нужно расталкивать объекты.
  • Глубина проникновения (Penetration Depth) — скалярное число, показывающее, на сколько пикселей (или метров) объекты вошли друг в друга.
  • !Схема пересечения двух окружностей. Показаны центры A и B, радиусы r1 и r2. Вектор нормали направлен от A к B. Красным цветом выделена зона перекрытия — глубина проникновения.

    Для окружностей нормаль — это просто нормализованный вектор разности их позиций. А глубина проникновения — это разница между суммой радиусов и фактическим расстоянием.

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

    Позиционная коррекция (Positional Correction)

    Зная нормаль и глубину , мы можем раздвинуть объекты. Если мы сдвинем первый объект на половину глубины назад по нормали, а второй — на половину глубины вперед, они ровно коснутся краями.

    Однако, если легкий теннисный мячик врезается в тяжелое пушечное ядро, они не должны сдвигаться одинаково. Мячик должен отскочить сильнее, а ядро — едва сдвинуться. Здесь нам на помощь приходит паттерн 'Обратная масса' (Inverse Mass), который мы ввели в статье по динамике.

    Мы распределяем выталкивание пропорционально обратным массам объектов:

    Этот код гарантирует, что статичная стена (масса = бесконечность, обратная масса = 0) не сдвинется ни на пиксель, а всю позиционную коррекцию возьмет на себя динамический объект.

    Пространственное разделение: Broad-phase и Narrow-phase

    Мы научились сталкивать два объекта. Но что если у нас в симуляции 10 000 частиц песка?

    Как мы обсуждали в главе про гравитацию, проверка «каждого с каждым» дает алгоритмическую сложность . Для 10 000 частиц это почти 50 миллионов проверок каждый кадр. Даже оптимизированная проверка AABB поставит процессор на колени.

    Поэтому архитектура физических движков всегда делится на две фазы:

  • Broad-phase (Широкая фаза) — быстрый, грубый алгоритм, который отсеивает пары объектов, которые точно не могут столкнуться, потому что находятся слишком далеко друг от друга.
  • Narrow-phase (Узкая фаза) — точная, математически сложная проверка (как наш resolvePenetration), которая применяется только к тем немногим парам, которые прошли широкую фазу.
  • Равномерная сетка (Uniform Grid)

    Самый популярный метод для Broad-phase в 2D-симуляциях с одинаковыми объектами (например, жидкость или песок) — это Равномерная сетка (Uniform Grid), также известная как пространственное хеширование.

    Представьте работу почтовой службы. Чтобы доставить письмо, почтальон не проверяет каждый дом в стране. Страна поделена на индексы (регионы), регионы — на улицы. Почтальон ищет нужный дом только на конкретной улице.

    Мы делим весь наш экран на квадратные ячейки. Размер ячейки обычно выбирается равным диаметру самого большого объекта в симуляции.

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

  • Очищаем все ячейки сетки.
  • Проходим по всем объектам. Для каждого объекта вычисляем, в какой ячейке он находится, используя целочисленное деление:
  • Добавляем указатель на объект в соответствующую ячейку.
  • Теперь самое главное: когда нам нужно проверить столкновения для конкретного объекта, мы не перебираем все 10 000 частиц. Мы смотрим, в какой ячейке находится наш объект, и проверяем столкновения только с объектами в этой же ячейке и в 8 соседних ячейках.

    Почему нужно проверять соседей? Потому что объект может находиться на самой границе ячейки, а его правый край будет пересекаться с объектом, центр которого лежит в соседней правой ячейке.

    Благодаря равномерной сетке, количество проверок для каждой частицы падает с 10 000 до, в среднем, 5-10. Сложность алгоритма снижается с до почти , что позволяет симулировать огромные массивы физических тел в реальном времени.

    В этой статье мы разобрали, как выталкивать объекты друг из друга, корректируя их позиции. Однако, если вы запустите этот код, объекты просто слипнутся или будут неестественно скользить. Чтобы они реалистично отскакивали, передавая импульс, нам нужно будет изменить их скорости с учетом закона сохранения импульса и коэффициента упругости (Restitution). Именно этим мы займемся в следующей главе.

    12. Реакция на столкновения: импульс и упругость

    Реакция на столкновения: импульс и упругость

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

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

    Импульс и закон его сохранения

    В основе механики столкновений лежит понятие импульса (или количества движения). Это векторная величина, которая описывает «инерционный заряд» движущегося тела.

    Импульс вычисляется как произведение массы тела на его скорость:

    Где — вектор импульса, — масса (скаляр), — вектор скорости.

    Представьте летящий теннисный мяч и медленно катящийся грузовик. Скорость мяча огромна, но его масса ничтожна, поэтому остановить его легко (импульс мал). Грузовик движется со скоростью пешехода, но из-за колоссальной массы его импульс огромен — бетонная стена при столкновении будет разрушена.

    Главное правило, которым руководствуется физический движок при обработке ударов — Закон сохранения импульса. Он гласит: в замкнутой системе векторная сумма импульсов всех тел до столкновения равна векторной сумме их импульсов после столкновения.

    Где — скорости до удара, а — скорости после удара.

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

    Относительная скорость и нормаль

    Чтобы вычислить, как именно изменятся скорости, нам нужно знать, насколько сильно объекты «впечатались» друг в друга. Для этого абсолютные скорости объектов в мировом пространстве бесполезны. Нас интересует относительная скорость — скорость одного объекта с точки зрения другого.

    Но и этого вектора недостаточно. Представьте два автомобиля, едущих параллельно по соседним полосам со скоростью 100 км/ч. Их скорости огромны, но они не сталкиваются. А теперь представьте, что один из них начал перестраиваться и задел соседа в бок со скоростью 5 км/ч. Удар будет слабым, несмотря на общую скорость потока.

    В физическом движке нас интересует только та часть относительной скорости, которая направлена строго навстречу другому объекту. И здесь нам на помощь приходит скалярное произведение (Dot Product) и нормаль столкновения, которую мы научились находить в прошлой статье.

    !Разложение вектора относительной скорости: нас интересует только проекция на нормаль столкновения.

    Мы проецируем вектор относительной скорости на вектор нормали:

    Где — скалярная величина скорости сближения, — вектор относительной скорости, — единичный вектор нормали столкновения.

    Знак полученного числа критически важен: * Если , объекты уже разлетаются в разные стороны. Столкновение обрабатывать не нужно. * Если , объекты сближаются. Чем меньше число (чем оно отрицательнее), тем жестче будет удар.

    Коэффициент упругости (Restitution)

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

    Это свойство материалов описывается коэффициентом упругости (обозначается буквой , от английского restitution). Это скалярное значение в диапазоне от 0 до 1, которое показывает, какая доля относительной скорости сохранится после удара (но поменяет направление).

    * Абсолютно упругий удар (): Кинетическая энергия сохраняется полностью. Объекты отскакивают с той же скоростью, с которой сближались. Идеально для бильярда или пинбола. * Абсолютно неупругий удар (): Энергия удара полностью гасится (переходит в тепло и деформацию). Объекты слипаются и продолжают движение как единое целое. Идеально для снежков или грязи.

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

    !Измените массу и упругость — и посмотрите, как распределяется энергия при ударе

    Вычисление скалярного импульса

    Теперь у нас есть все элементы пазла: мы знаем массу объектов, скорость их сближения по нормали и их «прыгучесть». Наша задача — найти одно магическое число, скалярный импульс столкновения (обозначается ).

    Это число покажет силу, с которой объекты оттолкнут друг друга. Формула для вычисления выводится из объединения закона сохранения импульса и определения коэффициента упругости:

    Разберем эту формулу по частям:

  • Числитель: . Мы берем скорость сближения (которая отрицательна, поэтому минус в начале делает результат положительным) и умножаем на упругость. Если , мы удваиваем импульс (чтобы остановить объект и запустить его обратно). Если , мы берем ровно столько импульса, чтобы просто погасить скорость сближения до нуля.
  • Знаменатель: . Это сумма обратных масс. Как мы помним, использование обратной массы элегантно решает проблему статических объектов (стен). Если стена имеет бесконечную массу, её обратная масса равна 0. Знаменатель никогда не станет нулем, пока хотя бы один объект динамический.
  • Применение импульса в коде

    Получив скаляр , мы должны превратить его обратно в вектор, направив вдоль нормали столкновения. Затем этот вектор импульса применяется к скоростям обоих объектов пропорционально их обратной массе.

    Реализация на C++:

    Обратите внимание на знаки в 6-м шаге. Нормаль всегда направлена от объекта A к объекту B. Поэтому импульс толкает объект B по направлению нормали (плюс), а объект A — в обратную сторону (минус). Статичная стена с inverseMass = 0 умножит импульс на ноль и её скорость останется нулевой.

    Проблема покоящегося контакта (Jitter)

    Если вы реализуете описанный выше алгоритм и бросите кубик на пол, вы заметите неприятный визуальный баг. Кубик упадет, отскочит пару раз, а затем, лежа на полу, начнет мелко дрожать. Это явление называется дребезгом контактов (Resting contact или Jitter).

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

    Чтобы решить эту проблему, физические движки вводят порог скорости (Velocity Threshold). Если скорость сближения объектов по нормали меньше определенного крошечного значения (например, меньше скорости, которую гравитация придает за один кадр), мы искусственно обнуляем коэффициент упругости для этого конкретного удара.

    Это заставляет алгоритм не пытаться оттолкнуть объект, а лишь погасить ту микроскопическую скорость, которую добавила гравитация. Объект плавно замирает на поверхности.

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

    13. Динамика твердого тела: вращение и момент силы

    Динамика твердого тела: вращение и момент силы

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

    Чтобы симуляция стала по-настоящему реалистичной, нам нужно перейти от модели материальной точки к модели абсолютно твердого тела. Это означает, что наши объекты больше не могут просто перемещаться в пространстве — они должны научиться вращаться.

    Разделение движений: Центр масс

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

  • Поступательное движение центра масс.
  • Вращательное движение всех остальных точек тела вокруг центра масс.
  • Центр масс — это уникальная геометрическая точка объекта, в которой как бы сосредоточена вся его масса. Если вы подбросите в воздух молоток, он будет кувыркаться совершенно хаотично на первый взгляд. Но если вы проследите исключительно за его центром масс (который находится ближе к тяжелому металлическому бойку), вы увидите, что эта точка летит по идеальной математической параболе, подчиняясь законам баллистики, которые мы уже реализовали.

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

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

    Момент силы: Заставляем объекты вращаться

    В линейной динамике причиной изменения скорости является сила. Если мы хотим сдвинуть ящик, мы прикладываем к нему вектор силы. Но что заставляет объекты начать вращаться?

    Представьте тяжелую дубовую дверь. Если вы упретесь руками прямо в петли и начнете толкать, дверь не сдвинется с места, какую бы огромную силу вы ни приложили. Если вы будете толкать дверь в торец, параллельно её полотну, она тоже не откроется. Чтобы легко открыть дверь, нужно толкать её за ручку (как можно дальше от петель) и строго перпендикулярно поверхности.

    Способность силы вызывать вращение описывается физической величиной, которая называется моментом силы (Torque).

    Где: * (греческая буква тау) — вектор момента силы. * — вектор плеча силы, проведенный от оси вращения (центра масс) к точке приложения силы. * — вектор приложенной силы. * — оператор векторного произведения.

    В 3D-пространстве момент силы — это вектор, который показывает ось, вокруг которой начнется вращение. Но в 2D-симуляции (на плоскости экрана) объекты могут вращаться только вокруг одной оси — той, что смотрит прямо на нас из монитора (ось Z). Поэтому в 2D векторное произведение упрощается до скалярного значения.

    Формула момента силы для 2D-движка выглядит так:

    Где — координаты вектора плеча, а — координаты вектора силы.

    Результат — это обычное число (скаляр). Если оно положительное, объект начнет закручиваться против часовой стрелки. Если отрицательное — по часовой. Если (например, сила направлена точно в центр масс), вращения не произойдет вообще, объект получит только линейное ускорение.

    Момент инерции: Сопротивление вращению

    Мы знаем, какую «вращательную силу» приложили. Теперь нужно понять, насколько быстро объект раскрутится.

    В линейной физике сопротивление ускорению определяется массой. Тяжелый грузовик разогнать сложнее, чем легкий велосипед. Во вращательном движении масса тоже важна, но появляется еще один критический фактор — распределение этой массы относительно оси вращения.

    Мера сопротивления тела изменению его скорости вращения называется моментом инерции (обозначается буквой ).

    Где: * — момент инерции. * — масса крошечной частицы, из которых состоит тело. * — расстояние от этой частицы до оси вращения (центра масс).

    Обратите внимание на квадрат расстояния (). Это означает, что масса, расположенная далеко от центра, сопротивляется вращению в геометрической прогрессии сильнее, чем масса вблизи центра.

    Классический пример — фигуристка, выполняющая вращение на льду. Когда она прижимает руки к груди (уменьшает для массы своих рук), её момент инерции резко падает, и она начинает вращаться с огромной скоростью. Когда она раскидывает руки в стороны, момент инерции возрастает, и вращение замедляется.

    !Измените распределение массы на стержне и приложите силу. Посмотрите, как расстояние грузов от центра влияет на скорость раскручивания.

    В физическом движке нам не нужно суммировать миллионы частиц каждый кадр. Для стандартных геометрических примитивов момент инерции вычисляется заранее по готовым аналитическим формулам (через интегралы):

    | Форма объекта | Формула момента инерции | Пояснение | | :--- | :--- | :--- | | Сплошной круг | | — масса, — радиус круга. | | Пустотелое кольцо | | Вся масса на краях, поэтому кольцо раскрутить в 2 раза сложнее, чем сплошной круг той же массы. | | Прямоугольник | | — ширина, — высота. |

    Второй закон Ньютона для вращения

    Теперь у нас есть все компоненты, чтобы перенести Второй закон Ньютона в мир углов и вращений.

    Линейная версия гласит: Ускорение равно силе, деленной на массу (). Угловая версия звучит так: Угловое ускорение равно моменту силы, деленному на момент инерции.

    Где: * (альфа) — угловое ускорение (измеряется в радианах в секунду за секунду, рад/с²). * — суммарный момент силы. * — момент инерции.

    Как и в случае с массой, в коде движка мы будем хранить не сам момент инерции, а обратный момент инерции (). Это позволяет нам элегантно обрабатывать статические объекты (например, неподвижные платформы). Если мы зададим платформе обратный момент инерции равный нулю, любое умножение на него даст ноль углового ускорения, и платформа никогда не сдвинется с места, какие бы силы к ней ни прикладывались.

    Интегрирование вращения в коде на C++

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

    Теперь реализуем функцию применения силы к произвольной точке объекта. Эта функция — сердце взаимодействия объектов. Когда пуля попадает в ящик, она бьет не в центр масс, а в конкретную точку на поверхности.

    Обратите внимание на красоту физики: одна и та же сила force делает сразу две работы. Она толкает объект вперед (добавляясь к forceAccum) и одновременно закручивает его (добавляясь к torqueAccum).

    Наконец, шаг численного интегрирования. Мы используем полунеявный метод Эйлера, обновляя сначала скорости, а затем позиции. Теперь мы делаем это параллельно для линейных и угловых величин.

    Отрисовка повернутого объекта

    Физика посчитана, объект сдвинулся и повернулся в памяти компьютера. Как теперь отобразить это на экране с помощью SFML?

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

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

    Однако добавление углов создает новую проблему для нашего конвейера обнаружения столкновений. Ранее мы использовали AABB (выровненные по осям прямоугольники), которые не умеют вращаться. В следующей статье мы разберем алгоритм SAT (Теорема о разделяющих осях), который позволит нам находить пересечения любых выпуклых многоугольников под любым углом наклона.

    14. Симуляция пружин и закон Гука

    Симуляция пружин и закон Гука

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

    Ответ на все эти вопросы кроется в одном из самых элегантных механизмов классической механики — пружинах.

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

    Закон Гука: Природа возвращающей силы

    В основе любой упругой деформации лежит физический принцип, открытый английским ученым Робертом Гуком в 1660 году. Закон Гука гласит, что сила упругости, возникающая при деформации тела, прямо пропорциональна удлинению этого тела.

    В виде классической математической формулы это записывается так:

    Где: * — вектор силы упругости пружины. * — коэффициент жесткости (Spring constant). Скалярная величина, измеряемая в Ньютонах на метр (Н/м). Она определяет, насколько «тугой» является пружина. * — вектор смещения (деформации). Это разница между текущей длиной пружины и её идеальным состоянием покоя.

    Обратите особое внимание на знак минус. Он означает, что сила упругости всегда направлена противоположно направлению деформации. Это возвращающая сила (Restoring force). Если вы растягиваете пружину (смещение положительное), сила тянет её обратно, пытаясь сжать. Если вы сжимаете пружину (смещение отрицательное), сила выталкивает её наружу.

    > Сила упругости не пытается переместить объект в какую-то глобальную точку пространства. Её единственная цель — вернуть саму пружину к её исходной длине.

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

    Сила, с которой пружина будет тянуть концы друг к другу: Ньютонов.

    Проблема вечного двигателя: Демпфирование

    Если мы реализуем в коде только чистый закон Гука, мы столкнемся с серьезной проблемой. В идеальном математическом мире пружина без потерь энергии будет колебаться вечно.

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

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

    Формула силы демпфирования выглядит так:

    Где: * — сила демпфирования. * — коэффициент демпфирования (Damping coefficient). Чем он выше, тем быстрее гасятся колебания (вспомните движение руки в воде по сравнению с воздухом). * — скорость объекта.

    Снова знак минус! Сила демпфирования всегда направлена против вектора скорости. Она не зависит от того, где находится объект, её волнует только то, как быстро он движется.

    Итоговая сила, которую пружина будет применять к привязанному объекту, является суммой упругости и демпфирования:

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

    Векторная математика пружин в 2D-пространстве

    Формулы выше отлично работают для одномерного случая (когда грузик прыгает вверх-вниз по оси Y). Но в нашем 2D-движке объекты могут находиться где угодно. Пружина соединяет две точки: (позиция первого объекта) и (позиция второго объекта).

    Нам нужно перевести скалярные формулы Гука в строгую векторную алгебру.

    Шаг 1: Вычисление вектора деформации

    Сначала найдем вектор, указывающий от первого объекта ко второму:

    Текущая длина пружины — это просто модуль этого вектора ().

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

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

    (Важное примечание для программистов: всегда проверяйте, не равен ли нулю перед делением, чтобы избежать краша симуляции при точном совпадении координат объектов!)

    Теперь мы можем записать векторную версию закона Гука. Сила, действующая на первый объект:

    Согласно Третьему закону Ньютона, сила, действующая на второй объект, будет точно такой же по модулю, но противоположной по направлению:

    !Схема векторного расчета пружины: показаны точки p1 и p2, вектор расстояния d, длина покоя L0 и результирующие векторы сил, направленные вдоль оси пружины.

    Шаг 2: Правильное демпфирование в 2D

    С демпфированием в 2D есть один коварный нюанс. Если мы просто возьмем скорости объектов и умножим их на коэффициент , мы создадим эффект вязкого болота.

    Представьте: два объекта соединены пружиной и вместе летят вправо со скоростью 10 м/с. Пружина при этом не растягивается и не сжимается. Должна ли она тормозить их полет? Нет! Пружина должна гасить только те скорости, которые заставляют её сжиматься или растягиваться.

    Нам нужна относительная скорость объектов вдоль оси самой пружины.

  • Находим вектор относительной скорости:
  • Проецируем эту скорость на ось пружины. Для этого мы используем скалярное произведение (Dot Product) вектора относительной скорости и единичного вектора направления пружины :
  • Скаляр показывает, насколько быстро объекты сближаются или отдаляются друг от друга. Если они летят параллельно, будет равен нулю.

  • Вычисляем скалярную силу демпфирования:
  • Превращаем её обратно в вектор, умножив на направление пружины:
  • Реализация на C++

    Теперь соберем всю эту математику в элегантный класс на C++. Мы предполагаем, что у вас уже есть класс RigidBody с полями position, velocity и методом addForce() (который добавляет вектор в аккумулятор сил).

    Этот класс Spring не имеет собственного состояния (кроме настроек). Он просто читает позиции двух тел, вычисляет силы и отправляет их в аккумуляторы. Это идеально ложится в архитектуру нашего игрового цикла: сначала мы вызываем update() для всех пружин, затем update() для гравитации, и только потом вызываем шаг интегрирования для RigidBody.

    Визуализация пружины в SFML

    Физика работает в памяти, но нам нужно показать её на экране. Самый простой способ нарисовать пружину в SFML — использовать графический примитив sf::Lines внутри sf::VertexArray.

    Чтобы сделать визуализацию по-настоящему интерактивной, мы можем менять цвет линии в зависимости от того, растянута пружина или сжата. Красный цвет будет символизировать высокое натяжение, синий — сжатие, а белый — состояние покоя.

    Подводные камни: Жесткость и стабильность

    При работе с пружинами в численных симуляциях вы неизбежно столкнетесь с проблемой стабильности.

    Если вы установите слишком высокий коэффициент жесткости , сила упругости станет огромной. За один кадр (дельта-время ) эта огромная сила придаст объекту такую колоссальную скорость, что в следующем кадре он пролетит точку покоя и растянет пружину в обратную сторону еще сильнее. Симуляция «взорвется» за доли секунды.

    Это фундаментальное ограничение явных методов интегрирования. У вас есть два пути решения:

  • Уменьшить шаг времени (). Если вы хотите симулировать очень жесткие пружины (например, стальные тросы), вам придется обновлять физику не 60 раз в секунду, а 1000 раз в секунду (Sub-stepping).
  • Использовать метод Верле. Как мы обсуждали в предыдущих статьях, интегрирование Верле гораздо лучше справляется с жесткими связями, так как оно работает напрямую с позициями, не позволяя скорости накапливать критические ошибки.
  • От одной пружины к симуляции тканей

    Понимание того, как работает одна пружина, открывает дверь к созданию сложных систем масс и пружин (Mass-Spring Systems).

    Если вы создадите сетку из частиц (масс) и соедините каждую частицу с её соседями (сверху, снизу, слева, справа) пружинами с небольшой жесткостью, вы получите базовую симуляцию ткани (Cloth simulation).

    Добавив диагональные пружины, вы придадите ткани сопротивление сдвигу. А если соединить пружинами частицы, образующие замкнутый контур (например, круг), и добавить внутрь силу давления, вы получите симуляцию мягкого тела (Soft body) — идеальный алгоритм для создания прыгающих слизней или деформируемых шин автомобиля.

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

    15. Основы современного OpenGL и графический конвейер

    Основы современного OpenGL и графический конвейер

    До сих пор для визуализации наших физических симуляций мы использовали высокоуровневые абстракции библиотеки SFML. Это отличный инструмент для старта, но когда дело доходит до отрисовки десятков тысяч сталкивающихся частиц, симуляции мягких тел или перехода в полноценное 3D-пространство, возможностей центрального процессора (CPU) становится недостаточно. Нам необходимо перенести вычислительную нагрузку по отрисовке графики на видеокарту (GPU).

    Для прямого общения с видеокартой используются графические API, и самым универсальным из них является OpenGL (Open Graphics Library). Переход от высокоуровневой графики к OpenGL требует фундаментального изменения мышления: мы больше не говорим компьютеру «нарисуй круг», мы отправляем массивы сырых математических данных в сложный фабричный механизм, называемый графическим конвейером.

    Архитектура: CPU против GPU

    Чтобы понять, зачем нам нужен OpenGL, необходимо осознать разницу в архитектуре процессоров.

    Центральный процессор (CPU) вашего компьютера — это гениальный ученый-универсал. У него всего несколько ядер (например, 8 или 16), но каждое ядро работает на огромной частоте и способно выполнять сложнейшие ветвящиеся алгоритмы. CPU идеально подходит для расчета физики: обнаружения столкновений, интегрирования Верле и разрешения ограничений.

    Графический процессор (GPU) — это армия из тысяч неквалифицированных рабочих. Каждое ядро GPU слабое и медленное, но их невероятно много (в современных видеокартах — от 3000 до 16000 ядер). Они не умеют решать сложные логические задачи с множеством условий if-else, но они безупречно выполняют одну и ту же простую математическую операцию (например, умножение матриц) над огромным массивом данных одновременно.

    > Главное правило высокопроизводительной графики: CPU должен рассчитывать физику и подготавливать данные, а затем отправлять их большим пакетом на GPU для параллельной отрисовки. Пересылка данных между оперативной памятью (RAM) и видеопамятью (VRAM) — самая медленная операция, которую нужно минимизировать.

    OpenGL — спецификация и конечный автомат

    Вопреки названию, OpenGL — это не библиотека с готовым кодом, которую можно просто скачать. Это спецификация — строгий текстовый документ, описывающий, как именно должны называться функции и что они должны делать. Сами же функции пишут производители видеокарт (NVIDIA, AMD, Intel) и встраивают их в драйверы.

    С точки зрения программиста, OpenGL представляет собой гигантский конечный автомат (State Machine). Это означает, что OpenGL хранит внутри себя глобальное состояние (Контекст), которое определяет, как будут интерпретироваться последующие команды.

    Представьте художника. Вы даете ему команду: «Возьми красную краску». Это изменение состояния. Затем вы говорите: «Рисуй треугольник». Художник нарисует красный треугольник. Он будет рисовать красным до тех пор, пока вы явно не дадите команду «Возьми синюю краску».

    В коде на C++ это выглядит так:

    Графический конвейер (Graphics Pipeline)

    Когда мы хотим нарисовать физический объект (например, куб), мы не можем просто передать видеокарте слово «куб». Видеокарта понимает только точки в пространстве, цвета и математику.

    Процесс превращения массива 3D-координат в цветные пиксели на вашем 2D-экране называется графическим конвейером. Это строгая последовательность этапов, через которые проходят данные. Некоторые этапы жестко зашиты в кремний видеокарты, а другие мы обязаны программировать сами.

    !Схема графического конвейера: путь от вершинных данных до готовых пикселей на экране.

    Рассмотрим ключевые этапы конвейера по порядку:

    1. Вершинные данные (Vertex Data)

    Всё начинается с массива чисел типа float в памяти C++. Каждые три числа могут представлять координаты одной вершины (Vertex). Вершина в OpenGL — это не просто точка в пространстве, это пакет данных, который может содержать позицию, цвет, нормаль для освещения и текстурные координаты.

    2. Вершинный шейдер (Vertex Shader)

    Это первый программируемый этап. Шейдер — это маленькая программа, написанная на специальном Си-подобном языке GLSL (OpenGL Shading Language), которая компилируется и выполняется прямо на ядрах видеокарты.

    Вершинный шейдер запускается параллельно для каждой переданной вершины. Если вы отправили 10 000 вершин, GPU запустит 10 000 копий этой программы одновременно. Главная задача вершинного шейдера — умножить локальные координаты вершины на матрицы Модели, Вида и Проекции (о которых мы говорили в предыдущих статьях), чтобы перевести их в экранные координаты.

    3. Сборка примитивов (Shape Assembly)

    На этом этапе видеокарта берет обработанные вершины и соединяет их линиями, образуя базовые геометрические фигуры — примитивы. В 99% случаев в современной графике примитивом является треугольник. Любая сложная 3D-модель, будь то сфера или персонаж, состоит из тысяч крошечных треугольников.

    4. Растеризация (Rasterization)

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

    Алгоритм определяет, центры каких пикселей попадают внутрь треугольника. Эти пиксели превращаются во фрагменты. Фрагмент — это «потенциальный пиксель», содержащий данные о своих координатах на экране и интерполированные значения (например, цвет), плавно перетекающие от одной вершины треугольника к другой.

    5. Фрагментный шейдер (Fragment Shader)

    Второй обязательный программируемый этап. Эта микропрограмма запускается для каждого фрагмента, сгенерированного на этапе растеризации. Если треугольник занимает на экране область 100x100 пикселей, фрагментный шейдер выполнится 10 000 раз.

    Именно здесь происходит магия визуализации: расчет освещения, наложение текстур, расчет теней и отражений. Итогом работы фрагментного шейдера всегда является одно значение — финальный цвет пикселя в формате RGBA (красный, зеленый, синий, прозрачность).

    !Измените код фрагментного шейдера — и увидите, как видеокарта мгновенно перекрашивает миллионы пикселей.

    6. Тесты и смешивание (Blending)

    Даже если фрагментный шейдер вычислил цвет, это не значит, что он попадет на экран. На финальном этапе фрагмент проходит проверки:
  • Тест глубины (Depth Test): не перекрыт ли этот пиксель другим объектом, который находится ближе к камере?
  • Смешивание (Alpha Blending): если объект полупрозрачный (например, стекло), его цвет математически смешивается с цветом пикселя, который уже был нарисован позади него.
  • Управление памятью: VBO и VAO

    Чтобы конвейер заработал, нам нужно доставить данные из оперативной памяти (где работает наша C++ программа с физикой) в быструю видеопамять GPU. Для этого используются специальные объекты OpenGL.

    Vertex Buffer Object (VBO)

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

    Преимущество VBO в том, что мы отправляем данные крупными партиями (батчинг). Вместо того чтобы посылать координаты каждой частицы по отдельности каждый кадр, мы упаковываем массив из 10 000 частиц и отправляем его за один вызов, минимизируя простой шины данных.

    Vertex Array Object (VAO)

    Отправив сырые байты в VBO, мы сталкиваемся с проблемой: видеокарта не знает, что означают эти числа. Это просто сплошной поток данных. Где заканчиваются координаты первой вершины и начинаются координаты второй? Есть ли в этом массиве цвета?

    Для решения этой задачи используется VAO (Объект массива вершин). VAO не хранит сами данные. Он хранит инструкции о том, как видеокарта должна читать данные из VBO.

    Представьте, что VBO — это длинная строка текста без пробелов, а VAO — это трафарет, который накладывается на текст и выделяет слова.

    Рассмотрим классический пример на C++, где мы создаем треугольник:

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

    Анатомия шейдеров на GLSL

    Чтобы наш треугольник отобразился, нам нужно написать и скомпилировать две программы на языке GLSL. Синтаксис GLSL очень похож на C, но имеет встроенные типы для векторной алгебры (vec2, vec3, mat4), которые обрабатываются на аппаратном уровне.

    Пример простейшего вершинного шейдера:

    Пример простейшего фрагментного шейдера:

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

    Игровой цикл с OpenGL

    Теперь, когда данные загружены в память GPU (VBO) и видеокарта знает, как их читать (VAO), а шейдеры скомпилированы, отрисовка в нашем игровом цикле сводится к нескольким элегантным строкам:

    Команда glDrawArrays — это спусковой крючок. Именно в этот момент CPU отправляет сигнал видеокарте, и графический конвейер оживает, прогоняя данные через шейдеры и растеризатор, чтобы в итоге выдать готовый кадр физической симуляции.

    Переход на OpenGL требует написания большего количества шаблонного кода (boilerplate) по сравнению с SFML. Однако этот контроль над памятью и графическим конвейером открывает двери к аппаратному ускорению. В следующих статьях мы рассмотрим, как передавать данные из нашего физического аккумулятора сил напрямую в матрицы трансформации OpenGL для визуализации динамики твердого тела в 3D.

    16. Матричные преобразования в OpenGL

    Матричные преобразования в OpenGL

    В предыдущих материалах мы разобрали теоретическую базу систем координат и познакомились с графическим конвейером. Мы научились загружать геометрию в память видеокарты (VBO) и писать базовые шейдеры. Однако наши объекты пока остаются неподвижными. Чтобы оживить физическую симуляцию, нам нужно научиться передавать данные о позиции, вращении и масштабе из нашего C++ движка напрямую в графический конвейер.

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

    Uniform-переменные: связь CPU и GPU

    До сих пор мы передавали данные в шейдер через вершинные атрибуты (атрибут layout (location = 0)). Атрибуты уникальны для каждой вершины: у первой точки одни координаты, у второй — другие.

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

    > Uniform-переменная — это глобальная переменная внутри шейдерной программы, значение которой остается неизменным (единообразным) для всех обрабатываемых вершин или фрагментов в рамках одного вызова отрисовки (Draw call).

    Аналогия из жизни: представьте, что VBO — это список ингредиентов для каждого отдельного кекса в партии (мука, сахар, яйца), а Uniform-переменная — это температура духовки. Температура устанавливается один раз и действует на всю партию кексов одновременно.

    В коде вершинного шейдера на языке GLSL объявление такой переменной выглядит так:

    Библиотека GLM: математика для OpenGL

    OpenGL — это графическое API, оно не содержит встроенных математических функций для работы с векторами и матрицами на стороне C++. Писать собственный класс матриц 4x4 с поддержкой инверсии и умножения можно, но в индустрии стандартом де-факто является библиотека GLM (OpenGL Mathematics).

    GLM — это заголовочная библиотека (header-only), классы и функции которой намеренно повторяют синтаксис языка шейдеров GLSL. Более того, GLM «из коробки» хранит матрицы в формате Column-major, что позволяет передавать их в OpenGL без дополнительного транспонирования.

    Подключение базовых функций выглядит так:

    Построение Матрицы Модели (Model Matrix)

    Как мы помним, Матрица Модели отвечает за перевод вершин из Локального пространства в Мировое. Нам нужно применить к единичной матрице масштабирование, поворот и перемещение.

    В GLM инициализация единичной матрицы (Identity Matrix) выглядит так:

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

    Обратите внимание на синтаксис: каждая функция принимает текущую матрицу, применяет к ней трансформацию и возвращает новую матрицу.

    Хотя в коде мы пишем операции в порядке «Перемещение Вращение Масштаб», математически GLM умножает новые матрицы справа. То есть итоговая формула, которая будет применена к вершине , выглядит как . Это именно тот стандартный порядок SRT, который гарантирует, что объект сначала изменит размер, затем повернется вокруг своего локального центра, и только потом переместится в нужную точку мира.

    !Подвигайте ползунки трансформаций — и увидите, как меняются числа в матрице 4x4 и как это влияет на итоговое положение объекта.

    Матрица Вида: симуляция камеры

    В OpenGL нет встроенного понятия «камера». Мы не можем переместить точку обзора. Вместо этого мы используем принцип относительности: чтобы сымитировать движение камеры вперед, мы двигаем весь остальной мир назад.

    За этот процесс отвечает Матрица Вида (View Matrix). В GLM для её создания используется невероятно удобная функция glm::lookAt.

    Функция lookAt автоматически рассчитывает векторы направления, вправо и вверх для камеры, используя векторное произведение (Cross Product), и формирует матрицу, которая сдвигает и поворачивает всю сцену так, чтобы она оказалась перед нашими «глазами».

    Матрица Проекции: перспектива и ортография

    Последний шаг перед тем, как отдать данные на растеризацию — перевести 3D-координаты в плоское 2D-пространство экрана с учетом глубины. Для этого используется Матрица Проекции (Projection Matrix).

    Существует два основных типа проекции:

  • Ортографическая проекция (glm::ortho). Сохраняет параллельность линий. Объекты не уменьшаются при удалении. Идеально подходит для 2D-симуляций, чертежей и изометрических игр.
  • Перспективная проекция (glm::perspective). Имитирует человеческое зрение: чем дальше объект, тем меньше он кажется. Используется в 99% 3D-приложений.
  • Для создания перспективной проекции нам нужно задать Усеченную пирамиду видимости (Frustum). Всё, что попадает внутрь этой пирамиды, будет отрисовано; всё, что оказывается за её пределами — отсекается (Clipping) для экономии ресурсов.

    !Схема усеченной пирамиды видимости (Frustum) — видно, как параметры FOV, Near и Far формируют объем, внутри которого объекты проецируются на экран.

    Создание перспективной матрицы в GLM:

    Важный нюанс: ближнюю плоскость (near) никогда не следует устанавливать точно в . Это приведет к математической ошибке при расчете Z-буфера (буфера глубины), и объекты начнут визуально мерцать и просвечивать друг сквозь друга (артефакт Z-fighting).

    Сборка MVP-матрицы и отправка на GPU

    Теперь у нас есть три матрицы: Model (M), View (V) и Projection (P). Чтобы получить финальные координаты вершины на экране, мы должны умножить локальные координаты вершины на эти матрицы в строгом порядке справа налево:

    Мы можем передать все три матрицы в шейдер по отдельности и умножать их там:

    Однако здесь кроется ловушка производительности. Вершинный шейдер выполняется для каждой вершины. Если в вашей физической симуляции ткани 100 000 вершин, видеокарта выполнит умножение ровно 100 000 раз за один кадр. Но ведь эти три матрицы одинаковы для всего куска ткани!

    Оптимизация: мы должны перемножить матрицы на центральном процессоре (CPU) один раз для каждого объекта, и отправить в шейдер уже готовую итоговую матрицу, которую принято называть MVP-матрицей (Model-View-Projection).

    Реализация на C++ в игровом цикле:

    А код вершинного шейдера сокращается до одной операции:

    Именно так современные физические движки визуализируют сцены. Физика рассчитывает координаты и углы поворота CPU формирует матрицу Модели умножает её на матрицы Вида и Проекции отправляет готовую MVP-матрицу в шейдер GPU мгновенно отрисовывает миллионы полигонов. В следующей статье мы применим эти знания для визуализации вращения абсолютно твердого тела в 3D-пространстве.

    17. Визуализация математических функций и фракталов

    Визуализация математических функций и фракталов

    В предыдущих материалах мы научились загружать геометрию в память видеокарты, настраивать графический конвейер и управлять объектами с помощью матричных преобразований. Мы работали с заранее заданными формами: треугольниками, квадратами, кубами. Но что, если форма объекта не задана жестко, а является результатом математического уравнения?

    В этой статье мы совершим переход от статической геометрии к процедурной. Мы научимся визуализировать трехмерные математические функции с помощью генерации сеток на C++, а затем погрузимся в мир бесконечной детализации, перенеся вычисления фракталов непосредственно на графический процессор (GPU).

    Процедурная генерация поверхностей

    В математике трехмерная поверхность часто описывается функцией двух переменных: . Это означает, что для каждой точки на горизонтальной плоскости (координаты и ) существует определенная высота .

    Классический пример — функция, описывающая затухающие волны (подобно кругам на воде от брошенного камня):

    Где: * — итоговая высота точки; * — амплитуда (максимальная высота волны); * — частота (насколько плотно расположены волны); * — координаты точки на плоскости; * — расстояние от начала координат до текущей точки (по теореме Пифагора).

    Видеокарта не умеет рисовать «функции». Она умеет рисовать только точки, линии и треугольники. Поэтому, чтобы визуализировать эту формулу, нам нужно создать Дискретную сетку.

    > Дискретная сетка (Discrete Grid) — это аппроксимация непрерывной математической поверхности с помощью конечного набора вершин, соединенных в полигоны (обычно треугольники).

    Аналогия: представьте, что вы хотите накрыть холмистую местность тканью. Ткань состоит из переплетенных нитей. Чем плотнее нити (выше разрешение сетки), тем точнее ткань повторит рельеф, но тем тяжелее она будет.

    Реализация сетки на C++

    Чтобы построить сетку, мы используем два вложенных цикла. Внешний цикл проходит по оси X, внутренний — по оси Y. На каждом шаге мы вычисляем координату Z по нашей формуле.

    Однако просто сгенерировать вершины недостаточно. Видеокарта должна знать, как соединить эти точки в треугольники. Для этого используется Индексный буфер.

    > Индексный буфер (Element Buffer Object, EBO) — это массив целых чисел, который указывает видеокарте, в каком порядке брать вершины из VBO для построения примитивов. Это позволяет использовать одну и ту же вершину в нескольких смежных треугольниках, экономя память.

    Для сетки размером ячеек, каждая ячейка состоит из двух треугольников (6 индексов). Математика обхода индексов выглядит так:

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

    Фракталы и бесконечная детализация

    Генерация сеток отлично работает для гладких функций. Но в природе формы редко бывают гладкими. Горы, облака, кровеносные сосуды, береговые линии — все они обладают изломанной, сложной структурой.

    Для описания таких структур математик Бенуа Мандельброт ввел понятие фрактала.

    > Фрактал — это геометрическая фигура, обладающая свойством самоподобия, то есть составленная из нескольких частей, каждая из которых подобна всей фигуре целиком.

    Если вы возьмете ветку дерева, она выглядит как маленькое дерево. Если отломите от нее веточку — она тоже похожа на дерево. Это и есть самоподобие. В математических фракталах это свойство сохраняется при бесконечном увеличении масштаба.

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

    Комплексные числа как координаты

    Чтобы визуализировать самый известный фрактал — множество Мандельброта — нам нужно расширить наше понимание систем координат. Ранее мы использовали 2D-векторы . Теперь мы будем использовать Комплексную плоскость.

    > Комплексное число — это число вида , где — действительная часть, — мнимая часть, а — мнимая единица, обладающая уникальным свойством: .

    > Комплексная плоскость — это система координат, где по горизонтальной оси откладывается действительная часть (), а по вертикальной — мнимая ().

    С точки зрения программиста, комплексное число — это тот же 2D-вектор. Сложение комплексных чисел работает абсолютно так же, как сложение векторов: .

    Разница заключается в умножении. Если мы возведем комплексное число в квадрат, мы должны раскрыть скобки по правилам алгебры, помня, что :

    Как видите, результатом возведения в квадрат снова является комплексное число (вектор), где новая координата X равна , а новая координата Y равна . Это математическое ядро, которое нам понадобится для кода.

    Множество Мандельброта и Алгоритм времени убегания

    Множество Мандельброта строится на основе невероятно простой рекурсивной формулы:

    Где: * — это комплексное число, соответствующее координатам конкретного пикселя на экране; * — переменная, которая в начале равна нулю (); * — номер шага (итерации).

    Суть Алгоритма времени убегания (Escape-time algorithm) заключается в следующем: мы берем координату пикселя , подставляем в формулу и вычисляем новое значение . Затем берем это новое и снова подставляем в формулу. И так раз за разом.

    При многократном повторении возможны два исхода:

  • Длина вектора начинает стремительно расти и уходит в бесконечность. Говорят, что точка «убежала».
  • Значение остается ограниченным (колеблется вокруг нуля), сколько бы итераций мы ни делали.
  • Точки, которые никогда не убегают в бесконечность, образуют само Множество Мандельброта (обычно их красят в черный цвет). А для точек, которые убегают, мы считаем, на какой именно итерации длина вектора превысила число 2. Это число итераций мы используем для выбора цвета пикселя.

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

    Перенос вычислений на GPU (Фрагментный шейдер)

    Если мы попытаемся рассчитать множество Мандельброта на C++ (CPU), мы столкнемся с катастрофой производительности.

    Допустим, у нас окно 1920x1080 пикселей. Это около 2 миллионов пикселей. Если для каждого пикселя мы делаем 100 итераций формулы, это 200 миллионов математических операций для одного кадра. Процессор, выполняющий задачи последовательно, выдаст в лучшем случае 1-2 кадра в секунду.

    Здесь на сцену выходит графический конвейер и Фрагментный шейдер.

    Вместо того чтобы генерировать сложную 3D-сетку, мы отправляем на видеокарту всего один прямоугольник, состоящий из двух треугольников, который растянут на весь экран. Вершинный шейдер просто пропускает эти 4 вершины дальше.

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

    Вот как выглядит реализация алгоритма времени убегания на языке GLSL:

    Разбор шейдера

  • Нормализация координат: gl_FragCoord содержит координаты пикселя (например, 800x600). Деление на resolution приводит их к диапазону [0, 1].
  • Проецирование на комплексную плоскость: Мы сдвигаем координаты так, чтобы центр экрана был нулем (uv - 0.5), умножаем на zoom и корректируем пропорции экрана, чтобы фрактал не сплющило.
  • Оптимизация проверки: Вместо вычисления дорогой функции квадратного корня sqrt() для проверки длины вектора (), мы проверяем квадрат длины (). Это классический паттерн оптимизации в графике.
  • Перенос этой математики во фрагментный шейдер позволяет исследовать фрактал в реальном времени при 60 FPS, плавно приближая изображение в миллионы раз. В следующих статьях мы применим этот принцип попиксельных вычислений на GPU для создания процедурных текстур и симуляции клеточных автоматов.

    18. Создание и рендеринг системы частиц

    Создание и рендеринг системы частиц

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

    Система частиц (Particle System) — это метод компьютерной графики, использующий большое количество очень мелких, простых спрайтов или полигонов для симуляции нечетких (fuzzy) явлений, которые сложно воспроизвести с помощью классической полигональной геометрии.

    В этой статье мы спроектируем систему частиц на C++, решим проблему фрагментации памяти при рождении и смерти тысяч объектов в секунду, добавим математику вероятностей для реалистичного разброса и оптимизируем рендеринг с помощью возможностей OpenGL.

    Анатомия частицы и Время жизни

    Частица — это максимально упрощенный физический объект. В отличие от твердых тел, частицы обычно не имеют массы (или она условно равна единице), не вращаются и не сталкиваются друг с другом (хотя бывают исключения).

    Главное отличие частицы от обычного игрового объекта — наличие Времени жизни (Lifespan).

    > Время жизни (Lifespan) — скалярная величина (обычно в секундах), определяющая, как долго частица будет существовать в симуляции до своего уничтожения.

    Время жизни используется не только как таймер смерти, но и как параметр для интерполяции. Нормализовав оставшееся время от 1.0 до 0.0, мы можем плавно менять цвет частицы (например, от желтого к красному, а затем к серому дыму) и уменьшать её прозрачность (Alpha-канал), чтобы она растворялась в воздухе.

    Базовая структура частицы на C++ выглядит так:

    Управление памятью: Паттерн «Пул объектов»

    Представьте костер, который испускает 1000 искр в секунду. Каждая искра живет 2 секунды. Если мы будем использовать стандартный подход с динамическим выделением памяти (new Particle()) или добавлением/удалением элементов в std::vector через push_back() и erase(), наша программа быстро столкнется с критической проблемой производительности.

    Удаление элемента из середины std::vector заставляет процессор сдвигать все последующие элементы влево, чтобы заполнить пустоту. Для массива из десятков тысяч элементов это катастрофа. Кроме того, постоянное выделение и освобождение памяти приводит к её фрагментации.

    Решение — архитектурный паттерн Пул объектов (Object Pool).

    > Пул объектов — шаблон проектирования, при котором набор инициализированных объектов создается заранее (преаллокация) и хранится в фиксированном массиве. Вместо создания и уничтожения объектов программа просто «заимствует» их из пула и возвращает обратно после использования.

    Реализация O(1) удаления

    Чтобы избежать сдвига элементов при смерти частицы, мы используем трюк с заменой. Мы выделяем массив фиксированного размера (например, 100 000 частиц) и заводим целочисленный счетчик activeCount, который указывает на границу между «живыми» и «мертвыми» частицами.

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

    !Схема работы Пула объектов с O(1) удалением

    Этот алгоритм работает за время — удаление происходит мгновенно, независимо от размера массива, а данные всегда лежат в памяти плотным куском, что идеально для кэша процессора.

    Эмиттеры и математика случайностей

    Частицы не появляются из ниоткуда. Ими управляет Эмиттер (Emitter).

    > Эмиттер — логический компонент системы, отвечающий за генерацию новых частиц с заданными начальными параметрами (позиция, направление, скорость, цвет) в определенном темпе (Rate of Fire).

    Если эмиттер будет задавать всем частицам одинаковую скорость и направление, мы получим сплошную линию. Нам нужна случайность. В C++ для этого используется библиотека <random>. Однако важно понимать разницу между типами распределений вероятностей.

    Обычно новички используют равномерное распределение (std::uniform_real_distribution). Оно выдает любое число в заданном диапазоне с равной вероятностью. Если мы применим его к углу вылета искр из костра, искры образуют идеальный, жесткий конус. В природе так не бывает.

    Для реалистичных эффектов используется Нормальное распределение (Распределение Гаусса).

    > Нормальное распределение — распределение вероятностей, при котором большинство генерируемых значений группируется вокруг среднего значения (математического ожидания), а вероятности появления сильных отклонений резко спадают по краям (образуя форму колокола).

    В C++ это реализуется через std::normal_distribution. Мы задаем средний угол (например, строго вверх, 90 градусов) и стандартное отклонение (насколько сильно искры могут отклоняться от центра). В результате большинство искр полетит вверх, и лишь редкие единицы отлетят сильно вбок, создавая органичный, естественный вид пламени.

    !Сравнение равномерного и нормального распределения в эмиттере

    Оптимизация рендеринга: Инстансинг

    Мы обновили физику 100 000 частиц на CPU. Теперь их нужно нарисовать.

    Как мы помним из предыдущих статей, отправка каждой частицы отдельным вызовом отрисовки (Draw call) убьет производительность. Мы уже знакомы с батчингом (Batching) через sf::VertexArray, где мы собираем все точки в один массив вершин и отправляем разом.

    Но что, если наша частица — это не просто пиксель, а полноценная 3D-модель (например, осколок камня при взрыве) или сложный полигон с текстурой? Батчинг потребует копирования геометрии этой модели 100 000 раз в оперативной памяти каждый кадр. Это слишком медленно.

    Здесь на помощь приходит Инстансинг (Instanced Rendering) в OpenGL.

    > Инстансинг — метод рендеринга, позволяющий видеокарте отрисовать один и тот же геометрический объект (Mesh) множество раз за один вызов отрисовки, используя разные параметры (позицию, цвет, масштаб) для каждой копии (инстанса).

    Как работает Инстансинг в OpenGL

    Вместо одного VBO (Vertex Buffer Object) мы используем два:

  • Геометрический VBO: Хранит вершины самой модели (например, 4 вершины для квадрата). Эти данные загружаются в видеопамять один раз при запуске программы.
  • Инстанс-VBO: Хранит массив позиций и цветов для всех 100 000 частиц. Этот буфер обновляется каждый кадр.
  • В вершинном шейдере мы используем специальную переменную gl_InstanceID, которая сообщает шейдеру, какую именно копию объекта он сейчас обрабатывает.

    | Характеристика | Батчинг (Batching) | Инстансинг (Instancing) | | :--- | :--- | :--- | | Что отправляется на GPU | Уникальные вершины для каждого объекта | Одна модель + Массив трансформаций | | Размер данных (каждый кадр) | Огромный (Геометрия × Количество) | Маленький (Только позиции/цвета) | | Идеально подходит для | 2D спрайтов, простых точек, UI | 3D моделей, астероидов, сложной геометрии |

    Вызов отрисовки в C++ меняется с обычного glDrawArrays на glDrawArraysInstanced:

    Эмерджентность: Симуляция "Particle Life"

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

    В физике мы опирались на Третий закон Ньютона: сила действия равна силе противодействия. Если объект А притягивает объект Б, то Б притягивает А с такой же силой.

    Но что, если мы нарушим этот закон в нашей симуляции? Введем Асимметричные силы.

    > Асимметричные силы — концепция в процедурных симуляциях, при которой взаимодействие между двумя типами объектов не является взаимным (например, тип А притягивается к типу Б, но тип Б отталкивается от типа А).

    Разделим наши частицы на несколько «видов» (например, по цветам: красные, зеленые, синие). И создадим матрицу правил взаимодействия (коэффициентов притяжения/отталкивания):

    * Красные притягивают зеленых (). * Зеленые убегают от красных (). * Синие игнорируют зеленых (), но сильно притягиваются к своим же синим.

    Если мы применим эти правила к тысячам частиц, используя алгоритмы пространственного хеширования (Uniform Grid) для быстрого поиска соседей, мы увидим поразительный эффект.

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

    > Эмерджентность — возникновение у сложной системы новых свойств, которые не присущи ни одному из её элементов в отдельности, а являются результатом их взаимодействия.

    Именно этот принцип лежит в основе симуляций искусственной жизни и клеточных автоматов, к которым мы перейдем в следующих материалах курса.

    19. Оптимизация симуляций: пространственное разбиение

    Оптимизация симуляций: продвинутое пространственное разбиение

    В предыдущих материалах мы научились генерировать сотни тысяч частиц и отрисовывать их за один вызов видеокарты с помощью инстансинга. Мы также знакомы с алгоритмом Равномерной сетки (Uniform Grid), который позволил нам снизить сложность поиска столкновений с до линейного времени.

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

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

    Квадродеревья (Quadtrees)

    Если Равномерная сетка делит мир на фиксированное количество одинаковых квадратов заранее, то Квадродерево (Quadtree) делает это динамически и только там, где это необходимо.

    > Квадродерево — это структура данных в виде дерева, в которой каждый внутренний узел имеет ровно четыре потомка. В 2D-пространстве оно рекурсивно разбивает двумерную область на четыре вложенных квадранта.

    Механика работы

    Алгоритм построения квадродерева опирается на понятие Вместимости узла (Node Capacity). Это максимальное количество объектов, которое разрешено хранить в одном квадрате пространства до того, как он будет разделен.

    Процесс вставки объекта выглядит так:

  • Мы пытаемся добавить объект в корневой узел (охватывающий весь мир).
  • Если в узле еще есть место (текущее количество объектов меньше вместимости), объект просто сохраняется в массив этого узла.
  • Если лимит превышен, узел разделяется (Subdivide): он создает четыре дочерних узла (Северо-Запад, Северо-Восток, Юго-Запад, Юго-Восток), деля свою площадь ровно пополам по осям X и Y.
  • Все объекты из переполненного узла перераспределяются по новым дочерним узлам в зависимости от их координат.
  • В результате там, где пусто — остается один гигантский квадрат. Там, где плотная толпа частиц — пространство дробится на микроскопические квадратики, в каждом из которых лежит не больше заданного лимита объектов (например, 4 или 8).

    !Сравнение Равномерной сетки и Квадродерева. Слева сетка с кучей пустых ячеек. Справа Квадродерево, где мелкие квадраты сгруппированы только вокруг скопления объектов, а пустые зоны остаются большими квадратами.

    Архитектура узла на C++

    Базовая (наивная) реализация узла квадродерева в объектно-ориентированном стиле выглядит следующим образом:

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

    Иерархия ограничивающих объемов (BVH)

    Квадродеревья отлично подходят для статических ландшафтов или частиц одинакового размера. Но в полноценном физическом движке (например, Box2D) объекты имеют совершенно разные габариты: от маленькой пули до длинной стены, пересекающей половину уровня.

    Квадродерево жестко рубит пространство пополам. Длинная стена неизбежно пересечет границы множества квадрантов, и нам придется либо хранить ссылки на неё во всех этих узлах (дублирование памяти), либо оставлять её в самом верхнем корневом узле (потеря эффективности поиска).

    Здесь на сцену выходит Иерархия ограничивающих объемов (Bounding Volume Hierarchy, BVH).

    > BVH — это древовидная структура, которая группирует не само пустое пространство, а непосредственно геометрические объекты. Каждый узел дерева описывается минимальным AABB, который полностью вмещает все дочерние объекты.

    В отличие от квадродерева, узлы в BVH могут пересекаться друг с другом, и они не обязаны покрывать всё пустое пространство сцены. BVH строится снизу вверх (или сверху вниз, но опираясь на геометрию): близко расположенные объекты объединяются в группу, эта группа оборачивается в общий AABB. Затем несколько таких групп объединяются в еще более крупный AABB, пока мы не дойдем до единого корневого узла, охватывающего всю сцену.

    Сравнение структур пространственного разбиения

    | Характеристика | Равномерная сетка (Uniform Grid) | Квадродерево (Quadtree) | BVH (Bounding Volume Hierarchy) | | :--- | :--- | :--- | :--- | | Принцип деления | Жесткая сетка ячеек | Рекурсивное деление пространства | Группировка самих объектов | | Адаптивность к плотности | Нет (много пустых ячеек) | Да (дробится только там, где густо) | Да (идеально облегает объекты) | | Работа с разными размерами | Плохо (объекты больше ячейки ломают логику) | Средне (объекты застревают на границах узлов) | Отлично (AABB узла подстраивается под размер) | | Сложность обновления | Очень быстро | Средне (нужно перестраивать ветви) | Сложно (требует балансировки дерева) |

    > «Структура сцены: дерево может выступать каркасом всей сцены. Пучки данных легко поддаются персистентности — их гораздо проще сохранять, восстанавливать и передавать между узлами». > > Создание физического движка при помощи BVH / Хабр

    Оптимизация памяти: Плоские деревья (Flat Trees)

    Вернемся к C++. Ранее мы написали структуру QuadtreeNode с использованием указателей (QuadtreeNode*). В классическом программировании деревья всегда строятся через динамическое выделение памяти (new Node()).

    Для высоконагруженных симуляций это фатальная ошибка архитектуры.

    Каждый вызов new выделяет память в случайном месте кучи (Heap). Когда мы обходим дерево (спускаемся от корня к листьям), процессор прыгает по случайным адресам оперативной памяти. Это гарантированно вызывает промахи кэша (Cache misses), заставляя процессор простаивать сотни тактов в ожидании данных.

    Решение в духе Data-Oriented Design — это Плоское дерево (Flat Tree).

    > Плоское дерево — метод реализации древовидных структур, при котором все узлы хранятся в едином непрерывном одномерном массиве (std::vector), а связи между узлами задаются не указателями, а целочисленными индексами этого массива.

    Перепишем наш узел для BVH или Квадродерева:

    Почему это работает в десятки раз быстрее?

  • Кэш-линии: Массив std::vector гарантирует, что узлы лежат в памяти строго друг за другом. Загружая один узел, процессор аппаратно подтягивает в кэш L1 сразу несколько соседних узлов.
  • Отсутствие фрагментации: Мы можем заранее выделить память (nodes.reserve(10000)), полностью исключив дорогостоящие системные вызовы аллокации во время симуляции.
  • Компактность: Целочисленный индекс (int) занимает 4 байта, тогда как указатель на 64-битной системе занимает 8 байт. Размер узла уменьшается, в кэш помещается больше данных.
  • Трассировка лучей (Raycasting) в пространственных структурах

    Пространственное разбиение нужно не только для столкновений объектов друг с другом. Одна из самых частых задач в интерактивной графике — определить, на какой объект навел курсор пользователь, или куда попадет пуля, выпущенная из оружия.

    Математически это описывается лучом. Луч задается начальной точкой (Origin) и нормализованным вектором направления (Direction). Уравнение луча:

    Где: * — точка на луче в момент . * — вектор начала луча (позиция курсора или ствола). * — единичный вектор направления. * — скаляр, расстояние от начала луча (всегда ).

    Если в мире 100 000 объектов, проверять пересечение луча с каждым из них — слишком долго. Вместо этого мы «пускаем» луч через наше Квадродерево или BVH.

    Метод плоскостей (Slab Method)

    Чтобы понять, пересекает ли луч узел дерева (который всегда является AABB), используется Метод плоскостей (Slab Method).

    Идея гениальна в своей простоте: 2D AABB можно представить как пересечение двух бесконечных полос (slabs). Одна полоса ограничена вертикальными линиями и . Вторая — горизонтальными и .

    Мы находим точки (точнее, дистанции ), где луч пересекает эти линии. Для оси X:

    Затем мы находим минимальное и максимальное значение из этой пары:

    То же самое делаем для оси Y, получая и .

    Луч пересекает AABB тогда и только тогда, когда интервалы времени нахождения луча внутри обеих полос перекрываются. Математически это условие записывается так:

    Если это условие выполняется, луч прошел сквозь AABB. В коде на C++ это выглядит так:

    !Подвигайте луч и посмотрите, как Метод плоскостей мгновенно отсекает целые ветви дерева, подсвечивая только те узлы, которые физически пересекаются с линией.

    Алгоритм обхода дерева лучом

    Имея функцию RayIntersectsAABB, мы можем мгновенно найти объект под курсором:

  • Проверяем пересечение луча с корневым узлом дерева.
  • Если пересечения нет — луч ушел в пустоту, завершаем поиск.
  • Если пересечение есть, проверяем дочерние узлы.
  • Если дочерний узел — это Листовой узел (Leaf Node) (узел, не имеющий потомков и содержащий реальные объекты), мы выполняем точную проверку пересечения луча с геометрией самих объектов (Narrow-phase).
  • Возвращаем объект с наименьшим значением (тот, что ближе всего к началу луча).
  • Благодаря пространственному разбиению, луч, проходящий через сцену с миллионом объектов, выполнит всего несколько десятков проверок AABB, отсекая целые континенты и галактики за одну математическую операцию.

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

    2. Векторная алгебра для программистов

    Векторная алгебра для программистов

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

    Векторная алгебра — это язык, на котором разговаривает любая физическая симуляция и 3D-движок. Без неё невозможно рассчитать отскок мяча от стены, заставить ракету навестись на цель или определить, освещён ли объект источником света.

    В этой статье мы разберём базовые кирпичики векторной математики, переведём их в эффективный код на C++ и посмотрим, как они применяются в реальных задачах интерактивной визуализации.

    Скаляры, Точки и Векторы: Разделяем понятия

    В повседневной жизни мы привыкли оперировать скалярами — одиночными числами, которые описывают величину. Температура воздуха, масса тела, количество денег на счету — всё это скалярные величины. В коде они представлены базовыми типами: int, float, double.

    Однако для описания пространства скаляров недостаточно. Когда мы выходим в 2D- или 3D-пространство, нам требуются составные типы данных. Здесь возникает частая путаница между двумя фундаментальными понятиями: точками и векторами.

    Точка (Point) — это конкретная позиция в пространстве. Она отвечает на вопрос «Где?». У неё нет ни длины, ни направления.

    Вектор (Vector) — это направленный отрезок. Он описывает смещение из одной точки в другую и отвечает на вопрос «Как далеко и в какую сторону?». У вектора есть длина (модуль) и направление, но нет фиксированной позиции — вектор можно свободно перемещать по пространству, и он останется тем же самым вектором.

    > Представьте, что вы вызываете такси. Ваш домашний адрес (улица Бейкер-стрит, 221Б) — это точка. А инструкция для водителя «проехать 5 километров на север» — это вектор.

    С математической точки зрения в 2D-пространстве и точка, и вектор описываются парой чисел . В коде на C++ мы часто используем одну и ту же структуру (например, sf::Vector2f из библиотеки SFML) для хранения обеих сущностей:

    Несмотря на одинаковый тип данных, логика работы с ними отличается. Если вычесть из одной точки другую, вы получите вектор (маршрут от второй точки к первой). Если прибавить к точке вектор, вы получите новую точку (новое положение объекта). А вот складывать две точки между собой бессмысленно — что значит сложить Москву и Нью-Йорк?

    Длина вектора и Теорема Пифагора

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

    Допустим, у нас есть игрок и враг. Мы вычли позицию игрока из позиции врага и получили вектор расстояния . Как превратить эти две координаты в одно понятное число — дистанцию в пикселях или метрах?

    Здесь на помощь приходит модуль вектора (его длина). Геометрически компоненты вектора и образуют прямоугольный треугольник, где сам вектор является гипотенузой. Следовательно, длину вектора можно найти по классической теореме Пифагора:

    Где — длина вектора, и — его компоненты, а — квадратный корень.

    Напишем функцию на C++ для вычисления длины:

    Подводный камень производительности: Операция извлечения квадратного корня (std::sqrt) работает относительно медленно. Если вам нужно просто сравнить расстояния (например, найти ближайшего врага из тысячи), корень вычислять не обязательно! Достаточно сравнить квадраты длин.

    Если квадрат расстояния до врага А меньше квадрата расстояния до врага Б, то и само расстояние до врага А меньше. Это классическая оптимизация в физических движках (часто реализуется как функция lengthSquared()), которая экономит драгоценные такты процессора.

    Нормализация: Единичные векторы

    Представьте задачу: вы кликаете мышкой по экрану, и ваш персонаж должен начать двигаться в точку клика со скоростью 100 пикселей в секунду.

    Вы находите вектор направления, вычитая позицию персонажа из позиции мыши. Но если вы просто прибавите этот вектор к позиции персонажа, произойдёт странное: чем дальше вы кликнули, тем быстрее полетит персонаж. Ведь длина вектора равна расстоянию до клика!

    Чтобы отделить направление от расстояния, применяется нормализация.

    Нормализация — это процесс изменения длины вектора ровно до , при котором его направление остаётся неизменным. Вектор с длиной 1 называется единичным вектором (или ортом) и обозначается «крышечкой»: .

    Чтобы нормализовать вектор, нужно разделить каждую его компоненту на его собственную длину:

    !Попробуйте потянуть за конец вектора. Обратите внимание: как бы вы ни меняли исходный вектор, нормализованный вектор всегда указывает в ту же сторону, но его конец скользит по идеальной окружности радиусом 1.

    Реализация на C++ требует осторожности. Что если мы попытаемся нормализовать нулевой вектор ? Его длина равна нулю, а деление на ноль приведёт к аварийному завершению программы (или появлению значения NaN — Not a Number). Мы обязаны обработать этот граничный случай:

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

    Скалярное произведение (Dot Product)

    Мы научились складывать векторы и умножать их на числа. Но можно ли умножить вектор на вектор? Да, и в векторной алгебре существует два основных способа это сделать. Первый из них — скалярное произведение.

    Скалярное произведение берёт два вектора и возвращает одно число (скаляр). Алгебраически оно вычисляется как сумма произведений соответствующих координат:

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

    Где — угол между векторами и .

    Зачем это нужно программисту? Скалярное произведение — это идеальный, вычислительно дешёвый инструмент для определения того, насколько два вектора «смотрят» в одну сторону.

    Если оба вектора нормализованы (их длины равны 1), то формула упрощается до . Значение косинуса даёт нам колоссальный объём информации без необходимости вычислять сам угол через тяжёлые тригонометрические функции:

  • Если результат больше 0: векторы смотрят примерно в одну сторону (угол меньше 90 градусов).
  • Если результат равен 0: векторы строго перпендикулярны (угол ровно 90 градусов).
  • Если результат меньше 0: векторы смотрят в противоположные стороны (угол больше 90 градусов).
  • !Схема поля зрения (Field of View). Скалярное произведение позволяет мгновенно отсечь объекты, находящиеся за спиной.

    Практический пример: Поле зрения (Field of View). Представьте, что вы пишете искусственный интеллект для охранника. Охранник смотрит вперёд (вектор ). Игрок крадётся где-то в комнате. Как понять, видит ли охранник игрока?

  • Находим вектор направления от охранника к игроку (вектор ) и нормализуем его.
  • Вычисляем скалярное произведение: float dot = f.x d.x + f.y d.y;
  • Если dot < 0, игрок находится строго за спиной охранника. Мы можем даже не проверять расстояние — охранник его точно не видит!
  • Если dot > 0.5f (что соответствует углу около 60 градусов), игрок находится в узком конусе зрения охранника.
  • В 3D-графике скалярное произведение используется повсеместно для расчёта освещения: чем сильнее нормаль поверхности совпадает с направлением на источник света, тем ярче освещён этот пиксель (модель освещения Ламберта).

    Векторное произведение в 2D (Cross Product)

    Второй способ умножить вектор на вектор — векторное произведение. В полноценной 3D-математике результатом этой операции является новый 3D-вектор, который перпендикулярен обоим исходным векторам.

    Но мы пока работаем в 2D. В двумерном пространстве невозможно построить вектор, перпендикулярный плоскости экрана (он бы торчал прямо вам в глаз). Поэтому в 2D-симуляциях используется так называемое псевдовекторное произведение, которое возвращает скаляр:

    Геометрически модуль этого числа равен площади параллелограмма, построенного на этих векторах. Но для программиста гораздо важнее знак этого результата.

    Векторное произведение в 2D отвечает на вопрос: «В какую сторону нужно повернуть первый вектор, чтобы он совпал со вторым кратчайшим путём?»

  • Если результат больше 0: второй вектор находится слева от первого (поворот против часовой стрелки).
  • Если результат меньше 0: второй вектор находится справа от первого (поворот по часовой стрелке).
  • Если результат равен 0: векторы параллельны (или лежат на одной прямой).
  • Практический пример: Самонаводящаяся ракета. Ракета летит вперёд (вектор скорости ). Цель находится в стороне (вектор направления на цель ). Ракета может поворачивать только на 5 градусов в секунду. В какую сторону ей крутить рули — влево или вправо?

    Вычисляем 2D векторное произведение cross = v.x t.y - v.y t.x;. Если cross > 0, применяем команду поворота влево. Если cross < 0 — вправо. Всего две операции умножения и одно вычитание заменяют сложнейшие расчёты углов через арктангенсы!

    Резюме

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

    Мы выяснили, что:

  • Точки и векторы — это разные логические сущности, хотя в коде они могут выглядеть одинаково.
  • Нормализация позволяет отделить направление от скорости.
  • Скалярное произведение (Dot) — это сверхбыстрый детектор «сонаправленности» (спереди или сзади).
  • Векторное произведение (Cross) — это компас для вращения (слева или справа).
  • В следующей статье мы объединим эти математические инструменты с игровым циклом и дельта-временем, чтобы построить полноценный кинематический движок: научим объекты двигаться с ускорением, применять силу трения и отскакивать от границ экрана.

    20. Разработка интерактивной физической песочницы

    Разработка интерактивной физической песочницы

    Создание изолированных физических симуляций — отличный способ изучить отдельные законы природы. Однако настоящая магия начинается тогда, когда разрозненные системы объединяются в единую интерактивную среду, где пользователь может свободно творить, разрушать и экспериментировать. Проекты вроде Garry's Mod, Algodoo или Noita построены именно на этой философии.

    Переход от жестко запрограммированной сцены к полноценной Физической песочнице (Physics Sandbox) требует пересмотра архитектуры приложения. Нам необходимо научиться безопасно вмешиваться в работу физического движка в реальном времени, реализовать физически корректный захват объектов и добавить недостающий элемент механики контактов — трение, без которого невозможно построить даже простую башню из кубиков.

    Архитектура песочницы и управление состояниями

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

    Для этого вводится Конечный автомат (State Machine) на уровне главного цикла. Мы разделяем логику на независимые фазы, выполнение которых зависит от текущего состояния системы (например, PLAY или PAUSE).

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

    Такое разделение позволяет реализовать Динамическую фабрику объектов (Dynamic Object Factory) — паттерн, при котором новые тела (круги, многоугольники) создаются по клику мыши, получают случайные физические параметры (массу, цвет, упругость) и помещаются в Пул объектов, готовые к взаимодействию в следующем кадре.

    !Архитектура физической песочницы — разделение потоков данных в зависимости от текущего состояния системы.

    Интерактивный захват: проблема телепортации

    Самое естественное желание пользователя в песочнице — схватить объект курсором и перетащить его. Интуитивный подход программиста-новичка выглядит так: при зажатой кнопке мыши просто приравнивать координаты объекта к координатам курсора.

    В контексте физического движка это фатальная ошибка, известная как Телепортация объекта.

    Если мы мгновенно меняем позицию тела, минуя интегрирование скорости, его фактическая скорость (которую движок вычисляет как разницу позиций, особенно в методе Верле) стремится к бесконечности. Если в этот момент перетаскиваемый объект заденет другой объект, алгоритм реакции на столкновение (опирающийся на закон сохранения импульса) передаст эту гигантскую скорость второму телу, и оно улетит за пределы экрана — симуляция «взорвется».

    Кинематическое ограничение: Мышиная пружина

    Чтобы физически корректно взаимодействовать с объектами, мы не должны менять их позицию напрямую. Мы должны применять к ним силы. Идеальным решением является Мышиная пружина (Mouse Spring) — невидимая упругая связь, которая создается между курсором и точкой захвата на объекте.

    Опираясь на закон Гука и демпфирование, мы вычисляем силу, которая тянет объект к мыши:

    Где: * — вектор силы, применяемый к объекту. * — высокий коэффициент жесткости (чтобы объект следовал за мышью почти мгновенно). * и — радиус-векторы позиции объекта и курсора. * — коэффициент демпфирования, гасящий скорость , чтобы объект не вращался бесконечно вокруг курсора как маятник.

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

    Механика контактов: Модель трения Кулона

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

    Трение — это сила, препятствующая относительному движению соприкасающихся тел. В физических движках стандартом де-факто является эмпирическая Модель трения Кулона (Coulomb Friction).

    Согласно этой модели, сила трения зависит только от силы нормального давления (с которой тела прижимаются друг к другу) и свойств поверхностей, но не зависит от площади контакта.

    Трение покоя и трение скольжения

    В реальности поверхности тел шероховатые на микроуровне. Когда два тела покоятся друг на друге, их микронеровности сцепляются, образуя микросварки. Чтобы сдвинуть тело с места, нужно приложить значительное усилие, чтобы разорвать эти связи. Это Трение покоя (Static Friction).

    Как только тело начало скользить, микронеровности уже не успевают глубоко сцепиться, они лишь «прыгают» друг по другу. Поэтому сопротивление движению падает. Это Трение скольжения (Kinetic Friction).

    Математически это выражается неравенством:

    Где: * — сила трения. * — коэффициент трения покоя (зависит от материалов, например, резина по асфальту имеет высокий , а сталь по льду — низкий). * — нормальная сила (в нашем случае — импульс отталкивания, который мы уже умеем считать).

    Если приложенная сдвигающая сила превышает порог , объект срывается с места, и в дело вступает коэффициент трения скольжения (который всегда меньше ).

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

    Реализация трения через импульсы

    В коде мы работаем не с силами, а с мгновенными изменениями скорости — импульсами. Логика расчета трения выполняется сразу после расчета импульса отскока (нормального импульса ).

  • Поиск касательного вектора: Мы находим вектор относительной скорости и проецируем его на плоскость контакта, получая единичный касательный вектор (тангенциаль).
  • Расчет скалярного импульса трения: По аналогии с отскоком, мы вычисляем, какой импульс нужен, чтобы полностью погасить скорость скольжения объектов друг относительно друга.
  • Ограничение по Кулону (Clamping): Мы проверяем, не превышает ли требуемый импульс трения предел трения покоя.
  • Добавление этого небольшого блока кода радикально меняет поведение песочницы. Объекты начинают цепляться друг за друга, вращаться при касательных ударах (если подключена динамика твердого тела) и образовывать устойчивые конструкции.

    Сыпучие материалы и эмерджентность

    Имея оптимизированное обнаружение столкновений (через пространственное разбиение) и корректное трение, мы можем перейти к симуляции Сыпучих материалов (Granular Materials) — песка, гравия, зерна.

    С точки зрения физического движка, песок — это просто тысячи маленьких жестких кругов (частиц). Мы не пишем для них отдельную логику жидкостей. Их поведение возникает само по себе благодаря массовому взаимодействию. Это классический пример эмерджентности.

    Ключевым макроскопическим свойством сыпучих материалов является Угол естественного откоса (Angle of Repose).

    > Угол естественного откоса — это максимальный угол наклона поверхности свободно насыпанного материала к горизонтали, при котором материал сохраняет стабильность и не осыпается под действием гравитации.

    Этот угол напрямую зависит от коэффициента трения между частицами. Если мы создадим в нашей песочнице эмиттер, который каждый кадр генерирует новые частицы-круги, они будут падать на дно экрана и образовывать горку.

    * Если коэффициент трения частиц близок к нулю, горка растечется ровным слоем (угол откоса ), имитируя жидкость. * Если трение высокое, частицы будут цепляться друг за друга, образуя крутую пирамиду (угол откоса может достигать , как у влажного песка или щебня).

    Проблема уплотнения (Stacking Stability)

    При симуляции тысяч частиц, лежащих друг на друге, возникает проблема стабильности. Нижние частицы испытывают колоссальное давление от веса всех верхних слоев. В дискретной симуляции (где время разбито на кадры ) это приводит к тому, что нижние круги начинают проникать друг в друга, а алгоритм позиционной коррекции пытается их вытолкнуть. Возникает эффект «кипящего песка» — куча постоянно дрожит и не может успокоиться.

    Для решения этой проблемы в профессиональных песочницах применяют два метода:

  • Sub-stepping (Подшаги): Вместо одного большого шага физики (например, секунды), движок делает 4-8 микрошагов с меньшим . Это дает алгоритму столкновений больше итераций для аккуратного расталкивания частиц до того, как они глубоко проникнут друг в друга.
  • Усыпление объектов (Sleeping): Если кинетическая энергия частицы (её скорость) падает ниже определенного микроскопического порога на протяжении нескольких кадров, движок помечает её как «спящую». Спящие объекты исключаются из расчетов интегрирования и столкновений до тех пор, пока в них не врежется активный объект. Это позволяет симулировать гигантские горы песка без падения FPS, так как процессор обсчитывает только активный поверхностный слой.
  • Объединив управление состояниями, мышиную пружину для взаимодействия, трение Кулона для стабильности и оптимизации для массовых скоплений, мы получаем полноценную интерактивную физическую песочницу. В ней математические формулы оживают, позволяя пользователю интуитивно, на кончиках пальцев, ощущать законы классической механики.

    3. Матрицы и системы координат

    Матрицы и системы координат

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

    Но интерактивная визуализация — это не только одиночные точки. Представьте, что вы загружаете в свою программу 3D-модель автомобиля, состоящую из 50 000 вершин. Автомобиль должен ехать вперед, поворачивать, а его колеса — вращаться. Если мы попытаемся вручную применять тригонометрические формулы к каждой из 50 000 точек на каждом кадре, наш процессор быстро «захлебнется», а код превратится в нечитаемую кашу из синусов и косинусов.

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

    Что такое матрица в контексте графики?

    С математической точки зрения, матрица — это просто прямоугольная таблица чисел. В 3D-графике мы чаще всего используем квадратные матрицы размером 4x4 (4 строки и 4 столбца), а в 2D-графике — 3x3.

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

    Самая базовая матрица — это Единичная матрица (Identity Matrix). Она состоит из единиц по главной диагонали и нулей во всех остальных ячейках.

    Единичная матрица работает в матричной алгебре так же, как число в обычной арифметике. Если вы умножите любой вектор на единичную матрицу, вектор не изменится. Это наше «исходное состояние», чистый холст, на который мы будем наслаивать трансформации.

    Линейные преобразования: Масштаб и Поворот

    Давайте посмотрим, как базовые трансформации кодируются внутри матрицы. Для простоты начнем с 3D-матрицы масштабирования. Чтобы увеличить объект в 2 раза по оси X и в 3 раза по оси Y, мы просто заменяем единицы на диагонали на нужные коэффициенты:

    Где — коэффициенты масштаба по соответствующим осям.

    С поворотом всё немного сложнее, так как в дело вступает тригонометрия. Например, матрица поворота вокруг оси Z (что эквивалентно повороту в 2D-плоскости экрана) на угол выглядит так:

    Масштабирование и поворот называются линейными преобразованиями. Их общая черта в том, что они всегда оставляют начало координат (точку ) на своем месте. Как бы вы ни крутили и ни масштабировали объект вокруг его центра, сам центр никуда не сдвинется.

    И здесь мы сталкиваемся с фундаментальной проблемой: как нам переместить объект?

    Проблема перемещения и Однородные координаты

    Если мы используем матрицу 3x3 для трансформации 3D-вектора , математика не позволит нам добавить к координатам простое смещение (Translation) с помощью одного лишь умножения. Умножение всегда масштабирует или комбинирует существующие координаты, но не может прибавить к ним константу.

    Чтобы обойти это ограничение, математики придумали гениальный трюк — однородные координаты (Homogeneous coordinates).

    Мы берем наш 3D-вектор и искусственно добавляем к нему четвертый компонент, который принято называть . Теперь наш вектор выглядит как . Соответственно, чтобы умножить такой вектор, нам нужна матрица размером 4x4.

    Матрица перемещения (Translation) выглядит так:

    Где — это то, насколько мы хотим сдвинуть объект по осям.

    А теперь самое важное правило компьютерной графики, касающееся компонента :

  • Если , то перед нами Точка (позиция в пространстве). При умножении на матрицу перемещения, значения умножатся на и прибавятся к координатам . Точка сдвинется!
  • Если , то перед нами Вектор (направление). При умножении на матрицу перемещения, значения умножатся на и исчезнут. Направление останется неизменным!
  • > Вспомните аналогию из прошлой статьи: точка — это адрес дома, а вектор — инструкция «проехать 5 км на север». Если мы сдвинем весь город на 10 километров вправо (применим перемещение), адрес дома изменится (), но инструкция «проехать 5 км на север» останется точно такой же (). Однородные координаты позволяют графическому движку автоматически применять это логическое правило ко всем вершинам и нормалям модели.

    Умножение матриц: Порядок имеет значение

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

    Векторная алгебра диктует строгое правило: умножение матриц некоммутативно. Это значит, что . Порядок, в котором вы перемножаете матрицы, критически важен.

    В 3D-графике золотым стандартом является порядок SRT (Scale -> Rotate -> Translate). При умножении вектора на итоговую матрицу, трансформации применяются справа налево:

  • Сначала объект масштабируется (пока он находится в центре координат).
  • Затем он поворачивается (вокруг своего центра).
  • И только в конце он перемещается в нужную точку мира.
  • Если вы нарушите порядок и сначала переместите объект, а затем попытаетесь его повернуть, он будет вращаться не вокруг своей оси, а вокруг глобального центра координат (точки ), описывая огромную орбиту, как планета вокруг Солнца.

    !Интерактивная демонстрация: как порядок умножения матриц меняет всё

    Матрицы в памяти C++: Строки против Столбцов

    Когда мы переносим математику в код на C++, возникает важный архитектурный нюанс. Как хранить матрицу 4x4 в оперативной памяти?

    Казалось бы, логично использовать двумерный массив: float matrix[4][4];. Однако на практике в профессиональных движках (и в графических API, таких как OpenGL) матрицы почти всегда хранятся как одномерный массив из 16 элементов: float matrix[16];.

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

    Но здесь кроется ловушка, погубившая нервы тысячам новичков. Как именно эти 16 чисел укладываются в массив?

    Существует два подхода:

  • Row-major (по строкам): элементы записываются строка за строкой. Сначала первая строка матрицы, затем вторая и так далее. Этот подход используется в математике и в API DirectX.
  • Column-major (по столбцам): элементы записываются столбец за столбцом. Сначала первый столбец (сверху вниз), затем второй. Этот подход исторически используется в OpenGL.
  • Если вы напишете матрицу перемещения на листке бумаги (где смещения находятся в правом столбце) и попытаетесь передать её в OpenGL как Row-major массив, видеокарта прочитает её по столбцам. В результате ваши смещения окажутся в нижней строке, и объект улетит в бесконечность или исчезнет с экрана.

    Пример реализации простейшей структуры матрицы для OpenGL на C++:

    Конвейер систем координат (Graphics Pipeline)

    Теперь, когда мы знаем, как работают матрицы, давайте посмотрим на общую картину. В интерактивной визуализации вершина 3D-модели проходит через несколько «миров» (систем координат), прежде чем стать пикселем на вашем мониторе. Этот путь называется графическим конвейером.

    !Конвейер систем координат: путь вершины от локальной модели до экрана монитора.

    1. Локальное пространство (Local Space)

    Когда художник создает 3D-модель (например, персонажа) в Blender или Maya, он работает в локальном пространстве. Центр координат обычно находится в ногах персонажа или в его центре тяжести. Координаты вершин руки могут быть . Они ничего не знают о том, где персонаж находится в игровом мире.

    2. Мировое пространство (World Space)

    Чтобы поместить персонажа на уровень, мы умножаем все его локальные вершины на Матрицу Модели (Model Matrix). Эта матрица содержит позицию, поворот и масштаб конкретного персонажа на уровне. Теперь координаты руки — это реальные координаты в огромном виртуальном мире, например .

    3. Пространство вида (View Space)

    Мир существует, но мы должны смотреть на него через виртуальную камеру. Пространство вида — это мир, пересчитанный так, будто камера находится в центре координат и смотрит вдоль оси Z.

    Чтобы перевести вершины из Мирового пространства в Пространство вида, используется Матрица Вида (View Matrix). Она работает как обратная трансформация камеры: если камера двигается на 5 метров вперед, Матрица Вида сдвигает весь остальной мир на 5 метров назад. Для построения этой матрицы активно используются скалярное и векторное произведения, которые мы разбирали в прошлой статье, чтобы вычислить векторы «Вперед», «Вправо» и «Вверх» для объектива камеры.

    4. Пространство отсечения и Проекция (Clip Space)

    Наконец, нам нужно сплющить 3D-мир на плоский 2D-экран монитора. Этим занимается Матрица Проекции (Projection Matrix).

    Она бывает двух видов:

  • Ортографическая: сохраняет параллельность линий. Объекты вдали имеют тот же размер, что и вблизи. Используется в чертежах, стратегиях и 2D-играх.
  • Перспективная: имитирует человеческий глаз. Чем дальше объект, тем меньше он кажется. Матрица проекции хитро использует тот самый компонент , записывая в него расстояние до объекта (глубину). Затем видеокарта делит координаты на (Perspective Divide), из-за чего далекие объекты «сжимаются» к центру экрана.
  • Резюме

    Матрицы — это кровеносная система любого графического движка на C++. Они позволяют элегантно комбинировать сложнейшие трансформации и применять их к миллионам вершин с максимальной эффективностью для процессора и видеокарты.

    Понимание того, как работают однородные координаты (компонент ) и как матрицы переводят данные из локального пространства модели в мировое, а затем в пространство камеры — это тот водораздел, который отделяет начинающего программиста от уверенного разработчика интерактивных симуляций.

    В следующей статье мы применим эти знания на практике: создадим физический объект, назначим ему массу и скорость, и заставим его двигаться по экрану, подчиняясь законам ньютоновской кинематики.

    4. Настройка окружения и основы библиотеки SFML

    Настройка окружения и основы библиотеки SFML

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

    Язык C++ изначально создавался как системный инструмент. В его стандартной библиотеке нет функций для отрисовки пикселей, создания окон или воспроизведения звука. Чтобы вывести графику на экран, нам необходимо обратиться к операционной системе (Windows, macOS, Linux), которая управляет видеокартой. Делать это напрямую крайне сложно и долго, поэтому программисты используют графические библиотеки — готовые наборы кода, которые служат мостом между вашей программой и видеокартой.

    В этом курсе мы будем использовать SFML (Simple and Fast Multimedia Library). Это современная, объектно-ориентированная библиотека, которая идеально подходит для создания 2D-симуляций и игр. Она берет на себя всю рутину по созданию окон и обработке ввода, позволяя нам сосредоточиться на главном — физике и математике.

    Анатомия сборки C++ проекта

    Прежде чем мы напишем первую строчку графического кода, необходимо понять, как именно внешняя библиотека подключается к вашей программе. Для программистов, пришедших из языков вроде Python или JavaScript (где достаточно написать npm install или pip install), процесс сборки в C++ часто кажется пугающим.

    Чтобы избежать многочасовой борьбы с ошибками, давайте разберем конвейер превращения текстового кода в готовую программу. Этот процесс состоит из трех ключевых этапов.

    1. Препроцессор (Preprocessor)

    Когда вы нажимаете кнопку «Собрать проект», первым в дело вступает препроцессор. Это текстовый редактор-робот, который просматривает ваш код в поисках директив, начинающихся с символа #.

    Когда он видит строку #include <SFML/Graphics.hpp>, он буквально копирует всё содержимое файла Graphics.hpp и вставляет его в ваш код. Файлы с расширением .hpp или .h называются заголовочными файлами. Они содержат только «оглавление» библиотеки: названия функций и структур, но не сам код их выполнения. Это нужно для того, чтобы ваша программа знала, какие инструменты ей доступны.

    2. Компилятор (Compiler)

    После работы препроцессора за дело берется компилятор. Он переводит ваш понятный человеку код (вместе со вставленными заголовочными файлами) в машинный код — набор нулей и единиц, понятный процессору. Результатом работы компилятора является объектный файл (обычно с расширением .obj или .o).

    На этом этапе компилятор знает, как называются функции SFML (благодаря препроцессору), но он понятия не имеет, где находится их реальный исполняемый код. Если процесс остановить здесь, программа не запустится.

    3. Компоновщик (Linker)

    Это финальный и самый важный этап при работе с библиотеками. Компоновщик берет ваши объектные файлы и «сшивает» их с реальным скомпилированным кодом библиотеки SFML.

    Здесь возникает важнейший архитектурный выбор: как именно сшивать код? Существует два подхода:

    Статическая компоновка (Static linking*): Компоновщик берет весь необходимый код из файлов библиотеки (с расширением .lib или .a) и физически копирует его внутрь вашего итогового .exe файла. Плюс:* Ваша программа становится полностью независимой. Вы можете скинуть один .exe файл другу, и он у него запустится. Минус:* Размер файла значительно увеличивается. Динамическая компоновка (Dynamic linking): Компоновщик не копирует код. Вместо этого он оставляет в вашем .exe файле «записку»: «Когда программа запустится, найди файл sfml-graphics-2.dll и возьми код оттуда»*. Плюс:* Размер .exe файла остается крошечным. Разные программы могут использовать одну и ту же .dll библиотеку, экономя оперативную память. Минус:* Если вы отправите другу только .exe файл без сопровождающих .dll файлов, программа выдаст ошибку при запуске.

    > В профессиональной разработке игр и симуляций чаще используют динамическую компоновку, так как она ускоряет процесс компиляции при постоянном внесении изменений в код.

    Настройка проекта (на примере Visual Studio)

    Понимая конвейер сборки, настройка SFML в любой среде разработки (Visual Studio, CLion, VS Code) сводится к трем логичным шагам. Допустим, вы скачали SFML и распаковали его в папку C:\SFML.

  • Помочь Препроцессору: В настройках проекта (C/C++ -> General -> Additional Include Directories) нужно указать путь к папке C:\SFML\include. Теперь #include <SFML/Graphics.hpp> будет работать.
  • Помочь Компоновщику найти файлы: В настройках (Linker -> General -> Additional Library Directories) указываем путь к папке C:\SFML\lib.
  • Сказать Компоновщику, что именно сшивать: В настройках (Linker -> Input -> Additional Dependencies) перечисляем конкретные модули, которые нам нужны. Для базовой симуляции это sfml-graphics.lib, sfml-window.lib и sfml-system.lib (для динамической сборки в режиме Release).
  • Наконец, чтобы операционная система смогла запустить вашу динамически скомпонованную программу, необходимо скопировать все файлы .dll из папки C:\SFML\bin в ту же папку, где создается ваш итоговый .exe файл.

    Модульная структура SFML

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

    * System — базовый модуль. Управляет временем, потоками и строками. Без него не работает ни один другой модуль. * Window — отвечает за создание окна операционной системы и перехват событий (нажатия клавиатуры, мыши, закрытие окна). * Graphics — предоставляет инструменты для 2D-отрисовки: спрайты, текст, геометрические фигуры и шейдеры. * Audio — загрузка и воспроизведение звуков и музыки. * Network — работа с сетью (TCP/UDP сокеты) для создания мультиплеера.

    Первое окно и Очередь событий

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

    Как мы обсуждали в первой статье, сердце любой интерактивной программы — это Игровой цикл. Но здесь появляется новый критически важный элемент — Очередь событий (Event queue).

    Операционная система (Windows/macOS) постоянно общается с вашим окном. Когда пользователь двигает мышь, нажимает клавишу или пытается закрыть окно крестиком, ОС отправляет вашему приложению «письмо» с описанием этого события. Эти письма складываются в очередь (почтовый ящик).

    Функция window.pollEvent(event) достает одно письмо из ящика. Цикл while (window.pollEvent(event)) означает: «Доставай письма по одному, пока ящик не опустеет».

    Если вы забудете написать этот внутренний цикл, почтовый ящик переполнится. Операционная система решит, что ваша программа зависла (перестала отвечать на письма), и покажет пользователю системное окно «Программа не отвечает». Обработка события sf::Event::Closed обязательна — без нее пользователь просто не сможет закрыть вашу симуляцию стандартным способом.

    Экранные координаты: мир вверх ногами

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

    В классической декартовой системе координат точка находится в левом нижнем углу (или в центре), а ось Y направлена вверх. Чем больше Y, тем выше объект.

    В компьютерной графике используются Экранные координаты. Точка всегда находится в левом верхнем углу экрана. Ось X направлена вправо, а вот ось Y направлена вниз.

    !Сравнение классической декартовой системы координат и экранной системы координат SFML. Видно, что ось Y в компьютерной графике направлена вниз.

    Эта странность — историческое наследие старых электронно-лучевых мониторов (ЭЛТ). Луч в таких мониторах отрисовывал картинку строчка за строчкой, начиная с левого верхнего угла и двигаясь вниз. Современные графические библиотеки сохранили этот стандарт.

    Если вы хотите, чтобы объект падал вниз (под действием гравитации), его координата Y должна увеличиваться. Если вы хотите, чтобы объект взлетел вверх, его координату Y нужно уменьшать.

    Интеграция физики: Кинематика и Дельта-время

    Теперь у нас есть окно, система координат и понимание игрового цикла. Давайте создадим физический объект — частицу, и заставим её двигаться.

    В физике равномерное прямолинейное движение — это движение, при котором объект за любые равные промежутки времени совершает одинаковые перемещения.

    Базовая формула кинематики для нахождения новой позиции выглядит так:

    Где: * — новая позиция (вектор). * — текущая позиция (вектор). * — скорость (вектор, показывающий направление и количество пикселей в секунду). * — дельта-время (скаляр, доля секунды, прошедшая с прошлого кадра).

    В SFML для работы с 2D-векторами есть встроенный шаблонный класс sf::Vector2f (буква 'f' означает, что внутри используются числа с плавающей точкой float).

    Давайте добавим в наш код частицу, которая движется вправо и вниз со скоростью 150 пикселей в секунду по оси X и 50 пикселей по оси Y.

    Обратите внимание на использование дельта-времени. Как мы выяснили ранее, если бы мы просто прибавляли скорость к позиции на каждом кадре без умножения на , скорость частицы зависела бы от мощности компьютера. На мониторе 144 Гц она летела бы в 2.4 раза быстрее, чем на мониторе 60 Гц.

    Умножение на переводит скорость из абстрактных «пикселей за кадр» в строгие «пиксели в секунду». Если кадр отрисовывался долго (например, секунды), частица совершит большой скачок. Если кадры мелькают быстро ( секунды), шаги будут крошечными. В обоих случаях за ровно одну реальную секунду частица пролетит ровно 150 пикселей по оси X.

    !Подвигайте ползунки скорости по осям X и Y, чтобы увидеть, как вектор скорости влияет на направление движения. Попробуйте включить имитацию «тормозов» (низкий FPS) и убедитесь, что благодаря дельта-времени объект всё равно достигает цели за то же самое время.

    Резюме

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

    Самое главное — мы применили наши знания векторной алгебры и дельта-времени на практике, создав первую математически корректную кинематическую симуляцию движения.

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

    5. Отрисовка базовых геометрических примитивов

    Отрисовка базовых геометрических примитивов

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

    Чтобы писать эффективный код на C++, нужно понимать, как именно видеокарта (GPU) воспринимает геометрию и почему бездумное использование высокоуровневых классов может привести к резкому падению производительности.

    Истинный язык видеокарт: Вершины и Треугольники

    Современные видеокарты — это вычислительные монстры, способные параллельно обрабатывать миллионы операций. Но их архитектура крайне специализирована. Видеокарта не знает, что такое «круг», «сфера» или «кривая Безье». Она понимает только простейшие графические примитивы — базовые неделимые элементы геометрии, из которых строится любое изображение.

    К графическим примитивам относятся: * Точки * Линии * Треугольники

    Любой сложный 2D или 3D объект в компьютерной графике в конечном итоге разбивается на треугольники. Почему именно треугольник? Это единственная геометрическая фигура, три вершины которой всегда лежат в одной плоскости. Четырехугольник можно «согнуть», сделав его неплоским, а треугольник — нет. Кроме того, треугольник всегда выпуклый, что радикально упрощает математику заливки его площади пикселями (растеризацию).

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

    !Схема аппроксимации круга треугольниками. В центре находится одна общая вершина, от которой лучами расходятся другие вершины, образуя контур. Соседние вершины на контуре соединяются линиями, формируя множество треугольников (Triangle Fan), которые вместе выглядят как круг.

    Высокоуровневые абстракции SFML

    Библиотека SFML предоставляет удобные классы для работы с геометрией, которые скрывают от нас низкоуровневую работу с треугольниками. Основные из них — это sf::RectangleShape (прямоугольник), sf::CircleShape (круг) и sf::ConvexShape (произвольный выпуклый многоугольник).

    Давайте рассмотрим создание прямоугольника:

    Этот код интуитивно понятен, но в нем кроется один из самых частых источников ошибок при расчете физики вращения — проблема локального центра.

    Локальный центр (Origin)

    По умолчанию точка привязки любой фигуры в SFML находится в её левом верхнем углу (координаты в локальном пространстве фигуры). Когда вы вызываете box.setPosition(300.f, 200.f), именно левый верхний угол прямоугольника помещается в эту точку экрана.

    Если вы попытаетесь применить к этому прямоугольнику поворот (box.setRotation(45.f)), он будет вращаться вокруг своего левого верхнего угла, описывая широкую дугу. В физических симуляциях объекты обычно вращаются вокруг своего центра масс.

    Локальный центр (Origin) — это точка внутри локальной системы координат объекта, которая служит осью для вращения, масштабирования и позиционирования.

    > Представьте, что вы прикрепляете прямоугольный кусок картона к стене с помощью канцелярской кнопки. Точка, куда вы втыкаете кнопку — это и есть Origin. Если воткнуть её в угол, картон будет качаться как маятник. Если в центр — будет крутиться на месте.

    Чтобы заставить прямоугольник размером вращаться вокруг своего геометрического центра, необходимо сместить его Origin на половину ширины и высоты:

    Математика под капотом: Как строится круг?

    Как мы уже выяснили, видеокарта не умеет рисовать идеальные круги. Класс sf::CircleShape на самом деле генерирует правильный многоугольник. Чем больше у него углов, тем более гладким кажется круг.

    Чтобы понять, как переносить математические формулы в код, давайте разберем алгоритм, по которому SFML вычисляет позиции вершин для круга. Для этого используется параметрическое уравнение окружности, основанное на тригонометрии:

    Где: * — координаты конкретной вершины на контуре. * — координаты центра круга. * — радиус круга. * (тета) — угол поворота для данной вершины.

    В программировании стандартные математические функции std::sin() и std::cos() принимают угол не в градусах, а в радианах.

    Радиан — это угол, соответствующий дуге, длина которой равна радиусу окружности. Полный оборот окружности (360 градусов) равен радиан (примерно 6.28). Чтобы перевести градусы в радианы, используется формула: .

    Если мы хотим нарисовать круг, состоящий из 30 точек, нам нужно разделить полный оборот () на 30 шагов. На каждом шаге мы вычисляем угол , подставляем его в формулу с синусом и косинусом, и получаем координаты новой вершины.

    !Подвигайте ползунок количества точек (от 3 до 60), чтобы увидеть, как правильный многоугольник постепенно превращается в гладкий круг. Обратите внимание, что при 3 точках формула выдает идеальный равносторонний треугольник, а при 4 — квадрат.

    Производительность и Массивы вершин (Vertex Arrays)

    Высокоуровневые классы вроде sf::CircleShape удобны, если вам нужно нарисовать игрока, пару врагов и несколько стен. Но что, если мы пишем симуляцию газа, где 50 000 частиц хаотично сталкиваются друг с другом?

    Если мы создадим 50 000 объектов sf::CircleShape и будем вызывать window.draw() для каждого из них в цикле, наша программа начнет безбожно тормозить, выдавая 5-10 кадров в секунду даже на мощном компьютере.

    Причина кроется в архитектуре взаимодействия процессора (CPU) и видеокарты (GPU). Каждый вызов функции window.draw() инициирует вызов отрисовки (Draw call).

    Вызов отрисовки — это команда от процессора к видеокарте: «Подготовь свои регистры, вот тебе данные о цвете, вот координаты, начинай рисовать». Сама видеокарта рисует треугольник за наносекунды. Но процесс подготовки и передачи этой команды от CPU к GPU занимает огромное (по меркам компьютера) время.

    > Отправлять 50 000 отдельных вызовов отрисовки — это как переносить тонну песка из песочницы в ведро, используя чайную ложку. Вы тратите больше времени на движение руки туда-сюда, чем на сам перенос песка.

    Решение этой проблемы в компьютерной графике называется батчингом (Batching — пакетирование). Вместо того чтобы отправлять по одной фигуре, мы собираем все вершины всех 50 000 частиц в один большой массив и отправляем его на видеокарту за один единственный вызов отрисовки (берем экскаватор вместо ложки).

    В SFML для этого используется класс sf::VertexArray (Массив вершин).

    Работа с sf::VertexArray

    Массив вершин позволяет нам напрямую управлять тем, как видеокарта будет интерпретировать наши точки. При создании массива мы должны указать тип примитива (sf::PrimitiveType).

    Доступные типы: * sf::Points — каждая вершина рисуется как отдельный пиксель. * sf::Lines — каждые две вершины соединяются в независимую линию. * sf::LineStrip — вершины соединяются последовательно, образуя непрерывную ломаную линию. * sf::Triangles — каждые три вершины образуют независимый треугольник.

    Давайте напишем код, который генерирует сетку из 10 000 точек, используя массив вершин. Это основа для создания симуляций тканевых материалов или визуализации векторных полей.

    Сравнение подходов

    Чтобы закрепить понимание, когда какой инструмент использовать, рассмотрим сравнительную таблицу:

    | Характеристика | Высокоуровневые фигуры (sf::CircleShape) | Массивы вершин (sf::VertexArray) | | :--- | :--- | :--- | | Простота использования | Высокая (готовые методы для цвета, контура, поворота) | Низкая (нужно вручную считать координаты каждой точки) | | Наличие контура (Outline) | Есть встроенный | Нет (нужно рисовать дополнительные линии вручную) | | Производительность | Низкая при большом количестве объектов (много Draw calls) | Максимальная (один Draw call на весь массив) | | Идеально подходит для... | Игрока, стен, UI-элементов, триггеров | Систем частиц, воксельных сеток, процедурной генерации ландшафта |

    Переход от высокоуровневых объектов к массивам вершин — это первый шаг к Data-Oriented Design в графике. Мы перестаем мыслить категориями «Объект Частица, у которой есть метод НарисоватьСебя» и начинаем мыслить категориями «Непрерывный массив данных в памяти, который мы целиком скармливаем видеокарте».

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

    6. Обработка пользовательского ввода в реальном времени

    Обработка пользовательского ввода в реальном времени

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

    В этой статье мы разберем, как правильно считывать нажатия клавиш и движения мыши в C++, как переводить пиксели экрана в физические координаты и как связывать ввод пользователя с математическими моделями кинематики и динамики.

    Две парадигмы ввода: События против Опроса

    В разработке интерактивных приложений на C++ существует два принципиально разных подхода к обработке действий пользователя. Непонимание разницы между ними — главная причина «залипающего» управления или пропущенных кликов.

    Событийный ввод (Event-driven Input)

    Событийный ввод — это реакция программы на дискретные (разовые) изменения состояния оборудования.

    Когда вы нажимаете клавишу «Пробел», операционная система генерирует сообщение: «В 12:00:01 клавиша Пробел была нажата». Когда вы отпускаете её, генерируется второе сообщение: «В 12:00:02 клавиша Пробел была отпущена». Эти сообщения попадают в очередь событий нашего окна.

    Этот подход идеален для действий, которые должны произойти ровно один раз за нажатие: * Прыжок персонажа * Выстрел из пушки * Открытие меню паузы * Ввод текста (например, при создании калькулятора на SFML, где важен каждый отдельный символ habr.com)

    В коде SFML это выглядит как обработка внутри цикла while (window.pollEvent(event)):

    Опрос состояния (State Polling)

    Опрос состояния — это прямое обращение к оборудованию (клавиатуре, мыши или геймпаду) с вопросом: «В каком состоянии находится эта кнопка прямо сейчас?».

    В отличие от событий, нас не интересует, когда кнопка была нажата. Нас интересует только факт: зажата ли она в данный кадр. Этот подход критически важен для непрерывных физических процессов: * Работа двигателей космического корабля * Перемещение камеры по сцене * Применение постоянной силы к объекту

    > Представьте, что вы ждете курьера. Событийный подход — это когда курьер звонит в дверь (событие произошло, вы отреагировали). Опрос состояния — это когда вы каждую секунду выглядываете в окно, чтобы проверить, не стоит ли курьер у подъезда.

    В SFML опрос состояния делается вне цикла событий, прямо в основном игровом цикле:

    Низкоуровневый взгляд: Callback-функции

    Библиотека SFML предоставляет удобный объектно-ориентированный интерфейс. Но если вы решите использовать более низкоуровневые библиотеки, такие как GLFW (часто используется в связке с OpenGL), вы столкнетесь с другой архитектурой — обратными вызовами.

    Callback-функция (Функция обратного вызова) — это функция, которую вы пишете в своем коде, но вызываете не вы сами, а сторонняя библиотека или операционная система в ответ на какое-то событие.

    В C++ это реализуется через передачу указателя на функцию. Вы говорите библиотеке: «Вот адрес моей функции в памяти. Когда пользователь нажмет кнопку, перейди по этому адресу и выполни мой код» tproger.ru.

    Пример архитектуры GLFW:

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

    От кнопок к физике: Численное интегрирование

    Допустим, мы считали, что пользователь удерживает клавишу «Вверх». Как правильно связать это с движением объекта на экране?

    Новички часто совершают ошибку, напрямую изменяя координаты объекта:

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

    Здесь на сцену выходит Численное интегрирование — математический метод пошагового вычисления состояния системы с течением времени. Самый базовый алгоритм в программировании физики называется Методом Эйлера.

    Согласно второму закону Ньютона, сила, действующая на объект, равна его массе, умноженной на ускорение:

    Отсюда мы можем найти ускорение (вектор ), разделив вектор силы () на скаляр массы ().

    Метод Эйлера предлагает обновлять состояние объекта каждый кадр в три шага:

  • Вычислить ускорение:
  • Обновить скорость с учетом ускорения и дельта-времени:
  • Обновить позицию с учетом новой скорости:
  • Теперь наш код обработки ввода выглядит совершенно иначе:

    При таком подходе объект приобретает инерцию. Если вы отпустите клавишу «W», сила станет равна нулю, ускорение станет равно нулю, но скорость сохранится! Объект продолжит полет по инерции, как и положено в космосе.

    !Попробуйте управлять кораблем. Обратите внимание, что кнопки применяют не скорость, а силу (ускорение). Чтобы остановиться, вам придется развернуться и дать тягу в обратном направлении — это наглядная демонстрация инерции и численного интегрирования.

    Мышь и магия преобразования координат

    Клавиатура дает нам простые бинарные состояния (нажато/не нажато). Мышь предоставляет гораздо более богатые данные — точные координаты курсора. Это позволяет реализовывать сложные взаимодействия: перетаскивание объектов (Drag-and-Drop), прицеливание или создание гравитационных аномалий.

    Но здесь кроется главный подводный камень компьютерной графики: мышь живет в одном пространстве, а физическая симуляция — в другом.

    Когда вы вызываете функцию sf::Mouse::getPosition(window), операционная система возвращает вам координаты в пикселях относительно левого верхнего угла окна. Например, .

    Однако ваша физическая симуляция может использовать совершенно другие единицы измерения (метры) и другую систему координат (где центр мира находится в середине экрана, а ось Y направлена вверх). Если вы попытаетесь напрямую приравнять позицию физического объекта к координатам мыши, объект улетит за пределы видимости.

    Процесс перевода пиксельных координат экрана обратно в координаты виртуального мира называется Обратным проецированием (Unprojection).

    !Схема обратного проецирования. Слева показан монитор с курсором мыши (координаты в пикселях, начало в левом верхнем углу). От курсора идет стрелка 'Unproject' через Матрицу Вида к виртуальной 2D-сцене справа, где начало координат находится в центре, а единицы измерения — метры.

    В SFML для этого существует встроенный механизм, который учитывает текущую камеру (View):

    Практика: Гравитационный курсор

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

    Для этого нам нужно найти вектор направления от частицы к мыши. Математически вектор между двумя точками и вычисляется как разность их координат: (из конечной точки вычитаем начальную).

    Архитектура: Абстракция ввода

    По мере роста проекта, жесткое связывание клавиш с действиями (Hardcoding) становится проблемой. Если вы напишете if (sf::Keyboard::W) moveUp(), вы лишите пользователя возможности переназначить управление.

    Профессиональный подход требует использования Паттерна "Команда" (Command Pattern) или системы маппинга (Input Mapping).

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

    Это дает колоссальную гибкость:

  • Переназначение клавиш: Клавиша 'W' и отклонение стика геймпада генерируют одну и ту же команду.
  • Запись и воспроизведение: Команды можно сохранять в массив. Это позволяет реализовать систему повторов (Replays) или визуализацию алгоритмов, где шаги воспроизводятся по таймеру teletype.in.
  • Отмена действий (Undo): Если команда хранит предыдущее состояние, действие можно отменить.
  • В контексте физического движка на основе BVH (Bounding Volume Hierarchy), о котором часто говорят при оптимизации столкновений habr.com, абстракция ввода позволяет применять силы не к конкретным объектам, а к узлам пространственного дерева, создавая локальные взрывы или гравитационные аномалии, не перебирая все объекты сцены вручную.

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

    7. Кинематика: движение, скорость и ускорение

    Кинематика: движение, скорость и ускорение

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

    Добро пожаловать в мир кинематики.

    Что такое кинематика?

    В физике механику традиционно делят на две большие части: кинематику и динамику.

    Кинематика (от греч. kinema — движение) — это раздел механики, который изучает математическое описание движения объектов, не вдаваясь в причины, вызывающие это движение.

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

    > Если вы пишете код, который рассчитывает, куда упадет граната, зная начальный угол и скорость — вы занимаетесь кинематикой. Если вы рассчитываете, какую силу взрыва нужно применить к ящику массой 50 кг, чтобы он отлетел на 10 метров — вы занимаетесь динамикой.

    Позиция, перемещение и путь

    Любая симуляция начинается с определения положения объекта в пространстве. Для этого мы используем радиус-вектор (или вектор позиции) — вектор, проведенный из начала координат (0,0) в точку, где находится объект.

    Когда объект движется, его позиция меняется. Здесь важно различать два понятия, которые в быту часто путают:

  • Пройденный путь — это скалярная величина, равная длине траектории, по которой двигался объект. Путь всегда положителен и только накапливается.
  • Перемещение (Displacement) — это векторная величина, соединяющая начальную и конечную точки движения.
  • Математически вектор перемещения вычисляется как разность между конечной позицией и начальной :

    Где (греческая буква дельта) традиционно обозначает изменение величины.

    Пример из жизни: Представьте, что вы бегаете по стадиону. Длина одного круга — 400 метров. Вы пробежали ровно один круг и вернулись на старт. Ваш пройденный путь составил 400 метров. Но ваше перемещение равно нулю, потому что ваша начальная и конечная позиции совпадают (вектор имеет длину 0).

    В программировании игр мы почти всегда работаем именно с перемещением, так как оно позволяет легко вычислять новые координаты объекта.

    Скорость: от средней к мгновенной

    Зная перемещение и время, за которое оно произошло, мы можем найти скорость.

    Средняя скорость — это отношение вектора перемещения ко времени, за которое это перемещение произошло:

    Где — вектор средней скорости, — вектор перемещения, а — скалярный интервал времени.

    Однако средняя скорость мало полезна для интерактивных симуляций. Если машина ехала 100 км/ч, потом стояла в пробке, а потом гнала 150 км/ч, её средняя скорость может составить 60 км/ч. Но для отрисовки каждого кадра нам нужно знать, что происходит прямо сейчас.

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

    К счастью, в программировании нам не нужно вычислять абстрактные пределы. Как мы помним из концепции игрового цикла, у нас уже есть очень маленький интервал времени — дельта-время (dt). Для компьютера dt (например, 0.016 секунды при 60 FPS) и есть то самое , стремящееся к нулю.

    Поэтому в коде формула мгновенной скорости трансформируется в уравнение обновления позиции:

    Ускорение: скорость изменения скорости

    Если скорость — это то, как быстро меняется позиция, то ускорение — это то, как быстро меняется сама скорость.

    Где — вектор ускорения, а — изменение вектора скорости.

    Ускорение — это вектор. Это значит, что объект ускоряется не только когда жмет на газ (увеличивает длину вектора скорости), но и когда тормозит (вектор ускорения направлен против вектора скорости), и даже когда просто поворачивает (меняется направление вектора скорости, даже если спидометр показывает постоянное значение).

    Самый известный пример постоянного ускорения — ускорение свободного падения (гравитация). На Земле оно направлено строго вниз и равно примерно . В 2D-симуляции на C++ это выглядит так:

    Аналитический подход против Численного

    В предыдущей статье мы познакомились с методом Эйлера — пошаговым обновлением позиции и скорости каждый кадр. Это численный метод (Numerical integration). Он отлично подходит для игр, где игрок может в любой момент нажать кнопку и изменить ускорение.

    Но что, если нам нужно заранее нарисовать траекторию полета стрелы, чтобы показать игроку, куда она попадет? Мы не можем прокрутить игровой цикл в будущем. Нам нужен аналитический метод.

    В классической кинематике существует уравнение движения с постоянным ускорением:

    Где: * — искомая позиция в момент времени * — начальная позиция * — начальная скорость * — постоянное ускорение (например, гравитация) * — общее время полета (не dt, а абсолютное время от начала броска)

    Это уравнение позволяет вычислить позицию объекта в любой момент времени , не вычисляя все предыдущие кадры.

    Сравнение подходов

    | Характеристика | Численный метод (Эйлер) | Аналитический метод (Уравнение кинематики) | | :--- | :--- | :--- | | Как работает | Шаг за шагом каждый кадр (dt) | Вычисляет точную позицию по формуле для любого | | Плюсы | Легко реагирует на непредсказуемый ввод, столкновения, переменные силы | Абсолютно точен, не накапливает математических ошибок, работает мгновенно | | Минусы | Накапливает погрешность со временем, зависит от частоты кадров | Работает только если ускорение постоянно. Не подходит для интерактивного управления | | Применение | Движение персонажа, физика машин, частицы | Предсказание траектории (прицел), движение планет по орбитам |

    В C++ функция для предсказания траектории (аналитический подход) выглядит так:

    Вы можете вызвать эту функцию в цикле for, передавая , и отрисовать точки, чтобы получить линию прицеливания.

    Баллистическое движение: принцип независимости осей

    Одно из величайших открытий Галилео Галилея заключается в том, что движение по осям X и Y абсолютно независимо друг от друга. Это основа баллистического движения (движения снаряда).

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

    !Схема баллистического движения. Показана параболическая траектория пушечного ядра. В нескольких точках траектории нарисованы векторы скорости, разложенные на компоненты X и Y. Вектор X всегда одинаковой длины (скорость постоянна). Вектор Y направлен вверх в начале, уменьшается до нуля в высшей точке, и растет вниз при падении под действием гравитации.

    Почему это происходит? * По оси X: На ядро не действуют никакие силы (если пренебречь сопротивлением воздуха). Ускорение равно нулю. Ядро летит равномерно и прямолинейно. * По оси Y: На ядро действует постоянное ускорение гравитации. Оно летит равноускоренно.

    В коде эта независимость обрабатывается автоматически благодаря векторной алгебре. Когда мы пишем velocity += gravity * dt, гравитация (у которой X равен 0) изменяет только Y-компоненту скорости.

    !Попробуйте изменить угол выстрела и начальную скорость. Обратите внимание, что максимальная дальность полета достигается ровно при 45 градусах, а компонента скорости по оси X остается неизменной на протяжении всего полета.

    Продвинутая кинематика: Интегрирование Верле

    Метод Эйлера, который мы использовали до сих пор, интуитивно понятен: мы храним позицию и скорость. Однако в профессиональных физических движках (например, при симуляции тканей, веревок или рэгдоллов) часто используется другой подход — Интегрирование Верле (Verlet integration).

    Главная особенность метода Верле в том, что он вообще не хранит скорость в явном виде. Вместо этого объект хранит свою текущую позицию и свою предыдущую позицию.

    Скорость вычисляется «на лету» как разница между текущей и старой позицией:

    Формула обновления позиции по методу Верле выглядит так:

    В коде на C++ один шаг симуляции Верле выглядит удивительно просто:

    Зачем это нужно? Метод Верле невероятно стабилен при работе с ограничениями (Constraints). Если вы хотите сделать маятник, вам нужно жестко ограничить расстояние между точкой подвеса и грузом. В методе Эйлера изменение позиции для удовлетворения ограничения ломает сохраненную скорость, и маятник начинает дергаться или взрываться. В методе Верле, если вы просто сдвинете p.position в правильное место, скорость в следующем кадре автоматически пересчитается корректно, так как она зависит от фактических координат.

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

    8. Динамика: силы и законы Ньютона

    Динамика: силы и законы Ньютона

    До сих пор мы рассматривали виртуальный мир исключительно через призму геометрии движения. Мы научились вычислять новые координаты объектов, зная их скорость и ускорение. Однако в реальности объекты не начинают двигаться сами по себе. Яблоко падает, потому что его тянет Земля. Бильярдный шар катится, потому что по нему ударил кий. Автомобиль тормозит из-за трения шин об асфальт.

    Чтобы наша симуляция на C++ стала по-настоящему реалистичной, нам нужно перейти от вопроса «Как движется объект?» к вопросу «Почему он движется?».

    Этим занимается динамика — раздел механики, изучающий причины возникновения движения. В основе классической динамики лежат три фундаментальных закона, сформулированных Исааком Ньютоном. В этой статье мы разберем, как перевести эти законы на язык программирования и создать ядро физического движка.

    Масса и Инерция (Первый закон Ньютона)

    В кинематике все объекты были для нас просто точками в пространстве. В динамике у объектов появляется важнейшее свойство — масса.

    > Первый закон Ньютона (Закон инерции): > Всякое тело продолжает удерживаться в состоянии покоя или равномерного и прямолинейного движения, пока и поскольку оно не понуждается приложенными силами изменить это состояние.

    Простыми словами: объекты ленивы. Если объект стоит, он хочет стоять дальше. Если летит сквозь космос со скоростью 100 м/с, он будет лететь так вечно, пока во что-нибудь не врежется.

    Свойство тел сопротивляться изменению своей скорости называется инерцией, а масса — это количественная мера этой инерции. Чем больше масса, тем сложнее сдвинуть объект с места или остановить его.

    Пример из жизни: Представьте, что на вас катятся два шара одинакового размера со скоростью 5 км/ч. Один сделан из пенопласта (масса 100 грамм), другой — из свинца (масса 100 кг). Пенопластовый шар вы легко остановите одной рукой. Свинцовый шар сломает вам ногу, хотя их скорости абсолютно одинаковы. Разница заключается в их массе (инерции).

    Обратная масса в программировании

    В коде на C++ мы могли бы просто добавить поле float mass в структуру нашего объекта. Однако в разработке физических движков применяется элегантный архитектурный трюк — хранение обратной массы (Inverse Mass).

    Обратная масса вычисляется как .

    Зачем это нужно? В играх часто встречаются статические объекты: стены, пол, непробиваемые препятствия. С точки зрения физики, это объекты с бесконечной массой. Никакая сила не способна их сдвинуть.

    Если мы будем хранить обычную массу, бесконечность вызовет проблемы в вычислениях (переполнение или необходимость писать множество проверок if (isStatic)). Но если мы используем обратную массу, то для статического объекта она будет равна .

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

    Сила и Ускорение (Второй закон Ньютона)

    Если первый закон говорит, что объекты не хотят менять скорость, то второй закон объясняет, что именно заставляет их это сделать.

    > Второй закон Ньютона: > Ускорение тела прямо пропорционально равнодействующей всех приложенных к нему сил и обратно пропорционально его массе.

    Это выражается самой известной формулой классической механики:

    Где: * — вектор силы (измеряется в Ньютонах, Н) * — скалярная масса (в килограммах, кг) * — вектор ускорения (в м/с²)

    Для программирования нам удобнее перевернуть эту формулу, так как обычно мы знаем приложенную силу и массу, а найти нам нужно ускорение, чтобы передать его в интегратор (например, метод Эйлера):

    Или, используя нашу концепцию обратной массы ():

    Принцип суперпозиции и Аккумулятор сил

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

    Согласно принципу суперпозиции, мы можем просто сложить все эти векторы сил вместе, чтобы получить одну равнодействующую силу (Net Force).

    В C++ это реализуется через паттерн Аккумулятор сил (Force Accumulator). Мы добавляем в наш объект вектор netForce, который накапливает все воздействия в течение одного кадра, а затем сбрасывается в ноль.

    !Попробуйте применить одинаковую силу к объектам разной массы. Обратите внимание, как масса влияет на итоговое ускорение.

    Виды сил в симуляциях

    Давайте рассмотрим, какие конкретно силы мы можем добавлять в наш аккумулятор netForce.

    1. Гравитация

    Гравитация — это сила, с которой планета притягивает объекты. По закону всемирного тяготения, сила тяжести вблизи поверхности Земли вычисляется как:

    Где — вектор ускорения свободного падения (направлен вниз, длина примерно м/с²).

    Если мы подставим эту силу во Второй закон Ньютона (), то получим:

    Масса сокращается! Вот почему в предыдущей статье про кинематику мы просто прибавляли гравитацию напрямую к скорости, игнорируя массу. В вакууме пушинка и гиря падают с одинаковым ускорением.

    Однако в полноценном физическом движке правильнее применять гравитацию именно как силу, чтобы она корректно взаимодействовала с другими силами:

    2. Сила сопротивления среды (Вязкое трение)

    В реальности пушинка падает медленнее гири из-за сопротивления воздуха. Эта сила всегда направлена противоположно вектору скорости объекта. Чем быстрее летит объект, тем сильнее сопротивление.

    Упрощенная формула линейного сопротивления (Drag force):

    Где — коэффициент сопротивления среды (скаляр), а — вектор текущей скорости.

    Эта сила крайне полезна в играх. Без нее объекты, к которым постоянно применяется тяга (например, космический корабль игрока), будут ускоряться до бесконечности. Сопротивление создает «терминальную скорость» — предел, при котором сила тяги уравновешивается силой сопротивления, и ускорение становится равным нулю.

    Действие и Противодействие (Третий закон Ньютона)

    Третий закон Ньютона — это основа взаимодействия объектов между собой (коллизий, пружин, сочленений).

    > Третий закон Ньютона: > Силы, с которыми два тела действуют друг на друга, равны по модулю и противоположны по направлению.

    Математически это записывается так:

    Пример из жизни: Вы стоите на скейтборде и с силой толкаете стену руками. Стена не сдвинется (у нее бесконечная масса), но вы покатитесь назад. Почему? Потому что в момент, когда вы приложили силу к стене, стена приложила точно такую же силу к вам, но в обратном направлении.

    В программировании физики этот закон критически важен при разрешении столкновений. Если объект А врезается в объект Б, мы вычисляем силу удара (импульс) и применяем её к объекту Б. Чтобы физика не сломалась (чтобы соблюдался закон сохранения импульса), мы обязаны взять этот же вектор силы, инвертировать его и применить к объекту А.

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

    Сборка физического конвейера

    Теперь у нас есть все элементы, чтобы написать полноценный шаг физической симуляции (Physics Tick). Процесс делится на три этапа:

  • Сбор сил: Применяем гравитацию, ввод игрока, сопротивление.
  • Интегрирование: Превращаем накопленную силу в ускорение, ускорение в скорость, а скорость в позицию.
  • Очистка: Обнуляем аккумулятор сил для следующего кадра.
  • Вот как выглядит классический метод update для физического тела с использованием полунеявного метода Эйлера (Semi-implicit Euler), который является стандартом де-факто для простых игровых движков:

    Сравнение подходов: Кинематика против Динамики

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

    | Характеристика | Кинематический подход | Динамический подход | | :--- | :--- | :--- | | Управление | Прямое изменение скорости (velocity.x = 5) | Применение сил (addForce(thrust)) | | Ощущения | Резкое, аркадное (как в Super Mario) | Плавное, инерционное (как в Asteroids) | | Взаимодействие | Объекты проходят друг сквозь друга или останавливаются кодом | Объекты отталкиваются, передают импульс | | Масса | Не учитывается | Напрямую влияет на поведение |

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

    9. Численное интегрирование: методы Эйлера и Верле

    Численное интегрирование: методы Эйлера и Верле

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

    Нам нужно превратить накопленную силу в ускорение, ускорение — в скорость, а скорость — в изменение позиции на экране. Процесс пошагового вычисления состояния динамической системы с течением времени называется численным интегрированием.

    Почему не работают школьные формулы?

    Из школьного курса физики мы помним аналитическое уравнение движения:

    Где: * — позиция объекта в момент времени * — начальная позиция * — начальная скорость * — вектор ускорения * — время в секундах

    Казалось бы, бери эту формулу, подставляй текущее время и получай идеальную позицию объекта. Зачем нужны сложные алгоритмы?

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

    Игрок может нажать кнопку управления в середине кадра. Объект может влететь в зону плотного воздуха, где сила сопротивления резко возрастет. Два шара могут столкнуться, мгновенно изменив векторы сил. Сила гравитации между планетами меняется каждый миллиметр их пути, так как зависит от расстояния между ними.

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

    Явный метод Эйлера (Explicit Euler)

    Самый интуитивный и исторически первый метод численного интегрирования был предложен Леонардом Эйлером в 18 веке.

    Логика Явного метода Эйлера предельно проста: чтобы узнать, где объект окажется в следующем кадре, нужно взять его текущую позицию и сдвинуть её вдоль вектора текущей скорости. А чтобы узнать новую скорость, нужно изменить текущую скорость на величину текущего ускорения.

    Математически это записывается так:

    Где: * Индекс означает текущий кадр (известные значения) * Индекс означает следующий кадр (то, что мы вычисляем) * — вектор скорости * — вектор позиции * — вектор ускорения (вычисленный как ) * — дельта-время (шаг интегрирования)

    Обратите внимание на вторую формулу: для вычисления новой позиции используется старая скорость .

    Проблема Явного Эйлера: Взрыв энергии

    Несмотря на простоту, Явный метод Эйлера практически не используется в разработке игр и симуляций из-за его катастрофической нестабильности.

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

    В физике это называется ошибкой интегрирования. При использовании Явного Эйлера в системах с возвращающими силами (орбита планет, пружины, маятники) эта ошибка всегда накапливается в «плюс». Система самопроизвольно получает лишнюю энергию. Планета, вращающаяся вокруг звезды, будет двигаться по раскручивающейся спирали и в итоге улетит в открытый космос.

    Полунеявный метод Эйлера (Semi-Implicit Euler)

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

    Встречайте Полунеявный метод Эйлера (также известный как Симплектический Эйлер):

    Заметили разницу? Во второй строке мы используем вместо .

    Мы сначала вычисляем новую скорость, и именно её используем для обновления позиции. Этот крошечный нюанс делает алгоритм симплектическим — то есть сохраняющим фазовый объем системы. Ошибка интегрирования всё еще присутствует, но теперь она не накапливается бесконечно, а колеблется вокруг истинного значения. Планета на орбите может слегка пульсировать, но она никогда не улетит в космос.

    Именно этот метод является стандартом де-факто для большинства базовых физических движков. Вот как выглядит его реализация на C++:

    !Попробуйте переключить метод интегрирования с Полунеявного на Явный Эйлер. Обратите внимание, как быстро планета накапливает ложную энергию и срывается с орбиты при Явном методе.

    Интегрирование Верле (Verlet Integration)

    Метод Эйлера отлично подходит для летящих снарядов, прыгающих персонажей и космических кораблей. Но когда дело доходит до симуляции веревок, тканей (плащи, флаги) или «тряпичных кукол» (ragdoll), Эйлер начинает сдавать позиции. В таких системах объекты связаны жесткими ограничениями (расстояниями), и малейшая ошибка в скорости приводит к тому, что ткань начинает дергаться и взрываться.

    В 1967 году французский физик Лупе Верле популяризовал метод, который совершил революцию в молекулярной динамике, а позже — и в игровой индустрии (именно он использовался в физике знаменитой игры Hitman: Codename 47).

    Главная идея Интегрирования Верле: мы вообще отказываемся от хранения скорости как отдельной переменной.

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

    Математика Верле

    Давайте выведем базовую формулу Верле (конкретно — метод Стёрмера-Верле).

    Мы знаем, что неявная скорость за прошедший кадр равна:

    Где — позиция в прошлом кадре.

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

    Но у нас есть силы и ускорение. Добавляем влияние ускорения за время :

    Раскрыв скобки, мы получаем классическую формулу Верле:

    !Геометрический смысл метода Верле. Вектор инерции строится от прошлой позиции к текущей и проецируется вперед. Затем к нему прибавляется вектор ускорения, давая итоговую новую позицию.

    Реализация Верле на C++

    Для использования этого метода нам нужно изменить структуру нашего физического тела. Поле velocity исчезает, появляется oldPosition.

    Суперсила Верле: Ограничения (Constraints)

    Почему отказ от явной скорости так важен? Представьте, что мы моделируем веревку, состоящую из множества частиц, соединенных невидимыми пружинами. Расстояние между соседними частицами всегда должно быть равно, скажем, 10 пикселям.

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

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

    И вот здесь происходит магия: поскольку скорость в Верле вычисляется как разница между position и oldPosition, наше грубое вмешательство в координаты автоматически корректирует скорость объекта! Нам не нужно писать ни строчки кода для пересчета импульсов. Система сама поймет, что раз мы сдвинули частицу, значит она приобрела нужную скорость.

    Это называется разрешением ограничений (Constraint Resolution), и именно благодаря этому свойству метод Верле доминирует в симуляциях мягких тел.

    Ловушка дельта-времени

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

    sf::Vector2f velocity = body.position - body.oldPosition;

    Мы предполагаем, что это смещение произошло за время . Но что, если в прошлом кадре игра выдавала 60 FPS ( с), а в текущем кадре произошел лаг, и FPS упал до 30 ( с)?

    Разница позиций осталась прежней, но времени прошло в два раза больше! Физический движок воспримет это так, будто объект внезапно разогнался. При нестабильной частоте кадров симуляция Верле может взорваться.

    Решением является либо использование строго фиксированного шага физики (Fixed Time Step, о котором мы говорили в первой статье), либо применение модифицированного алгоритма — Time-Corrected Verlet (TCV), который масштабирует неявную скорость пропорционально изменению .

    Сравнение методов

    Чтобы вам было проще выбрать подходящий инструмент для вашего проекта на C++, подведем итоги в виде сравнительной таблицы:

    | Характеристика | Явный Эйлер | Полунеявный Эйлер | Метод Верле | | :--- | :--- | :--- | :--- | | Сложность реализации | Очень низкая | Очень низкая | Средняя | | Хранение данных | Позиция, Скорость | Позиция, Скорость | Позиция, Старая позиция | | Стабильность орбит | Ужасная (взрыв) | Хорошая | Отличная | | Симуляция ткани/веревок | Невозможно | Очень сложно | Идеально | | Зависимость от | Высокая | Высокая | Критическая (требует TCV или Fixed Step) |

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