Урок 39. Введение в физический симулятор

  Если вы знакомы с физикой и хотите написать физический симулятор – этот урок поможет вам. Для того чтобы достигнуть успеха вам понадобятся знания векторных операций в 3D и базовых физических понятий, таких как сила и скорость.

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

 

Cодержание (в порядке следования):

  Дизайн:

 * class Vector3D           ---> Объект представляющий 3D - вектор или точку в пространстве

  Сила и движение:

 * class Mass               ---> Объект представляющий массу.

  Как работает симуляция:

 * class Simulation         ---> Контейнер для симуляции масс.

  Управление симуляцией из приложения:

 * class ConstantVelocity :

   public Simulation        ---> Объект создающий массу с постоянной скоростью.

  Применение силы:

 * class MotionUnderGravitation :

   public Simulation        ---> Объект создающий массу, движущуюся под воздействием гравитации.

 * class MassConnectedWithSpring :

   public Simulation        ---> Объект создающий массу, соединенную с пружиной в точке.

 

Дизайн:

  Создание физического движка задача не всегда простая. Но есть несколько очевидных зависимостей: приложение зависит от движка, а движок в свою очередь зависит от библиотек математики. Наша задача получить контейнер для симуляции движения масс. Наш движок будет включать в себя класс «Mass» и класс «Simulation» - наш контейнер. После того как эти классы будут готовы, мы сможем писать приложения. Но, прежде всего нам понадобится математическая библиотека. Эта библиотека содержит только один класс «Vector3D», который используется для представления точек, векторов, положений, скорости и силы в 3D.

 

 * class Vector3D           ---> Объект представляющий вектор или точку в пространстве

 

Vector3D – единственный класс нашей математической библиотеки. Он хранит значения x, y и z координат и реализует простейшие векторные операции: сложение, вычитание, умножение, деление. Поскольку этот урок, прежде всего о физике, я не буду вдаваться в детали, описывая этот класс. Если вы посмотрите Lesson39.h вам сразу станет ясно насколько он прост.

 

Cила и движение:

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

 

После того как мы поняли, что такое масса, давайте, разберемся с силой и движением. Масса с не нулевой скоростью в пространстве движется в направлении вектора скорости. Поэтому вектор скорости – одна из причин изменения положения массы в пространстве. Вторая причина – прошествие времени. Т.е. изменение положения зависит от того, насколько быстро движется масса и сколько времени прошло.  Если к этому месту что-то остается не ясным, обдумайте отношения между положением, скоростью и временем. (А так же сдуйте пыль с учебника физики 6-го класса, глава начала механики – прим. переводчика).

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

  Отсюда:

 

ускорение = сила / масса

  Из этого, знаменитая формула:

 

сила = масса * ускорение

 

(мы будем часто использовать формулу ускорения)

  До подготовки «физического носителя» для симуляции, вы должны знать, в каком окружении мы будем работать. В этом уроке наше окружение – пустое пространство. ;)

  Во первых, мы должны определить в каких единицах мы будем выражать время и массу. Я решил в качестве единиц времени использовать секунды и для расстояния (положения) - метры. Отсюда единицы скорости – метры в секунду (м/с), ускорения (м/с/c или м/с2). Единицы массы – килограммы (кг).

 

* class Mass                ---> Объект представляющий массу.

  Теперь начнем применять теорию! Мы должны написать класс представляющий массу. Он должен хранить значение массы, положение, скорость, и воздействующую силу.

 

class Mass

{

public:

         float     m;        // Значение массы.

         Vector3D  pos;      // Положение в пространстве.

         Vector3D  vel;      // Скорость.

         Vector3D  force;    // Воздействующая сила.

 

         Mass(float m)       // Конструктор

         {

           this->m = m;

         }

 

         ...

  Мы хотим воздействовать на массу силой. В единицу времени на массу может воздействовать несколько сил. Векторная сумма этих сил дает нам общее значение вектора силы в конкретную единицу времени. До того как мы начнем применять силы к массе нам необходимо обнулить силу в классе (force). После этого нам остается только добавлять силы.

 

         (class Mass продолжение)

 

         void applyForce(Vector3D force)

         {

             this->force += force;    // Внешнюю силу прибавляем к «нашей».

         }

 

         void init()          // Обнуляем «нашу» силу

         {

           force.x = 0;

           force.y = 0;

           force.z = 0;

         }

 

         ...

  Итак, чтобы воздействовать силами на массу нам нужно:

 

         1. Сбросить силу ( метод init() )

         2. Добавить все воздействующие силы ( applyForce(Vector3D) )

         3. Итерационно изменить время

   Здесь, изменение времени организовано по методу Эйлера (Euler). Метод Эйлера очень простой. Существуют и более продвинутые методы, но для большинства приложений этого метода достаточно. Большая часть игрушек использует именно его. Этот метод вычисляет скорость и положение массы, в следующий момент времени исходя из примененных сил и прошедшего времени. Итерации организованы в методе void simulate(float dt):

 

         (class Mass продолжение)

 

         void simulate(float dt)

         {

          vel += (force / m) * dt;  // Изменение в скорости добавляем к

                                    // текущей скорости. Изменение

                                    // пропорционально ускорению

                                    // (сила/масса) и изменению времени

          pos += vel * dt;          // Изменение в положении добавляем к

                                    // текущему положению. Изменение в

                                    // положении Скорость*время

         }

 

Как должна происходить симуляция:

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

 

* class Simulation          ---> Контейнер для симуляции масс.

 

(Автор выбрал не слишком удачное определение – этот класс просто организует массив и к контейнерам не имеет никакого отношения – прим. переводчика).

  Класс Simulation хранит массы как свои переменные. Задача этого класса создавать/удалять массы и проводить симуляцию.

 

class Simulation

{

public:

         int    numOfMasses;    // Количество масс в контейнере.

         Mass** masses;         // Массы хранятся в 1d массиве

                                // указателей

 

         // Конструктор создает numOfMasses масс с массой m.

        

         Simulation(int numOfMasses, float m)

         {

            this->numOfMasses = numOfMasses;

            masses = new Mass*[numOfMasses]; // Создаем массив указателей.

 

            // Создаем Mass и заносим его в массив

            for (int a = 0; a < numOfMasses; ++a)

                 masses[a] = new Mass(m);   

         }

 

         virtual void release()      // Чистим массив масс

         {

           for (int a = 0; a < numOfMasses; ++a)

           {

             delete(masses[a]);

             masses[a] = NULL;

           }

 

           delete(masses);

           masses = NULL;

         }

 

         Mass* getMass(int index)

         {

           // Если индекс выходит за рамки массива возвращаем null

           if (index < 0 || index >= numOfMasses) return NULL;

 

           // Возвращаем массу по индексу

           return masses[index];

         }

        

         ...

  Процедура симуляции имеет три шага:

 

      1. init() – устанавливаем силу (Mass->force) в 0

      2. solve() – применяем силы

      3. simulate(float dt) – конец итерации

 

         (class Simulation продолжение)

 

         virtual void init()      // вызываем init() для каждой массы

         {

            for (int a = 0; a < numOfMasses; ++a)

                 masses[a]->init();

         }

 

         virtual void solve()

         {

           // Нет кода т.к. в базовом классе у нас нет сил

           // В других контейнерах мы переопределим этот метод

         }

 

         virtual void simulate(float dt)  // Итерация для каждой массы

         {

           for (int a = 0; a < numOfMasses; ++a)

                masses[a]->simulate(dt); 

         }

 

         ...

  Объединим процедуру симуляции в один метод:

 

        (class Simulation продолжение)

 

         virtual void operate(float dt) // Полная процедура симуляции.

         {

            init();            // 1. Силу в 0

            solve();           // 2. Применяем силы

            simulate(dt);      // 3. Итерация

         }

      };

  Теперь у нас есть простейший движок физики. Он базируется на библиотеке математики и состоит из классов Mass и Simulation. Теперь мы готовы приступить к разработке приложений (реальных применений). Приложения, которые мы обсудим:

 

      1. Масса с постоянной скоростью

      2. Масса в условиях гравитации

      3. Масса, соединенная с точкой пружиной

 

Управление симуляцией из приложения:

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

 

void Update (DWORD milliseconds)    // обновление движения.

  Эта функция вызывается при обновлении каждого кадра. "DWORD milliseconds" время прошедшее с момента предыдущего обновления кадра. Здесь нужно сказать, что приращение времени мы будем получать в миллисекундах, в этом случае процесс симуляции будет идти параллельно с реальным временем. Чтобы перейти к следующему шагу симуляции, мы вызываем метод void operate(float dt), для этого нам необходимо знать значение dt. Поскольку этот метод получает время в секундах, мы должны сделать соответствующие поправки (см. код ниже). Затем мы используем slowMotionRatio – эта переменная определяет насколько медленно идет время по отношению к реальному. Мы делим dt на это значение и получаем новое dt.  Теперь мы можем прибавить dt к timeElapsed. "timeElapsed" – время нашей симуляции.

 

void Update (DWORD milliseconds)

{

  ...

  ...

  ...

 

  float dt = milliseconds / 1000.0f; // Преобразуем миллисекунды в секунды

  dt /= slowMotionRatio;             // Делим на slowMotionRatio

  timeElapsed += dt;                 // Изменяем кол-во прошедшего времени

 

  ...

  Теперь dt практически готово, но есть еще один важный момент – dt влияет на точность симуляции. Если dt не достаточно мало, симуляция будет не стабильной, и движение будет рассчитываться не точно. Для того чтобы выяснить максимальное допустимое значение dt используется анализ стабильности. В этом уроке мы не будем останавливаться на таких подробностях – для серьезных научных расчетов это необходимо, но для большинства игр достаточно подобрать значение «на глаз».

  Например, в авто-симуляторе оправдано использовать dt от 2 до 5 миллисекунд для обычной машины и от 1 до 3 для гоночной. Для аркадных симуляторов значение dt может колебаться 10 до 200 мс. Чем меньше значение dt, тем больше процессорного времени потребуется для обсчетов. Именно поэтому физические движки так редко использовались в старых играх. (На самом деле все притянуто за уши, реальные причины отсутствия нормальной физики в старых играх несколько глубже – прим. переводчика).

  В следующем коде мы определяем максимальное возможное значение dt 0.1c (100мс). С помощью этого значения мы получим количество итераций к моменту текущего обновления. Запишем формулу:

 

      int numOfIterations = (int)(dt / maxPossible_dt) + 1;

 

numOfIterations это число итераций, которые необходимо выполнить. Скажем прога работает со скоростью 20fps, которые дают dt=0.05 с. Количество итераций (numOfIterations) будет 1. (т.е 1 итерация в 0.05с.)  Если бы dt было 0.12 с. - numOfIterations было бы  2. Сразу после "int numOfIterations = (int)(dt / maxPossible_dt) + 1;", dt рассчитывается еще раз, делим его на numOfIterations и получаем dt = 0.12 / 2 = 0.06. dt было больше максимально допустимого значения  (0.1с). Теперь мы имеем dt = 0.06, но поскольку итерации 2 в результате получим 0.12 с. Изучите следующий кусок кода и удостоверьтесь, что все поняли.

 

    ...

 

    float maxPossible_dt = 0.1f; // максимально возможное dt = 0.1 с

                                 // Необходимо чтобы мы не «вылетели» за

                                 // пределы точности.

 

    // Рассчитываем количество итераций, которые необходимо провести в ходе текущего

    // обновления(зависит от maxPossible_dt и dt).

 

    int numOfIterations = (int)(dt / maxPossible_dt) + 1; 

 

    if (numOfIterations != 0)      // Проверяем деление на 0

        dt = dt / numOfIterations; // dt нужно обновить, опираясь на

                                   // numOfIterations.

 

    // мы должны повторить симуляцию "numOfIterations" раз.

    for (int a = 0; a < numOfIterations; ++a)

    {

      constantVelocity->operate(dt);

      motionUnderGravitation->operate(dt);

      massConnectedWithSpring->operate(dt);

    }

}

  Начнем писать приложения:

 

1. Масса с постоянной скоростью

        

* class ConstantVelocity :

  public Simulation         ---> Объект создающий массу с постоянной скоростью.

  Масса с постоянной скоростью не нуждается, в каких либо внешних силах. Нам всего лишь нужно создать 1 массу и установить ее скорость в (1.0f, 0.0f, 0.0f), т.е. она будет двигаться вдоль оси x со скоростью 1 м/с. Мы напишем наследника от класса Simulation – класс ConstantVelocity:

 

class ConstantVelocity : public Simulation

{

public:

 

         // Конструктор сначала создает предка с 1й массой в 1 кг.

         ConstantVelocity() : Simulation(1, 1.0f)

         {

           // Масса создалась, и мы устанавливаем ее координаты и скорость

           masses[0]->pos = Vector3D(0.0f, 0.0f, 0.0f);

           masses[0]->vel = Vector3D(1.0f, 0.0f, 0.0f);

         }

};

  Метод operate(float dt) класса ConstantVelocity рассчитывает следующее положение массы. Он вызывается основным приложением до перерисовки окна. Скажем, приложение выполняется с 10 fps. Соответственно dt при вызове operate(float dt) будет 0.1 с. Когда произойдет вызов simulate(float dt) массы, ее новое положение будет увеличено на скорость (velocity) * dt, т.е.

 

      Vector3D(1.0f, 0.0f, 0.0f) * 0.1 = Vector3D(0.1f, 0.0f, 0.0f)

  При каждой итерации масса двигается на 0.1 метра вправо. После 10 кадров она переместится вправо на 1 метр. Скорость была 1.0 м/с и масса перемещается на 1.0 м в течение 1 с. Это совпадение или логический результат? Если этот вопрос вызывает у вас затруднение, обдумайте рассуждения изложенные выше.

  Когда вы запускаете приложение, вы видите массу, с постоянной скоростью перемещающуюся в направлении x. Приложение имеет два режима движения. При нажатии F3 время будет замедленно в 10 раз (по отношению к реальному). При нажатии F3 время будет идти параллельно реальному. На экране вы увидите линии представляющие координатную плоскость. Расстояние между этими линиями 1 метр. Используя эти линии не трудно заметить, что в режиме реального времени масса перемещается на 1 метр в секунду (соответственно, при замедленном времени 1 метр в 10 секунд). Техника, описанная выше обычна для симуляции в реальном времени. Для того чтобы ее применять, вам необходимо четко оговорить единицы.

 

Применение силы:

  При симуляции массы движущейся с постоянной скоростью мы не применяем сил к массе, т.к. мы знаем, что в случае применения сил тело ускоряется. Когда мы хотим получить ускоренное движение, мы применяем силу. При симуляции мы применяем силы в методе "solve". Когда операции доходят до фазы "simulate" мы имеем результирующий вектор силы (полученный суммированием всех векторов). Он определяет движение.

  Скажем, мы хотим применить силу (=1) к массе в направлении x. Тогда мы пишем в методе solve:

 

         mass->applyForce(Vector3D(1.0f, 0.0f, 0.0f));

  Если мы хотим применить другую силу (=2) в направлении y мы добавим:

 

         mass->applyForce(Vector3D(0.0f, 2.0f, 0.0f));

  Таким образом, вы имеете возможность применять любые силы.

 

2. Масса в условиях гравитации

* class MotionUnderGravitation :

  public Simulation         ---> Объект создающий массу, движущуюся под воздействием гравитации.

  Класс MotionUnderGravitation создает массу и применяет силу (гравитации) к ней. Сила гравитации равна массе умноженной на ускорение свободного падения (g):

 

F = m * g

  Ускорение свободного падения это ускорение свободного тела. На земле, когда вы бросаете предмет он наращивает скорость на 9.81 м/c в секунду (пока на него действует только сила гравитации). Ускорение свободного падения – константа для всех масс и равна 9.81 м/с/с. (Это не зависит от массы – все массы падают с одинаковым ускорением).

  Класс MotionUnderGravitation имеет такой конструктор:

 

         class MotionUnderGravitation : public Simulation

         {

           Vector3D gravitation;    // ускорение свободного падения

 

           // Конструктор сначала создает предка с 1й массой в 1 кг.

           // Vector3D Gravitation - ускорение свободного падения

        

           MotionUnderGravitation(Vector3D gravitation):Simulation(1, 1.0f)

           {

            this->gravitation = gravitation;

            masses[0]->pos = Vector3D(-10.0f, 0.0f, 0.0f);

            masses[0]->vel = Vector3D(10.0f, 15.0f, 0.0f);

           }

 

           ...

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

 

           // Поскольку мы применяем силу, нам понадобится метод "Solve".

           virtual void solve()

           {

             // Применяем силу ко всем массам

             for (int a = 0; a < numOfMasses; ++a)

               // Сила гравитации это F = m * g.

                masses[a]->applyForce(gravitation * masses[a]->m);

           }

  Вы, наверное, заметили в коде формулы силы F=m*g. Приложение создает MotionUnderGravitation с параметром Vector3D(0.0f, -9.81f, 0.0f). -9.81 означает ускорение в направлении – y, таким образом, мы получаем «падающую» массу. Запустите приложение, и понаблюдайте что происходит.

 

3. Масса, соединенная с точкой пружиной

 

* class MassConnectedWithSpring :

  public Simulation         ---> Объект создающий массу соединенную с точкой пружиной.

  В этом примере мы хотим присоединить массу к фиксированной точке пружиной. Пружина должна тянуть массу в сторону точки, к которой она присоединена. В конструкторе класс MassConnectedWithSpring устанавливает точку присоединения и положение массы.

 

         class MassConnectedWithSpring : public Simulation

         {

           public:

            float springConstant;    // больше springConstant, сильнее сила

                                     // притяжения.

            Vector3D connectionPos;  // Точка

 

            // Конструктор сначала создает предка с 1й массой в 1 кг.

            MassConnectedWithSpring(float springConstant) : Simulation(1, 1.0f)

            {

             // установили springConstant.

             this->springConstant = springConstant;

             // и connectionPos.

             connectionPos = Vector3D(0.0f, -5.0f, 0.0f);

 

             // положение массы на 10 метров правее connectionPos.

             masses[0]->pos = connectionPos + Vector3D(10.0f, 0.0f, 0.0f);

             // Скорость 0

             masses[0]->vel = Vector3D(0.0f, 0.0f, 0.0f);

         }

 

         ...

  Скорость массы 0 и положение на 10 метров правее точки присоединения connectionPos соответственно в начале массу должно тянуть влево. Сила пружины определяется так

 

F = -k * x

  Значение k определяет насколько жесткой должна быть пружина, а x – расстояние от массы до точки присоединения. Отрицательный знак в формуле означает, что это сила притяжения. Если бы знак был положительным пружина толкала бы массу, что, в общем-то не удовлетворяет нашим требованиям ;).

 

         virtual void solve() // Будет применяться сила пружины

         {

           for (int a = 0; a < numOfMasses; ++a)

           {

            // Находим вектор от массы до точки притяжения

            Vector3D springVector = masses[a]->pos - connectionPos; 

            // Применяем силу опираясь на формулу

            masses[a]->applyForce(-springVector * springConstant);

           }

         }

 

  Сила притяжения в коде выше такая же, как и в формуле F=-k*x. Здесь вместо x мы использовали Vector3D поскольку мы хотим работать в 3D. «springVector» определяет расстояние между положением массы и connectionPos, а springConstant заменяет k. Чем больше значение springConstant, тем больше сила, и тем быстрее движется масса.

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

 

© Erkin Tunca
Jeff Molofee (NeHe)

 15 января 2002 (c)  Артем Чирцов