Упрощённые рейкасты в Unity
Всем привет, меня зовут Григорий Дядиченко, и я технический продюсер. Недавно я столкнулся с одной интересной задачкой в ходе реализации проекта, и подумал что стоит наверное рассказать про физику в 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) находящиеся на этой сфере можно описать, как:
запись в векторной форме этого выражения будет выглядеть
где P - это точка на сфере, а C - это точка центра сферы.
Что эквивалентно
где функция dot - это скалярное произведение и для него в Unity3d есть функция Vector3.Dot.
Уравнение же прямой выглядит, как
где p(t) - это точка на прямой, o - это начало луча, а d - это направление луча.
При существовании пересечения окружности и луча. P = p(t). И подставив всё в исходное уравнение окружности и раскрыв все скобки мы получим уравнение вида.
что в свою очередь является стандартным квадратным уравнением вида:
где
или если записать чуть более читаемо
и мы приходим к старому доброму школьному дискриминанту, что t так же равно
Мы знаем из математики, что если:
Дискриминант меньше нуля, то луч не пересекает сферу (так как не существует решений уравнения)
Дискриминант равен 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. Но компьют шейдеры - это очень крутой инструмент, и если появится время то я постараюсь раскрыть эту тему отдельно.
В заключении
Полное решение вы можете найти по ссылке. Может кому-то пригодится и будет полезно, если вдруг нужно будет визуализировать огромный массив данных. Надеюсь появится время написать ещё про интересную укладку графов в трёхмерном пространстве и компьют шейдеры, если кому-это данная тема интересна.
Спасибо за внимание! Если у вас есть идеи на тему того, как можно ещё интересно обыграть подобную задачу - добро пожаловать в комментарии.