05 апреля 2020

Скалярное произведение в разработке игр: проекции и прыгающие мячики в Unity ⛹

Frontend-разработчик в Foquz. https://www.cat-in-web.ru/
О скалярном произведении векторов в моделировании и геймдеве. Анимированные иллюстрации и код на С# для разработки игр в Unity.
Скалярное произведение в разработке игр: проекции и прыгающие мячики в Unity ⛹

Скалярное произведение – простой, но чрезвычайно полезный математический инструмент. Он кодирует отношение между величинами и направлениями двух векторов в единственное скалярное значение. Его можно использовать для вычисления проекции, отражения, расчета тени и постановки освещения. Из этого руководства вы узнаете:

  • Геометрический смысл скалярного произведения.
  • Как спроецировать один вектор на другой.
  • Как измерить размер объекта вдоль произвольной оси.
  • Как отразить вектор относительно плоскости.
  • Как создать эффект отскока мяча от наклонной поверхности.

Скалярное произведение

Представьте два вектора a и b. Вектор характеризуется только направлением и величиной (длиной), поэтому не имеет значения, в каком месте плоскости он расположен. Допустим, оба вектора начинаются в одной точке:

Два вектора <code class="inline-code"><i><b>a</b></i></code> и <code class="inline-code"><b><i>b</i></b></code>, выходящие из одной точки плоскости
Два вектора a и b, выходящие из одной точки плоскости
Скалярное произведение
Математическая операция. В качестве входных данных она ожидает два вектора, а на выходе возвращает одно скалярное значение – произведение длины проекции первого вектора на второй (с учетом знака) и длины второго вектора.

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

Проекция одного вектора на другой
Проекция одного вектора на другой

Как вы знаете, операция вычисления произведения векторов записывается так:

Скалярное произведение в разработке игр: проекции и прыгающие мячики в Unity ⛹

Далее в статье мы будем использоваться запись a * b.

Если между векторами острый угол, то длина проекции будет положительной величиной, если больше – то отрицательной.

Порядок не важен
Не имеет значения, какой из векторов выбран "первым", а какой "вторым". Реверсирование порядка дает тот же результат: a * b = b * a.
Реверсирование порядка векторов не изменяет результат скалярного произведения
Реверсирование порядка векторов не изменяет результат скалярного произведения

Если b – единичный вектор, то величина проекции a на b – это просто произведение a * b.

Вычисление скалярного произведения через косинус угла

На схеме изображен прямоугольный треугольник. Угол между векторами a и b равен θ.

Два вектора образуют прямоугольный треугольник
Два вектора образуют прямоугольный треугольник

Для начала требуется рассчитать величину проекции вектора a на вектор b – это нижний катет в нарисованном нами треугольнике. Длину катета стороны можно найти, умножив длину гипотенузы треугольника на косинус прилежащего угла.

Проекция вектора <i><b><code class="inline-code"><b>a</b></code></b></i> на вектор <i><code class="inline-code"><b>b</b></code></i> – это катет прямоугольного треугольника
Проекция вектора a на вектор b – это катет прямоугольного треугольника

Итак, длина проекции равна произведению модуля вектора a на косинус угла θ. Скалярное произведение можно выразить следующим образом:

Формула вычисления скалярного произведения через косинус
Формула вычисления скалярного произведения через косинус

Эта формула лишний раз подтверждает, что порядок умножения не важен – в результат входят беззнаковые длины обоих векторов. Если оба вектора – единичные, правая часть формулы упрощается до cos(θ). А если угол равен 90° (векторы перпендикулярны), то их произведение равно 0.

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

Функция косинуса монотонно убывает на промежутке от 0 до 180° (от 1 до -1). Следовательно, чем ближе направления двух векторов, тем больше их скалярное произведение и наоборот.

Крайние случаи:

  • Направления совпадают, угол θ равен 0°, произведение равно |a| * |b|.
  • Направления противоположны, угол θ равен 180°, произведение – -1* |a| * |b|.

Вычисление скалярного произведения через компоненты векторов

Если наши векторы расположены в 3D-пространстве и имеют по три координаты каждый, не совсем понятно, где тот угол, косинус которого нужно вычислить. К счастью, существует другой способ расчета скалярного произведения – без всякой тригонометрии! Для начала нужно разложить каждый вектор на компоненты:

Разложение векторов на компоненты
Разложение векторов на компоненты
Скалярное произведение через компоненты
Скалярное произведение двух векторов равно сумме попарных произведений соответствующих компонент.
Вычисление скалярного произведения векторов через сумму произведений соответствующих компонент
Вычисление скалярного произведения векторов через сумму произведений соответствующих компонент

Намного проще и без всяких косинусов! В Unity есть встроенный метод Vector3.Dot для вычисления скалярного произведения двух векторов:

        float dotProduct = Vector3.Dot(a, b);
    

Его реализация выглядит следующим образом:

        Vector3 Dot(Vector3 a, Vector b)
{
  return a.x * b.x + a.y * b.y + a.z * b.z;
}
    

Нам известно, как найти длину вектора по его координатам:

|a|=ax2+ay2+az2

Но ее можно выразить и через скалярное произведение вектора на себя:

|a|=aa
Другими словами, скалярное произведение вектора на самого себя равно его длине в квадрате.

Вернемся к формуле a * b = |a| * |b| * cos θ. При известных длинах векторов мы можем вычислить угол между ними с помощью функции арккосинуса:

Вычисление величины угла между векторами
Вычисление величины угла между векторами

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

Упрощенные формулы для единичных векторов
Упрощенные формулы для единичных векторов

Проекция вектора

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

Проекция – это вектор
Результатом проекции одного вектора на другой также является вектор (имеет направление и длину).

Пусть вектор с = projectba – это проекция вектора a на вектор b.

Вектор c – проекция вектора <i>a</i> на вектор <i>b</i>
Вектор c – проекция вектора a на вектор b

Возьмем единичный вектор в направлении вектора b. Он будет равен b / |b|. Если мы возьмем величину проекции a на b со знаком и умножим на этот единичный вектор, то получим вектор c. Cкалярное произведение a * b – это результат умножения длины b на длину проекции a на b. Отсюда получаем, что длину c со знаком можно найти, разделив скалярное произведение a * b на длину b:

Вычисление длины проекции одного вектора на другой
Вычисление длины проекции одного вектора на другой

Умножив полученное значение на единичный вектор b / |b|, получаем формулу для нахождения проекции вектора:

Вычисление проекции одного вектора на другой
Вычисление проекции одного вектора на другой

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

Вычисление проекции одного вектора на другой
Вычисление проекции одного вектора на другой

Если b – единичный вектор, то можно упростить еще больше:

Вычисление проекции вектора на единичный вектор
Вычисление проекции вектора на единичный вектор

В Unity для вычисления проекции одного вектора на другой есть специальная функция Vector3.Project:

        Vector3 projection = Vector3.Project(vec, onto);
    

Вот так выглядит ее реализация:

        Vector3 Project(Vector3 vec, Vector3 onto)
{
  float numerator = Vector3.Dot(vec, onto);
  float denominator = Vector3.Dot(onto, onto);
  return (numerator / denominator) * onto;
}
    

Следует остерегаться возможного вырожденного случая, когда вектор, на который происходит проекция, – нулевой или имеет малую величину. При этом произойдет численный «взрыв» из-за деления на 0 или близкое к нему значение. Один из способов решить проблему – заранее вычислять величину вектора и при необходимости использовать резервный вариант (единичный вектор).

        Vector3 SafeProject(Vector3 vec, Vector3 onto, Vector3 fallback)
{
  float sqrMag = v.sqrMagnitude;
   
  if (sqrMag > Epsilon) // проверка величины вектора
    return Vector3.Project(vec, onto);
  else
    return Vector3.Project(vec, fallback);
}
    

Упраженение #1. Линейка

В качестве упражнения для закрепления знаний о векторной проекции сделаем линейку, которая измеряет размер объекта вдоль произвольной оси.

Линейка характеризуется базовой позицией (точка) и осью (единичный вектор):

        struct Ruler
{
  Vector3 Base;
  Vector3 Axis;
}
    

Как спроецировать какую-либо точку (Point) на линейку? Прежде всего, найдем относительный вектор от базовой позиции линейки (Base) до этой точки. Затем спроецируем его на ось линейки (Axis). Проекция точки (Projection) – это базовое положение линейки, смещенное на проекцию относительного вектора.

Проекция точки на линейку
Проекция точки на линейку
        Vector3 Project(Vector3 vec, Ruler ruler)
{
  // вычисляем относительный вектор
  Vector3 relative = vec - ruler.Base;
   
  // проекция
  float relativeDot = Vector3.Dot(vec, ruler.Axis);
  Vector3 projectedRelative = relativeDot * ruler.Axis;
 
  // смещение от базовой позиции
  Vector3 result = ruler.Base + projectedRelative;
 
  return result;
}
    

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

Чтобы найти размер объекта вдоль оси линейки, нужно провести такие измерения для каждой вершины меша (Mesh) и найти минимальное и максимальное значение. Ответ будет равен разнице между ними.

        void Measure
(
  Mesh mesh, 
  Ruler ruler, 
  out float dimension, 
  out Vector3 minPoint, 
  out Vector3 maxPoint
)
{
  float min = float.MaxValue;
  float max = float.MinValue;
 
  foreach (Vector3 vert in mesh.vertices)
  {
    Vector3 relative = vert- ruler.Base;
    float relativeDot = Vector3.Dot(relative , ruler.Axis);
    min = Mathf.Min(min, relativeDot);
    max = Mathf.Max(max, relativeDot);
  }
   
  dimension = max - min;
  minPoint = ruler.Base+ min * ruler.Axis;
  maxPoint = ruler.Base+ max * ruler.Axis;
}
    

Отражение вектора

Еще одно практическое применение скалярного произведения – отражение вектора относительно плоскости. Рассмотрим вектор v и плоскость с нормальным вектором (перпендикуляром) n.

Отражение вектора <i>v</i> от плоскости
Отражение вектора v от плоскости

Мы можем разложить отражаемый вектор на параллельную и перпендикулярную к плоскости составляющие:

Разложение отражаемого вектора на составляющие
Разложение отражаемого вектора на составляющие

Сам вектор является суммой параллельной и перпендикулярной составляющих:

Разложение отражаемого вектора на составляющие
Разложение отражаемого вектора на составляющие

Перпендикулярный компонент, как вы уже догадались – это проекция вектор на нормаль плоскости. Параллельный компонент можно получить вычитанием этой проекции из самого вектора:

Получение параллельного плоскости компонента вектора
Получение параллельного плоскости компонента вектора

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

Получение отраженного от плоскости вектора
Получение отраженного от плоскости вектора
        reflect(v) = v(парал.) - v(перп.)
    

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

        reflect(v) = v - 2v(перп.)

    

То есть разность самого вектора и его удвоенной проекции на нормаль плоскости.

В Unity, конечно же, есть встроенная функция для расчета отраженного вектора – Vector3.Reflect:

        float reflection = Vector3.Reflect(vec, normal);
    

Так выглядит реализация согласно первой выведенной нами формуле:

        Vector3 Reflect(Vector vec, Vector normal)
{
  Vector3 perpendicular= Vector3.Project(vec, normal);
  Vector3 parallel = vec - perpendicular;
  return parallel - perpendicular;
}
    

А вот вторая:

        Vector3 Reflect(Vector vec, Vector normal)
{
  return vec - 2.0f * Vector3.Project(vec, normal);
}
    

Упражнение #2. Отскок мяча от наклонной плоскости

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

Для моделирования траектории движения шара под действием силы тяжести мы будем использовать метод Эйлера.

        ballVelocity += gravity * deltaTime;
ballCenter += ballVelocity * deltaTime;
    

Чтобы определить, когда мяч ударяется о склон, нужно как-то определить момент, когда мяч проникает в плоскость.

Сфера может быть определена центром (C) и радиусом (R). Плоскость определяется нормальным вектором (n) и точкой на плоскости (P). Вектор от P до С обозначим u.

Сфера и плоскость
Сфера и плоскость

Если сфера НЕ проникает в плоскость, перпендикулярный плоскости компонент вектора u, должен иметь то же направление, что и вектор n, а также длину не менее R.

Перпендикулярный плоскости компонент вектора
Перпендикулярный плоскости компонент вектора

Другими словами, сфера не проникает в плоскость, если скалярное произведение векторов u и n больше R. В противном случае величина проникновения составляет R – u * n, и положение сферы нужно исправить.

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

        // если нет проникновения, возвращает оригинальный центр сферы
// если есть - скорректированный
void SphereVsPlane
(
  Vector3 c,        // центр сферы
  float r,          // радиус сферы
  Vector3 n,        // нормаль плоскости (единичный вектор)
  Vector3 p,        // точка на плоскости
  out Vector3 cNew, // новый центр сферы
)
{
  // по умолчанию устанавливается исходное значение
  cNew = c;
 
  Vector3 u = c - p;
  float d = Vector3.Dot(u, n);
  float penetration = r - d;
 
  // проверка на проникновение
  if (penetration > 0.0f)
  {
    cNew = c + penetration * n;
  }
}
    

Добавим логику для коррекции позиции:

        ballVelocity += gravity * deltaTime;
ballCenter += ballVelocity * deltaTime;
 
Vector3 newSpherePosition;
SphereVsPlane
(
  ballCenter, 
  ballRadius, 
  planeNormal, 
  pointOnPlane, 
  out newBallPosition
);
 
ballPosition = newBallPosition;
    

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

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

Это поведение обычно моделируется значением реституции (восстановления) между двумя сталкивающимися объектами. При 100% реституции мяч идеально отскакивает от плоскости. При 50% – величина перпендикулярной к плоскости составляющей скорости мяча будет уменьшена вдвое.

Величина реституции – это отношение величин перпендикулярного к плоскости компонента скорости мяча до и после отскока.

Вот пересмотренный с учетом коэффициента восстановления скорости вариант функции отражения:

        Vector3 Reflect
(
  Vector3 vec, 
  Vector3 normal, 
  float restitution
)
{
  Vector3 perpendicular= Vector3.Project(vec, normal);
  Vector3 parallel = vec - perpendicular;
  return parallel - restitution * perpendicular;
}
    

Вот так выглядит обновленная функция SphereVsPlane:

        // если нет проникновения, возвращает оригинальный центр сферф
// если есть - скорректированный
void SphereVsPlane
(
  Vector3 c,        // центр сферы
  float r,          // радиус сферы
  Vector3 v,        // скорость сферы
  Vector3 n,        // нормаль плоскости (единичный вектор)
  Vector3 p,        // точка на плоскости
  float e,          // коэффициент восстановления
  out Vector3 cNew, // новый центр сферы
  out Vector3 vNew  // новая скорость сферы
)
{
  // дефолтные значения позиции центра и скорости
  cNew = c;
  vNew = v;
 
  Vector3 u = c - p;
  float d = Vector3.Dot(u, n);
  float penetration = r - d;
 
  // проверка на проникновение
  if (penetration > 0.0f)
  {
    cNew = c + penetration * n;
    vNew = Reflect(v, n, e);
  }
}
    

Логика корректировки позиции заменяется логикой полноценного отскока:

        ballVelocity+= gravity * deltaTime;
spherePosition += ballVelocity* deltaTime;
 
Vector3 newSpherePosition;
Vector3 newSphereVelocity;
SphereVsPlane
(
  spherePosition , 
  ballRadius, 
  ballVelocity, 
  planeNormal, 
  pointOnPlane, 
  restitution, 
  out newBallPosition, 
  out newBallVelocity;
);
 
ballPosition= newBallPosition;
ballVelocity= newBallVelocity;
    

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

Заключение

Мы ответили на все вопросы, заданные в начале этого руководства.

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

Расскажите в комментариях – разрабатываете ли вы сейчас какую-нибудь игру и если да, то о чем она. Каких уроков по Unity вам не хватает?

Источники

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Java Team Lead
Москва, по итогам собеседования
Senior Java Developer
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ