Когда я раньше задумывался о вращении в 3д, мне было неуютно. Оно казалось сложным. Вспомнить, например, эффект Джанибекова с прецессией свободно вращающейся гайки. Настало время разобраться!
В статье Вас ждут математика, физика, а заодно численное моделирование и визуализация в libgdx.
Можно провести аналогии между массой тела в поступательном движении и моментом инерции. Разница только в том, что масса выражается одним-единственным числом, а момент инерции — матрицей 3х3. В большинстве примеров ограничиваются вращением в 2д, где существует только одна возможная ось вращения, либо симметричными телами типа мяча, когда момен инерции по всем осям одинаковый. Вместо этого я рассмотрю наиболее общий случай.
Математический аппарат
Скалярное произведение векторов:
Для векторов единичной длины позволяет узнать косинус угла между ними.
Векторное произведение — вектор, перпендикулярный векторам a и b, с длинной, равной произведениею длин векторов на синус угла между ними
Для векторного произведения порядок a и b важен:
При выборе системы отсчёта есть произвол, в какую из двух сторон откладывать векторное произведение. Системы координат можно разделить на «правые» и «левые». Дальнейшие рассуждения будуть работать и там и там.
Вектора и матрицы:
Я считаю, что вектор — столбик. Поэтому при умножении вектор ставится справа от матрицы, например
При умножении нескольких матриц можно смотреть как на преобразование вектора матрицей B, а потом матрицей A.
В игровых движках часто делают наоборот — вектор считается строчкой, домножается на матрицу слева, и преобразования «идут слева направо»:
Я буду использовать вектора-столбики, при желании можно транспонировать матрицы и применять то же самое к строчкам.
Представление вращения
Можно использовать углы Эйлера. Это три числа, но углы Эйлера неудобно комбинировать друг с другом. Кроме того, есть вырожденные состояния, при переходе через которые углы резко поменяются (например, направление «вверх»).
Матрицы 3х3. Из можно инвертировать, комбинировать (перемножать), но использование девяти чисел для представления трёх степеней свободы выглядит избыточным. Со временем в матрице могут копиться ошибки, и тогда кроме вращения в матрице появятся масштабирование и прочие эффекты. Существует ортогонализация Грамма-Шмидта для исправления накопившихся ошибок.
Кватернионы: на мой взгляд, очень красивая конструкция из четырёх чисел. В отличие от углов Эйлера, вырожденных состояний нет. Обычно для представления вращения используют кватернионы единичной длины, что оставляет три степени свободы. Накопившиеся при перемножении кватернионов ошибки легко убираюстя нормализацией. В математике их используют разными способами, нас будет интересовать возможность выразить вращение.
Компоненты кватерниона: (w, x, y, z)
Допустим, если есть ось вращения (v_x, v_y, v_z) и угол поворота a. В виде кватерниона это можно записать так:
В квантовой физике кватернионом можно описать спин частицы, и тогда между кватирнионами (1, 0, 0, 0) и (-1, 0, 0, 0) будет разница, но для нас они представляют одно и то же вращение. Это нетрудно заметить, если в предыдущей формуле увеличить угол a на 360 градусов. Вместо кватерниона (w, x, y, z) получится (-w, -x, -y, -z)
Кватернион очень легко инвертировать — поменять угол вращения на отрицательный (w, -x, -y, -z), ну или перевернуть w: (-w, x, y, z) Замена угла вращения на отрицательный выглядит как «переворот» оси вращения.
Нулевому вращению соответствуют кватернионы (1, 0, 0, 0) и (-1, 0, 0, 0)
Из кватерниона довольно легко «извлечь» ось вращения и угол поворота. Благодаря этому кватернион можно возводить в произвольную (нецелую) степень и использовать для интерполяции вращения.
Не обязательно ограничиваться чем-то одним, можно свободно преобразовывать одно представление вращения в другое и использовать то, что удобнее всего в данный момент.
Например, в игре можно:
1. Хранить и комбинировать вращения в виде кватернионов.
2. Для рисования графики преобразовывать кватернион в матрицу.
3. В логи писать углы Эйлера, как более понятные для человека.
В дальнейшем я храню ориентацию тела в виде кватерниона, а производные типа угловой скорости и ускорения — в виде векторов.
Физические обозначения
Лирическое отступление: на хабре какие-то диверсанты отключили маркдаун. Вместо этого есть «божественный» редактор, в котором формулы можно располагать только по центру страницы. Я не могу вставить формулу из пары символов прямо в текст. Статья в изначальном виде лежит на гитхабе, возможно будет удобнее читать прямо там.
Сила — F.
Аналог силы для вращения — крутящий момент:
Разница в домножении на «длину рычага».
У тела есть момент инерции I. Если тело не является шаром или кубом, то вдоль разных осей момент может быть разным. В общем случае I — тензор 3×3 с 9 чиселками. Из курса физики я помню, что с помощью поворота I всегда можно привести к диагональной форме с тремя числами вокруг трёх главных осей и остальными нулями.
Cкорость v, угловая скорость ω. Импульс:
Момент импульса:
Энергия поступательного движения
Кинетическая энергия вращения:
Линейное ускорение a, угловое ускорение ε.
Если моменты вращения тела вокруг главных осей равны, то
и уравнение упрощается до
Аналог для поступательного движения —
В общем случае:
Поэтому для свободно вращающегося тела ось вращения может меняться. Наглядной демонстрацией является эффект Джанибекова
Кроме того, нужно чётко понимать, в какой системе проводятся эти вычисления. Если тело как-то повёрнуто (допустим, матрицей R), то в глобальной системе отсчёта I преобразуется:
В виде кода у меня получилось так:
def globalI: Matrix3d = rot * objectI * rot.inverted()
def getAcceleration(torque: IVector3d): Vector3d = val I = globalI I.inverted() * (torque - omega.cross(I * omega))
и симуляция вращения:
def update(dt: Double, moment: IVector3d): Unit = val acc = body.getE(moment) body.omega.madd(acc, dt) val dw = Quaternion().setFromExp(body.omega * 0.5 * dt) body.rot := dw * body.rot t += dt
Хочешь сделать хорошо — сделай сам
Так уж получилось, что в различных библиотеках для линейной алгребы были те или иные недостатки — например, не было кватернионов, или не хватало каких-то методов. Кроме того, есть произвол в определении направления вектороного произведения, в направлениях вращения и в том, считать вектора столбиками или строчками, что влияет на все дальнейшие формулы.
Спустя много лет использования разных библиотек и копирования кода туда-сюда я наконец-то собрался и написал свою библиотеку: https://github.com/Kright/ScalaGameMath
Возможно, было бы лучше написать библиотеку на java, чтобы её можно было использовать из любого jvm языка. Но тогда для scala придётся писать обёртку, чтобы вместо методов типа «`plus, minus«` и т.п. были доступны более удобные + и -.
Вектора, матрицы, кватернионы и т.п. используют double, а не float. Я не хочу бороться с ошибками округления и перспектива удвоенного количества занятых байтов меня не пугает.
По старой памяти и по аналогии с libgdx я cделал кучу изменяющих методов типа
«`+=«`, «`*=«` и т.п., а так же варианты типа «`Matrix *> vector«`, когда результат операции записывается в правый аргумент. Но потом я взял JMH), написал какие-то бенчмарки и выяснил, что JVM умеет в escape analysis. Создание временных объектов почти не влияет на производительность.
Можно писать код типа
a := b + c + d * m
Вместо менее читаемого опимизированного
a := b a += c a.madd(d, m)
Или ещё менее читаемого
((a := b) += c).madd(d. m)
Я не буду делать никаких общих утверждений, просто призываю не заниматься преждевременной оптимизацией без нужды. Простота и читаемость кода — это тоже очень важные свойства.
Кроме того, в моей библиотеке операции с кватернионами, матрицами, углами Эйлера и преобразования между ними согласованы друг с другом.
Например, можно превратить углы Эйлера в матрицы, перемножить их, превратить обратно в углы Эйлера и получить тот же самый результат, как если бы я в качестве промежуточного преставления использовал кватернионы.
Это очевидное требование, но его легко нарушить, если, допустим, добавлять кватернионы в чей-то проект с готовыми матрицами.
Я использовал property based testing и в тестах явно требовал математических свойств типа ассоциативности, обратимости и т.п.
Например, что для любых кватернионов
Как проверить корректность
По-сути, есть дифференциальное уравнение ε = f(w, R), которое можно численно решить.
Можно проверить на эффекте Джанибекова — взять тело с моментами инерции
И отправить его вращатья вокруг оси Y с каким-то возмущением.
Ось вращения будет прецессировать, а вот кинетическая энергия и момент импульса — оставаться постоянными. Если они не будут сохраняться, значит где-то есть ошибка — либо в формулах, либо в коде.
Для меня было небольшим открытием, что именно это нужно и тестировать. Получается очень просто! Взять произвольное тело с вращающимся телом и проверить, как сохраняются физические инварианты — импульс с энергия. Если что-то выглядит и крякает как утка — это она и есть 🙂 Такой тест по своей полезности оказывается важнее десятка юнит-тестов для отдельных функций.
Численное решение уравнения движения
Здесь и далее будет более прикладная часть с кодом.
Итак, вращение тела можно описать дифференциальным уравнением второго порядка. Для оценки того, насколько точное получилось решение, я буду запускать в свободный полёт «гайку» и смотреть на вращаельный импульс и энергию. Чем сильнее они отклоняются от начальных значений — тем менее точное решение.
Дальше будет сравнение четырёх численных способов решения:
-
Метод Эйлера первого порядка — точность ужасна, используется в качестве простейшего примера.
-
Улучшенный метод Эйлера, второго порядка.
-
Метод Рунге-Кутты второго порядка.
-
Метод Рунге-Кутты четвёртого порядка.
Ещё существует метод метод Верле — его часто используют в играх, вместо скорости надо хранить координаты в текущий и в предыдущий момент времени. Но для рассчёта ускорения вращающегося тела надо знать его точную скорость, а её в явном виде нет.
В защиту своей лени скажу, что метод Рунге-Кутты второго порядка очень похож на метод Верле — делается «подшаг» на половину шага вперёд, там считается ускорение и это ускорение применяется для рассчёта следующего состояния из текущего.
Для наглядности для каждого метода решения я построил графики отклонений по вращательной энергии и по кинетической энергии.
В общем виде решаем уравнение с каким-то шагом dt и известным $y_0$
Конкретно в нашем случае уравнение второго порядка, которое можно записать так:
вектор (y»,y’) можно обозвать как-нибудь типа Y и свести всё к диффуру первого порядка:
В моём коде есть вспомогательные классы типа State3d(Position3d, Velocity3d), а так же производная State3dDerivative(Velocity3d, Acceleration3d)
В этих терминах решение выражается просто и красиво.
Метод Эйлера
Именем Леонарда Эйлера названо очень много всего — и углы, и уравнение вращения, и способ численного решения дифференциальных уравнений.
Самый простой, но катастрофически неточный.
Точность линейно зависит от размера шага.
В виде кода получается так:
val k1 = getDerivative(initial, time) nextState(initial, k1, dt)
Для каждого из методов — график изменения энергии и отклонения момента импульса. Шаг рассчётов 0.01 сек, скорость вращения — порядка 1 радиана в секунду, время симуляции — 100 сек.
Синяя линяя — изменение кинетической энергии, делённое на начальную энергию для нормирования.
Красная Оранжевая линия — отклонение момента импульса L от начального, опять же делённое на начальный |L|.
Улучшенный метод Эйлера
Второй порядок точности.
Считаем ускорение в начальной точке, по нему находим «приблизительную следующую» точку (в обычном методе Эйлера мы бы тут и остановились)
Потом считаем ускорение в «приблизительно следующей» точке, усредняем его с ускорением в начальной и уже по этому ускорению находим конечную.
val k1 = getDerivative(initial, time) val k2 = getDerivative(nextState(initial, k1, dt), time + dt) val kMean = newZeroDerivative() madd(kMean, k1, 0.5) madd(kMean, k2, 0.5) nextState(initial, kMean, dt)
Советую обратить внимание на масштаб. Относительная ошибка составляет 0.0001 от L, кинетическая энергия осцилирует слабее, но решительно и неутомимо растёт.
Метод Рунге-Кутты второго порядка
Второй порядок, похоже на метод Верле.
Находим ускорение, делаем половину шага вперёд и находим ускорение там.
А потом применяем делаем шаг от _начального_ состояния с этим ускорением.
val k1 = getDerivative(initial, time) val k2 = getDerivative(nextState(initial, k1, 0.5 * dt), time + 0.5 * dt) nextState(initial, k2, dt)
Точность примерно такая же как в предыдущем методе. Энергия вращения потихоньку убывает. Я не проверял этот факт для всех возможных моментов инерции, просто забавный момент.
Метод Рунге-Кутты четвёртого порядка
Если предыдущие методы было легко представить и понять, то в этом я не могу сказать, почему коэффициенты именно такие.
Берутся аж 4 точки — начальная, пол-шага вперёд, «уточнённые» пол-шага и потом шаг вперёд. Ускорения из всех четырёх точек усредняются с какими-то весами и с этим усреднённым делается шаг.
Я не буду писать длинные формулы, напишу сразу код:
val k1 = getDerivative(initial, time) val k2 = getDerivative(nextState(initial, k1, 0.5 * dt), time + 0.5 * dt) val k3 = getDerivative(nextState(initial, k2, 0.5 * dt), time + 0.5 * dt) val k4 = getDerivative(nextState(initial, k3, dt), time + dt) val kMean = newZeroDerivative() madd(kMean, k1, 1.0 / 6.0) madd(kMean, k2, 2.0 / 6.0) madd(kMean, k3, 2.0 / 6.0) madd(kMean, k4, 1.0 / 6.0) nextState(initial, kMean, dt)
Для L ошибка меньше раза в 3, энергия вращения в среднем растёт на какую-то крохотную капелюшечку — меньше одной стотысячной. Если между методами первого и второго порядка радикальная разница в точности, то переход к четвёртмоу порядку, похоже, даёт небольшой вклад.
Возможно, я где-то ошибся и вместо четвёртого порядка точности у меня так и остался второй. Надеюсь, что нет.
Если симулировать только линейные движения тел, то толку от метода четвёртого порядка может и не быть — например свободно падающее тело двигатеся с постоянным ускорением g, производная от ускорения и последующие производные равны нулю, метод второго порядка точности даст идеально точное решение.
Для сравнения три графика — с шагом в 0.1, 0.01 и 0.001.
Предлагаю обратить внимание на левый график. За шаг модель поворачивается примерно на 0.1 радиана. Примерно 6 градусов. Точность хуже, за пару десятков оборотов ошибка успевает вырасти на 0.2%. Можно добавить в модель слабую силу трения, чтобы энергия не росла и использовать её в играх. Вряд ли игрок расстроится, если вращение будет потихоньку «затухать» с характерным временем в несколько часов.
Кроме явных схем решения дифференциальных уравнений есть ещё и неявные. Я в них не разбираюсь, поэтому ничего про них не пишу.
Код солверов в двух местах:
Результаты
В репозитории лежит sbt проект. Можно запустить «`sbt run«` и выбрать один из двух вариантов:
-
libgdxDemo — симуляция + визуализация вращения «гайки»
-
precisionTest — сравнение разных солверов на разных шагах по времени. Порядок скорости вращения — единица. Вместо увеличения скорости вращения тела можно «эмулировать» это увеличением шага по времени для той же скорости. Для понимания происходящего результаты сохранятся в виде csv файлов. В репозитории их нет, надо хотя бы разок запустить precisionTest.
Рядом лежит jupyter notebook, в котором можно полюбоваться на графики изменения ошибок в зависимости от времени.
Например, для шага в 0.1 секунды методы второго порядка становятся не очень точными, за условные 100 секунд и пару десятков оборотов накапливается ошибка в два-три процента. У Рунге-Кутты четвёртого порядка точность лучше, накопленная ошибка — около одной четвёртой процента.
Графики такие же как в статье, но их больше. Для разных методов сравниваются разные шаги по времени.
Выводы
Я попробовал собрать всё в одном месте и пройти путь от теории до практического применения.
Методы второго порядка выглядят хорошим компромиссом между точностью и простотой. Четвёртый порядок точности лучше, я бы советовал использовать именно его. Первый порядок точности можно использовать разве что в образовательных целях.
Наверно, минимальным разумным шагом симуляции можно считать шаг, за который тело повернётся на 0.1 радиан (примерно 6 градусов). Ошибка будет расти не очень быстро, особенно если для использовать метод четвёртого порядка.
Если уменьшить шаг симуляции до угловой скорости в 0.01-0.001 радиана за шаг, то можно получить точность порядка 10^5 — 10^7 и её, наверно, хватит всем.
ссылка на оригинал статьи https://habr.com/ru/post/697534/
Добавить комментарий