company_banner

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



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

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



    А так:



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

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

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

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


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



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

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



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

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

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



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

    Что почитать


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

    Pixonic
    Разрабатываем и издаем игры с 2009 года

    Comments 7

      +1

      Есть отличная статья, которая рассказывает о сетевой механике в Source. Затрагиваются такие проблемы, как интерполяция, лагокомпенсация, предсказание ввода. https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking:ru

      0
      Странное решение с двумя разными движками. Неужели вы так не доверяете движкам на C++?
        +1
        Тут довольно сложная ситуация.
        Если исходить из того что нужно использовать один и тот же движок на клиенте и на сервере, то возникают следующие проблемы:
        1) Если использовать PhysX Unity на клиенте, то мы не можем гарантировать что на сервере будет использоваться та же версия PhysX. Да Unity публикует данные о том какую версию PhysX они используют, но в той версии которую они поставляют с Unity вполне могут быть кастомные изменения для поддержки движка. Либо использовать Unity на сервере, но на мой взгляд это плохое решение.
        2) Если не использовать PhysX, то появляется проблема портации C++ на мобильные платформы и интеграция его с Unity. Наш проект запускался одновременно под Android и iOS. Тратить несколько месяцев разработки на такую задачу, когда проект находится на стадии софт-лаунча, довольно рискованно.
        К тому же, как я уже говорил, у нас довольно простая физика в игре, все что нам требовалось делать рейкасты и свипкасты.
        0
        На кой чёрт вам понадобилось делать симуляции на клиенте? Есть множество объектов, у каждого есть свои данные на текущий стейт, у тех данных которые меняются, должен быть значение-вектор изменений данных, который будет синхронизироваться, вся симуляция происходит на сервере для текущих данных, а в слепках «вектора изменений» для каждых данных в каждом тике за n-тиков с помощью них, можно вычислять все предыдущие стейты, для только текущих обрабатываемых данных клиента
          +1
          Не понял ваш комментарий по поводу «вектора изменений».

          Физика на клиенте нужна, для расчета предикшена локального игрока, в частности такие вещи как передвижение и ассист в системе прицеливания делаются на основе рейкастов. Более подробнее как работает сетевой код я писал в этой статье habr.com/ru/company/pixonic/blog/415959

          Если же вы про то, зачем нам на клиенте хранить историю физики (UnityPhysicsHistorySlice). То в Production-билдах не зачем, вся история храниться в ECS. История физики нужна только для специального режима «локальной симуляции», когда клиент помимо собственно клиентского кода, еще эмулирует сервер. Это довольно сильно ускоряет прототипирование фичей, так как можно разрабатывать игровую логику, без редеплоя сервера.
            +2
            На кой чёрт вам понадобилось делать симуляции на клиенте?
            Действительно есть ММО и MOBA игры, которые не используют симуляцию на клиенте (самым ярким примером можно привести Dota 2, где нет предсказания ввода — инпут отправляется на сервер, сервер производит симуляцию, результат (дельта изменений) присылается клиенту — по итогу в случае лага игра «застывает», произвести реверс игровой логики можно только создав карту и став хостом).

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

            При реверсе игр Apex Legend / PUBG можно в том или ином виде увидеть очередь инпутов, размер которой динамически изменяется в зависимости от состояния сети, и timeline произведенных предсказаний — если полученные от сервера данные не сошлись (клиент не просчитал определенные факторы, например оглушение) с данными в timeline, производится rollback — начиная с последнего полученного от сервера фрейма данных идет повторная симуляция вплоть до текущего фрейма клиента.

          Only users with full accounts can post comments. Log in, please.