Когда я раньше задумывался о вращении в 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. В логи писать углы Эйлера, как более понятные для человека.
В дальнейшем я храню ориентацию тела в виде кватерниона, а производные типа угловой скорости и ускорения - в виде векторов.
P.S. Как оказалось позже, при решении методом Рунге-Кутты четвёртого порядка надо хранить и покомпонентно усреднять именно производную от кватерниона, а не угловую скорость w. Для методов первого и второго порядка большой разницы нет.
Физические обозначения
Лирическое отступление: на хабре какие-то диверсанты отключили маркдаун. Вместо этого есть "божественный" редактор, в котором формулы можно располагать только по центру страницы. Я не могу вставить формулу из пары символов прямо в текст. Статья в изначальном виде лежит на гитхабе, возможно будет удобнее читать прямо там.
Сила - F.
Аналог силы для вращения - крутящий момент:
Разница в домножении на "длину рычага".
У тела есть момент инерции I. Если тело не является шаром или кубом, то вдоль разных осей момент может быть разным. В общем случае I - тензор 3x3 с 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)
val dw = Quaternion().setFromExp(body.omega * 0.5 * dt)
body.rot := dw * body.rot
body.omega.madd(acc, dt)
t += dt
P.S. В примере была ошибка, исправил - ориентация должна обновляться на основе предыдущего значения скорости, а не нового.
P.P.S. Код выше применим только для методов решения первого-второго порядков точности, для четвёртого надо хранить производные от кватерниона и усреднять именно их. Иначе точность остаётся на уровне методов решения второго порядка.
Можно посмотреть пример из этого комментария - он работает хорошо. Я повторил его и в репозиторий с примерами добавил SolverEuler2Alt, SolverRK2Alt, SolverRK4Alt. Для второго порядка разницы нет или точность даже хуже, для четвёртого порядка - радикально лучше.
Хочешь сделать хорошо - сделай сам
Так уж получилось, что в различных библиотеках для линейной алгребы были те или иные недостатки - например, не было кватернионов, или не хватало каких-то методов. Кроме того, есть произвол в определении направления вектороного произведения, в направлениях вращения и в том, считать вектора столбиками или строчками, что влияет на все дальнейшие формулы.
Спустя много лет использования разных библиотек и копирования кода туда-сюда я наконец-то собрался и написал свою библиотеку: 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, энергия вращения в среднем растёт на какую-то крохотную капелюшечку - меньше одной стотысячной. Если между методами первого и второго порядка радикальная разница в точности, то переход к четвёртмоу порядку, похоже, даёт небольшой вклад.
Возможно, я где-то ошибся и вместо четвёртого порядка точности у меня так и остался второй. Надеюсь, что нет.
P. P. S. Ошибка дейтвительно была. Именно для метода четвёртого порядка надо считать честную производную от кватерниона (она тоже будет иметь четыре компонента, как и кватернион), усреднять производные с какими-то весами, добавлять к кватерниону ориентации и только потом нормализовать его. График для такого подхода:
Если симулировать только линейные движения тел, то толку от метода четвёртого порядка может и не быть - например свободно падающее тело двигатеся с постоянным ускорением g, производная от ускорения и последующие производные равны нулю, метод второго порядка точности даст идеально точное решение.
Для сравнения три графика (для старой некорректной версии) - с шагом в 0.1, 0.01 и 0.001.
Предлагаю обратить внимание на левый график. За шаг модель поворачивается примерно на 0.1 радиана. Примерно 6 градусов. Точность хуже, за пару десятков оборотов ошибка успевает вырасти на 0.2%. Можно добавить в модель слабую силу трения, чтобы энергия не росла и использовать её в играх. Вряд ли игрок расстроится, если вращение будет потихоньку "затухать" с характерным временем в несколько часов.
P.P.S. Ниже графики для исправленного метода. Точность лучше для любого шага по времени, но шаг 0.1 всё равно можно считать минимально допустимым.
Кроме явных схем решения дифференциальных уравнений есть ещё и неявные. Я в них не разбираюсь, поэтому ничего про них не пишу.
Код солверов в двух местах:
Результаты
В репозитории лежит sbt проект. Можно запустить sbt run и выбрать один из двух вариантов:
libgdxDemo - симуляция + визуализация вращения "гайки"
precisionTest - сравнение разных солверов на разных шагах по времени. Порядок скорости вращения - единица. Вместо увеличения скорости вращения тела можно "эмулировать" это увеличением шага по времени для той же скорости. Для понимания происходящего результаты сохранятся в виде csv файлов. В репозитории их нет, надо хотя бы разок запустить precisionTest.
Рядом лежит jupyter notebook, в котором можно полюбоваться на графики изменения ошибок в зависимости от времени.
Например, для шага в 0.1 секунды методы второго порядка становятся не очень точными, за условные 100 секунд и пару десятков оборотов накапливается ошибка в два-три процента. У Рунге-Кутты четвёртого порядка точность лучше, накопленная ошибка - около одной четвёртой процента.
Графики такие же как в статье, но их больше. Для разных методов сравниваются разные шаги по времени.
Выводы
Я попробовал собрать всё в одном месте и пройти путь от теории до практического применения.
Методы второго порядка выглядят хорошим компромиссом между точностью и простотой. Четвёртый порядок точности лучше, я бы советовал использовать именно его. Первый порядок точности можно использовать разве что в образовательных целях.
Наверно, минимальным разумным шагом симуляции можно считать шаг, за который тело повернётся на 0.1 радиан (примерно 6 градусов). Ошибка будет расти не очень быстро, особенно если для использовать метод четвёртого порядка.
Если уменьшить шаг симуляции до угловой скорости в 0.01-0.001 радиана за шаг, то можно получить точность порядка 10^5 - 10^7 и её, наверно, хватит всем.
Комментарии (17)
omxela
05.11.2022 21:31+2Раз уж проводится сравнение численных методов интегрирования ДУ, то,вероятно, следует сказать о том, что для немонотонных решений использование постоянного шага - не самая лучшая идея. Есть версии Рунге-Кутта с оценкой ошибки на шаге (версия Мерсона, например). Более естественным является использование многошаговых методов типа Адамса-Башфорта, где оценка ошибки на шаге получается естественным образом.
andy_p
05.11.2022 23:07Лучше использовать роторы из алгебры Клиффорда вместо кватернионов.
Daddy_Cool
05.11.2022 23:59+1А подробнее можно? Чем лучше? Очень интересно. Что такое кватернион я понимаю, а вот ротор из алгербы Клиффорда...
andy_p
06.11.2022 01:57Кватернионы - это подалгебра алгебры Клиффорда, причём кватернионы в трёхмерном пространстве в силу их законов умножения работают в левостороннем базисе. Ротор - это сумма скаляра и бивектора, который действует не только на вектор, но и на другие объекты алгебры.
DmitryMurinov
06.11.2022 11:56Интересно, но... Мне напомнило про Kerbal Space Program, в котором первые несколько лет корабли, особенно при ускоренном в тысячи раз полёте, разрывало на составные части. У игроков довольно быстро появилась теория невидимого космического Кракена. А у меня, благодаря этому примеру, крайне негативное отношение к использованию double не только в финтехе.
Автор, Вы уверены что при вычислениях с double достаточно крупная фигура будет проходить через те же точки? А если составить изображение из нескольких разных фигур, вращающихся вокруг общего центра вращения, точно ли будет всё ок (не будут ли между ними возникать наложения или появляться щели)?
lgorSL Автор
06.11.2022 20:49У double мантисса 52 бита, это примерно 16 значащих цифр. Я думаю, такой точности с запасом хватит почти для всего, кроме каких-то спецефических случаев.
DmitryMurinov
07.11.2022 00:05https://www.programiz.com/java-programming/online-compiler/
Пожалуйста, скопипастите это и выполните:
class HelloWorld {
public static void main(String[] args) {
System.out.println(2.0 - 1.1);
}
}При ускорении времени в симуляции KSP, вероятно, использовались расчёты с округлением. Ну и в других случаях тоже...
Если размер пискеля в игре равен 1 см, то орбита на которой не хватит 16 значащих цифр не столь велика...
С другой стороны упорство в применении double может создать новое верование (и кто я такой, чтобы мешать Вам основать новую религию :-)... Пример: https://www.youtube.com/watch?v=H0bKAeFoV64&ab_channel=Astronomical
lgorSL Автор
07.11.2022 13:26Уместить в одно число и галактические масштабы и сантиметры — это очень спецефическая задача.
Например, расстояние от Солнца до реальной Земли 150 млн км, это 1.5 * 10^8 км = 1.5 * 10^14 мм. Даже в таких масштабах double что-то может.
Если же ограничиться только Землёй c радиусом 6400 км, то это 6.4 * 10^9 мм или 6.4 * 10^15 нм.KSP внутри сделаны сложнее, чем кажется на первый взгляд: https://www.youtube.com/watch?v=mXTxQko-JH0
Формулы для операций с векторами, матрицами и численным решением диффуров не зависят от того, как представлены числа. Там можно использовать float, double или что Вашей душе угодно.
andrettv
06.11.2022 13:01+1Интересно, спасибо! А про гайку тут как-то был целый цикл https://habr.com/ru/post/264381/ с расчётом периода смены оси вращения. Ещё материалы на https://bivector.net рекомендую посмотреть.
Uranix
Если просто уравнения Эйлера интегрировать, метод четвертого порядка заметно лучше метода второго. Подозреваю, что все-таки у вас где-то есть ошибка
https://gist.github.com/uranix/7c137c2c3a0fb22579f54d96721e69be
Думаю, что ошибка в update - вы сначала обновляете omega, а затем этой обновленной omega доворачиваете углы поворота. Правильно же применять метод интегрирования к совместной системе для углов и скоростей
lgorSL Автор
К сожалению да, в примере ошибка, которую в коде я давно исправил, а там забыл. В коде сейчас вроде бы корректно.
На момент построения графиков было так:
Я явно копирую transform и velocity, state3d не меняется, создаётся новая копия.
Я посмотрел Ваш пример, но в нём производятся вычисления в локальной системе отсчёта и вообще не затрагивается вопрос ориентации тела в пространстве. Попробую его воспроизвести и прям по шагам сравнить вычисления со своими, но не уверен, что быстро найду ошибку.
Uranix
Сделал в инерциальной системе отсчета
https://gist.github.com/uranix/b101ca6ce8dcc2b616502dd8e9a2f9e9
Тут, конечно, нужно следить за единичностью кватерниона в процессе, иначе она плывет (для Эйлера критично, для остальных терпимо). Но порядок погрешности первых интегралов тот же.
lgorSL Автор
Огромное спасибо, а то я уже отчаялся.
К Вашему коду можно добавить
и построить график разницы между начальным L и конечным.
И это то самое место, где мой солвер почему-то теряет точность, а Ваш — нет!
lgorSL Автор
Я смог воспроизвести Ваш способ!
Идея брать производную от кватерниона даже не приходила мне в голову. Что для меня странно — на курсе теормеха кватернионов вообще не было, я их как-то сам потом освоил.
Вместо производной я просто на основе w делал кватернион поворота на приращение угла вокруг оси и домножал на него. Что интересно, на методах второго порядка точности это работает немного лучше или так же.
А вот на Рунге-Кутте четвёртого порядка, видимо, влияет на порядок.
Я добавил новые солверы в код и в графики в ноутбуке — они там с именем типа RungeKutta4Alt.
Точность намного выше, порядка 10^-9 для шага 0.01 и 10^-13 для шага 0.001
AAngstrom
А ещё правильней использовать симплектический метод интегрирования (схема Верле, полу-неявный метод Ньютона: у него много названий). Этот метод консервативен (т.е. сохраняет Гамильтониан) по построению.
lgorSL Автор
Большое спасибо за пример!
В Вашем примере в качестве ошибки берётся (|L| — |L0|) / |L0|. Если в моём коде считать ошибку так, то она тоже получется порядка 10^-8.
Но если считать ошибку как |L — L0|/|L0|, то получается, что L отклоняется в сторону и получается ошибка порядка 10^-4.
Я повторил Ваши рассчёты в локальной СО для скорости и ускорений. Они работают с такой же точностью: 10^8 при dt =0.01. И для E, и для модуля L.
После этого я попробовал добавить обновление ориентации. Рассчёты в локальной СО от неё никак не зависят, но станет можно повернуть L из локальной СО в глобальную. В глобальной СО L не должен отклоняться. К сожалению, он отклоняется, и итоговая ошибка тоже получается порядка 10^-4 (как и у меня). По крайней мере, у меня лучше не получилось :(