Математика в Gamedev по-простому. Векторы и интегралы

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



    Векторная математика.

    Векторы и векторная математика являются необходимыми инструментами для разработки игр. Многие операции и действия завязаны на ней целиком. Забавно, что для реализации класса, который отображает стрелочку вектора в 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. С помощью него можно задать распределение скорости с течением времени. Создадим вот такой класс.

    класс MoveObj
    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
      –2
      Думал, тут будет что-то кроме сборника базовых примеров. Но для нубасов будет очень интересно. Я бы ещё формулы вставил…
      Если перейти от теории к практике, то в Unity есть замечательный инструмент под названием AnimationCurve.

      Только вот это не интегрирование, а суммирование конечной последовательности.
        0
        Нет, это интегрирование функции скорости по времени по сути численным методом. Определённый интегралл от 0 до 1 по времени функции скорости. Если взять определённый интеграл от той же функции скорости, то мы получим тоже самое значение с точностью до ошибки.

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

            Есть очень много задач, про которые можно рассказать. Про звук и преобразования фурье, про жидкостные симуляции и комплексные числа, про метод конечных элементов и пластические деформации. Тут скорее вопрос в том, что я написал пока пробную простенькую статью, чтобы посмотреть интересна ли кому-то тема. Учитывая реализацию всего и вся на юнити и открытых исходников (нельзя юзать ничего, что не под GPL, MIT, Apache и подобными лицензиями), могу сказать что это достаточно трудоёмкий процесс.

            Поэтому если тематика будет интересна в общем, то может возьмусь за задачки посложнее. С аффинными и TRS матрицами согласен, хотя на самом деле это тоже базовые вещи про которые не все знают. Или тот же линал и шейдеры.
              +1
              Без юнити проекта и исходников не вижу смысла писать, так как когда решение можно «потыкать» так гораздо интереснее, на мой взгляд)
          +2

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

            –1
            Статья «галопом по европам». Только для общего ознакомления пайплайна математики для игр.
              0
              Да, классная. И вот она именно по теории и из неё можно взять все формулы. Мне хотелось в статье показать конкретные применения (скрипт для камеры и скрипт для перемещения объекта в конкретную точку с неравномерной скоростью в общем случае), чтобы в особенности новичкам было понятно, «а зачем эту самую теорию изучать?», и где она может пригодиться
              0
              это связано с ошибкой 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;
              }
                0
                Вообще на таком количестве итераций и по такой кривой — нет. Ошибка накапливается вот тут, которая имеет значение.
                _Rigidbody.velocity = speedLaw.Evaluate(timer / time) * direction * speedK;

                Та ошибка, которая накапливается в методе интегрирования в сравнении с этим не такая большая из-за количества знаков в числах и огромного числа итераций. Метод трапеций просто был бы оптимальнее.
                Но вообще там есть ещё один нюанс, если брать контекст Unity и Rigidbody, помимо скорости у объекта есть иннерция + действует сила трения, по этой причине тут тоже есть пара нюансов. В конкретной реализации в репозитории просто накинут материал, у которого коэффициент трения равен нулю, но строго говоря на нормальных поверхностях формула посложнее. Ну либо работать не c _RidigBody (или навешивать физические компоненты юнити после смещения) Это уже зависит от конкретной задачи
                  0
                  Ошибка «метода прямоугольников»

                  image

                  Намного выше точности float
                    0
                    Я хз, что это за формула, и что в ней такое кси, но вообще на сколько мне известно, абсолютная погрешность на определённом интеграле равна 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 почти всегда будет хуже, чем вычисление подобными методами на достаточно большом количестве операций
                      0
                      В целом есть много численных методов интегрирования лучше, чем метод прямоугольников, я не спорю, но ещё раз. Цель статьи показать, какие задачи в целом решаются математикой, а не показать наилучшее решение конкретной задачи.

                      В ней нет открытий, откровений и прочего. И была написала исключительно из-за того, что очень часто встречается абсолютное непонимание того, зачем в целом нужно знать математику, и какие прикольные задачи можно решать с помощью неё. Я искренне верю, что многим в вузе неинтересно изучать математику, так как непонятно, а зачем нужны эти матрицы, интегралы и прочие довольно абстрактные вещи. Можно придраться и к тому, что интеграл — это совсем не площадь под кривой. Так как площадь под кривой — это лишь его геометрическое представление. Кратные и поверхностные интегралы уже так просто не опишешь

                      Можно вспомнить много численных методов, из популярных метод Симпсона ещё лучше, но тем не менее это не цель статьи, научить математике. На это в вузах по 4 года тратят. Интеграл нужен для вот такой-то задачи. Какой метод интегрирования подставить в GetApproxSquareAnimCurve — дело третье.
                  0
                  Просто не надо забывать, что float хранит всего 7 значащих символов, поэтому копейки которые потерялись при интегрировании — это мелочь, по сравнению с тем, как это суммирование может ломаться на нормальных числах в части расчёта скорости
                  0
                  Что такое интеграл?

                  По сути это площадь под кривой.


                  Я прям сразу осознал, что же такое интеграл и для чего он нужен.

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

                  Самое читаемое