Хабрахабр

Физика для мобильного PvP шутера, или как мы из двумерной игру в трёхмерную переделывали

А теперь я хочу поделиться тем, как мы выкинули всё, что делали до этого, и начали с нуля ― иными словами, как мы перевели нашу игру из 2D-мира в 3D.
Всё началось с того, что как-то раз к нам в отдел программистов пришли продюсер и ведущий геймдизайнер поставили перед нами челлендж: мобильный PvP Top-Down шутер с перестрелками в замкнутых пространствах надо было переделать в шутер от третьего лица со стрельбой на открытой местности. В предыдущей статье мой коллега рассказал о том, как мы использовали двумерный физический движок в нашем мобильном мультиплеерном шутере. При этом желательно, чтобы карта выглядела не так:

А так:

Технические требования при этом выглядели следующим образом:

  • размер карты ― 100×100 метров;
  • перепад высот ― 40 метров;
  • поддержка туннелей, мостов;
  • стрельба по целям, находящимся на разной высоте;
  • коллизии со статической геометрией (коллизии с другими персонажами в игре у нас отсутствуют);
  • физика свободного падения с высоты;
  • физика броска гранаты.

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

Вариант первый: слоистая структура

Первой была предложена идея не менять физический движок, а просто добавить несколько слоев «этажности» уровней. Получалось что-то вроде планов этажей в здании:

Однако при попытке реализовать его мы столкнулись с несколькими критическими проблемами: При подобном подходе нам не нужно было радикально переделывать ни клиентское, ни серверное приложение, и вообще казалось, что таким образом задача решается довольно просто.

  1. После уточнения деталей у левел-дизайнеров мы пришли к выводу, что количество «этажей» в такой схеме может оказаться внушительным: часть карт располагается на открытой местности с пологими склонами и холмами.
  2. Расчёт попаданий при стрельбе с одного слоя в другой становился нетривиальной задачей. Пример проблемной ситуации изображен на рисунке ниже: здесь игрок 1 может попасть в игрока 3, но не в игрока 2, так как путь выстрела преграждает слой 2, хотя при этом и игрок 2, и игрок 3 находятся на одном слое.

Словом, от идеи разбивать пространство на 2D-слои мы отказались быстро ― и решили, что будем действовать посредством полной замены физического движка.

Что привело нас к необходимости выбрать этот самый движок и встроить его в существующие приложения клиента и сервера.

Вариант второй: выбор готовой библиотеки

Так как клиент игры у нас написан на Unity, мы решили рассмотреть возможность использования того физического движка, который встроен в Unity по умолчанию ― PhysX. В целом он полностью удовлетворял требованиям наших геймдизайнеров по поддержке 3D-физики в игре, но всё же была и существенная проблема. Заключалась она в том, что наше серверное приложение было написано на C# без использования Unity.

Также смущала низкая производительность Interop операций и уникальность сборки PhysX чисто под Unity, исключающая использование его в другой среде. Был вариант использования C++ библиотеки на сервере ― например, того же PhysX, ― но всерьёз мы его не рассматривали: из-за использования нативного кода при таком подходе была высокая вероятность падения серверов.

Помимо этого, в попытке внедрить эту идею обнаружились и другие проблемы:

  • отсутствие поддержки для сборки Unity с IL2CPP на Linux, что оказалось довольно критичным, поскольку в одном из последних релизов мы перевели наши игровые сервера на .Net Core 2.1 и разворачивали их на машинах с Linux;
  • отсутствие удобных инструментов для профилирования серверов на Unity;
  • низкая производительность приложения на Unity: нам требовался только физический движок, а не весь имеющийся функционал в Unity.

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

Тогда мы решили оставить игровые сервера на . Совокупность перечисленных проблем заставила нас отказаться и от этой идеи тоже. 1 и подобрать вместо VolatilePhysics, использованного нами ранее, другой открытый физический движок, написанный на C#. Net Core 2. А именно движок на C# нам потребовался, так как мы опасались непредвиденных крашей при использовании движков, написанных на C++.

В результате для тестов были отобраны следующие движки:

Основными критериями для нас являлись производительность движка, возможность его интеграции в Unity и его поддерживаемость: он не должен был оказаться заброшенным на случай, если мы найдём в нём какие-то баги.

К тому же, он единственный из этой тройки всё ещё продолжает активно развиваться. Итак, мы протестировали движки Bepu Physics v1, Bepu Physics v2 и Jitter Physics на производительность, и среди них наиболее производительным показал себя Bepu Physics v2.

Numerics, и поскольку при сборках на мобильные устройства с IL2CPP в Unity нет поддержки SIMD, все преимущества оптимизаций Bepu терялись. Однако последнему оставшемуся критерию интеграции с Unity Bepu Physics v2 не удовлетворял: эта библиотека использует SIMD-операции и System. Мы не могли использовать это решение на мобильных устройствах. Demo-сцена в билде на iOS на iPhone 5S сильно тормозила.

В одной из своих предыдущих статей я рассказывал о том, как у нас реализована сетевая часть игры и как работает локальное предсказание действий игрока. Тут следует пояснить, почему нас вообще интересовало использование физического движка. Клиент реагирует на действия игрока моментально, не дожидаясь ответа от сервера, ― происходит так называемое предсказание (prediction). Если вкратце, то на клиенте и на сервере исполняется один и тот же код ― система ECS. Когда с сервера приходит ответ, клиент сверяет предсказанное состояние мира с полученным, и если они не совпадают (misprediction), то на основе ответа с сервера выполняется коррекция (reconciliation) того, что видит игрок.

Однако ни один из найденных нами физических движков на C# не удовлетворял нашим требованиям при работе на мобильных устройствах: например, не мог обеспечить стабильную работу 30 fps на iPhone 5S. Основная идея заключается в том, что мы исполняем один и тот же код как на клиенте, так и на сервере, и ситуации с misprediction происходят крайне редко.

Вариант третий, финальный: два разных движка

Тогда мы решились на эксперимент: использовать два разных физических движка на клиенте и сервере. Мы посчитали, что в нашем случае это может сработать: у нас в игре довольно простая физика коллизий, к тому же она была реализована нами как отдельная система ECS и не являлась частью физического движка. Всё, что нам требовалось от физического движка ― это возможность делать рейкасты и свипкасты в 3D-пространстве.

В результате мы решили использовать встроенную физику Unity ― PhysX ― на клиенте и Bepu Physics v2 на сервере.

В первую очередь мы выделили интерфейс для использования физического движка:

Посмотреть код

using System;
using System.Collections.Generic;
using System.Numerics; namespace Prototype.Common.Physics

}

На клиенте и сервере были разные реализации этого интерфейса: как уже говорилось, на сервере мы использовали реализацию с Bepu, а на клиенте ― Unity.

Здесь стоит упомянуть о нюансах работы с нашей физикой на сервере.

Из-за этого получается, что игрок локально стреляет в цель, которая находится на сервере в другом месте. Из-за того, что клиент получает обновления мира с сервера с задержкой (лагом), игрок видит мир немного не таким, каким он представляется на сервере: себя он видит в настоящем, а весь остальной мир — в прошлом. Так что, поскольку мы используем систему предсказания действий локального игрока, нам необходимо компенсировать лаги при стрельбе на сервере.

То есть, наша система должна уметь рассчитывать столкновения, рейкасты и свипкасты «в прошлом». Для того, чтобы их компенсировать, нам необходимо хранить на сервере историю мира за последние N миллисекунд, а также уметь работать с объектами из истории, включая их физику. Поэтому нам пришлось реализовать такой функционал самостоятельно. Как правило, физические движки не умеют этого делать, и Bepu с PhysX не исключение.

Идея заключалась в том чтобы создавать не один экземпляр симуляции в физическом движке, а N ― на каждый тик, хранящийся в истории, ― и использовать циклический буфер этих симуляций для их хранения в истории: Так как симуляция игры у нас происходит с фиксированной частотой ― 30 тиков в секунду, ― нам нужно было сохранять данные физического мира за каждый тик.

private readonly SimulationSlice[] _simulationHistory = new SimulationSlice[PhysicsConfigs.HistoryLength]; public BepupPhysicsWorld() { _currentSimulationTick = 1; for (int i = 0; i < PhysicsConfigs.HistoryLength; i++) { _simulationHistory[i] = new SimulationSlice(_bufferPool); } }

В нашей ECS существует ряд read-write систем, работающих с физикой:

  • InitPhysicsWorldSystem;
  • SpawnPhysicsDynamicsBodiesSystem;
  • DestroyPhysicsDynamicsBodiesSystem;
  • UpdatePhysicsTransformsSystem;
  • MovePhysicsSystem,

а также ряд read-only систем, таких как система расчёта попаданий выстрелов, взрывов от гранат и т. д.

На каждом тике симуляции мира первой исполняется InitPhysicsWorldSystem, которая устанавливает физическому движку текущий номер тика (SimulationSlice):

public void SetCurrentSimulationTick(int tick)
{ var oldTick = tick - 1; var newSlice = _simulationHistory[tick % PhysicsConfigs.HistoryLength]; var oldSlice = _simulationHistory[oldTick % PhysicsConfigs.HistoryLength]; newSlice.RestoreBodiesFromPreviousTick(oldSlice); _currentSimulationTick = tick;
}

Метод RestoreBodiesFromPreviousTick восстанавливает положение объектов в физическом движке на момент предыдущего тика из данных, хранящихся в истории:

Посмотреть код

public void RestoreBodiesFromPreviousTick(SimulationSlice previous)
{ var oldStaticCount = previous._staticIds.Count; // add created static objects for (int i = 0; i < oldStaticCount; i++) { var oldId = previous._staticIds[i]; if (!_staticIds.Contains(oldId)) { var oldHandler = previous._staticIdToHandler[oldId]; var oldBody = previous._staticHandlerToBody[oldHandler]; if (oldBody.IsCapsule) { var handler = CreateStatic(oldBody.Capsule, oldBody.Description.Pose, true, oldId, oldBody.CollisionLayer); var body = _staticHandlerToBody[handler]; body.Capsule = oldBody.Capsule; _staticHandlerToBody[handler] = body; } else { var handler = CreateStatic(oldBody.Box, oldBody.Description.Pose, false, oldId, oldBody.CollisionLayer); var body = _staticHandlerToBody[handler]; body.Box = oldBody.Box; _staticHandlerToBody[handler] = body; } } } // delete not existing dynamic objects var newDynamicCount = _dynamicIds.Count; var idsToDel = stackalloc uint[_dynamicIds.Count]; int delIndex = 0; for (int i = 0; i < newDynamicCount; i++) { var newId = _dynamicIds[i]; if (!previous._dynamicIds.Contains(newId)) { idsToDel[delIndex] = newId; delIndex++; } } for (int i = 0; i < delIndex; i++) { var id = idsToDel[i]; var handler = _dynamicIdToHandler[id]; _simulation.Bodies.Remove(handler); _dynamicHandlerToBody.Remove(handler); _dynamicIds.Remove(id); _dynamicIdToHandler.Remove(id); } // add created dynamic objects var oldDynamicCount = previous._dynamicIds.Count; for (int i = 0; i < oldDynamicCount; i++) { var oldId = previous._dynamicIds[i]; if (!_dynamicIds.Contains(oldId)) { var oldHandler = previous._dynamicIdToHandler[oldId]; var oldBody = previous._dynamicHandlerToBody[oldHandler]; if (oldBody.IsCapsule) { var handler = CreateDynamic(oldBody.Capsule, oldBody.BodyReference.Pose, true, oldId, oldBody.CollisionLayer); var body = _dynamicHandlerToBody[handler]; body.Capsule = oldBody.Capsule; _dynamicHandlerToBody[handler] = body; } else { var handler = CreateDynamic(oldBody.Box, oldBody.BodyReference.Pose, false, oldId, oldBody.CollisionLayer); var body = _dynamicHandlerToBody[handler]; body.Box = oldBody.Box; _dynamicHandlerToBody[handler] = body; } } }
}

После этого системы SpawnPhysicsDynamicsBodiesSystem и DestroyPhysicsDynamicsBodiesSystem создают или удаляют объекты в физическом движке в соответствии с тем, как они были изменены в прошлом тике ECS. Затем система UpdatePhysicsTransformsSystem обновляет положение всех динамических тел в соответствии с данными в ECS.

Когда все read-write операции оказываются пройдены, в ход вступают read-only системы по расчёту игровой логики (выстрелов, взрывов, тумана войны...) Как только данные в ECS и физическом движке оказываются синхронизированы, мы выполняем расчёт движения объектов.

Полный код реализации SimulationSlice для Bepu Physics:

Посмотреть код

using System;
using System.Collections.Generic;
using System.Numerics;
using BepuPhysics;
using BepuPhysics.Collidables;
using BepuUtilities.Memory;
using Quaternion = BepuUtilities.Quaternion; namespace Prototype.Physics
{ public partial class BepupPhysicsWorld { private unsafe partial class SimulationSlice : IDisposable { private readonly Dictionary<int, StaticBody> _staticHandlerToBody = new Dictionary<int, StaticBody>(); private readonly Dictionary<int, DynamicBody> _dynamicHandlerToBody = new Dictionary<int, DynamicBody>(); private readonly Dictionary<uint, int> _staticIdToHandler = new Dictionary<uint, int>(); private readonly Dictionary<uint, int> _dynamicIdToHandler = new Dictionary<uint, int>(); private readonly List<uint> _staticIds = new List<uint>(); private readonly List<uint> _dynamicIds = new List<uint>(); private readonly BufferPool _bufferPool; private readonly Simulation _simulation; public SimulationSlice(BufferPool bufferPool) { _bufferPool = bufferPool; _simulation = Simulation.Create(_bufferPool, new NarrowPhaseCallbacks(), new PoseIntegratorCallbacks(new Vector3(0, -9.81f, 0))); } public RayCastHit RayCast(Vector3 origin, Vector3 direction, float distance, CollisionLayer layer, List<uint> ignoreIds=null) { direction = direction.Normalized(); BepupRayCastHitHandler handler = new BepupRayCastHitHandler(_staticHandlerToBody, _dynamicHandlerToBody, layer, ignoreIds); _simulation.RayCast(origin, direction, distance, ref handler); var result = handler.RayCastHit; if (result.IsValid) { var collidableReference = handler.CollidableReference; if (handler.CollidableReference.Mobility == CollidableMobility.Static) { _simulation.Statics.GetDescription(collidableReference.Handle, out var description); result.HitEntityId = _staticHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = description.Pose.Position; } else { _simulation.Bodies.GetDescription(collidableReference.Handle, out var description); result.HitEntityId = _dynamicHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = description.Pose.Position; } } return result; } public RayCastHit SphereCast(Vector3 origin, Vector3 direction, float distance, float radius, CollisionLayer layer, List<uint> ignoreIds = null) { direction = direction.Normalized(); SweepCastHitHandler handler = new SweepCastHitHandler(_staticHandlerToBody, _dynamicHandlerToBody, layer, ignoreIds); _simulation.Sweep(new Sphere(radius), new RigidPose(origin, Quaternion.Identity), new BodyVelocity(direction.Normalized()), distance, _bufferPool, ref handler); var result = handler.RayCastHit; if (result.IsValid) { var collidableReference = handler.CollidableReference; if (handler.CollidableReference.Mobility == CollidableMobility.Static) { _simulation.Statics.GetDescription(collidableReference.Handle, out var description); result.HitEntityId = _staticHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = description.Pose.Position; } else { var reference = new BodyReference(collidableReference.Handle, _simulation.Bodies); result.HitEntityId = _dynamicHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = reference.Pose.Position; } } return result; } public RayCastHit CapsuleCast(Vector3 origin, Vector3 direction, float distance, float radius, float height, CollisionLayer layer, List<uint> ignoreIds = null) { direction = direction.Normalized(); var length = height - 2 * radius; SweepCastHitHandler handler = new SweepCastHitHandler(_staticHandlerToBody, _dynamicHandlerToBody, layer, ignoreIds); _simulation.Sweep(new Capsule(radius, length), new RigidPose(origin, Quaternion.Identity), new BodyVelocity(direction.Normalized()), distance, _bufferPool, ref handler); var result = handler.RayCastHit; if (result.IsValid) { var collidableReference = handler.CollidableReference; if (handler.CollidableReference.Mobility == CollidableMobility.Static) { _simulation.Statics.GetDescription(collidableReference.Handle, out var description); result.HitEntityId = _staticHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = description.Pose.Position; } else { var reference = new BodyReference(collidableReference.Handle, _simulation.Bodies); result.HitEntityId = _dynamicHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = reference.Pose.Position; } } return result; } public void CapsuleOverlap(Vector3 origin, float radius, float height, BodyMobilityField bodyMobilityField, CollisionLayer layer, List<Overlap> overlaps) { var length = height - 2 * radius; var handler = new BepupOverlapHitHandler( bodyMobilityField, layer, _staticHandlerToBody, _dynamicHandlerToBody, overlaps); _simulation.Sweep( new Capsule(radius, length), new RigidPose(origin, Quaternion.Identity), new BodyVelocity(Vector3.Zero), 0, _bufferPool, ref handler); } public void CreateDynamicBox(Vector3 origin, Quaternion rotation, Vector3 size, uint id, CollisionLayer layer) { var shape = new Box(size.X, size.Y, size.Z); var pose = new RigidPose() { Position = origin, Orientation = rotation }; var handler = CreateDynamic(shape, pose, false, id, layer); var body = _dynamicHandlerToBody[handler]; body.Box = shape; _dynamicHandlerToBody[handler] = body; } public void CreateStaticBox(Vector3 origin, Quaternion rotation, Vector3 size, uint id, CollisionLayer layer) { var shape = new Box(size.X, size.Y, size.Z); var pose = new RigidPose() { Position = origin, Orientation = rotation }; var handler =CreateStatic(shape, pose, false, id, layer); var body = _staticHandlerToBody[handler]; body.Box = shape; _staticHandlerToBody[handler] = body; } public void CreateStaticCapsule(Vector3 origin, Quaternion rotation, float radius, float height, uint id, CollisionLayer layer) { var length = height - 2 * radius; var shape = new Capsule(radius, length); var pose = new RigidPose() { Position = origin, Orientation = rotation }; var handler =CreateStatic(shape, pose, true, id, layer); var body = _staticHandlerToBody[handler]; body.Capsule = shape; _staticHandlerToBody[handler] = body; } public void CreateDynamicCapsule(Vector3 origin, Quaternion rotation, float radius, float height, uint id, CollisionLayer layer) { var length = height - 2 * radius; var shape = new Capsule(radius, length); var pose = new RigidPose() { Position = origin, Orientation = rotation }; var handler = CreateDynamic(shape, pose, true, id, layer); var body = _dynamicHandlerToBody[handler]; body.Capsule = shape; _dynamicHandlerToBody[handler] = body; } private int CreateDynamic<TShape>(TShape shape, RigidPose pose, bool isCapsule, uint id, CollisionLayer collisionLayer) where TShape : struct, IShape { var activity = new BodyActivityDescription() { SleepThreshold = -1 }; var collidable = new CollidableDescription() { Shape = _simulation.Shapes.Add(shape), SpeculativeMargin = 0.1f, }; var capsuleDescription = BodyDescription.CreateKinematic(pose, collidable, activity); var handler = _simulation.Bodies.Add(capsuleDescription); _dynamicIds.Add(id); _dynamicIdToHandler.Add(id, handler); _dynamicHandlerToBody.Add(handler, new DynamicBody { BodyReference = new BodyReference(handler, _simulation.Bodies), Id = id, IsCapsule = isCapsule, CollisionLayer = collisionLayer }); return handler; } private int CreateStatic<TShape>(TShape shape, RigidPose pose, bool isCapsule, uint id, CollisionLayer collisionLayer) where TShape : struct, IShape { var capsuleDescription = new StaticDescription() { Pose = pose, Collidable = new CollidableDescription() { Shape = _simulation.Shapes.Add(shape), SpeculativeMargin = 0.1f, } }; var handler = _simulation.Statics.Add(capsuleDescription); _staticIds.Add(id); _staticIdToHandler.Add(id, handler); _staticHandlerToBody.Add(handler, new StaticBody { Description = capsuleDescription, Id = id, IsCapsule = isCapsule, CollisionLayer = collisionLayer }); return handler; } public void RemoveOrphanedDynamicBodies(TableSet currentWorld) { var toDel = stackalloc uint[_dynamicIds.Count]; var toDelIndex = 0; foreach (var i in _dynamicIdToHandler) { if (currentWorld.DynamicPhysicsBody.HasCmp(i.Key)) { continue; } toDel[toDelIndex] = i.Key; toDelIndex++; } for (int i = 0; i < toDelIndex; i++) { var id = toDel[i]; var handler = _dynamicIdToHandler[id]; _simulation.Bodies.Remove(handler); _dynamicHandlerToBody.Remove(handler); _dynamicIds.Remove(id); _dynamicIdToHandler.Remove(id); } } public bool HasBody(uint id) { return _staticIdToHandler.ContainsKey(id) || _dynamicIdToHandler.ContainsKey(id); } public void RestoreBodiesFromPreviousTick(SimulationSlice previous) { var oldStaticCount = previous._staticIds.Count; // add created static objects for (int i = 0; i < oldStaticCount; i++) { var oldId = previous._staticIds[i]; if (!_staticIds.Contains(oldId)) { var oldHandler = previous._staticIdToHandler[oldId]; var oldBody = previous._staticHandlerToBody[oldHandler]; if (oldBody.IsCapsule) { var handler = CreateStatic(oldBody.Capsule, oldBody.Description.Pose, true, oldId, oldBody.CollisionLayer); var body = _staticHandlerToBody[handler]; body.Capsule = oldBody.Capsule; _staticHandlerToBody[handler] = body; } else { var handler = CreateStatic(oldBody.Box, oldBody.Description.Pose, false, oldId, oldBody.CollisionLayer); var body = _staticHandlerToBody[handler]; body.Box = oldBody.Box; _staticHandlerToBody[handler] = body; } } } // delete not existing dynamic objects var newDynamicCount = _dynamicIds.Count; var idsToDel = stackalloc uint[_dynamicIds.Count]; int delIndex = 0; for (int i = 0; i < newDynamicCount; i++) { var newId = _dynamicIds[i]; if (!previous._dynamicIds.Contains(newId)) { idsToDel[delIndex] = newId; delIndex++; } } for (int i = 0; i < delIndex; i++) { var id = idsToDel[i]; var handler = _dynamicIdToHandler[id]; _simulation.Bodies.Remove(handler); _dynamicHandlerToBody.Remove(handler); _dynamicIds.Remove(id); _dynamicIdToHandler.Remove(id); } // add created dynamic objects var oldDynamicCount = previous._dynamicIds.Count; for (int i = 0; i < oldDynamicCount; i++) { var oldId = previous._dynamicIds[i]; if (!_dynamicIds.Contains(oldId)) { var oldHandler = previous._dynamicIdToHandler[oldId]; var oldBody = previous._dynamicHandlerToBody[oldHandler]; if (oldBody.IsCapsule) { var handler = CreateDynamic(oldBody.Capsule, oldBody.BodyReference.Pose, true, oldId, oldBody.CollisionLayer); var body = _dynamicHandlerToBody[handler]; body.Capsule = oldBody.Capsule; _dynamicHandlerToBody[handler] = body; } else { var handler = CreateDynamic(oldBody.Box, oldBody.BodyReference.Pose, false, oldId, oldBody.CollisionLayer); var body = _dynamicHandlerToBody[handler]; body.Box = oldBody.Box; _dynamicHandlerToBody[handler] = body; } } } } public void Update() { _simulation.Timestep(GameState.TickDurationSec); } public void UpdateBody(uint id, Vector3 position, float angle) { if (_staticIdToHandler.TryGetValue(id, out var handler)) { _simulation.Statics.GetDescription(handler, out var staticDescription); staticDescription.Pose.Position = position; staticDescription.Pose.Orientation = Quaternion.CreateFromAxisAngle(new Vector3(0, 1, 0), angle); _simulation.Statics.ApplyDescription(handler, staticDescription); } else if(_dynamicIdToHandler.TryGetValue(id, out handler)) { BodyReference reference = new BodyReference(handler, _simulation.Bodies); reference.Pose.Position = position; reference.Pose.Orientation = Quaternion.CreateFromAxisAngle(new Vector3(0, 1, 0), angle); } } public void Dispose() { _simulation.Clear(); } } public void Dispose() { _bufferPool.Clear(); } }
}

Также, помимо реализации истории на сервере, нам была необходима реализация истории физики на клиенте. В нашем клиенте на Unity есть режим эмуляции сервера ― мы называем его локальной симуляцией, ― в котором вместе с клиентом запускается код сервера. Этот режим у нас используется для быстрого прототипирования игровых фичей.

Здесь мы использовали ту же идею с использованием нескольких физических симуляций на каждый тик в истории, что и на сервере. Как и в Bepu, в PhysX нет поддержки истории. Впрочем, тут следует отметить, что наш проект разрабатывался на Unity 2018. Однако Unity накладывает свою специфику на работу с физическими движками. 4 (LTS), и какие-то API могут поменяться в более новых версиях, так что таких проблем, как у нас, и не возникнет.

Проблема заключалась в том, что Unity не позволял создать отдельно физическую симуляцию (или, в терминологии PhysX, ― сцену), поэтому каждый тик в истории физики на Unity мы реализовали как отдельную сцену.

Был написан класс-обёртка над такими сценами ― UnityPhysicsHistorySlice:

public UnityPhysicsHistorySlice(SphereCastDelegate sphereCastDelegate, OverlapSphereNonAlloc overlapSphere, CapsuleCastDelegate capsuleCast, OverlapCapsuleNonAlloc overlapCapsule, string name)
{ _scene = SceneManager.CreateScene(name, new CreateSceneParameters() { localPhysicsMode = LocalPhysicsMode.Physics3D }); _physicsScene = _scene.GetPhysicsScene(); _sphereCast = sphereCastDelegate; _capsuleCast = capsuleCast; _overlapSphere = overlapSphere; _overlapCapsule = overlapCapsule; _boxPool = new PhysicsSceneObjectsPool<BoxCollider>(_scene, "box", 0); _capsulePool = new PhysicsSceneObjectsPool<UnityEngine.CapsuleCollider>(_scene, "sphere", 0);
}

Вторая проблема Unity ― вся работа с физикой здесь ведётся через статический класс Physics, API которого не позволяет выполнять рейкасты и свипкасты в конкретной сцене. Этот API работает только с одной ― активной ― сценой. Однако сам движок PhysX позволяет работать с несколькими сценами одновременно, нужно только вызвать правильные методы. К счастью, Unity за интерфейсом класса Physics.cs прятала такие методы, оставалось лишь получить к ним доступ. Сделали мы это так:

Посмотреть код

MethodInfo raycastMethod = typeof(Physics).GetMethod("Internal_SphereCast", BindingFlags.NonPublic | BindingFlags.Static);
var sphereCast = (SphereCastDelegate) Delegate.CreateDelegate(typeof(SphereCastDelegate), raycastMethod); MethodInfo overlapSphereMethod = typeof(Physics).GetMethod("OverlapSphereNonAlloc_Internal", BindingFlags.NonPublic | BindingFlags.Static);
var overlapSphere = (OverlapSphereNonAlloc) Delegate.CreateDelegate(typeof(OverlapSphereNonAlloc), overlapSphereMethod); MethodInfo capsuleCastMethod = typeof(Physics).GetMethod("Internal_CapsuleCast", BindingFlags.NonPublic | BindingFlags.Static);
var capsuleCast = (CapsuleCastDelegate) Delegate.CreateDelegate(typeof(CapsuleCastDelegate), capsuleCastMethod); MethodInfo overlapCapsuleMethod = typeof(Physics).GetMethod("OverlapCapsuleNonAlloc_Internal", BindingFlags.NonPublic | BindingFlags.Static);
var overlapCapsule = (OverlapCapsuleNonAlloc) Delegate.CreateDelegate(typeof(OverlapCapsuleNonAlloc), overlapCapsuleMethod);

В остальном код реализации UnityPhysicsHistorySlice мало чем отличался от того, что было в BepuSimulationSlice.

Таким образом мы получили две реализации игровой физики: на клиенте и на сервере.

Следующий шаг ― тестирование.

До перехода на разные физические движки этот показатель варьировался в пределах 1-2% ― то есть, за бой длительностью 9000 тиков (или 5 минут) мы ошибались в 90-180 тиках симуляции. Одним из важнейших показателей «здоровья» нашего клиента является параметр количества расхождений (mispredictions) с сервером. После перехода на разные движки мы ожидали сильный рост этого показателя ― возможно, даже в несколько раз, ― ведь теперь мы исполняли разный код на клиенте и сервере, и казалось логичным, что погрешности при расчётах разными алгоритмами будут быстро накапливаться. Такие результаты мы получали на протяжении нескольких релизов игры в софт-лаунче. 2-0. На практике же оказалось, что параметр расхождений вырос лишь 0. 5% и в среднем стал составлять 2-2,5% за бой, что полностью нас устраивало.

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

Что почитать

В заключение, как обычно, приведём несколько ссылок по теме:

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть