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

foifazxdmxh_smqsswnkxhn0ww4.png

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

hej32pvogui5sukjz842wjpyqry.png

А так:

n825xwkx_jsgqgoemkcor-r4ti0.jpeg

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

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


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

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


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

lzdyoquthw76ajisyvgqcn7zc_i.jpeg

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

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

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 ignoreIds = null);

        RayCastHit SphereCast(Vector3 origin, Vector3 direction, float distance, float radius, CollisionLayer layer, int ticksBehind = 0,
            List ignoreIds = null);
        
        RayCastHit CapsuleCast(Vector3 origin, Vector3 direction, float distance, float radius, float height, CollisionLayer layer, int ticksBehind = 0,
            List ignoreIds = null);

        void CapsuleOverlap(Vector3 origin, float radius, float height, BodyMobilityField bodyMobilityField, CollisionLayer layer, List 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.

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

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

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 _staticHandlerToBody = new Dictionary();
            private readonly Dictionary _dynamicHandlerToBody = new Dictionary();

            private readonly Dictionary _staticIdToHandler = new Dictionary();
            private readonly Dictionary _dynamicIdToHandler = new Dictionary();

            private readonly List _staticIds = new List();
            private readonly List _dynamicIds = new List();

            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 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 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 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 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 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 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(_scene, "box", 0);
    _capsulePool = new PhysicsSceneObjectsPool(_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. Этот код одинаков как на клиенте, так и на сервере. От физического же движка нам требовался быстрый расчёт рейкастов и свипкастов, и результаты этих операций на практике для двух наших движков отличались не сильно.

Что почитать


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

© Habrahabr.ru