Pull to refresh

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

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

Если статья зайдёт — сделаю продолжение в котором расскажу уже про применение чуть более сложных понятий, таких как комплексные числа, поля, группы и другое.
Tags:
Hubs:
+13
Comments 15
Comments Comments 15

Articles