Применение некомм. дуал. компл. чисел в 2D-графике

Aug 16, 2024 05:28

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

Одно такое некомм. дуал. компл. число позволяет выразить любое движение на плоскости, то есть параллельные переносы и повороты. При этом, чтобы применить несколько преобразований подряд, достаточно эти числа перемножить. "Вектор", представленный таким числом, оказывается немного избыточным (нужно лишь два значения, x,y, а полей аж 4 штуки), но часть этой избыточности оказывается полезной - можно работать в нескольких "плоскостях", при этом на вращение они будут реагировать одинаково, а вот смещаться они будут по-разному, в частности, одна из плоскостей вообще двигаться "не захочет".





Эта анимация реализована на некомм. дуал. компл. числах, фактически "из коробки", т.е никаких дополнительных костылей не потребовалось. Из приятного, все вычисления здесь исключительно АРИФМЕТИЧЕСКИЕ, фактически СЛОЖЕНИЯ И УМНОЖЕНИЯ, даже делений не нужно, не говоря уже о корнях и тригонометрии. Включая и расчёт кинематики - как будет меняться угловое положение дышла в процессе работы.

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


Сразу скажу, паровоз я "одолжил". Ввёл в duckduckgo "steam engine svg", начал искать в картинках, множество из найденного было ни разу не svg, несколько раз пообещали svg, но за денежку, и, наконец, нашёл бесплатное скачивание в формате EPS. Потом, не долго думая, поискал "EPS to SVG", нашёл сайтик, в который этот файл загрузил и получил искомую SVGшку.

Ещё я обнаружил, что независимо от настроек Inkscape, внутри самого SVG все координаты отсчитываются так, как принято в компьютерной графике - от левого верхнего угла. Т.е ось X - вправо и ось Y - вниз. Если даже в Inkscape попросить его всё отсчитывать от левого нижнего угла (так это воспринимает и LaserGRBL, для вырезания и гравирования всяких фанерок лазером), он это делает "на лету", не трогая сам файл.

Так что я решил в этой же системе координат (от левого верхнего угла) сейчас и работать. Она, конечно, ЛЕВАЯ, но на плоскости уж как-нибудь не запутаемся.

Паровоз я смасштабировал до ширины 1020 пикселей (пиксели тут весьма условные, это всё же векторная графика), и сместил его так, что он стал занимать координаты от -510 до +510 по оси X и от -260 до +260 по оси Y. При попытке нарисовать его на "полотне" 1024х768 "как есть", получаем следующее:





что не особо удивительно. Зададим ему более подходящее положение с помощью нашего н.д.к.ч. Поворот нам не нужен, только сдвиг, на 512 вправо (хотим иметь небольшие зазоры на краях) и на 504 вниз. Иными словами, dX=512, dY=504, т.е оба сдвига - в сторону увеличения координат.

Оператор параллельного переноса на вектор (X,Y) будет выражаться числом 1-(Y/2)j+(X/2)k. Иными словами, набором из 4 чисел: (1;0;-Y/2;X/2). Проще всего это объяснить, вспомнив, что эти числа - это те же самые кватернионы, но с бесконечно отличающимся масштабом по оси X и Y/Z. Условно можно представить, что начало координат мы поместили в центр Земли, масштаб скалярной компоненты и по оси X таков, что радиус Земли равен ЕДИНИЦЕ, а вот по Y/Z у нас масштаб - миллиметры, может быть, сантиметры, в общем, мы на письменном столе положили бумажку и на ней рисуем! Тогда и получится, что поворот вокруг оси X мы воспримем именно как поворот, а вот поворот вокруг оси Y мы увидим как смещение по оси Z. И точно так же, поворот вокруг оси Z приведёт к смещению по оси Y. Деление на 2 происходит, поскольку у кватерниона все углы половинные.

Но чтобы он правильно подействовал, нам ещё и паровоз надо разместить у себя "на письменном столе", т.е все описанные в файле координаты X,Y будут записываться в компоненты j,k (последние две), а в компоненту i мы помещаем единицу.

Итак, введём число (переменную) engine, выражающую положение паровоза на плоскости (включая и вращение), и для начала установим:

engine = 1-252j+256k, или (1;0;-252;256).

И получаем следующую картинку:





Это мы нарисовали "тушку", без ведущих колёс и тяг. Возьмёмся за колёса. Все три ведущих колеса оказались совершенно идентичными, поэтому я сохранил только одно, также отцентрировав его в (0;0), после чего оно занимает координаты от -73 до +73 по осям X,Y. Поворачивать его пока не требуется, надо лишь поместить на нужные места, ОТНОСИТЕЛЬНО ТУШКИ, т.е её начала координат. Колёса должны занять позиции (-289; 189), (-128; 189) и (34; 189). Для этого вводим ещё 3 переменные, каждая из них - некомм. дуал. компл. число:

wheel1 = 1 - (189/2)j - (289/2)k;
wheel2 = 1 - (189/2)j - (128/2)k;
wheel3 = 1 - (189/2)j + (34/2)k;

А при отображении мы преобразуем "тушку" паровоза с помощью числа engine, а колёса - через engine*wheel1, engine*wheel2 и engine*wheel3. Т.е, мы начинаем с какой-то точки на колесе. Для начала мы её помещаем в правильное место на паровозе, а потом уже вместе с паровозом перемещаем на финальную позицию. В каком-то плане оно логично: ведь в жизни так и было: сначала колесо приладили, а потом он уже "своим ходом" уехал туда, где мы его сфоткали!

Вот что пока получается:





Теперь пора их покрутить. Здесь довольно красиво всё формулируется:
- хотим повернуть объект вокруг центра вышестоящего объекта - тогда нужно умножать СЛЕВА,
- хотим повернуть объект вокруг своей оси - умножаем СПРАВА.

В данном случае колесо крутится вокруг своей оси (т.е вокруг начала координат, в которых нарисована картинка колеса), поэтому мы сформируем оператор поворота на угол φ: R = cos(φ/2)+i*sin(φ/2), или (cos(φ/2); sin(φ/2); 0; 0). И затем, отрисовав каждый кадр, производим обновление переменных:

wheel1 = wheel1 * R;
wheel2 = wheel2 * R;
wheel3 = wheel3 * R;

В итоге, получаем следующее:





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

А вот если мы перепутаем порядок перемножения и взамен сделаем:

wheel1 = R * wheel1;
wheel2 = R * wheel2;
wheel3 = R * wheel3;

то получим такое:





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

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

R = 1 + (ϕ/2)i

и попробуем снова. На моих гифках выставлено 25 кадров в секунду, и между кадрами поворот 3 градуса. Попробуем:





КУДА??? А НУ СТОЯТЬ! А это у нас норма уползла, и колёса вслед за ней. Я предполагал изначально, что увижу колёса, увеличивающиеся в размере (и они действительно увеличиваются), но быстрее проявляется эффект увеличения расстояния от начала "абсолютной системы координат", т.е от левого верхнего угла изображения, поскольку именно в эту систему координат мы по итогу переходим. Вот для наглядности я повесил одно колесо "в воздухе", посмотреть, куда оно "покатится":





Да, оно уходит по прямой от левого верхнего угла, и заметно увеличивается в размерах! Здесь за каждый кадр я сделал вращение на 6 градусов, чтобы усилить эффект.

Такое поведение никуда не годится, но, к счастью, мы ещё со времён кватернионов знаем действенное средство: нормировка! В некомм. дуал. компл. числах норма равна корню из суммы квадратов только ПЕРВЫХ ДВУХ КОМПОНЕНТ, то есть как раз наших "синуса" и "косинуса". А вот найдя норму, надо поделить на неё КАЖДЫЙ из 4 компонент.

Но использовать "нехорошие" операции деления и квадратного корня вовсе не обязательно, т.к если производить нормировку на каждом кадре, то норма не будет "успевать" далеко отклониться от единицы, и можно применить линейное приближение. Назовём наши 4 компоненты буквами c,s,x,y (т.е, условно, косинус, синус и смещения по осям x,y). Тогда с этими 4 компонентами надо произвести следующие действия:

norm = 1.5 - 0.5 * (c*c + s*s);
c = c * norm;
s = s * norm;
x = x * norm;
y = y * norm;

Так называемый FastNorm. Если мы его применим к wheel1, wheel2, wheel3, то получим следующий результат:





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





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

R = 1 - ϕ * ϕ/8 + (ϕ/2)i

Его мы когда-то назвали методом второго порядка (см. Ликбез по кватернионам, интегрирование угловых скоростей, методы 2-го порядка). Его особенность: норма умножается на величину 1 + φ4/8, т.е при малых углах поворота она ОЧЕНЬ БЛИЗКА К ЕДИНИЦЕ. (в методе первого порядка мы получали 1+φ2/8) Допустим, нас устроит смещение не более чем на 0,5 пикселя, даже если колесо оказалось в самом дальнем углу. Все величины масштабируются как КВАДРАТ нормы, так что сама норма должна оказаться не более 1,0002. По методу первого порядка, если взять поворот на 3 градуса на каждом кадре, мы нарушим это условие УЖЕ ВТОРОМ кадре. А с методом второго порядка мы продержимся около 200 кадров. Так что нормировку всё равно никто не отменяет.

И ещё мы предлагали "модифицированный метод второго порядка":

R = 1 - ϕ * ϕ/12 + (ϕ/2)i

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

Для иллюстрации, снова посмотрим на поворот по 90 градусов на каждом кадре. Метод первого порядка на этом "накрылся", см. выше. Опробуем обычный метод второго порядка:





Колёса всё равно немного приподнялись, да и поворотом на 90 градусов тут и не пахнет. Какой поворот в реальности, можно посчитать. Мы должны были по-хорошему задать некомм. дуал. компл. число 1/sqrt(2) + 1/sqrt(2) * i ≈ 0,707+0,707i, это бы и означало поворот на 90 градусов. Взамен у нас получилось 0,692 + 0,785i, что выражает угол 97 градусов.

А теперь попробуем модифицированный метод второго порядка:





Из-за того, что здесь ЗА ОДИН РАЗ норма настолько возрастает, что приближённый нормировщик не может её немедленно вернуть на место, колёса опять существенно приподнимаются, но поворот на 90 градусов они держат более чётко. На этот раз мы получили число 0,794+0,785i, что соответствует углу 89,3 градуса - можно считать, на порядок улучшили предыдущий результат.

Понятно, это всё утрированные ситуации. Анимировать поворот на 90 градусов каждый кадр не имеет смысла, это будет некрасиво (а то и эпилепсия какая), тут впору городить motion blur. А пытаться что-либо моделировать с таким шагом - тоже дохлый номер. Зато наглядно!

Ладно, поехали дальше. Добавляем тягу, соединяющую все три колеса между собой (сцепное дышло). Её я опять отцентрировал (т.е в SVG-файле начало координат приходится на центр тяги) и для начала "присоединил" ко второму колесу, выставив следующую переменную:

connector = 1 - (27,5/2)j

Т.е относительно центра колеса мы должны сдвинуться вниз на 27,5 пикселей. И далее при отрисовке тяги мы преобразуем точки изображения (узлы и управляющие точки Безье) с помощью величины engine*wheel2*connector. Вот, что получается:





Этого и следовало ожидать. Такая цепочка, engine*wheel2*connector, и означает: на паровозе жёстко закреплено колесо, а на колесе жёстко закреплена тяга. Колесо можно вращать относительно паровоза, обновляя переменную wheel2. А переменную connector мы пока оставляем неизменной, из-за чего тяга и "приварилась намертво" к колесу!

Исправляется это легко: повернув все колёса, мы изменим знак поворота,

R = Conj(R);

(т.е сопряжённая величина, действительная компонента осталась какой была, а мнимая сменила знак)

и теперь повернём тягу с помощью этого поворота:

connector = connector * R;

т.е она будет вращаться в своём сочленении с той же угловой скоростью, только в обратную сторону. Ровно это нам и надо:





Но может захотеться поступить ещё проще: РУЧКАМИ ВЫСТАВИТЬ НУЛЕВОЙ ПОВОРОТ этой тяги, в тот момент, когда мы перейдём к системе координат паровоза. Т.е сначала посчитаем wheel2*connector, потом установим первые две компоненты, равные единице и нулю, соответственно, и только потом двигаемся дальше. Посмотрим:





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

И теперь самое сложное: поршневое дышло! Или, по современной терминологии, шатун. Его мы тоже хотим "привесить" на второе колесо, и, по крайней мере, положение его левого конца мы будем отображать правильно. Но нужно ещё каждый кадр узнавать его правильный наклон. Для точного решения надо считать арксинус (т.е мы знаем, что правый конец остаётся на одной и той же высоте, и знаем текущую высоту левого конца. Так что разницу высот делим на длину шатуна, она также постоянна, и от результата берём арксинус), а нам НЕ ХОЧЕТСЯ. Хотим СЛОЖЕНИЯ И УМНОЖЕНИЯ!

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




А эта исходная картиночка - плюшевый паровозик, ну не хотелось художнику, чтобы цилиндр загородил собой передние колёсики, вот и разместил его повыше! Но сейчас уж не будем его переделывать, оставим это на будущее. При нормальном исполнении, угловая скорость дышла должна варьироваться от r/L*ω в верхней мёртвой точке (поршень максимально вдвинут в цилиндр) до -r/(L+2r)*ω в нижней мёртвой точке. Но мы несколько "линеаризируем" весь этот процесс и скажем, что скорость меняется от r/(L+r)*ω до -r/(L+r)*ω.

Короче, я написал такую вот хреновину:

rodAngle = angle * (1 - 2 * wheel2.c * wheel2.s * 21.2/(258.54+21.2));

опять же, я обещал, что не будет делений. Будет, но "на этапе компиляции" :)

2 * wheel2.c * wheel2.s - это синус двойного угла. Ведь в наших некомм. дуал. компл. числах углы получаются половинные, а здесь нам нужен обычный угол. Синус - поскольку начальным положением мы выбрали дышло в нижней точке на колесе. Тут тоже всё понятно: самая фотогеничная позиция, на всех фотках изображено именно так!

Ах да, ещё мы вводим переменную rod:

rod = 1 - (27.15/2)j;

и на каждом кадре умножаем её справа на R (rotation), который посчитан из угла rodAngle. Вот что получается:





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

Но есть совершенно простой и железобетонный способ обеспечить горизонтальное движение правого конца дышла! Прежде чем заняться отрисовкой, мы отдельно находим положение правой оси дышла, её смещение относительно правильной высоты. Это вертикальное смещение мы делим на длину дышла, получая с хорошим приближением (ошибаясь на единицы, максимум 10%) угол, на который надо довернуть дышло, чтобы оно, наконец-то, приняло правильный наклон!

Примерно так:

endOfRodPos = i+251j-62k;
rotation = wheel2 * rod; //композиция двух движений - вращение колеса и положение дышла на нём. Т.е выходим в систему координат паровоза
endOfRodPos = NonCommDualComplexRotate(endOfRodPos, rotation);
angle = (endOfRodPos.y - 154) / 258.54;
rotation = 1 - (angle/2)i;
rod = rod * rotation;

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

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




Ну и финальный штрих на сегодня: поршень и шток. Их мы всё-таки "привяжем" непосредственно к паровозу, а не к концу дышла, так кажется проще. Благо, координаты правого конца дышла мы и так уже посчитали. Буквально одна строка, которая выполняется на каждом кадре, после рассмотренных выше вычислений:

piston = 1 - (157/2)j + (endOfRodPos.x/2)k;

И при отрисовке мы используем число engine * piston, чтобы пересчитать точки поршня/штока в "абсолютные" координаты.

Именно так мы и получаем ту анимацию, что приведена в начале поста.

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

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

PS. Когда шрифт обычный, φ это φ, а ϕ это ϕ. Если же переходишь на моноширинный шрифт с помощью
, то ОНИ МЕНЯЮТСЯ МЕСТАМИ!!! Вот же кто-то придумал, явно человек с богатым внутренним миром.

кватернионы-это просто (том 1), странные девайсы, математика, программки, ЖД, работа

Previous post Next post
Up