Как стать автором
Обновить

Упрощённые рейкасты в Unity

Разработка игр *C# *Unity *Разработка под AR и VR *

Всем привет, меня зовут Григорий Дядиченко, и я технический продюсер. Недавно я столкнулся с одной интересной задачкой в ходе реализации проекта, и подумал что стоит наверное рассказать про физику в Unity, про нюансы работы с ней и про то, какие существуют альтернативные варианты в решении разных задач. Как связывать это всё дело с EventSystem и расширять Unity классы. Если вам это интересно - добро пожаловать под кат.

На данный момент я в основном занимаюсь визуализациями данных, работой с дополненной реальностью, AR/VR и интерактивными стендами на выставках. В одной из задач было необходимо визуализировать огромный граф с данными в виртуальной реальности, который состоял из порядка 10000 тысяч объектов. И классическая физика в Юнити оказалась слишком медленной. Но прежде чем “писать своё с блекджеком и куртизанками”, давайте пройдёмся по тому, о чём нужно знать и что нужно делать при работе с физикой Unity.

Советы и нюансы по работе с встроенной физикой

На самом деле большую часть про это можно прочесть в этом материале https://learn.unity.com/tutorial/physics-best-practices#5c7f8528edbc2a002053b5b4 Не хочется особо сильно повторяться про “простые коллайдеры лучше”, кроме самого важного совета, который я не устану повторять. Если объект подвижен и на нём есть коллайдер или триггер, то на нём должно быть Rigidbody. А вот из того, что тут не сказано в Unity есть возможность управлять обсчётом физики в ручную. В классе Physics во-первых, есть bool переменная autoSimulate (так же есть в настройках) и метод Simulate, чтобы посчитать кадр физики. В целом у этого есть две основные проблемы: это всё работает только в главном потоке и оно посчитает всё. Вот вообще всё. Коллизии, что там с джоинтами и т.п. И это не очень удобно как раз в случае описанном выше.

В общем в AR/VR задачах часто встречается такое, что сами по себе коллизии тебе не особо нужны. В первую очередь интересны рейкасты, и недостаток встроенной физики юнити (а то есть интеграции PhysX) что нельзя пользоваться рейкастами просто отключив коллизии. Даже выключив всю матрицу со слоями коллизий система всё равно производит расчёты для коллизий. И это непозволяет решать какие-то задачи. В плюс к тому ресурсы в AR на мобильных телефонах или в VR на standalone системах (типа Oculus Quest) довольно ограничены. Поэтому экономить приходится на всём.

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

Пишем своё или зачем нужно знать математику

Последнее время под своими старыми выступлениями я видел комментарий: “Зачем нужно знать математику?”, “Математика в игровой индустрии не нужна”. Я скажу так. На данный момент, если мы говорим про высшую математику, игровая индустрия - это одна из тех индустрий, где она сильно упрощает жизнь. Не существует лишних знаний, а математика позволяет решать огромное число задач проще и лучше. Поэтому математику изучать и разбираться в ней - это очень полезный навык, который выводит вас, как разработчика, на несколько иной уровень. Причём хотя бы не на уровне доказательства теорем, понимания гипотез математики и т.п. А хотя бы на уровне чтения и понимания математических текстов и превращения их в алгоритмы. Так как далеко не всё из существующего написано.

В данной задаче - это конечно в меньшей степени необходимо, но в разы удобнее понимать, что ты пишешь. Собственно в своей системе нам так же понадобятся коллайдеры.

Сферический коллайдер

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

В контексте Unity проверка пересечения будет выглядеть вот так:

public override bool CheckIntersection (Ray ray, out Vector3 hitPosition)
{
  var pos =  transform.position + center;
  var originCenterVector = ray.origin - pos;
  var direction = ray.direction;

  float a = Vector3.Dot(direction, direction);
  float b = 2f * Vector3.Dot(originCenterVector, direction);
  float c = Vector3.Dot(originCenterVector, originCenterVector) - raduis * raduis;

  float discriminant = b * b - 4 * a * c;

  if (discriminant < 0)
  {
  hitPosition = new Vector3();
  return false;
  }

  float numerator = -b + Mathf.Sqrt(discriminant);
  hitPosition = ray.origin + ray.direction * numerator;
  if (numerator > 0) return true;

  numerator = -b - Mathf.Sqrt(discriminant);
  hitPosition = ray.origin + ray.direction * numerator;
  if (numerator > 0) return true;

  hitPosition = new Vector3();
  return false;
}

Из аналитической геометрии мы знаем, что если у нас есть сфера с центром (x0, y0, z0) и радиус r, то все точки (x, y, z) находящиеся на этой сфере можно описать, как:

(x - x0)^2 + (y - y0)^2 + (z - z0)^2 = r^2

запись в векторной форме этого выражения будет выглядеть

||P - C||^2 = r^2

 где P - это точка на сфере, а C - это точка центра сферы.

Что эквивалентно

dot(P-C, P-C) = r^2

где функция dot - это скалярное произведение и для него в Unity3d есть функция Vector3.Dot.

Уравнение же прямой выглядит, как

p(t) = o + t * d

где p(t) - это точка на прямой, o - это начало луча, а d - это направление луча.

При существовании пересечения окружности и луча. P = p(t). И подставив всё в исходное уравнение окружности и раскрыв все скобки мы получим уравнение вида.

t^2 * dot(d, d) + 2t * dot(d, o - C) + dot (o - C, o - C)  - r^2 = 0;

что в свою очередь является стандартным квадратным уравнением вида:

a*t^2 + b*t + c = 0;

где

a = dot(d, d); b = 2 * dot(d, o - c);c = doc(o - c, o - c) - r^2

или если записать чуть более читаемо

a = dot(direction, direction);b = 2 * dot(direction, origin - center);c = doc(origin  - center, origin - center) - radius^2

и мы приходим к старому доброму школьному дискриминанту, что t так же равно

t = (-b +- sqrt(b^2 - 4ac)) / 2a

Мы знаем из математики, что если:

Дискриминант меньше нуля, то луч не пересекает сферу (так как не существует решений уравнения)

Дискриминант равен 0, то луч касается сферы в одной точке (касательная, ровно одно решение)

Дискриминант больше 0, луч пересекает сферу в двух точках (два решения)

Это уже спокойно превращается в код выше.

Для кубического коллайдера код можно посмотреть в репозитории и разобрать самостоятельно. Опирался на эту статью, но на данный момент там есть проблема с поворотами, так как куб описывается, как 2 точки угла куба. https://www.researchgate.net/publication/220494140_An_Efficient_and_Robust_Ray-Box_Intersection_Algorithm

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

Хватит бороться с Unity

Итак, коллайдеры есть. А что по лучам? На самом деле для меня самое странное во многих плагинах и библиотеках под Unity, что многие не используют фишки Unity, а пишут своё поверх Unity. Мы так делать конечно же не будем. В юнити есть замечательная вещь под названием EventSystem, про примеры использования которой я писал в этой статье. Но из важного для нашей системы она позволяет вешать на камеру рейкастеры, которые работают с интерфейсами IPointerDownHadler и т.п. на объектах. И ничего не мешает создать новый рейкастер под нашу систему рейкастов.

Мы просто наследуемся от класса BaseRaycaster реализуем метод Raycast, заполняя правильно List<RaycastResult> resultAppendList и всё, наша физика работает с EventSystem.

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;

[RequireComponent(typeof(Camera))]
public class SimpleRaycaster : BaseRaycaster
{
	protected Camera m_EventCamera;
	public override Camera eventCamera
	{
		get
		{
			if (m_EventCamera == null)
				m_EventCamera = GetComponent<Camera>();
			return m_EventCamera ? m_EventCamera : Camera.main;
		}
	}
	protected bool ComputeRayAndDistance(PointerEventData eventData, ref Ray ray, ref int eventDisplayIndex, ref float distanceToClipPlane)
	{
		if (eventCamera == null)
			return false;

		var eventPosition = Display.RelativeMouseAt(eventData.position);
		if (eventPosition != Vector3.zero)
		{
			// We support multiple display and display identification based on event position.
			eventDisplayIndex = (int)eventPosition.z;

			// Discard events that are not part of this display so the user does not interact with multiple displays at once.
			if (eventDisplayIndex != eventCamera.targetDisplay)
				return false;
		}
		else
		{
			// The multiple display system is not supported on all platforms, when it is not supported the returned position
			// will be all zeros so when the returned index is 0 we will default to the event data to be safe.
			eventPosition = eventData.position;
		}

		// Cull ray casts that are outside of the view rect. (case 636595)
		if (!eventCamera.pixelRect.Contains(eventPosition))
			return false;

		ray = eventCamera.ScreenPointToRay(eventPosition);
		// compensate far plane distance - see MouseEvents.cs
		float projectionDirection = ray.direction.z;
		distanceToClipPlane = Mathf.Approximately(0.0f, projectionDirection)
			? Mathf.Infinity
			: Mathf.Abs((eventCamera.farClipPlane - eventCamera.nearClipPlane) / projectionDirection);
		return true;
	}
	public override void Raycast (PointerEventData eventData, List<RaycastResult> resultAppendList)
	{
		var ray = new Ray();
		int displayIndex = 0;
		float distanceToClipPlane = 0;
		if (!ComputeRayAndDistance(eventData, ref ray, ref displayIndex, ref distanceToClipPlane))
			return;
		IEnumerable<SimpleRaycastHit> hits;
		SimpleRaycastSystem.RaycastAll(ray, out hits);
		if (hits != null)
		{
			foreach (var raycastHit in hits)
			{
				resultAppendList.Add(new RaycastResult()
				{
					gameObject = raycastHit.collider.gameObject,
					module = this,
					distance = raycastHit.distance,
					worldPosition = raycastHit.point,
					worldNormal = Vector3.zero,
					screenPosition = eventData.position,
					displayIndex = displayIndex,
					index = resultAppendList.Count,
					sortingLayer = 0,
					sortingOrder = 0
				});
			}
		}
	}
}

Метод ComputeRayAndDistance формирования луча и расчёта индекса дисплея нужен для поддержки нескольких экранов. В целом в Unity много крутых систем, и я очень рекомендую изучить то, как они кастомизируются.

А что по производительности?

Я сделал несколько тестов для демонстрации работы данного подхода.

500 элементов

5000 элементов

10000 элементов

Выигрыш на большом числе объектов (учитывая что в видео ещё на перфоманс влияет Unity Recorder) довольно ощутимый. Самостоятельно можно посмотреть и протестировать в репозитории на своём железе. Но конечно же система даже на CPU работает быстрее, чем PhysX в подобном случае.

А можно ли сделать быстрее?

Да, это довольно простая система, которая была написана за несколько часов, в которой на данный момент даже не весь функционал поддерживается. Но путей к улучшению здесь ещё много. В первую очередь конечно же логика обхода коллайдеров. Сейчас система работает на линейной скорости, что неплохо, но можно в разы лучше. Для этого нужно интегрировать технику под названием “двоичное разбиение пространства” (Binary Space Partitioning или BSP). Построение BSP-дерева по сути позволяет нам в случае огромного числа объектов увеличить скорость обсчёта коллизий за счёт лучшего разбиения пространства. Но тут уже в ход идут нюансы. Чтобы оно работало действительно быстрее нужно либо формировать дерево в ручную, когда объекты (в случае графа актуально) нашли точку равновесия и больше не перемещаются. Либо же писать алгоритм динамического изменения дерева, что в общем добавит нагрузки на систему и в этом случае уже нужно искать некий баланс между алгоритмом обновления BSP дерева и поиском рейкастов. В целом BSP это довольно полезная штука. Она используется для occlusion culling и других вещей в трёхмерной графике.

Либо же вторая оптимизация под названием - компьют шейдеры . Так как структура задачи позволяет считать нам результат коллизии параллельно и довольно оптимально простыми операциями на GPU, то это тоже один из путей, как выжать максимум скорости. Проблема данного подхода по сути в поддержке платформами, тот же WebGL не поддерживает compute shader. Но компьют шейдеры - это очень крутой инструмент, и если появится время то я постараюсь раскрыть эту тему отдельно.

В заключении

Полное решение вы можете найти по ссылке. Может кому-то пригодится и будет полезно, если вдруг нужно будет визуализировать огромный массив данных. Надеюсь появится время написать ещё про интересную укладку графов в трёхмерном пространстве и компьют шейдеры, если кому-это данная тема интересна.

Спасибо за внимание! Если у вас есть идеи на тему того, как можно ещё интересно обыграть подобную задачу - добро пожаловать в комментарии.

Теги: physxraycastрейкаступрощённые рейкастыфизикаphysicsunity3dunityюнитиevent system
Хабы: Разработка игр C# Unity Разработка под AR и VR
Всего голосов 2: ↑2 и ↓0 +2
Комментарии 0
Комментарии Комментировать

Похожие публикации

Лучшие публикации за сутки