Векторная математика.
Векторы и векторная математика являются необходимыми инструментами для разработки игр. Многие операции и действия завязаны на ней целиком. Забавно, что для реализации класса, который отображает стрелочку вектора в Unity, уже потребовалось большинство типовых операций. Если вы хорошо разбираетесь в векторной математике данный блок вам будет неинтересен.
Векторная арифметика и полезные функции
Аналитические формулы и прочие детали легко нагуглить, так что не будем тратить на это время. Сами операции будут проиллюстрированы гиф-анимациями ниже.
Важно понимать, что любая точка в сущности является вектором с началом в нулевой точке.
Гифки делались с помощью Unity, так что нужно было бы реализовывать класс, отвечающий за отрисовку стрелочек. Стрелка вектора состоит из трех основных компонент – линии, наконечника и текста с именем вектора. Для отрисовки линии и наконечника я воспользовался LineRenderer. Посмотрим на класс самого вектора:
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
public class VectorArrow : MonoBehaviour
{
[SerializeField] private Vector3 _VectorStart;
[SerializeField] private Vector3 _VectorEnd;
[SerializeField] private float TextOffsetY;
[SerializeField] private TMP_Text _Label;
[SerializeField] private Color _Color;
[SerializeField] private LineRenderer _Line;
[SerializeField] private float _CupLength;
[SerializeField] private LineRenderer _Cup;
private void OnValidate()
{
UpdateVector();
}
private void UpdateVector()
{
if(_Line == null || _Cup == null) return;
SetColor(_Color);
_Line.positionCount = _Cup.positionCount = 2;
_Line.SetPosition(0, _VectorStart);
_Line.SetPosition(1, _VectorEnd - (_VectorEnd - _VectorStart).normalized * _CupLength);
_Cup.SetPosition(0, _VectorEnd - (_VectorEnd - _VectorStart).normalized * _CupLength);
_Cup.SetPosition(1, _VectorEnd );
if (_Label != null)
{
var dv = _VectorEnd - _VectorStart;
var normal = new Vector3(-dv.y, dv.x).normalized;
normal = normal.y > 0 ? normal : -normal;
_Label.transform.localPosition
= (_VectorEnd + _VectorStart) / 2
+ normal * TextOffsetY;
_Label.transform.up = normal;
}
}
public void SetPositions(Vector3 start, Vector3 end)
{
_VectorStart = start;
_VectorEnd = end;
UpdateVector();
}
public void SetLabel(string label)
{
_Label.text = label;
}
public void SetColor(Color color)
{
_Color = color;
_Line.startColor = _Line.endColor = _Cup.startColor = _Cup.endColor = _Color;
}
}
Так как мы хотим, чтобы вектор был определённой длинны и точно соответствовал точкам, которые мы задаём, то длинна линии рассчитывается по формуле:
_VectorEnd - (_VectorEnd - _VectorStart).normalized * _CupLength
В данной формуле (_VectorEnd — _VectorStart).normalized – это направление вектора. Это можно понять из анимации с разницей векторов, приняв что _VectorEnd и _VectorStart – это вектора с началом в (0,0,0).
Дальше разберём две оставшиеся базовые операции:
Нахождение нормали (перпендикуляра) и середины вектора – это очень часто встречающиеся задачи при разработке игр. Разберём их на примере размещения подписи над вектором.
var dv = _VectorEnd - _VectorStart;
var normal = new Vector3(-dv.y, dv.x).normalized;
normal = normal.y > 0 ? normal : -normal;
_Label.transform.localPosition = (_VectorEnd + _VectorStart) / 2 + normal * TextOffsetY;
_Label.transform.up = normal;
Для того, чтобы разместить текст перпендикулярно вектору нам понадобится нормаль. В 2D графике нормаль находится достаточно просто.
var dv = _VectorEnd - _VectorStart;
var normal = new Vector3(-dv.y, dv.x).normalized;
Вот мы и получили нормаль к отрезку.
normal = normal.y > 0? normal: -normal; — эта операция отвечает за то, чтобы текст всегда показывался над вектором.
Дальше остаётся поместить его в середину вектора и поднять по нормали на расстояние, которое будет смотреться красиво.
_Label.transform.localPosition
= (_VectorEnd + _VectorStart) / 2
+ normal * TextOffsetY;
В коде использованы локальные позиции, чтобы можно была возможность двигать получившуюся стрелочку.
Но это было про 2D, а что же с 3D?
В 3D плюс-минус всё тоже самое. Отличается только формула нормали, так как нормаль уже берётся не к отрезку, а к плоскости.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SphereCameraController : MonoBehaviour
{
[SerializeField] private Camera _Camera;
[SerializeField] private float _DistanceFromPlanet = 10;
[SerializeField] private float _Offset = 5;
private bool _IsMoving;
public event Action<Vector3, Vector3, Vector3, float, float> OnMove;
private void Update()
{
if (Input.GetMouseButtonDown(0) && !_IsMoving)
{
RaycastHit hit;
Debug.Log("Click");
var ray = _Camera.ScreenPointToRay(Input.mousePosition);
if(Physics.Raycast(ray, out hit))
{
Debug.Log("hit");
var startPosition = _Camera.transform.position;
var right = Vector3.Cross(hit.normal, Vector3.up).normalized;
var endPosition = hit.point + hit.normal * _DistanceFromPlanet + right * _Offset;
StartCoroutine(MoveCoroutine(startPosition, endPosition, hit.point + right * _Offset));
OnMove?.Invoke(startPosition, hit.point, hit.normal, _DistanceFromPlanet, _Offset);
}
}
}
private IEnumerator MoveCoroutine(Vector3 start, Vector3 end, Vector3 lookAt)
{
_IsMoving = true;
var startForward = transform.forward;
float timer = 0;
while (timer < Scenario.AnimTime)
{
transform.position = Vector3.Slerp(start, end, timer / Scenario.AnimTime);
transform.forward = Vector3.Slerp(startForward, (lookAt - transform.position).normalized,
timer / Scenario.AnimTime);
yield return null;
timer += Time.deltaTime;
}
transform.position = end;
transform.forward = (lookAt - transform.position).normalized;
_IsMoving = false;
}
}
В данном примере контролла нормаль к плоскости используется, чтобы сместить конечную точку траектории право, чтобы планету не загораживал интерфейс. Нормаль в 3д графике – это нормализованное векторное произведение двух векторов. Что удобно, в Юнити есть обе эти операции и мы получаем красивую компактную запись:
var right = Vector3.Cross(hit.normal, Vector3.up).normalized;
Думаю, многим, кто думает, что математика не нужна и зачем вообще это знать, стало чуть понятнее какие задачи с помощью неё можно решать просто и элегантно. Но это был простой вариант, который должен знать каждый разработчик игр не стажёр. Поднимем планку — поговорим об интегралах.
Интегралы
Вообще у интегралов очень много применений, таких как: физические симуляции, VFX, аналитика и многое другое. Я не готов сейчас детально описывать все. Хочется описать простой и визуально понятный. Поговорим про физику.
Допустим есть задача – двигать объект в определённую точку. К примеру, чтобы при вхождении в определённый триггер, должны вылетать книги с полок. Если вы хотите двигать равномерно и без физики, то задача тривиальна и не требует интегралов, но когда книги выталкивает с полки призрак, такое распределение скорости будет смотреться совсем не так.
Что такое интеграл?
По сути это площадь под кривой. Но что это означает в контексте физики? Допустим у вас есть распределение скорости по времени. В данном случае площадь под кривой – это путь который пройдёт объект, а это как раз то, что нам и нужно.
Если перейти от теории к практике, то в Unity есть замечательный инструмент под названием AnimationCurve. С помощью него можно задать распределение скорости с течением времени. Создадим вот такой класс.
using System.Collections;
using UnityEngine;
[RequireComponent(typeof(Rigidbody))]
public class MoveObject : MonoBehaviour
{
[SerializeField] private Transform _Target;
[SerializeField] private GraphData _Data;
private Rigidbody _Rigidbody;
private void Start()
{
_Rigidbody = GetComponent<Rigidbody>();
Move(2f, _Data.AnimationCurve);
}
public void Move(float time, AnimationCurve speedLaw)
{
StartCoroutine(MovingCoroutine(time, speedLaw));
}
private IEnumerator MovingCoroutine(float time, AnimationCurve speedLaw)
{
float timer = 0;
var dv = (_Target.position - transform.position);
var distance = dv.magnitude;
var direction = dv.normalized;
var speedK = distance / (Utils.GetApproxSquareAnimCurve(speedLaw) * time);
while (timer < time)
{
_Rigidbody.velocity = speedLaw.Evaluate(timer / time) * direction * speedK;
yield return new WaitForFixedUpdate();
timer += Time.fixedDeltaTime;
}
_Rigidbody.isKinematic = true;
}
}
Метод GetApproxSquareAnimCurve – это и есть наше интегрирование. Мы делаем его простейшим численным методом, просто идём по значениям фукнции и суммируем их определённое число раз. Я выставил 1000 для верности, в целом можно подобрать оптимальнее.
private const int Iterations = 1000;
public static float GetApproxSquareAnimCurve(AnimationCurve curve)
{
float square = 0;
for (int i = 0; i <= Iterations; i++)
{
square += curve.Evaluate((float) i / Iterations);
}
return square / Iterations;
}
Благодаря этой площади мы дальше уже знаем, какое относительное расстояние. А дальше сравнив два пройденных пути мы получаем коэффициент скорости speedK, который отвечает за то, чтобы мы прошли заданное расстояние.
Можно заметить, что объекты не совсем совпадают, это связано с ошибкой float. В целом можно пересчитать тоже самое в decimal, а потом перегнать в float для большей точности.
Собственно на этом на сегодня всё. Как всегда в конце ссылка на GitHub проект, в котором все исходники по данной статье. И с ними можно поиграться.
Если статья зайдёт — сделаю продолжение в котором расскажу уже про применение чуть более сложных понятий, таких как комплексные числа, поля, группы и другое.
Комментарии (15)
PkXwmpgN
17.11.2018 00:29+2Еще очень хорошая статья про линейную алгебру для игр, безотносительно движка
konshyn
17.11.2018 07:45Статья «галопом по европам». Только для общего ознакомления пайплайна математики для игр.
DyadichenkoGA Автор
17.11.2018 09:13Да, классная. И вот она именно по теории и из неё можно взять все формулы. Мне хотелось в статье показать конкретные применения (скрипт для камеры и скрипт для перемещения объекта в конкретную точку с неравномерной скоростью в общем случае), чтобы в особенности новичкам было понятно, «а зачем эту самую теорию изучать?», и где она может пригодиться
Delics
17.11.2018 16:56это связано с ошибкой float
Не с ошибкой float, а с ошибкой самого метода интегрирования.
Чтобы было поточнее, надо взять хотя бы метод трапеций:
private const int Iterations = 1000; public static float GetApproxSquareAnimCurve(AnimationCurve curve) { float square = curve.Evaluate(1); float step = (float) 1 / Iterations; for (int i = 1; i < Iterations; i++) { square += 2 * (curve.Evaluate(step * i)); } return square; }
DyadichenkoGA Автор
17.11.2018 17:04Вообще на таком количестве итераций и по такой кривой — нет. Ошибка накапливается вот тут, которая имеет значение.
_Rigidbody.velocity = speedLaw.Evaluate(timer / time) * direction * speedK;
Та ошибка, которая накапливается в методе интегрирования в сравнении с этим не такая большая из-за количества знаков в числах и огромного числа итераций. Метод трапеций просто был бы оптимальнее.
Но вообще там есть ещё один нюанс, если брать контекст Unity и Rigidbody, помимо скорости у объекта есть иннерция + действует сила трения, по этой причине тут тоже есть пара нюансов. В конкретной реализации в репозитории просто накинут материал, у которого коэффициент трения равен нулю, но строго говоря на нормальных поверхностях формула посложнее. Ну либо работать не c _RidigBody (или навешивать физические компоненты юнити после смещения) Это уже зависит от конкретной задачиDelics
17.11.2018 17:13Ошибка «метода прямоугольников»
Намного выше точности floatDyadichenkoGA Автор
17.11.2018 19:06Я хз, что это за формула, и что в ней такое кси, но вообще на сколько мне известно, абсолютная погрешность на определённом интеграле равна v * (b — a)^2/(2n). А теперь подставим значения из конкретно приведённого мной примера. Распределение скорости в целом лучше брать нормализованным, так как дистанция определяется позже, но в задаче у нас v = 1, a = 0, b = 1, n = 1000, то есть мы получаем 1/2000 — казалось бы, что это много. Но при вычислении интеграла мы 1000 раз складываем float значения < 1, что даёт ошибку 10^(-6)*1000 = 1/1000, что уже больше. Но помимо этого в следующей формуле для вычисления скорости ошибка ещё выше, так как там увеличивается степень вычислений.
Так что в данном конкретном примере проблема во float. Но вообще точность float почти всегда будет хуже, чем вычисление подобными методами на достаточно большом количестве операцийDyadichenkoGA Автор
17.11.2018 19:19В целом есть много численных методов интегрирования лучше, чем метод прямоугольников, я не спорю, но ещё раз. Цель статьи показать, какие задачи в целом решаются математикой, а не показать наилучшее решение конкретной задачи.
В ней нет открытий, откровений и прочего. И была написала исключительно из-за того, что очень часто встречается абсолютное непонимание того, зачем в целом нужно знать математику, и какие прикольные задачи можно решать с помощью неё. Я искренне верю, что многим в вузе неинтересно изучать математику, так как непонятно, а зачем нужны эти матрицы, интегралы и прочие довольно абстрактные вещи. Можно придраться и к тому, что интеграл — это совсем не площадь под кривой. Так как площадь под кривой — это лишь его геометрическое представление. Кратные и поверхностные интегралы уже так просто не опишешь
Можно вспомнить много численных методов, из популярных метод Симпсона ещё лучше, но тем не менее это не цель статьи, научить математике. На это в вузах по 4 года тратят. Интеграл нужен для вот такой-то задачи. Какой метод интегрирования подставить в GetApproxSquareAnimCurve — дело третье.
DyadichenkoGA Автор
17.11.2018 17:08Просто не надо забывать, что float хранит всего 7 значащих символов, поэтому копейки которые потерялись при интегрировании — это мелочь, по сравнению с тем, как это суммирование может ломаться на нормальных числах в части расчёта скорости
Viacheslav01
19.11.2018 13:02Что такое интеграл?
По сути это площадь под кривой.
Я прям сразу осознал, что же такое интеграл и для чего он нужен.
Griboks
Думал, тут будет что-то кроме сборника базовых примеров. Но для нубасов будет очень интересно. Я бы ещё формулы вставил…
Только вот это не интегрирование, а суммирование конечной последовательности.
DyadichenkoGA Автор
Нет, это интегрирование функции скорости по времени по сути численным методом. Определённый интегралл от 0 до 1 по времени функции скорости. Если взять определённый интеграл от той же функции скорости, то мы получим тоже самое значение с точностью до ошибки.
По векторам примеры базовые, по тому как переместить объект по физическому распределению скорости (можно подобрать подходящую аналитическую функцию), как показывает практика — нет
Griboks
Дело в том, что этот метод и заключается в суммировании приближённой последовательности.
Я бы ещё написал про классические задачки, типа рикошета пули, строительство в майнкрафте, плавание и колебания, аффинные преобразования (в визуальном конструкторе, например).
DyadichenkoGA Автор
Ну да, просто это всё же интегрирование. Так то да, через суммирование последовательности.
Есть очень много задач, про которые можно рассказать. Про звук и преобразования фурье, про жидкостные симуляции и комплексные числа, про метод конечных элементов и пластические деформации. Тут скорее вопрос в том, что я написал пока пробную простенькую статью, чтобы посмотреть интересна ли кому-то тема. Учитывая реализацию всего и вся на юнити и открытых исходников (нельзя юзать ничего, что не под GPL, MIT, Apache и подобными лицензиями), могу сказать что это достаточно трудоёмкий процесс.
Поэтому если тематика будет интересна в общем, то может возьмусь за задачки посложнее. С аффинными и TRS матрицами согласен, хотя на самом деле это тоже базовые вещи про которые не все знают. Или тот же линал и шейдеры.
DyadichenkoGA Автор
Без юнити проекта и исходников не вижу смысла писать, так как когда решение можно «потыкать» так гораздо интереснее, на мой взгляд)