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

в 9:09, , рубрики: C#, ecs, entity component system, Gamedev, multiplayer, online, unity, Блог компании Pixonic, геймдев, игровая физика, мобильные игры, мультиплеер, проектирование, Проектирование и рефакторинг, разработка игр, разработка мобильных приложений, физический движок, шутер

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

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

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

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

А так:

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

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

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

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

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

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

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

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

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

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

Словом, от идеи разбивать пространство на 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.

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

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

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

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

Что почитать

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

Автор: Алексей Дюдя

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js