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


  1. Griboks
    16.11.2018 23:19
    -2

    Думал, тут будет что-то кроме сборника базовых примеров. Но для нубасов будет очень интересно. Я бы ещё формулы вставил…

    Если перейти от теории к практике, то в Unity есть замечательный инструмент под названием AnimationCurve.

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


    1. DyadichenkoGA Автор
      16.11.2018 23:22

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

      По векторам примеры базовые, по тому как переместить объект по физическому распределению скорости (можно подобрать подходящую аналитическую функцию), как показывает практика — нет


      1. Griboks
        16.11.2018 23:40

        Дело в том, что этот метод и заключается в суммировании приближённой последовательности.
        Я бы ещё написал про классические задачки, типа рикошета пули, строительство в майнкрафте, плавание и колебания, аффинные преобразования (в визуальном конструкторе, например).


        1. DyadichenkoGA Автор
          16.11.2018 23:44
          +1

          Ну да, просто это всё же интегрирование. Так то да, через суммирование последовательности.

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

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


        1. DyadichenkoGA Автор
          16.11.2018 23:45
          +1

          Без юнити проекта и исходников не вижу смысла писать, так как когда решение можно «потыкать» так гораздо интереснее, на мой взгляд)


  1. PkXwmpgN
    17.11.2018 00:29
    +2

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


    1. konshyn
      17.11.2018 07:45

      Статья «галопом по европам». Только для общего ознакомления пайплайна математики для игр.


    1. DyadichenkoGA Автор
      17.11.2018 09:13

      Да, классная. И вот она именно по теории и из неё можно взять все формулы. Мне хотелось в статье показать конкретные применения (скрипт для камеры и скрипт для перемещения объекта в конкретную точку с неравномерной скоростью в общем случае), чтобы в особенности новичкам было понятно, «а зачем эту самую теорию изучать?», и где она может пригодиться


  1. 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;
    }


    1. DyadichenkoGA Автор
      17.11.2018 17:04

      Вообще на таком количестве итераций и по такой кривой — нет. Ошибка накапливается вот тут, которая имеет значение.
      _Rigidbody.velocity = speedLaw.Evaluate(timer / time) * direction * speedK;

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


      1. Delics
        17.11.2018 17:13

        Ошибка «метода прямоугольников»

        image

        Намного выше точности float


        1. DyadichenkoGA Автор
          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 почти всегда будет хуже, чем вычисление подобными методами на достаточно большом количестве операций


          1. DyadichenkoGA Автор
            17.11.2018 19:19

            В целом есть много численных методов интегрирования лучше, чем метод прямоугольников, я не спорю, но ещё раз. Цель статьи показать, какие задачи в целом решаются математикой, а не показать наилучшее решение конкретной задачи.

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

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


    1. DyadichenkoGA Автор
      17.11.2018 17:08

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


  1. Viacheslav01
    19.11.2018 13:02

    Что такое интеграл?

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


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