Пишем свой физический движок с преферансом и барышнями

в 7:47, , рубрики: box2d, game development, physics engine, xna, метки: , , ,

Пишем свой физический движок с преферансом и барышнями

Привет дорогой друг! В прошлой статье я говорил, что больше не буду затрагивать тему 2D игр на XNA. Пожалуй, я вас обманул, но не совсем. Многие начинающие геймдевелоперы используют в своих физических головоломках — физический движок Box2D, о нем довольно много писали на хабре. Да что уж там новички, многие довольно опытные геймдевелоперы — его используют. Но вот мало кто знает, как на самом деле он работает. Остальное под хабракатом.

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

Общие принципы. Как это вообще работает?

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

  • Воздействуем на физический мир
  • Обновляем физический мир
  • Отрисовываем его новое состояние
  • Повторяем

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

  • Обнаружение столкновений
  • Разрешение столкновений
  • Интегририрование

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

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

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

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

Допустим, имеем контакт:

Пишем свой физический движок с преферансом и барышнями

А вот коллизия двух выпуклых полилиний:

Пишем свой физический движок с преферансом и барышнями

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

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

Т.к. в XNA многие операции над векторами у нас уже есть — мы его просто расширим, листинг расширяющегося класса:

namespace phys.V2Math
{
    public static class V2Extend
    {
        public static float PerpDot(this Vector2 self, Vector2 vector) // перпендикуляр с скалярным произведением
        {
            return self.X * vector.Y - self.Y * vector.X;
        }

        public static Vector2 Perp(this Vector2 self) // перпендикуляр
        {
            return new Vector2(-self.Y, self.X);
        }

        public static float Dot(this Vector2 self, Vector2 vector) // скалярное произведение
        {
            return self.X * vector.X + self.Y * vector.Y;
        }

        public static Vector2 Negative(this Vector2 self) // отрицательный вектор
        {
            return new Vector2(-self.X, -self.Y);
        }

        public static Vector2 Rotate(this Vector2 self, Vector2 vector) // вращение вектора
        {
            return new Vector2(self.X * vector.X - self.Y * vector.Y, self.X * vector.Y + self.Y * vector.X);
        }

        public static Vector2 Normalize2(this Vector2 self) // нормирование вектора
        {
            Vector2 vector = self;
            vector.Normalize();
            return vector;
        }
    }
}

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

namespace phys
{
    public class Body
    {
        public Vector2 position; // позиция
        public Vector2 velocity; // ускорение
        public float angle;   // текущий угол в радианах
        public float w;   // угловое ускорение в радианах
        public float m;   // масса
        public float f;   // трение
        public float e;   // упругость
        public bool isStatic;  // статичный ли объект

        internal float i;   // момент инерции

        public List<Poly> shapes; // формы данного тела

        // функция накладывает импульс на тело
        // j - импульс (вектор)
        // r - точка приложения импульса в локальных координатах
        public void ApplyImpulse(Vector2 j, Vector2 r)
        {
            if (isStatic)
                return;

            velocity += j / m;
            w += r.PerpDot(j) / i;
        }

        
    }
}

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

Этот класс в себе будет хранить список тел, будет содержать метод step, который и будет у нас за все отвечать. Рассмотрим класс:

namespace phys
{
    public class World
    {
        public static float c_Depth = 0.1f;
       
        public Vector2 gravity;
        public List<Body> bodies;

        public World(Vector2 gravity)
        {
            this.gravity = gravity;
            bodies = new List<Body>();
        }

        public void CreateDemo() // создаем демо-сцену
        {
            ...
        }

        public Body Body2;
        public Body sBody;

        //Обновляем позицию, ускорение и угол тела за промежуток
        // времени dt и гравитацией gravity, действующей на тело
        // в нормальной среде (вакум)
 
        public void Step(float delta, int interations)
        {
            float dt = delta / (float)interations;

            for (int interation = 0; interation < interations; interation++)
            {
                
                foreach (Body body in bodies)
                {
                    if (!body.isStatic)
                        body.velocity += gravity * dt;        // добавляем гравитацию

                    body.angle += body.w * dt;            // обновляем угол поворота
                    body.position += body.velocity * dt;  // обновляем позицию тела

                    foreach (Poly poly in body.shapes)
                    {
                        // вычисляем кос и син угла поворота тела
                        poly.rot = new Vector2((float)Math.Cos(poly.body.angle), (float)Math.Sin(poly.body.angle));

                        for (int i = 0; i < poly.VertexsCount; i++)
                        {
                            // находим координаты вершин (мировые)
                            poly.v[i] = poly.body.position + poly.v_base[i].Rotate(poly.rot);

                            // нормаль и скаляр для ребер
                            poly.ed[i].n = poly.ed_base[i].n.Rotate(poly.rot);
                            poly.ed[i].d = poly.body.position.Dot(poly.ed[i].n) + poly.ed_base[i].d;
                        }

                        poly.broadphase = Poly.GetBroadphase(poly);
                    }
                }

                foreach (Body body1 in bodies)
                    foreach (Body body2 in bodies)
                    {
                        if (body1 != body2)
                        {
                            bool collided = false;

                            foreach (Poly poly1 in body1.shapes)
                            {
                                foreach (Poly poly2 in body2.shapes)
                                    if (Broadphase.Collided(poly1.broadphase, poly2.broadphase))
                                    {
                                        Collide(body1, body2);
                                        collided = true;
                                        break;
                                    }

                                if (collided)
                                    break;

                            }
                            
                        }
                    }
            }
        }

        public bool Collide(Body b1, Body b2)
        {
            foreach (Poly poly1 in b1.shapes)
                foreach (Poly poly2 in b2.shapes)
                    if (Poly.FindCollision(poly1, poly2))
                        return true;

            return false;
        }

        public IEnumerable<DebugLine> getDebugLines() // получем линии для отрисовки объектов
        {
		...
        }
}

Теперь рассмотрим код шейпа (Poly):

namespace phys
{
    public class Poly
    {
        public Vector2[] v;           // мировые координаты вершин
        public Vector2[] v_base;       // локальные координаты вершин

        public Edge[] ed;          // мировые данные о гранях
        public Edge[] ed_base;      // локальные данные о гранях
        public Broadphase broadphase;

        public int VertexsCount
        {
            get { return v_base.Length; }   
        }

        public Vector2 rot;    // коссин для поворота вершин
        public Body body;

        public Poly(Body body, Vector2[] vertexs)
        {
            Vector2 a, b;
            
            this.body = body;

            // копируем вершины
            this.v_base = vertexs;
            this.v = new Vector2[VertexsCount];
            this.ed = new Edge[VertexsCount];
            this.ed_base = new Edge[VertexsCount];

            // подсчитываем нормаль и скаляр к ребрам (возможно тут нужен фикс)
            for(int i = 0; i < this.VertexsCount; i++)
            {
                a = this.v_base[i];
                b = this.v_base[((i+1) % VertexsCount)];

                Vector2 someRENAME = ((Vector2)(b - a)).Perp();

                this.ed_base[i].n = someRENAME.Normalize2();
                this.ed_base[i].d = this.ed_base[i].n.Dot(a);
            }


            // присоединяем форму к телу
       
            body.shapes.Add(this);

            Poly poly = this;

            // вычисляем кос и син угла поворота тела
            poly.rot = new Vector2((float)Math.Cos(poly.body.angle), (float)Math.Sin(poly.body.angle));


            for (int i = 0; i < poly.VertexsCount; i++)
            {
                // находим координаты вершин (мировые)
                poly.v[i] = poly.body.position + poly.v_base[i].Rotate(poly.rot);

                // нормаль и скаляр для ребер
                poly.ed[i].n = poly.ed_base[i].n.Rotate(poly.rot);
                poly.ed[i].d = poly.body.position.Dot(poly.ed[i].n) + poly.ed_base[i].d;
            }

            broadphase = Poly.GetBroadphase(this);
        }

        /*
         * Вычисление момента инерции полигона.
         * m-масса тела, verts-вершины полигона, nVerts-их количество
         * Момент инерции всего тела равен сумме моментов инерции всех его форм.
         */

        public float IMoment()
        {
            float sum1, a, b, sum2;
            Vector2 v1, v2;

            sum1 = 0;
            sum2 = 0;

            for (int i = 0; i < VertexsCount; i++)
            {
                v1 = v_base[i];
                v2 = v_base[(i + 1) % VertexsCount];

                a = (v2.X * v1.Y) - (v2.Y * v1.X);
                b = v1.Dot(v1) + v1.Dot(v2) + v2.Dot(v2);

                sum1 += a * b;
                sum2 += a;
            }

            return (body.m * sum1) / (6.0f * sum2);
        }

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

        public static float EdgeDist(Poly poly, Vector2 n, float d)
        {
            float _m = n.Dot(poly.v[0]);

            for (int i = 1; i < poly.VertexsCount; i++)
                _m = Math.Min(_m, n.Dot(poly.v[i]));

            return _m - d;
        }

        // Находим минимальное расстояние между ребром и точкой полигона
        public static int FindEdgeMinDist(Poly poly, Edge[] ed, int num, ref float _m)
        {
            int _mi = 0;
            float __m, dist;

            __m = Poly.EdgeDist(poly, ed[0].n, ed[0].d);
            if (__m > 0f)
                return -1;

            for (int i = 0; i < num; i++)
            {
                dist = Poly.EdgeDist(poly, ed[i].n, ed[i].d);
                if (dist > 0f)
                    return -1;
                else if (dist > __m)
                {
                    __m = dist;
                    _mi = i;
                }
            }
            _m = __m;
            return _mi;
        }

        // находим какая точка лежит внутри полика
        public static bool PointIn(Poly poly, Vector2 p)
        {
            float dist;

            for (int i = 0; i < poly.VertexsCount; i++)
            {
                dist = poly.ed[i].n.Dot(p) - poly.ed[i].d;
                if (dist > 0f)
                    return false;
            }

            return true;
        }

        /* Ищем точки взаимопроникновения, самая главная функция нашего движка, в ней мы ищем,
         * какими точками полигоны проникли друг в друга, и если проникли,
         * то ищем все необходимые данные для контакта и отправляем на обработку.
         */
        public static void VertsProc(Poly p1, Poly p2, Vector2 n, float d)
        {
            float k;
            // используем абсолютное значение глубины
            d = Math.Abs(d);

            // сначала ищем точки первого полигона внутри второго
            for (int i = 0; i < p1.VertexsCount; i++)
                if (Poly.PointIn(p2, p1.v[i])) // если нашли, то заполняем данные контакта:
                {

                    Contact contact = new Contact();
                    contact.p = p1.v[i];        // точка контакта и есть данная вершина
                    contact.n = n;              // нормаль мы получили из вызывающей функции
                    contact.depth = d * World.c_Depth;  // глубину получили таким ж способом
                    contact.r1 = contact.p - p1.body.position;  // вспомогательные вектора, направлены из // точки контакта в центр масс каждого тела
                    contact.r2 = contact.p - p2.body.position;

                    // далее считаем коэффициент выталкивания тел,
                    // в зависимости от их состояния(статичный или нет)
                    if (p1.body.isStatic)
                        k = 0f;
                    else if (p2.body.isStatic)
                        k = 1f;
                    else k = 0.5f;

                    // расталкиваем тела по нормали в разные стороны на глубину проникновения
                    // учитывая что нормаль направлена относительно 1 тела ко 2
                    p1.body.position -= contact.n * (k * contact.depth);
                    p2.body.position += contact.n * ((1 - k) * contact.depth);

                    // после того, как нашли все данные отправляем их на обработку
                    contact.Solve(p1.body, p2.body);
                }
        }

        /* Далее - функция, которая ищет факт пересечения полигонов (на основе предыдущей функции)
         * и в случае успеха будет вызывать нашу главную функцию поиска точек контакта
         * и передавать ей в качестве параметров найденное ближайшее ребро (а следовательно, и все его данные).
         */
        public static bool FindCollision(Poly p1, Poly p2)
        {
            float min1 = 0f;
            float min2 = 0f;

            int min1_idx, min2_idx;

            min1_idx = Poly.FindEdgeMinDist(p2, p1.ed, p1.VertexsCount, ref min1);
	        if (min1_idx == -1)
                return false;

	        min2_idx = Poly.FindEdgeMinDist(p1, p2.ed, p2.VertexsCount, ref min2);
            if (min2_idx == -1)
	            return false;
            
             if (min1 > min2)
                Poly.VertsProc(p1, p2, p1.ed[min1_idx].n, min1);
                    else
                        Poly.VertsProc(p1, p2, p2.ed[min2_idx].n.Negative(), min2);

            return true;
        }

        public static Broadphase GetBroadphase(Poly poly)
        {
            ...
        }
    }
}

Теперь нужно решить проблему просчета контактов, реализуем класс Contact и метод Solve:

namespace phys
{
    public class Contact
    {
        public Vector2 p;  // координаты точки пересечения 
        public Vector2 n;  // нормаль относительно 1 тела к 2
        public Vector2 r1;  // вектор из 1 тела в точку контакта
        public Vector2 r2;  // вектор из 2 тела в точку контакта
        public float depth; // глубина проникновения 

        // Решаем контакт
        public void Solve(Body c1, Body c2)
        {
            Vector2 v1, v2, vr, t, j;
            float vrn, jn, jnOld, bounce, e, u, mass_sum,
            r1cn, r2cn, vrt, kn, nMass, jtMax, jt, jtOld,
            r1ct, r2ct, kt, tMass, jnAcc, jtAcc;

	     // у статических тел — масса и инерция бесконечны
            float m1 = c1.isStatic ? float.PositiveInfinity : c1.m;
            float m2 = c2.isStatic ? float.PositiveInfinity : c2.m;
            float i1 = c1.isStatic ? float.PositiveInfinity : c1.i;
            float i2 = c2.isStatic ? float.PositiveInfinity : c2.i;

            e = c1.e * c2.e;    // вычисляем общий коэффициент трения поверхностей
            u = c1.f * c2.f;  // и общий коэффициент эластичности

            // {расчет общих коэффициентов может быть другой, например средний арифметический (c1^.f+c2^.f)/2.0}
            jtAcc = 0.0f;
            jnAcc = 0.0f;

            v1 = c1.velocity + (r1.Perp() * c1.w);
            v2 = c2.velocity + (r2.Perp() * c2.w);

            vr = v2 - v1;
            vrn = vr.Dot(n);

            bounce = n.Dot(v2 - v1) * e;
            mass_sum = (1f / m1) + (1f / m2);
            r1cn = r1.PerpDot(n);
            r2cn = r2.PerpDot(n);

            kn = mass_sum + ((r1cn * r1cn) / i1) + ((r2cn * r2cn) / i2);
            nMass = 1.0f / kn;

            jn = -(bounce + vrn) * nMass;

            jnOld = jnAcc;
            jnAcc = Math.Max(jnOld + jn, 0.0f);

            jn = jnAcc - jnOld;
            t = n.Perp();

            vrt = vr.Dot(t); // (0,0)
            t = n.Perp();

            r1ct = r1.PerpDot(t);
            r2ct = r2.PerpDot(t);

            kt = mass_sum + ((r1cn * r1cn) / i1) + ((r2cn * r2cn) / i2);
            tMass = 1.0f / kt;

            // трение
            jtMax = u * jnAcc;
            jt = -vrt * tMass;
            jtOld = jtAcc;
            jtAcc = Math.Min(Math.Max(jtOld + jt, -jtMax), jtMax);
            jt = jtAcc - jtOld;
            j = (n * jn) + (t * jt);

            // накладываем импульсы
            c1.ApplyImpulse(j.Negative(), r1);
            c2.ApplyImpulse(j, r2);
        }
    }
}

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

Итак, подведем итоги. Очевидно, что написать физический движок на основе импульсов в 2D не так сложно, как может показаться на первый взгляд. Удачи в начинаниях!

Исходный код можно скачать тут, а exe-демо тут.

P.S. огромная просьба, об очепятках/ошибках писать мне личным сообщением, не стоит писать комментарии без полезной смысловой нагрузки.

P.S.S. правильность и скорость кода не претендует на что-либо, код изложен только в обучающих целях.

P.S.S.S. если вы хотите, чтобы я осветил одну из тем, связанных с геймдевом — рад видеть вас в личных сообщениях.

Автор: ForhaxeD


  1. Алексей:

    У кого-то остались полный исходный текст к статье или .exe файл?

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js