![](https://habrastorage.org/webt/fo/if/az/foifazxdmxh_smqsswnkxhn0ww4.png)
В предыдущей статье мой коллега рассказал о том, как мы использовали двумерный физический движок в нашем мобильном мультиплеерном шутере. А теперь я хочу поделиться тем, как мы выкинули всё, что делали до этого, и начали с нуля ― иными словами, как мы перевели нашу игру из 2D-мира в 3D.
Всё началось с того, что как-то раз к нам в отдел программистов пришли продюсер и ведущий геймдизайнер поставили перед нами челлендж: мобильный PvP Top-Down шутер с перестрелками в замкнутых пространствах надо было переделать в шутер от третьего лица со стрельбой на открытой местности. При этом желательно, чтобы карта выглядела не так:
![](https://habrastorage.org/webt/he/j3/2p/hej32pvogui5sukjz842wjpyqry.png)
А так:
![](https://habrastorage.org/webt/n8/25/xw/n825xwkx_jsgqgoemkcor-r4ti0.jpeg)
Технические требования при этом выглядели следующим образом:
- размер карты ― 100×100 метров;
- перепад высот ― 40 метров;
- поддержка туннелей, мостов;
- стрельба по целям, находящимся на разной высоте;
- коллизии со статической геометрией (коллизии с другими персонажами в игре у нас отсутствуют);
- физика свободного падения с высоты;
- физика броска гранаты.
Забегая вперед, могу сказать, что на последний скриншот наша игра так и не стала похожа: получилось нечто среднее между первым и вторым вариантом.
Вариант первый: слоистая структура
Первой была предложена идея не менять физический движок, а просто добавить несколько слоев «этажности» уровней. Получалось что-то вроде планов этажей в здании:
![](https://habrastorage.org/webt/lz/dy/oq/lzdyoquthw76ajisyvgqcn7zc_i.jpeg)
При подобном подходе нам не нужно было радикально переделывать ни клиентское, ни серверное приложение, и вообще казалось, что таким образом задача решается довольно просто. Однако при попытке реализовать его мы столкнулись с несколькими критическими проблемами:
- После уточнения деталей у левел-дизайнеров мы пришли к выводу, что количество «этажей» в такой схеме может оказаться внушительным: часть карт располагается на открытой местности с пологими склонами и холмами.
- Расчёт попаданий при стрельбе с одного слоя в другой становился нетривиальной задачей. Пример проблемной ситуации изображен на рисунке ниже: здесь игрок 1 может попасть в игрока 3, но не в игрока 2, так как путь выстрела преграждает слой 2, хотя при этом и игрок 2, и игрок 3 находятся на одном слое.
![](https://habrastorage.org/webt/be/rg/be/bergbenv9gtcjzpkci26w_jmsik.png)
Словом, от идеи разбивать пространство на 2D-слои мы отказались быстро ― и решили, что будем действовать посредством полной замены физического движка.
Что привело нас к необходимости выбрать этот самый движок и встроить его в существующие приложения клиента и сервера.
Вариант второй: выбор готовой библиотеки
Так как клиент игры у нас написан на Unity, мы решили рассмотреть возможность использования того физического движка, который встроен в Unity по умолчанию ― PhysX. В целом он полностью удовлетворял требованиям наших геймдизайнеров по поддержке 3D-физики в игре, но всё же была и существенная проблема. Заключалась она в том, что наше серверное приложение было написано на C# без использования Unity.
Был вариант использования C++ библиотеки на сервере ― например, того же PhysX, ― но всерьёз мы его не рассматривали: из-за использования нативного кода при таком подходе была высокая вероятность падения серверов. Также смущала низкая производительность Interop операций и уникальность сборки PhysX чисто под Unity, исключающая использование его в другой среде.
Помимо этого, в попытке внедрить эту идею обнаружились и другие проблемы:
- отсутствие поддержки для сборки Unity с IL2CPP на Linux, что оказалось довольно критичным, поскольку в одном из последних релизов мы перевели наши игровые сервера на .Net Core 2.1 и разворачивали их на машинах с Linux;
- отсутствие удобных инструментов для профилирования серверов на Unity;
- низкая производительность приложения на Unity: нам требовался только физический движок, а не весь имеющийся функционал в Unity.
Кроме того, параллельно с нашим проектом в компании разрабатывался ещё один прототип мультиплеерной PvP-игры. Её разработчики использовали Unity-сервера, и мы получили довольно много негативного фидбека касательно предложенного подхода. В частности, одна из претензий заключалась в том, что Unity-сервера сильно «текут», и их приходится перезапускать каждые несколько часов.
Совокупность перечисленных проблем заставила нас отказаться и от этой идеи тоже. Тогда мы решили оставить игровые сервера на .Net Core 2.1 и подобрать вместо VolatilePhysics, использованного нами ранее, другой открытый физический движок, написанный на C#. А именно движок на C# нам потребовался, так как мы опасались непредвиденных крашей при использовании движков, написанных на C++.
В результате для тестов были отобраны следующие движки:
Основными критериями для нас являлись производительность движка, возможность его интеграции в Unity и его поддерживаемость: он не должен был оказаться заброшенным на случай, если мы найдём в нём какие-то баги.
Итак, мы протестировали движки Bepu Physics v1, Bepu Physics v2 и Jitter Physics на производительность, и среди них наиболее производительным показал себя Bepu Physics v2. К тому же, он единственный из этой тройки всё ещё продолжает активно развиваться.
Однако последнему оставшемуся критерию интеграции с Unity Bepu Physics v2 не удовлетворял: эта библиотека использует SIMD-операции и System.Numerics, и поскольку при сборках на мобильные устройства с IL2CPP в Unity нет поддержки SIMD, все преимущества оптимизаций Bepu терялись. Demo-сцена в билде на iOS на iPhone 5S сильно тормозила. Мы не могли использовать это решение на мобильных устройствах.
Тут следует пояснить, почему нас вообще интересовало использование физического движка. В одной из своих предыдущих статей я рассказывал о том, как у нас реализована сетевая часть игры и как работает локальное предсказание действий игрока. Если вкратце, то на клиенте и на сервере исполняется один и тот же код ― система ECS. Клиент реагирует на действия игрока моментально, не дожидаясь ответа от сервера, ― происходит так называемое предсказание (prediction). Когда с сервера приходит ответ, клиент сверяет предсказанное состояние мира с полученным, и если они не совпадают (misprediction), то на основе ответа с сервера выполняется коррекция (reconciliation) того, что видит игрок.
Основная идея заключается в том, что мы исполняем один и тот же код как на клиенте, так и на сервере, и ситуации с misprediction происходят крайне редко. Однако ни один из найденных нами физических движков на C# не удовлетворял нашим требованиям при работе на мобильных устройствах: например, не мог обеспечить стабильную работу 30 fps на iPhone 5S.
Вариант третий, финальный: два разных движка
Тогда мы решились на эксперимент: использовать два разных физических движка на клиенте и сервере. Мы посчитали, что в нашем случае это может сработать: у нас в игре довольно простая физика коллизий, к тому же она была реализована нами как отдельная система ECS и не являлась частью физического движка. Всё, что нам требовалось от физического движка ― это возможность делать рейкасты и свипкасты в 3D-пространстве.
В результате мы решили использовать встроенную физику Unity ― PhysX ― на клиенте и Bepu Physics v2 на сервере.
В первую очередь мы выделили интерфейс для использования физического движка:
Посмотреть код
using System;
using System.Collections.Generic;
using System.Numerics;
namespace Prototype.Common.Physics
{
public interface IPhysicsWorld : IDisposable
{
bool HasBody(uint id);
void SetCurrentSimulationTick(int tick);
void Update();
RayCastHit RayCast(Vector3 origin, Vector3 direction, float distance, CollisionLayer layer,
int ticksBehind = 0, List<uint> ignoreIds = null);
RayCastHit SphereCast(Vector3 origin, Vector3 direction, float distance, float radius, CollisionLayer layer, int ticksBehind = 0,
List<uint> ignoreIds = null);
RayCastHit CapsuleCast(Vector3 origin, Vector3 direction, float distance, float radius, float height, CollisionLayer layer, int ticksBehind = 0,
List<uint> ignoreIds = null);
void CapsuleOverlap(Vector3 origin, float radius, float height, BodyMobilityField bodyMobilityField, CollisionLayer layer, List<Overlap> overlaps, int ticksBehind = 0);
void RemoveOrphanedDynamicBodies(WorldState.TableSet currentWorld);
void UpdateBody(uint id, Vector3 position, float angle);
void CreateStaticCapsule(Vector3 origin, Quaternion rotation, float radius, float height, uint id, CollisionLayer layer);
void CreateDynamicCapsule(Vector3 origin, Quaternion rotation, float radius, float height, uint id, CollisionLayer layer);
void CreateStaticBox(Vector3 origin, Quaternion rotation, Vector3 size, uint id, CollisionLayer layer);
void CreateDynamicBox(Vector3 origin, Quaternion rotation, Vector3 size, uint id, CollisionLayer layer);
}
}
На клиенте и сервере были разные реализации этого интерфейса: как уже говорилось, на сервере мы использовали реализацию с Bepu, а на клиенте ― Unity.
Здесь стоит упомянуть о нюансах работы с нашей физикой на сервере.
Из-за того, что клиент получает обновления мира с сервера с задержкой (лагом), игрок видит мир немного не таким, каким он представляется на сервере: себя он видит в настоящем, а весь остальной мир — в прошлом. Из-за этого получается, что игрок локально стреляет в цель, которая находится на сервере в другом месте. Так что, поскольку мы используем систему предсказания действий локального игрока, нам необходимо компенсировать лаги при стрельбе на сервере.
![](https://habrastorage.org/webt/h5/gy/k3/h5gyk3svohmmd61k-xll9wq8zps.png)
Для того, чтобы их компенсировать, нам необходимо хранить на сервере историю мира за последние N миллисекунд, а также уметь работать с объектами из истории, включая их физику. То есть, наша система должна уметь рассчитывать столкновения, рейкасты и свипкасты «в прошлом». Как правило, физические движки не умеют этого делать, и Bepu с PhysX не исключение. Поэтому нам пришлось реализовать такой функционал самостоятельно.
Так как симуляция игры у нас происходит с фиксированной частотой ― 30 тиков в секунду, ― нам нужно было сохранять данные физического мира за каждый тик. Идея заключалась в том чтобы создавать не один экземпляр симуляции в физическом движке, а N ― на каждый тик, хранящийся в истории, ― и использовать циклический буфер этих симуляций для их хранения в истории:
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.
Как только данные в ECS и физическом движке оказываются синхронизированы, мы выполняем расчёт движения объектов. Когда все read-write операции оказываются пройдены, в ход вступают read-only системы по расчёту игровой логики (выстрелов, взрывов, тумана войны...)
Полный код реализации 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 накладывает свою специфику на работу с физическими движками. Впрочем, тут следует отметить, что наш проект разрабатывался на Unity 2018.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.
Таким образом мы получили две реализации игровой физики: на клиенте и на сервере.
Следующий шаг ― тестирование.
Одним из важнейших показателей «здоровья» нашего клиента является параметр количества расхождений (mispredictions) с сервером. До перехода на разные физические движки этот показатель варьировался в пределах 1-2% ― то есть, за бой длительностью 9000 тиков (или 5 минут) мы ошибались в 90-180 тиках симуляции. Такие результаты мы получали на протяжении нескольких релизов игры в софт-лаунче. После перехода на разные движки мы ожидали сильный рост этого показателя ― возможно, даже в несколько раз, ― ведь теперь мы исполняли разный код на клиенте и сервере, и казалось логичным, что погрешности при расчётах разными алгоритмами будут быстро накапливаться. На практике же оказалось, что параметр расхождений вырос лишь 0.2-0.5% и в среднем стал составлять 2-2,5% за бой, что полностью нас устраивало.
В большинстве движков и технологий, которые мы исследовали, использовался один и тот же код как на клиенте, так и на сервере. Однако наша гипотеза с возможностью применения разных физических движков подтвердилась. Основная причина, по которой показатель расхождений вырос так незначительно, заключалась в том, что передвижение тел в пространстве и столкновения мы рассчитываем сами одной из своих систем ECS. Этот код одинаков как на клиенте, так и на сервере. От физического же движка нам требовался быстрый расчёт рейкастов и свипкастов, и результаты этих операций на практике для двух наших движков отличались не сильно.
Что почитать
В заключение, как обычно, приведём несколько ссылок по теме: