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

В заключении

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

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

Комментарии (1)


  1. KonH
    12.10.2021 15:54

    Спасибо за статью!

    Отправил PR чтобы этим можно было пользоваться как Unity пакетом, должно быть полезно если планируете развивать - https://github.com/Nox7atra/SimpleRaycastSystem/pull/1