Физические движки для чайников

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

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

    Вы можете написать код для этого?

    Я всегда считал название серии книг «… для чайников» обнадеживающим. Ведь если даже чайник сможет это изучить, то у вас и подавно получится, верно?

    … для чайников?

    Отсюда и название этого туториала.

    План действий

    Итак, я собираюсь осветить несколько вещей о физических движках, которые вы, возможно, захотите узнать:

    •  Абсолютно твёрдые тела
    •  Коллизии
    •  Трение покоя
    •  Ограничения (Соединения)

    Моделирование

    Имейте в виду, что мы начнем с самых азов. Надеюсь, в дальнейшем станет ясно, почему.

    Начнем с частиц, которые, предположим, имеют позицию P и скорость V. Каждый кадр мы изменяем позицию P, добавляя к ней скорость V. Этот процесс называется интегрированием.

    Вы можете использовать любые единицы, подходящие вашей модели. Обычно выбор падает на ед/метр, что я в данном случае и использую. Экран имеет размер два на два метра (в нашем мире), поэтому скорость определяется в метрах в секунду. Чтобы наш пример заработал, нам нужно знать, число секунд в кадре. Теперь, для правильного движения частиц воспользуемся формулой P += V * dt, где dt — время (в секундах), прошедшее с предыдущего кадра.

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

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

    for all i
      if (P[i].x < -1 && V[i].x < 0)
      {
        V[i].x = -V[i].x;
      }
      if (P[i].y > 1 && V[i].y > 0)
      {
        V[i].y = -V[i].y;
      };
      if (P[i].x > 1 && V[i].x > 0)
      {
        V[i].x = -V[i].x;
      }
      if (P[i].y < -1 && V[i].y < 0)
      {
        V[i].y = -V[i].y;
      }
    

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

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

    Что происходит в данной простейшей модели?

    Сами того не зная, мы присвоили нашим частицам такой тип физического материала, который включает в себя коэффициент восстановления (EN/RU) и подчиняется закону сохранения импульса (EN/RU). Кроме того, мы обозначили, что мир имеет бесконечную массу, таким образом он никак не реагирует при воздействии частиц на него. Отражая частицы при ударе, мы сохраняем их импульс (проигнорировав их массу в вычислениях), указывая этим, что коэффициент восстановления частицы равен единице, то есть, как у совершенно упругого супер-мяча. В добавок, для реакции на столкновение, мы предпочли модель импульса/скорости модели силы/ускорения, изменяя скорость частиц мгновенно, а не за определенный промежуток времени.

    На самом деле, здесь мы имеем дело с высоко оптимизированным случаем имитации физики. «Каким образом оптимизированный?» — спросите вы. Позвольте мне объяснить:

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

    Planes[4] =
    {
       (1,  0, 1),
       (0, -1, 1),
       (-1, 0, 1),
       (0,  1, 1)
    }
    

    Теперь мы должны определить столкновения, как мы делали это ранее, но на этот раз мы не будем ничего упрощать. Чтобы обнаружить пересечение частиц с плоскостями, мы должны выполнить скалярное произведение (EN/RU) и добавить расстояние до начала координат.

    for all particles i
    {
      for all planes j
      {
        distance = P[i].x*Planes[j].a + P[i].y*Planes[j].b + Planes[j].c;
    
        if (distance < 0)
        {
          // collision responce
        }
      }
    }
    

    Этот код определяет длину проекции (EN/RU) вектора направленного от плоскости к частице на нормаль плоскости. А поскольку нормали наших плоскостей имеют единичную длину, это значение является расстоянием от частицы до плоскости. Очевидно, что если вычисленное расстояние оказалось меньше нуля, то наша частица проникла в плоскость и нам следует среагировать на столкновение.

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

    plane0dist = P[i].x*1  + P[i].y*0  + 1;
    plane1dist = P[i].x*0  + P[i].y*-1 + 1;
    plane2dist = P[i].x*-1 + P[i].y*0  + 1;
    plane3dist = P[i].x*0  + P[i].y*1  + 1;
    

    Немного упростив,

    plane0dist =   P[i].x + 1;
    plane1dist =  -P[i].y + 1;
    plane2dist =  -P[i].x + 1;
    plane3dist =   P[i].y + 1;
    

    и чуть-чуть преобразовав, мы получим условия проверки для этих плоскостей

    if (P[i].x  < -1)
    if (-P[i].y < -1) = if (P[i].y > 1)
    if (-P[i].x < -1) = if (P[i].x > 1)
    if (P[i].y  < -1)
    

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

    if (P[i].x < -1 && V[i]•N[i] < 0)
    

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

    Это свидетельствует от том, что оба наших подхода являются, фактически, одним и тем же.

    Хорошо, как же в данном случае нам обрабатывать столкновение?

    Нам нужно решение, которое даст тот же результат, что и исходная программа, но которое, в тоже время, будет являться полным. Мы можем сделать это с помощью вектора отражения (EN) из закона отражения (EN/RU). Он рассчитывается следующим образом:

    R = V – 2*N*(V•N)

    Где V — это вектор скорости, а N — нормаль поверхности. Мы должны упростить выражение, подобно тому, как мы делали это в коде проверке столкновения:

    plane0vel x = V.x - 2* 1*(V.x* 1 + V.y* 0) = V.x -2*V.x   =   -V.x
    plane0vel y = V.y - 2* 0*(V.x* 1 + V.y* 0) = V.y - 0      =    V.y
    plane1vel x = V.x - 2* 0*(V.x* 0 + V.y*-1) = V.x - 0      =    V.x
    plane1vel y = V.y - 2*-1*(V.x* 0 + V.y*-1) = V.y - 2*V.y  =   -V.y
    plane2vel x = V.x - 2*-1*(V.x*-1 + V.y* 0) = V.x - 2*V.x  =   -V.x
    plane2vel y = V.y - 2* 0*(V.x*-1 + V.y* 0)                =    V.y
    plane3vel x = V.x - 2* 0*(V.x* 0 + V.y* 1)                =    V.x
    plane3vel y = V.y - 2* 1*(V.x* 0 + V.y* 1) = V.y - 2*V.y  =   -V.y
    

    Теперь вы видите, что в результате, мы получим в точности такое же выражение, как и в нашем первом, простом, примере.

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

    Так, как же будет выглядеть новая версия в псевдокоде?

    for all particles i
    {
      for all planes j
      {
        N = {Planes.a, Planes.b};
        distance = P[i]•N + Planes[j].c;
    
        if (distance < 0 && V[i]•N < 0)
        {
          // collision response, reflect particle
          V[i] -= 2*N*N•V[i];
        }
      }
    }
    

    Отлично, мы превратили простой пример в более сложный (чем следовало), но зачем?

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

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

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

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

    V[i] += G*dt

    В данном случае, G — вектор {0, −9.8}, а dt — время, прошедшее с предыдущего кадра.

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

    R = V – 2*N*(V•N)

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

    R = V – (1+e)*N*(V•N)

    Где e — это коэффициент восстановления, который варьируется от нуля (абсолютно пластичный) до единицы (абсолютно упругий). Как вы можете видеть, эти два равенства эквивалентны, при e = 1, мы получим исходное уравнение. Коэффициент восстановления частиц в примере ниже варьируется от 0 до 1.

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

    Рисунок 1

    Как показано на Рисунке 1, два круга A и B пересекутся, если расстояние между ними будет меньше, чем сумма их радиусов. Перпендикуляр столкновения — это предварительно нормированный вектор d между ними.

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

    ratioa = Mb / (Ma + Mb)

    ratiob = Ma / (Ma + Mb)

    Два соотношения выше помогут решить нашу задачу. Например:

    Ma = 1.0

    Mb = 0.5

    ratioa = 0.5 / (1.0 + 0.5) = 1/3

    ratiob = 1.0 / (1.0 + 0.5) = 2/3

    Как видно, ⅓ + ⅔ = 1, что говорит о том, что импульс сохраняется корректно. А если масса тела b равна половине массы тела a, то оно должно будет получить двойную силу воздействия при столкновении.

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

    Va = Va – (1+e)*N*((Vb–Va) • N)*(Mb / (Ma+Mb))

    Vb = Vb – (1+e)*-N*((Vb-Va) • –N)*(Ma / (Ma+Mb))

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

    Vr = Va – Vb

    Теперь, когда у нас осталась всего одна скорость, выражение упростилось.

    I = (1+e)*N*(Vr • N)

    Va = – I*(Mb / (Ma+Mb))

    Vb = +I*(Ma / (Ma+Mb))

    Здесь, мы рассматриваем один объект, как неподвижный относительно другого. Но мы можем ещё больше упростить эти выражения:

    I = (1+e)*N*(Vr • N)*(Ma*Mb)/(Ma+Mb)

    Va – = I / Ma

    Vb + = I / Mb

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

    Вы можете увидеть, как это работает:

    (Ma*Mb)/(Ma+Mb) / Ma = Mb/(Ma+Mb)

    и

    (Ma*Mb)/(Ma+Mb) / Mb = Ma / (Ma+Mb)

    Наконец, мы можем вывести (из дробной части), что:

    (Ma+Mb)/(Ma*Mb) = 1/Ma + 1/Mb

    А это значит, что мы теперь можем переписать линейное уравнение импульса так:

    I = (1+e)*N*(Vr • N) / (1/Ma + 1/Mb)

    Это удобно, поскольку мы можем сохранить значения отношений 1/Ma и 1/Mb в определениях наших твердотельных кругов, чтобы не пересчитывать их постоянно. Это также значит, что теперь у нас есть способ представления объектов с бесконечной массой (сохраняя инверсную массу равной нулю).

    Va – = I * 1/Ma

    Vb + = I * 1/Mb

    В этом примере, вы можете взаимодействовать с телом с помощью мыши.

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

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

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

    Пишем код

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

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

    public class RigidBody
    {
        protected var m_pos:Vector2;
        protected var m_vel:Vector2;
        protected var m_invMass:Number;
    
        ///
        ///
        ///
        public function RigidBody( pos:Vector2, vel:Vector2, invMass:Number )
        {
            m_pos=pos;
            m_vel=vel;
            m_invMass=invMass;
        }
    
        ///
        ///
        ///
        public function GenerateContact( rb:RigidBody ):Contact
        {
            throw new Error("Not implemented on base class");
        }
    
        ///
        ///
        ///
        public function Integrate( dt:Number ):void
        {
            m_pos=m_pos.Add( m_vel.MulScalar( dt ) );
        }
    }
    

    Итак, я создал базовый класс RigidBody, который содержит три параметра, используемые в примерах выше. Также, в нем существует метод Integrate, который отвечает за перемещение объекта во времени и (то, что должно быть виртуальным) метод GenerateContact(), который возвращает перпендикуляр столкновения и расстояние между объектами типа RigidBody.

    public class Circle extends RigidBody
    {
        private var m_radius:Number;
    
        ///
        ///
        ///
        public function Circle( pos:Vector2, radius:Number, invMass:Number )
        {
            m_radius = radius;
            super(pos, new Vector2(), invMass);
        }
    
        ///
        ///
        ///
        public override function GenerateContact( rb:RigidBody ) :Contact
        {
            if ( rb is Circle )
            {
                ...
            }
                    else if (rb is ...)
                    {
                            ...
                    }
                    else
            {
                throw new Error("unahandled case!");
                    }
        }
    }
    

    Также, у нас есть с класс Circle, который является производным класса RigidBody и расширяет его функционал наличием радиуса (параметр m_radius). Также, в нем присутствует реализация метода GenerateContact(), который, по мере надобности, возвращает всю необходимую информацию.

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

    public class Plane extends RigidBody
    {
        private var m_n:Vector2;
        private var m_d:Number
    
        public function Plane(n:Vector2, d:Number )
        {
            m_n=n;
            m_d=d;
    
            super(n.MulScalar(-d), new Vector2(), 0);
        }
    
        ///
        ///
        ///
        public override function GenerateContact( rb:RigidBody ):Contact
        {
            if ( rb is Particle )
            {
                ...
            }
            else if ( rb is Circle )
            {
                ...
            }
                else
                    {
                throw new Error("unhandled case!");
                    }
        }
    

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

    public class Contact
    {
        public var m_normal:Vector2;
        public var m_dist:Number;
    
        ///
        ///
        ///
        public function Contact( n:Vector2, dist:Number )
        {
            m_normal = n;
            m_dist = dist;
        }
    }
    

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

    private function Update( e:GameLoopEvent ):void
    {
        const dt:Number = Math.min(e.m_elapsed, 1.0/15.0);
    
        // apply gravity
        for each ( var p:RigidBody in m_rigidBodies )
        {
            if ( p.m_InvMass>0 )
            {
                p.m_vel=p.m_vel.Add( Constants.kGravity.MulScalar( dt ) );
            }
        }
    
        // collide
        for ( var i:int=0; i<0||rbj.m_InvMass>0 )
        {
            const c:Contact=rbi.GenerateContact( rbj );
    
            ...
    
            //resolve collision
        }
    
        // integrate
        for each ( p in m_rigidBodies )
        {
            p.Integrate( dt );
        }
    }
    

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

    1. Сначала внешние силы — гравитация.
    2. Затем, определение столкновений, чтобы получить информацию о контакте и обработать ее.
    3. И наконец, каждый элемент RigidBody интегрируется во времени.

    Фиксирование временного шага в начале, необходимо при отладке, чтобы не получить такое его значение, которое взрывало бы все!

    Ограничения

    Хорошо, теперь у нас есть множество сфер, взаимодействующих между собой, и наш физический движок приобретает форму, но что насчет ограничений?

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

    Ограничение

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

    I = (1+e)*N*(Vr • N) / (1/Ma + 1/Mb)

    Все ограничения, с которыми мы будем иметь здесь дело, являются импульсно-скоростными. Сила и ускорение сюда не входят.

    Ограничение-неравенство

    Это ограничение, которое действует только в одном направлении. Таким образом, ограничение столкновения — это ограничение-неравенство, поскольку позволяет толкать, но не позволяет тянуть. Если бы это было позволено, то твердое тело приклеилось бы к объекту, с которым оно столкнулось. Мы приводим это ограничение в действие путем проверки, о которой я писал выше:

    if (V•N < 0)
    {
       // handle constaint
    }
    

    Игра Little Big Planet содержит множество ограничений этого типа; лебедки, поршни, веревки, вот несколько примеров — им разрешено движение только в одном направлении в пределах ограничения. Так, например, движение поршня ограничено верхними и нижними границами.

    Ограничение-равенство

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

    Проектирование ограничений

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

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

    Рисунок 2

    Как вы можете видеть на Рисунке 2, тела А и B никогда не окажутся к друг другу ближе чем на расстояние d, но в тоже время они могут перемещаться, что в конечном итоге позволяет им вращаться друг относительно друга.

    Уровень импульса/скорости

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

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

    Резюмируем: ограничение расстояния не дает конечным точкам приближаться или отдаляться друг от друга. Говоря на языке скоростей — это одномерное ограничение; относительная скорость тел по заданной оси равняется нулю. Эта ось определяется вектором между конечными точками.

    Рисунок 3

    На Рисунке 3, тела A и B имеют начальные скорости, которые нарушают указанное нами ограничение расстояния (Рисунок 2). Чтобы решить данную задачу, нам, для начала, необходимо выяснить фактические скорости объектов. В данном случае, их будут представлять проекции скоростей Av и Bv на ось ограничения.

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

    I = (1+e)*N*(Vr • N) / (1/Ma + 1/Mb)

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

    I = RelativeVelocityMagnitudeToRemove / (1/Ma + 1/Mb)

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

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

    Пишем код

    Итак, давайте давайте дополним существующий код:

    public class Constraint
    {
        protected var m_bodyA:RigidBody;
        protected var m_bodyB:RigidBody;
    
        /// 
        ///
        /// 
        public function Constraint( bodyA:RigidBody, bodyB:RigidBody )
        {
            m_bodyA = bodyA;
            m_bodyB = bodyB;
    
            Assert( m_bodyA.m_InvMass>0||m_bodyB.m_InvMass>0, "Constraint between two infinite mass bodies not allowed" );
        }
    
        /// 
        ///
        /// 
        public function ApplyImpulse( I:Vector2 ):void
        {
            m_bodyA.m_vel = m_bodyA.m_vel.Add( I.MulScalar(m_bodyA.m_InvMass) );
            m_bodyB.m_vel = m_bodyB.m_vel.Sub( I.MulScalar(m_bodyB.m_InvMass) );
        }
    
        /// 
        ///
        /// 
        public function Solve( dt:Number ):void
        {
            throw new Error("base class doesn't implement!");
        }
    }
    

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

    public class DistanceConstraint extends Constraint
    {
        private var m_distance:Number;
    
        /// 
        ///
        /// 
        public function DistanceConstraint( bodyA:RigidBody, bodyB:RigidBody, distance:Number )
        {
            super(bodyA, bodyB);
    
            m_distance = distance;
        }
    
        /// 
        ///
        /// 
        public override function Solve( dt:Number ):void
        {
            // get some information that we need
            const axis:Vector2 = m_bodyB.m_Pos.Sub(m_bodyA.m_Pos);
            const currentDistance:Number = axis.m_Len;
            const unitAxis:Vector2 = axis.MulScalar(1/currentDistance);
    
            // calculate relative velocity in the axis, we want to remove this
            const relVel:Number = m_bodyB.m_vel.Sub(m_bodyA.m_vel).Dot(unitAxis);
    
            const relDist:Number = currentDistance-m_distance;
    
            // calculate impulse to solve
            const remove:Number = relVel+relDist/dt;
            const impulse:Number = remove / (m_bodyA.m_InvMass + m_bodyB.m_InvMass);
    
            // generate impulse vector
            const I:Vector2 = unitAxis.MulScalar(impulse);
    
            // apply
            ApplyImpulse(I);
        }
    }
    

    Выше показана реализация ограничения расстояния, принцип работы которого был описан выше.

    private var m_joints:Vector.<Constraint>;
    

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

    const dt:Number = Math.min(e.m_elapsed, 1.0/15.0);
    
    // apply gravity
    for each ( var p:RigidBody in m_rigidBodies )
    {
        ...
    }
    
    // collide
    for ( var i:int=0; i<m_rigidBodies.length-1; i++ )
    {
        const rbi:RigidBody=m_rigidBodies[i];
        for ( var j:int=i+1; j<m_rigidBodies.length; j++ )
        {
            const rbj:RigidBody=m_rigidBodies[j];
    
            if ( rbi.m_InvMass>0||rbj.m_InvMass>0 )
            {
                ...
            }
        }
    }
    
    // solve constraints
    for each(var jt:Constraint in m_joints)
    {
        jt.Solve(dt);
    } 
    
    // integrate
    for each ( p in m_rigidBodies )
    {
        p.Integrate( dt );
    }
    

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

    Заключение

    Эта статья раскрыла физику, скрытую за самой простой 2d-игрой (pong), показав, что ее принципы основаны на реальных законах. Также, в статье подробно, шаг за шагом, изложена техника, используемая в данной игре, до того момента, когда проектирование ограничения и физического движка можно было бы рассматривать как расширенный случай столкновения двух объектов

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

    Исходный код

    К данной статье прилагается исходный код, который вы можете приобрести в блоге автора.

    Исходный код к данной статье содержит весь код фреймворка (включая демки), написанный на actionscript 3.0 (применим для любого объектно-ориентированного языка).