company_banner

Физика для мобильного PvP шутера и как мы подружили её с ECS

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



    Для начала разберёмся, для чего же нужен физический движок. Универсального ответа здесь нет: в каждой игре он служит для своей цели. Некоторые игры используют физические движки для корректной симуляции поведения объектов в мире, чтобы достичь эффекта погружения игрока. В других физика является основой геймплея ― к таковым относятся, например, Angry Birds и Red Faction. Ещё существуют «песочницы», в которых физические законы отличаются от привычных и таким образом делают геймплей более интересным и необычным (Portal, A Slower Speed of Light).

    С точки зрения программирования физический движок позволяет упростить процесс симуляции поведения объектов в игре. По сути он является библиотекой, в которой хранятся описания физических свойств предметов. При наличии физического движка нам не нужно самим разрабатывать систему взаимодействия тел и универсальные законы, по которым будет жить игровой мир. Это экономит кучу времени и сил на разработку.

    image
    На схеме выше описаны сущность Player, её компоненты и их данные, и системы, которые работают с игроком и его компонентами. Ключевым объектом на схеме является игрок: он может перемещаться в пространстве — компоненты Transform и Movement, система MoveSystem; имеет некоторое кол-во здоровья и может погибнуть — компонент Health, Damage, система DamageSystem; после смерти появляется на точке возрождения (respawn) — компонент Transform для положения, система RespawnSystem; может быть неуязвимым — компонент Invincible.

    В чём же особенности реализации игровой физики для шутеров?


    В нашей игре нет сложных физических взаимодействий, но существует ряд вещей, для которых физический движок всё-таки нужен. Изначально мы планировали использовать его для перемещения персонажа в мире в соответствии с заданными законами. Обычно это осуществляется путём сообщения телу некоего импульса или постоянной скорости, после чего посредством метода Simulate/Update библиотеки все зарегистрированные в ней тела симулируются ровно на один шаг вперёд.

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

    Но в случае нашей игры существует ряд ограничений. Поскольку наш шутер мобильный, он не предполагает сложных взаимодействий персонажей друг с другом и с окружающим миром, не требует красивой баллистики, разрушаемости, прыжков по неровной поверхности. Но в то же время и по той же причине есть очень жесткие требования по трафику. 3D-физика в этом случае была бы излишней: она использовала бы лишь малую часть своих вычислительных ресурсов и генерировала ненужные данные, которые в условиях мобильной сети и постоянной синхронизации клиента с сервером по UDP занимали бы слишком много места. Тут стоит напомнить, что в нашей сетевой модели ещё присутствуют такие вещи, как Prediction и Reconciliation, которые также предполагают проведение расчётов на клиенте. В результате получаем, что наша физика должна работать максимально быстро, чтобы успешно запускаться и работать на мобильных устройствах, не мешая при этом рендеру и другим подсистемам клиента.

    Итак, 3D-физика нам не подходила. Но здесь стоит вспомнить о том, что даже если игра выглядит как трёхмерная, не факт, что физика в ней реализована тоже трёхмерная: всё определяет характер взаимодействия объектов друг с другом. Зачастую эффекты, которые невозможно покрыть 2D-физикой, либо кастомизируются ― то есть, пишется логика, внешне напоминающая трёхмерные взаимодействия, ― либо просто заменяются на визуальные эффекты, не влияющие на геймплей. В Heroes of the Storm, Defense of the Ancients, League of Legends двумерная физика способна обеспечивать все геймплейные возможности игры, не влияя негативно на качество картинки или ощущение правдоподобности создаваемого геймдизайнерами и художниками мира. Так, например, в этих играх есть прыгающие персонажи, но физического смысла в высоте их прыжка нет, поэтому всё сводится к двумерной симуляции и установке какого-нибудь флага вида _isInTheAir, когда персонаж находится в воздухе, ― он учитывается при расчёте логики.

    Так было решено использовать 2D-физику. Игру мы пишем на Unity, но сервер используем Unity-less .net, который язык Unity не понимает. Поскольку львиная доля кода симуляции пошарена между клиентом и сервером, мы стали подыскивать что-то кроссплатформенное ― а именно, физическую библиотеку, написанную на чистом C# без использования нативного кода, чтобы исключить опасность краша мобильных платформ. Более того, принимая во внимание специфику работы шутеров, в частности ― постоянные перемотки на сервере с целью определения, куда стрелял игрок, нам было важно, чтобы библиотека могла работать с историей ― то есть, можно было дёшево посмотреть положение тел N кадров назад во времени. И, конечно, проект не должен быть заброшенным: важно, чтобы автор поддерживал его и мог быстро исправить баги, если таковые найдутся в процессе эксплуатации.

    Как оказалось, на тот момент очень мало библиотек могли удовлетворить нашим требованиям. Фактически, нам подходила только одна ― VolatilePhysics.

    Библиотека примечательна тем, что работает как с Unity, так и с Unity-less решениями, а также позволяет делать рейкасты в прошлое состояние объектов из коробки, т.е. подходит для логики шутеров. Кроме того, удобство библиотеки заключается в том, что механизм контроля запуска симуляции Simulate() позволяет произвести её в любой момент, когда это будет необходимо клиенту. И ещё одна фишка ― возможность записывать дополнительные данные в физическое тело. Это может быть полезно при адресации объекта из симуляции в результатах рейкастов ― правда, при этом сильно снижается производительность.

    Сделав пару тестов и убедившись, что клиент и сервер хорошо взаимодействуют с VolatilePhysics без падений системы, мы остановили свой выбор на ней.

    Как мы вписали библиотеку в привычный порядок работы с ECS и что из этого вышло


    Первым шагом при работе с VolatilePhysics является создание физического мира VoltWorld. Он представляет из себя прокси-класс, с которым и происходит основная работа: настройка, симуляция данных об объектах, рейкасты и т. д. Мы обернули его в специальный фасад, чтобы можно было в будущем сменить реализацию библиотеки на что-нибудь другое. Код фасада выглядел так:

    Посмотреть код
    public sealed class PhysicsWorld
    {
        public const int HistoryLength = 32;
        private readonly VoltWorld _voltWorld;
        private readonly Dictionary<uint, VoltBody> _cache = new Dictionary<uint, VoltBody>();
    
        public PhysicsWorld(float deltaTime)
        {
            _voltWorld = new VoltWorld(HistoryLength) { DeltaTime = deltaTime };
        }
    
        public bool HasBody(uint tag)
        {
            return _cache.ContainsKey(tag);
        }
    
        public VoltBody GetBody(uint tag)
        {
            VoltBody body;
            _cache.TryGetValue(tag, out body);
            return body;
        }
    
        public VoltRayResult RayCast(Vector2 origin, Vector2 direction, float distance, VoltBodyFilter filter, int ticksBehind)
        {
            var ray = new VoltRayCast(origin, direction.normalized, distance);
            var result = new VoltRayResult();
    
            _voltWorld.RayCast(ref ray, ref result, filter, ticksBehind);
            return result;
        }
    
        public VoltRayResult CircleCast(Vector2 origin, Vector2 direction, float distance, float radius, VoltBodyFilter filter, int ticksBehind)
        {
            var ray = new VoltRayCast(origin, direction.normalized, distance);
            var result = new VoltRayResult();
    
            _voltWorld.CircleCast(ref ray, radius, ref result, filter, ticksBehind);
    
            return result;
        }
    
        public void Update()
        {
            _voltWorld.Update();
        }
    
        public void Update(uint tag)
        {
            var body = _cache[tag];
            _voltWorld.Update(body, true);
        }
    
        public void UpdateBody(uint tag, Vector2 position, float angle)
        {
            var body = _cache[tag];
            body.Set(position, angle);
        }
    
        public void CreateStaticCircle(Vector2 origin, float radius, uint tag)
        {
            var shape = _voltWorld.CreateCircleWorldSpace(origin, radius, 1f, 0f, 0f);
            var body = _voltWorld.CreateStaticBody(origin, 0, shape);
            body.UserData = tag;
        }
    
        public void CreateDynamicCircle(Vector2 origin, float radius, uint tag)
        {
            var shape = _voltWorld.CreateCircleWorldSpace(origin, radius, 1f, 0f, 0f);
            var body = _voltWorld.CreateDynamicBody(origin, 0, shape);
            body.UserData = tag;
            body.CollisionFilter = StaticCollisionFilter;
            _cache.Add(tag, body);
        }
    
        public void CreateStaticSquare(Vector2 origin, float rotationAngle, Vector2 extents, uint tag)
        {
            var shape = _voltWorld.CreatePolygonBodySpace(extents.GetRectFromExtents(), 1, 0, 0);
            var body = _voltWorld.CreateStaticBody(origin, rotationAngle, shape);
            body.UserData = tag;
        }
    
        public void CreateDynamicSquare(Vector2 origin, float rotationAngle, Vector2 extents, uint tag)
        {
            var shape = _voltWorld.CreatePolygonBodySpace(extents.GetRectFromExtents(), 1, 0, 0);
            var body = _voltWorld.CreateDynamicBody(origin, rotationAngle, shape);
            body.UserData = tag;
            body.CollisionFilter = StaticCollisionFilter;
            _cache.Add(tag, body);
        }
    
        public IEnumerable<VoltBody> GetBodies()
        {
            return _voltWorld.Bodies;
        }
    
        private static bool StaticCollisionFilter(VoltBody a, VoltBody b)
        {
            return b.IsStatic;
        }
    }


    При создании мира указывается величина истории ― количество состояний мира, которое библиотека будет хранить. В нашем случае их число равнялось 32: 30 кадров в секунду нам понадобится исходя из требования к обновлению логики и ещё 2 дополнительных на случай, если в процессе отладки мы выйдем за пределы истории. В коде также учтены прокинутые наружу методы, порождающие физические тела, и различного рода рейкасты.

    Как мы помним из предыдущих статей, мир ECS по сути вращается вокруг регулярного вызова методов Execute для всех входящих в него систем. В нужных местах каждой системы мы используем вызовы к нашему фасаду. Изначально мы не писали никакого батчинга к вызову физического движка, хотя такие мысли были. Внутри фасада происходит вызов Update() физического мира, и библиотека симулирует все взаимодействия объектов, произошедшие за кадр.

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

    По результатам наших тестов мы быстро поняли, что библиотека очень плохо работает с разными скоростями, и на определённой скорости тела легко начинают проходить сквозь стены. Настроек, связанных с continuous collision detection, для решения этой проблемы в нашем движке предусмотрено не было. Но альтернатив нашему решению на рынке на тот момент не существовало, поэтому пришлось придумывать свой вариант передвижения объектов по миру и синхронизации данных физики с ECS. Так, например, наш код для системы передвижения выглядит следующим образом:

    Посмотреть код
    using System;
    ...
    using Volatile;
    
    public sealed class MovePhysicsSystem : ExecutableSystem
    {
        private readonly PhysicsWorld _physicsWorld;
        private readonly CollisionFilter _moveFilter;
        private readonly VoltBodyFilter _collisionFilterDelegate;
    
        public MovePhysicsSystem(PhysicsWorld physicsWorld)
        {
            _physicsWorld = physicsWorld;
            _moveFilter = new CollisionFilter(true, CollisionLayer.ExplosiveBarrel);
            _collisionFilterDelegate = _moveFilter.Filter;
        }
    
        public override void Execute(GameState gs)
        {
            _moveFilter.State = gs;
            foreach (var pair in gs.WorldState.Movement)
            {
                ExecuteMovement(gs, pair.Key, pair.Value);
            }
    
            _physicsWorld.Update();
            foreach (var pair in gs.WorldState.PhysicsDynamicBody)
            {
                if(pair.Value.IsAlive)
                {
                    ExecutePhysicsDynamicBody(gs, pair.Key);
                }
            }
        }
    
        public override void Execute(GameState gs, uint avatarId)
        {
            _moveFilter.State = gs;
            var movement = gs.WorldState.Movement[avatarId];
            if (movement != null)
            {
                ExecuteMovement(gs, avatarId, movement);
    
                _physicsWorld.Update(avatarId);
                var physicsDynamicBody = gs.WorldState.PhysicsDynamicBody[avatarId];
                if (physicsDynamicBody != null && physicsDynamicBody.IsAlive)
                    ExecutePhysicsDynamicBody(gs, avatarId);
            }
    
        }
    
        private void ExecutePhysicsDynamicBody(GameState gs, uint entityId)
        {
            var body = _physicsWorld.GetBody(entityId);
            if (body != null)
            {
                var transform = gs.WorldState.Transform[entityId];
                transform.Position = body.Position;
            }
        }
    
        private void ExecuteMovement(GameState gs, uint entityId, Movement movement)
        {
            var body = _physicsWorld.GetBody(entityId);
    
            if (body != null)
            {
                float raycastRadius;
                if (CalculateRadius(gs, entityId, out raycastRadius))
                {
                    return;
                }
    
                body.AngularVelocity = 0;
                body.LinearVelocity = movement.Velocity;
                var movPhysicInfo = gs.WorldState.MovementPhysicInfo[entityId];
                var collisionDirection = CircleRayCastSpeedCorrection(body, GameState.TickDurationSec, raycastRadius);
                CheckMoveInWall(movement, movPhysicInfo, collisionDirection, gs.WorldState.Transform[entityId]);
            }
        }
    
        private static bool CalculateRadius(GameState gs, uint id, out float raycastRadius)
        {
            raycastRadius = 0;
            var circleShape = gs.WorldState.DynamicCircleCollider[id];
            if (circleShape != null)
            {
                raycastRadius = circleShape.Radius;
            }
            else
            {
                var boxShape = gs.WorldState.DynamicBoxCollider[id];
                if (boxShape != null)
                {
                    raycastRadius = boxShape.RaycastRadius;
                }
                else
                {
                    gs.Log.Error(string.Format("Physics body {0} doesn't contains shape!", id));
                    return true;
                }
            }
            return false;
        }
    
        private static void CheckMoveInWall(Movement movement, MovementPhysicInfo movPhysicInfo,
            Vector2 collisionDirection, Transform transform)
        {
            // 60 is the max angle when player move in wall and can shoot through the wall from weapon without target.
            const float maxAngleToWall = 60;
    
            if (movement.Velocity.IsEqual(Vector2.zero))
            {
                if (movPhysicInfo.LastCollisionDirection.IsEqual(Vector2.zero))
                {
                    var angleToCollision = transform.Angle.GetDirection().CalculateAbsoluteAngleInDegrees(movPhysicInfo.LastCollisionDirection);
                    movPhysicInfo.TurnOnWall = angleToCollision <= maxAngleToWall;
                }
                return;
            }
    
            movPhysicInfo.LastCollisionDirection = collisionDirection * -1f;
            if (movPhysicInfo.LastCollisionDirection.IsEqual(Vector2.zero))
            {
                movPhysicInfo.TurnOnWall = false;
                movPhysicInfo.LastCollisionDirection = collisionDirection;
            }
            else
            {
                var angleToCollision = transform.Angle.GetDirection().CalculateAbsoluteAngleInDegrees(movPhysicInfo.LastCollisionDirection);
                movPhysicInfo.TurnOnWall = angleToCollision <= maxAngleToWall;
            }
        }
    
        // I can't believe we are using a physics engine and have to write such kludges
        private Vector2 CircleRayCastSpeedCorrection(VoltBody targetBody, float deltaSeconds, float rayCastRadius)
        {
            if (rayCastRadius <= 0)
            {
                return Vector2.zero;
            }
    
            var speed = targetBody.LinearVelocity;
            var position = targetBody.Position;
    
            var direction = speed * deltaSeconds;
    
            var rayCastResult = _physicsWorld.CircleCast(position + direction.normalized * 0.1f, direction, direction.magnitude, rayCastRadius, _collisionFilterDelegate, 0);
    
            if (rayCastResult.Body == null)
            {
                return Vector2.zero;
            }
    
    
            var magSpeed = speed.magnitude;
    
            if (rayCastResult.Distance > 0)
            {
                var penetratingDistance = magSpeed * deltaSeconds - rayCastResult.Distance;
                var sinVelocityEdge = Vector2.Dot(-speed.normalized, rayCastResult.Normal);
    
                var biasSpeed = penetratingDistance * sinVelocityEdge / deltaSeconds;
                var biasVector = rayCastResult.Normal * biasSpeed * 1.1f;
    
                var resultVelocity = speed + biasVector;
    
                if (magSpeed <= 0)
                {
                    resultVelocity = Vector2.zero;
                }
                targetBody.LinearVelocity = resultVelocity;
                return rayCastResult.Normal;
            }
    
            var destination = rayCastResult.Body.Position;
            direction = destination - position;
    
            var rayCastResultToBody =
                _physicsWorld.RayCast(position, direction, direction.magnitude, _collisionFilterDelegate, 0);
    
            if (rayCastResultToBody.IsValid)
                targetBody.LinearVelocity = rayCastResultToBody.Normal * magSpeed * deltaSeconds;
    
            return rayCastResultToBody.Normal;
        }
    }


    Идея состоит в том, что перед каждым ходом персонажа мы делаем CircleCast по направлению его движения с целью определения, есть ли перед ним препятствие. CircleCast нужен потому, что проекции персонажей в игре представляют круг, и мы не хотим, чтобы они застревали в углах между разными геометриями. Затем мы считаем приращение скорости и назначаем объекту физического мира это значение в качестве его скорости за один кадр. Следующим шагом будет вызов метода симуляции физического движка Update(), который двигает все необходимые нам объекты, попутно записывая старое состояние в историю. После того, как симуляция внутри движка завершилась, мы считываем эти просимулированные данные, копируем их в компонент Transform нашего ECS и далее продолжаем работать уже с ними, в частности ― рассылаем их по сети.

    Такой подход в обновлении физики небольшими контролируемыми порциями данных о скорости перемещения персонажа оказался очень эффективным при борьбе с расхождениями физики на клиенте и сервере. И поскольку наша физика не детерминированная ― то есть, при одних и тех же входных данных результат симуляции может различаться, ― было много дискуссий на тему того, стоит ли вообще её использовать, и делает ли кто-либо в индустрии что-то подобное, не имея на руках детерминированного физического движка. К счастью, мы нашли отличный доклад от разработчиков NetherRealm Studios на Game Developers Conference о сетевой составляющей их игр и поняли, что такой подход действительно имеет место быть. Собрав полностью систему и прогнав её на нескольких тестах, мы получили порядка 50 лже-предсказаний за 9000 тиков, т. е. за время пятиминутного боя. Такое количество промахов предсказаний легко нивелируется механизмом Reconciliation и визуальной интерполяцией позиции игрока. Ошибки, возникающие при частом обновлении физики вручную при помощи собственных данных, являются незначительными, поэтому визуальная интерполяция может проходить довольно быстро ― она нужна лишь для того, чтобы не возникало визуального скачка модели персонажа.

    Для проверки совпадения состояний клиента и сервера мы использовали самописный класс следующего вида:

    Посмотреть код
    using PS.Logs.Unity;
    
    /// <summary>
    /// Compares the same avatar in two states. Compares the values potentially
    /// affected by prediction.
    /// </summary>
    public sealed class GameStateComparer : IGameStateComparer
    {
        public bool IsSame(GameState s1, GameState s2, uint avatarId)
        {
            if (s1 == null && s2 != null ||
                s1 != null && s2 == null)
            {
                return false;
            }
    
            if (s1 == null && s2 == null)
                return false;
    
            var entity1 = s1.WorldState[avatarId];
            var entity2 = s2.WorldState[avatarId];
    
            if (entity1 == null && entity2 == null)
            {
                return false;
            }
    
            if (entity1 == null || entity2 == null)
            {
                LogManager.Debug("entity is different");
                return false;
            }
    
            if (s1.Time != s2.Time)
            {
                LogManager.Warning(string.Format("Trying to compare states with different time! Predicted time: {0} Server time: {1}", 
                    s1.Time, s2.Time));
                return false;
            }
    
            if (s1.WorldState.Transform[avatarId] != s2.WorldState.Transform[avatarId])
            {
                LogManager.Debug("Transform is different");
                return false;
            }
            
            // ... some code ...
    
            return true;
        }
    }


    При необходимости его можно автоматизировать, но мы этого делать не стали, хотя и думали об этом в будущем.

    Код сравнения трансформов:

    Посмотреть код
    public static bool operator ==(Transform a, Transform b)
    {
        if ((object)a == null && (object)b == null)
        {
            return true;
        }
        if ((object)a == null && (object)b != null)
        {
            return false;
        }
        if ((object)a != null && (object)b == null)
        {
            return false;
        }
        if (Math.Abs(a.Angle - b.Angle) > 0.01f)
        {
            return false;
        }
        if (Math.Abs(a.Position.x - b.Position.x) > 0.01f || Math.Abs(a.Position.y - b.Position.y) > 0.01f)
        {
            return false;
        }
        return true;
    }



    Первые трудности


    Не возникало никаких проблем с симуляцией движения, пока его можно было проецировать на 2D-плоскость, ― физика в таких случаях работала очень хорошо, но в один момент пришли геймдизайнеры и сказали: «Хотим гранаты!» И мы подумали, чтобы сильно ничего не менять, почему бы не сэмулировать 3D-полет физического тела, имея на руках только 2D-данные.

    И ввели понятие высоты для некоторых объектов.

    Как выглядит закон изменения высоты с течением времени для брошенного тела, проходят на уроках физики в восьмом классе, так что решение по баллистике оказалось тривиальным. Но уже не столь тривиальным получилось решение с коллизиями. Давайте представим себе этот случай: граната при полёте должна или столкнуться со стеной, или перелететь её в зависимости от своей текущей высоты и высоты стены. Решать задачу будем только в двумерном мире, где граната представлена кругом, а стена ― прямоугольником.


    Вид геометрии объектов для решения задачи.

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

    Мы ввели для гранаты отдельный компонент GrenadeMovement, в котором завели понятие высоты:

    [Component]
    public class GrenadeMovement
    {
       public float Height;
       [DontPack]
       public Vector2 Velocity;
       [DontPack]
       public float VerticalVelocity;
    
       public GrenadeMovement(float height, Vector2 velocity, float verticalVelocity) { }
    }

    Теперь граната имеет координату высоты, однако остальному миру эта информация ничего не даёт. Поэтому мы решили схитрить и добавили следующее условие: граната может перелетать через стены, но только определённой высоты. Таким образом, всё определение коллизий свелось к проверке коллизий проекции и сравнению высоты стены со значением поля GrenadeMovement.Height. Если высота полёта гранаты оказывается меньше, она сталкивается со стеной, иначе может спокойно продолжать движение по своей траектории, в том числе и в 2D-пространстве.

    В первой итерации граната просто падала при нахождении пересечений, но затем мы добавили упругие столкновения, и она стала вести себя практически неотличимо от того результата, который мы получили бы в 3D.

    Полный код расчёта траектории движения гранаты и упругих коллизий приведён ниже:

    Посмотреть код
    using System;
    // ... some code ...
    using Volatile;
    
    namespace Common.WorldState
    {
        public sealed class GrenadeMovementSystem : ExecutableSystem
        {
            private struct Projection
            {
                public float Min;
                public float Max;
            }
    
            private float _r;
    
            private readonly Vector2[] _vertices = new Vector2[4];
            private readonly Vector2[] _verticesV = new Vector2[4];
            private Vector2 _Vunit;
            private Vector2 _VTunit;
            private Projection _wallProj1;
            private Projection _wallProj2;
            private Projection _wallProj1V;
            private Projection _wallProj2V;
    
            private const float CollisionPrecision = 1e-3f;
            private static readonly float HalfSlope = Mathf.Cos(Mathf.PI / 4.0f);
    
            private readonly ContactPointList _contactPoints = new ContactPointList(3);
    
            public override void Execute(GameState gs)
            {
                var settings = gs.RuleBook.GrenadeConfig[1];
                _r = settings.R;
    
                var floorDampeningPerTick = (float)Math.Pow(settings.FloorDampening, 1.0 / GameState.Hz);
    
                foreach (var grenade in gs.WorldState.GrenadeMovement)
                {
                    // Gravity must take effect before collision
                    // because contact with walls may and will adjust vertical velocity
                    // and penetration will even move the ball up.
                    grenade.Value.VerticalVelocity -= settings.Gravity * GameState.TickDurationSec;
                    grenade.Value.Height += grenade.Value.VerticalVelocity * GameState.TickDurationSec;
                    // prevent falling through floor
                    if (grenade.Value.Height <= _r)
                    {
                        // slow down horizontal movement by floor friction
                        // actually, friciton is simplified to just dampening coefficient
                        var spdH = grenade.Value.Velocity.sqrMagnitude;
                        var spdV = grenade.Value.VerticalVelocity;
                        var cos = spdH / Mathf.Sqrt(spdH * spdH + spdV * spdV);
                        grenade.Value.Velocity *= floorDampeningPerTick * cos;
    
                        // slow down vertical movement
                        grenade.Value.VerticalVelocity = settings.FloorRestitution * Math.Abs(grenade.Value.VerticalVelocity);
    
                        // move up to the floor level
                        grenade.Value.Height = _r;
                    }
    
                    // A collision will stop the ball and change its velocity.
                    // Otherwise it will be moved by velocity
                    PerformCollisionAndMovement(gs, grenade.Key, grenade.Value);
                }
            }
    
            private void PerformCollisionAndMovement(GameState gs, uint id, GrenadeMovement grenade)
            {
                var settings = gs.RuleBook.GrenadeConfig[1];
                var velocity = grenade.Velocity * GameState.TickDurationSec;
    
                var trans = gs.WorldState.Transform[id];
                var position = trans.Position;
                _Vunit = velocity.normalized;
                _VTunit = new Vector2(-_Vunit.y, _Vunit.x);
    
                _vertices[0] = position + _VTunit * _r;
                _vertices[1] = position - _VTunit * _r;
                _vertices[2] = _vertices[1] + velocity;
                _vertices[3] = _vertices[0] + velocity;
    
                _contactPoints.Reset();
    
                int collisions = 0;
    
                var grenProj1V = ProjectCapsule(_Vunit, _vertices, position, velocity);
                var grenProj2V = ProjectCapsule(_VTunit, _vertices, position, velocity);
                collisions += CollideWithStaticBoxes(gs, id, position, velocity, grenade, grenProj1V, grenProj2V);
                collisions += CollideWithCircles(gs, gs.RuleBook.StaticCircleCollider, gs.RuleBook.Transform, id, position, velocity, grenade, grenProj1V, grenProj2V, (CollisionLayer)~0);
                collisions += CollideWithCircles(gs, gs.WorldState.DynamicCircleCollider, gs.WorldState.Transform, id, position, velocity, grenade, grenProj1V, grenProj2V, ~CollisionLayer.Character);
    
                if (collisions == 0)
                {
                    trans.Position += velocity;
                }
                else
                {
                    var contactSuperposition = CalculateContactSuperposition();
    
                    trans.Position += velocity * contactSuperposition.TravelDistance;
    
                    var reflectedVelocity = grenade.Velocity - 2.0f * Vector2.Dot(grenade.Velocity, contactSuperposition.Normal) * contactSuperposition.Normal;
                    reflectedVelocity *= settings.WallRestitution;
    
    #if DEBUG_GRENADES
                    gs.Log.Debug("contact"
                                 + "\n\ttravel " + contactSuperposition.TravelDistance
                                 + "\n\tcontactNormal " + contactSuperposition.Normal.x + ":" + contactSuperposition.Normal.y
                                 + "\n\treflected V " + reflectedVelocity.x + ":" + reflectedVelocity.y);
    #endif
    
                    grenade.Velocity = reflectedVelocity;
                }
            }
    
            private int CollideWithStaticBoxes(
                GameState gs,
                uint id,
                Vector2 position,
                Vector2 velocity,
                GrenadeMovement grenade,
                Projection grenProj1V,
                Projection grenProj2V)
            {
                var settings = gs.RuleBook.GrenadeConfig[1];
                var collisions = 0;
    
                // TODO spatial query
                foreach (var collider in gs.RuleBook.StaticBoxCollider)
                {
                    var wall = collider.Value;
    
                    var transform = gs.RuleBook.Transform[collider.Key];
                    var colliderData = gs.RuleBook.PrecomputedColliderData[collider.Key];
    
                    // test projection to V
                    _wallProj1V = ProjectPolygon(_Vunit, colliderData.Vertices);
                    if (!Overlap(_wallProj1V, grenProj1V)) continue;
    
                    // test projection to VT
                    _wallProj2V = ProjectPolygon(_VTunit, colliderData.Vertices);
                    if (!Overlap(_wallProj2V, grenProj2V)) continue;
    
                    // test projection to wall axis 1
                    _wallProj1 = ProjectPolygon(colliderData.Axis1, colliderData.Vertices);
                    var grenProj1 = ProjectCapsule(colliderData.Axis1, _vertices, position, velocity);
                    if (!Overlap(_wallProj1, grenProj1)) continue;
    
                    // test projection to wall axis 2
                    _wallProj2 = ProjectPolygon(colliderData.Axis2, colliderData.Vertices);
                    var grenProj2 = ProjectCapsule(colliderData.Axis2, _vertices, position, velocity);
                    if (!Overlap(_wallProj2, grenProj2)) continue;
    
                    var lowWall = wall.Height < settings.TallWallHeight;
                    if (lowWall)
                    {
                        // the wall is too far below, ignore it completely
                        if (grenade.Height > wall.Height + _r) continue;
    
                        // if grenade if falling down, it can bounce off the top of the wall
                        if (grenade.VerticalVelocity < 0f)
                        {
                            if (grenade.Height > wall.Height - _r)
                            {
                                var localPV = WorldToBoxLocal(transform.Position, colliderData, position + velocity);
    
    #if DEBUG_GRENADES
                                gs.Log.Debug("fall on wall"
                                             + "\n\tP+V " + (P.x + V.x) + ":" + (P.y + V.y)
                                             + "\n\tlocal " + localPV.x + ":" + localPV.y
                                             + "\n\tH w " + wall.Height + " g " + grenade.Height
                                );
    #endif
    
                                if (Math.Abs(localPV.x) < wall.Size.x * 0.5f || Math.Abs(localPV.y) < wall.Size.y * 0.5f)
                                {
                                    grenade.Height = wall.Height + _r;
                                    grenade.VerticalVelocity = settings.WallRestitution * Math.Abs(grenade.VerticalVelocity);
                                    continue;
                                }
                            }
                        }
                    }
    
                    // collision detected
    
                    // try to find minimal V before collision
                    var scaleV = CalcTranslationScaleBeforeCollision(CheckBoxCollision, colliderData, 0, position, velocity);
    
                    var contactPoint = CalcBoxContactPoint(transform.Position, wall, colliderData, position);
    
    #if DEBUG_GRENADES
                    gs.Log.Debug("collision grenade #" + id + " with static box #" + collider.Key
                                 + "\n\tP=" + P.x + ":" + P.y
                                 + "\n\tV=" + V.x + ":" + V.y + " scale=" + scaleV
                                 + "\n\tP+Vs=" + (P.x + V.x * scaleV) + ":" + (P.y + V.y * scaleV)
                                 + "\n\twall pos " + transform.Position.x + ":" + transform.Position.y + " sz " + wall.Size.x + ":" + wall.Size.y + " angle " + transform.Angle
                                 + "\n\tproj V  w " + _wallProj1V.Min + ":" + _wallProj1V.Max + " g " + grenProj1V.Min + ":" + grenProj1V.Max + " overlap=" + Overlap(_wallProj1V, grenProj1V)
                                 + "\n\tproj VT w " + _wallProj2V.Min + ":" + _wallProj2V.Max + " g " + grenProj2V.Min + ":" + grenProj2V.Max + " overlap=" + Overlap(_wallProj2V, grenProj2V)
                                 + "\n\taxis1 " + colliderData.Axis1.x + ":" + colliderData.Axis1.y
                                 + "\n\tproj  1 w " + _wallProj1.Min + ":" + _wallProj1.Max + " g " + grenProj1.Min + ":" + grenProj1.Max + " overlap=" + Overlap(_wallProj1, grenProj1)
                                 + "\n\taxis2 " + colliderData.Axis2.x + ":" + colliderData.Axis2.y
                                 + "\n\tproj  2 w " + _wallProj2.Min + ":" + _wallProj2.Max + " g " + grenProj2.Min + ":" + grenProj2.Max + " overlap=" + Overlap(_wallProj2, grenProj2)
                                 + "\n\tpoint " + contactPoint.Point.x + ":" + contactPoint.Point.y + " dotV " + Vector2.Dot(P - contactPoint.Point, V)
                    );
    #endif
    
                    // ignore colliders that are behind
                    if (Vector2.Dot(position - contactPoint.Point, velocity) >= 0.0f) continue;
    
                    contactPoint.TravelDistance = velocity.magnitude * scaleV;
                    _contactPoints.Add(ref contactPoint);
    
                    collisions++;
                }
    
                return collisions;
            }
    
            private bool CheckBoxCollision(PrecomputedColliderData colliderData, int x, Vector2 position, Vector2 velocity)
            {
                _verticesV[0] = _vertices[0];
                _verticesV[1] = _vertices[1];
                _verticesV[2] = _vertices[1] + velocity;
                _verticesV[3] = _vertices[0] + velocity;
    
                // test projection to V
                var grenProj1V = ProjectCapsule(_Vunit, _verticesV, position, velocity);
                if (!Overlap(_wallProj1V, grenProj1V)) return false;
    
                // testing projection to VT would be redundant
    
                // test projection to wall axis 1
                var grenProj1 = ProjectCapsule(colliderData.Axis1, _verticesV, position, velocity);
                if (!Overlap(_wallProj1, grenProj1)) return false;
    
                // test projection to wall axis 2
                var grenProj2 = ProjectCapsule(colliderData.Axis2, _verticesV, position, velocity);
                if (!Overlap(_wallProj2, grenProj2)) return false;
    
                return true;
            }
    
            private int CollideWithCircles(
                GameState gs,
                Table<CircleCollider> colliderTable,
                Table<Transform> transformTable,
                uint id,
                Vector2 position,
                Vector2 velocity,
                GrenadeMovement grenade,
                Projection grenProj1V,
                Projection grenProj2V,
                CollisionLayer collisionLayers)
            {
                var settings = gs.RuleBook.GrenadeConfig[1];
                var collisions = 0;
    
                foreach (var collider in colliderTable)
                {
                    if ((int)collisionLayers != ~0)
                    {
                        var body = gs.WorldState.PhysicsDynamicBody[collider.Key];
                        if (body != null && (body.CollisionLayer & collisionLayers) == 0) continue;
                    }
    
                    var wall = collider.Value;
                    var transform = transformTable[collider.Key];
    
                    // test projection to V
                    _wallProj1V = ProjectCircle(_Vunit, transform.Position, wall.Radius);
                    if (!Overlap(_wallProj1V, grenProj1V)) continue;
    
                    // test projection to VT
                    _wallProj2V = ProjectCircle(_VTunit, transform.Position, wall.Radius);
                    if (!Overlap(_wallProj2V, grenProj2V)) continue;
    
                    // test distance from the circle wall to semicircles on capsule ends
                    var collisionDistance = (_r + wall.Radius) * (_r + wall.Radius);
                    if ((position - transform.Position).sqrMagnitude > collisionDistance) continue;
                    var distSqr = (position + velocity - transform.Position).sqrMagnitude;
                    if (distSqr > collisionDistance) continue;
    
                    var lowWall = wall.Height < settings.TallWallHeight;
                    if (lowWall)
                    {
                        // the wall is too far below, ignore it completely
                        if (grenade.Height > wall.Height + _r) continue;
    
                        // if grenade if falling down, it can bounce off the top of the wall
                        if (grenade.VerticalVelocity < 0f)
                        {
                            if (grenade.Height > wall.Height - _r)
                            {
    #if DEBUG_GRENADES
                                gs.Log.Debug("grenade #" + id + " falls on wall"
                                             + "\n\tP+V " + (P.x + V.x) + ":" + (P.y + V.y)
                                             + "\n\tdist " + Mathf.Sqrt(distSqr)
                                             + "\n\tH w " + wall.Height + " g " + grenade.Height
                                );
    #endif
    
                                if (distSqr < wall.Radius * wall.Radius)
                                {
                                    grenade.Height = wall.Height + _r;
                                    grenade.VerticalVelocity = settings.WallRestitution * Math.Abs(grenade.VerticalVelocity);
                                    continue;
                                }
                            }
                        }
                    }
    
                    // collision detected
    
                    // try to find minimal V before collision
                    var scaleV = CalcTranslationScaleBeforeCollision(CheckCircleCollision, transform.Position, wall, position, velocity);
    
                    var contactPoint = CalcCircleContactPoint(transform.Position, wall, position);
    
    #if DEBUG_GRENADES
                    gs.Log.Debug("collision grenade #" + id + " with circle #" + collider.Key
                                 + "\n\tP=" + P.x + ":" + P.y
                                 + "\n\tV=" + V.x + ":" + V.y + " scale=" + scaleV
                                 + "\n\tP+Vs=" + (P.x + V.x * scaleV) + ":" + (P.y + V.y * scaleV)
                                 + "\n\tcircle pos " + transform.Position.x + ":" + transform.Position.y + " r " + wall.Radius
                                 + "\n\tdist " + (transform.Position - (P + V * scaleV)).magnitude
                                 + "\n\tproj V  w " + _wallProj1V.Min + ":" + _wallProj1V.Max + " g " + grenProj1V.Min + ":" + grenProj1V.Max + " overlap=" + Overlap(_wallProj1V, grenProj1V)
                                 + "\n\tproj VT w " + _wallProj2V.Min + ":" + _wallProj2V.Max + " g " + grenProj2V.Min + ":" + grenProj2V.Max + " overlap=" + Overlap(_wallProj2V, grenProj2V)
                                 + "\n\tpoint " + contactPoint.Point.x + ":" + contactPoint.Point.y + " dotV " + Vector2.Dot(P - contactPoint.Point, V)
                                 );
    #endif
    
                    // ignore colliders that are behind
                    if (Vector2.Dot(position - contactPoint.Point, velocity) >= 0.0f) continue;
    
                    contactPoint.TravelDistance = velocity.magnitude * scaleV;
                    _contactPoints.Add(ref contactPoint);
    
                    collisions++;
                }
    
                return collisions;
            }
    
            private bool CheckCircleCollision(Vector2 wallCentre, CircleCollider wall, Vector2 position, Vector2 velocity)
            {
                _verticesV[0] = _vertices[0];
                _verticesV[1] = _vertices[1];
                _verticesV[2] = _vertices[1] + velocity;
                _verticesV[3] = _vertices[0] + velocity;
    
                // test projection to V
                var grenProj1V = ProjectCapsule(_Vunit, _verticesV, position, velocity);
                if (!Overlap(_wallProj1V, grenProj1V)) return false;
    
                // testing projection to VT would be redundant
    
                // test distance from the circle wall to the semicircle on the second capsule end
                var dSqr = (_r + wall.Radius) * (_r + wall.Radius);
                return (position + velocity - wallCentre).sqrMagnitude < dSqr;
            }
    
            private static float CalcTranslationScaleBeforeCollision<TData1, TData2>(
                Func<TData1, TData2, Vector2, Vector2, bool> collision,
                TData1 colliderData1,
                TData2 colliderData2,
                Vector2 position,
                Vector2 vector)
            {
                var min = 0.0f;
                var max = 1.0f;
                while (true)
                {
                    var d = (max - min) * 0.5f;
                    if (d < CollisionPrecision) break;
    
                    var scale = min + d;
    
                    if (collision(colliderData1, colliderData2, position, vector * scale))
                    {
                        max = scale;
                    }
                    else
                    {
                        min = scale;
                    }
                }
    
                return min;
            }
    
            private ContactPoint CalculateContactSuperposition()
            {
                ContactPoint contactSuperposition;
                _contactPoints.TryPopClosest(1000f, out contactSuperposition);
    
                ContactPoint contact;
                while (_contactPoints.TryPopClosest(contactSuperposition.TravelDistance, out contact))
                {
                    contactSuperposition.Normal += contact.Normal;
                }
    
                contactSuperposition.Normal = contactSuperposition.Normal.normalized;
    
                return contactSuperposition;
            }
    
            private static Projection ProjectPolygon(Vector2 axisNormalised, Vector2[] vertices)
            {
                Projection proj;
                var d = Vector2.Dot(axisNormalised, vertices[0]);
                proj.Min = d;
                proj.Max = d;
    
                for (var i = 1; i < vertices.Length; i++)
                {
                    d = Vector2.Dot(axisNormalised, vertices[i]);
                    proj.Min = Mathf.Min(proj.Min, d);
                    proj.Max = Mathf.Max(proj.Max, d);
                }
    
                return proj;
            }
    
            private Projection ProjectCapsule(Vector2 axisNormalised, Vector2[] vertices, Vector2 p, Vector2 v)
            {
                var proj = ProjectPolygon(axisNormalised, vertices);
                proj = AddCircleProjection(proj, axisNormalised, p, _r);
                proj = AddCircleProjection(proj, axisNormalised, p + v, _r);
                return proj;
            }
    
            private static Projection AddCircleProjection(Projection proj, Vector2 axisNormalised, Vector2 centre, float r)
            {
                var c = Vector2.Dot(axisNormalised, centre);
                proj.Min = Mathf.Min(proj.Min, c - r);
                proj.Max = Mathf.Max(proj.Max, c + r);
                return proj;
            }
    
            private static Projection ProjectCircle(Vector2 axisNormalised, Vector2 centre, float r)
            {
                Projection proj;
                var c = Vector2.Dot(axisNormalised, centre);
                proj.Min = c - r;
                proj.Max = c + r;
                return proj;
            }
    
            private static bool Overlap(Projection p1, Projection p2)
            {
                return p1.Min < p2.Min ? p1.Max > p2.Min : p2.Max > p1.Min;
            }
    
            private static Vector2 WorldToBoxLocal(Vector2 wallCentre, PrecomputedColliderData colliderData, Vector2 position)
            {
                return new Vector2(
                    Vector2.Dot(colliderData.Axis1, position) - Vector2.Dot(colliderData.Axis1, wallCentre),
                    Vector2.Dot(colliderData.Axis2, position) - Vector2.Dot(colliderData.Axis2, wallCentre)
                );
            }
    
            private static ContactPoint CalcBoxContactPoint(Vector2 wallCentre, BoxCollider wall, PrecomputedColliderData colliderData, Vector2 position)
            {
                var contactPoint = CaclBoxLocalContactPoint(wall.Size * 0.5f, WorldToBoxLocal(wallCentre, colliderData, position));
    
                var worldAxisX = new Vector2(colliderData.Axis1.x, -colliderData.Axis1.y);
                var worldAxisY = new Vector2(colliderData.Axis1.y, colliderData.Axis1.x);
                contactPoint.Point = wallCentre + new Vector2(Vector2.Dot(worldAxisX, contactPoint.Point), Vector2.Dot(worldAxisY, contactPoint.Point));
                contactPoint.Normal = new Vector2(Vector2.Dot(worldAxisX, contactPoint.Normal), Vector2.Dot(worldAxisY, contactPoint.Normal));
    
                return contactPoint;
            }
    
            private static ContactPoint CaclBoxLocalContactPoint(Vector2 boxHalfSize, Vector2 localPosition)
            {
                ContactPoint localContactPoint = default(ContactPoint);
    
                // cases are numbered like numpad keys
    
                // 1, 2, 3
                if (localPosition.y < -boxHalfSize.y)
                {
                    // 1
                    if (localPosition.x < -boxHalfSize.x)
                    {
                        localContactPoint.Point = new Vector2(-boxHalfSize.x, -boxHalfSize.y);
                        localContactPoint.Normal = new Vector2(-HalfSlope, -HalfSlope);
                    }
                    // 2, 3
                    else
                    {
                        // 3
                        if (localPosition.x > boxHalfSize.x)
                        {
                            localContactPoint.Point = new Vector2(boxHalfSize.x, -boxHalfSize.y);
                            localContactPoint.Normal = new Vector2(HalfSlope, -HalfSlope);
                        }
                        // 2
                        else
                        {
                            localContactPoint.Point = new Vector2(localPosition.x, -boxHalfSize.y);
                            localContactPoint.Normal = new Vector2(0.0f, -1.0f);
                        }
                    }
                }
                // 4, 6, 7, 8, 9
                else
                {
                    // 7, 8, 9
                    if (localPosition.y > boxHalfSize.y)
                    {
                        // 7
                        if (localPosition.x < -boxHalfSize.x)
                        {
                            localContactPoint.Point = new Vector2(-boxHalfSize.x, boxHalfSize.y);
                            localContactPoint.Normal = new Vector2(-HalfSlope, HalfSlope);
                        }
                        // 8, 9
                        else
                        {
                            // 9
                            if (localPosition.x > boxHalfSize.x)
                            {
                                localContactPoint.Point = new Vector2(boxHalfSize.x, boxHalfSize.y);
                                localContactPoint.Normal = new Vector2(HalfSlope, HalfSlope);
                            }
                            // 8
                            else
                            {
                                localContactPoint.Point = new Vector2(localPosition.x, boxHalfSize.y);
                                localContactPoint.Normal = new Vector2(0.0f, 1.0f);
                            }
                        }
                    }
                    // 4, 6
                    else
                    {
                        // 4
                        if (localPosition.x < -boxHalfSize.x)
                        {
                            localContactPoint.Point = new Vector2(-boxHalfSize.x, localPosition.y);
                            localContactPoint.Normal = new Vector2(-1.0f, 0.0f);
                        }
                        // 6
                        else
                        {
                            localContactPoint.Point = new Vector2(boxHalfSize.x, localPosition.y);
                            localContactPoint.Normal = new Vector2(1.0f, 0.0f);
                        }
                    }
                }
    
                return localContactPoint;
            }
    
            private static ContactPoint CalcCircleContactPoint(Vector2 wallCentre, CircleCollider wall, Vector2 position)
            {
                ContactPoint contactPoint = default(ContactPoint);
    
                contactPoint.Normal = (position - wallCentre).normalized;
                contactPoint.Point = wallCentre + wall.Radius * contactPoint.Normal;
    
                return contactPoint;
            }
        }
    }


    Физику написали. Что дальше?


    Игра живёт, развивается, и со временем нам стало необходимо каким-то образом отлаживать то, что происходит с её физическим движком на сервере. В одной из статей я уже описывал подробно отладку ECS на сервере. Что же до физики, у нас есть непосредственный визуальный редактор, который берёт данные из JSON, структура которых генерируется вместе с остальным лейаутом ECS. Выглядит этот редактор так:



    Видно, что фактически у нас существует два «мира». Первый представляет из себя наш ECS, в котором мы описываем состояния объектов игры, в том числе их физические параметры. Второй мир ― физический ― создан библиотекой и, естественно, не написан в стиле ECS, но задачу синхронизации данных между этими мирами ECS берёт на себя. Важно, чтобы библиотека предоставляла API, с помощью которого можно управлять её данными, и их можно было и читать, и писать. Также очень важным выводом для нас стало то, что фактически из всех возможностей физического движка наиболее надёжным в плане предотвращения промахов предсказаний является построение системы взаимодействия и передвижения объектов на коротких квантах постоянной скорости и рейкастах.

    Какое-то время мы продолжали успешно жить с 2D-физикой: она полностью покрывала наши задачи и потребности геймдизайна, и в целом была очень удачным решением. Конечно, были и минусы: нас не всегда устраивала её производительность, но подходящих альтернатив на рынке opensource всё равно не было, а писать что-то своё было слишком энергозатратно. По результатам профайлинга из более сотни систем ECS, исполняющихся в одном тике симуляции, системы на физическом движке оказались наиболее прожорливыми. Кроме того, в библиотеке отсутствует множество оптимизаций на основе геометрии физических тел и их симметрии, которые могли бы оказаться действительно полезными. Также смущает ООП-направленность кода библиотеки и работа с индивидуальными телами, а не пакетами данных, что было бы куда оптимальнее для физического движка. И да ― в итоге разработчик всё-таки забросил библиотеку.

    Через какое-то время в геймдизайне нашей игры всё же появились новые требования, предполагающие использование 3D-физики, и оказалось, что превентивно написанный фасад физики во многом ошибался и совершенно не был приспособлен к новым реалиям.

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

    Полезные ссылки


    На наши предыдущие статьи по теме:


    А так же:

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

    Комментарии 13

      0
      Спасибо за статью. Можете поделиться, какой 3D движок выбрали?
        +1
        Будет отдельная статья, в которой подробно расскажем как переходили с 2D физики на 3D, но забегая вперед могу сказать, что взяли bepuphysics2 (https://github.com/bepu/bepuphysics2).
          0
          Спросил в надежде — может используете что-нибудь от Unity, нативно на клиенте и сервере, может что-нибудь уже можно применять для мультиплеера от них. Я пробовал BulletSharp, он недетерменированный, но это все решается (синхронизируется) — при компиляции из плюсов под нужную платформу можно убрать кучу ненужного функционала: ткани, канаты, сложные джоинты и т.д.

          Для 2d мы пробовали Volatile — только из-за хранения истории его использовать смысла нет, это дело второстепенное; пробовали нативно Box2d — на iOS были большие проблемы с производительностью; по итогу написали свой, с применением ECS подхода — детерминированно, но простенько — без джоинтов, только коллизии, сенсоры и рейкасты.
            +1
            Не рассматривали физику Unity ни в каком виде, т.к. на сервере у нас нет Unity. При выборе движка смотрели в сторону и BulletSharp и Box2D, но были опасения насчет нативной части — не хотелось тратить время на рандомные нативные краши и поиск ошибок в коде огромной библиотеки, вместо того, чтобы сосредоточиться на разработке продуктовой логики, что очень важно на начальном этапе проекта. Опять же дописывать историю в либах, борясь с их внутренними кешами тоже то ещё удовольствие. Мы взяли Volatile и не прогадали. В результате отсутствия Unity на сервере наши серверы справляются с довольно внушительными нагрузками, библиотеке не требуется поддержка — это значит, что всё сэкономленное время мы можем вложить непосредственно в игру.

            А вообще, если стоит вопрос о Unity решении я бы начал смотреть в сторону ещё превьюшного Unity.Physics: docs.unity3d.com/Packages/com.unity.physics@0.0/manual/design.html
            Тут важно понимать, что решения по ECS и физике мы принимали задолго до того, как Unity решила пойти в ногу со временем.

            Вообще при переходе от 2D к 3D рассматривали даже такой радикальный шаг как запуск сервера на Unity, и как следствие использование Unity физики или на худой конец использование той же версии PhysX на сервере, что и на клиенте. Но остановились на более экстравагантном варианте. В следующей статье подробно расскажем про него — там будет очень интересно.

            Опять же забегая вперед могу проспойлерить, что не обязательно крутить ту же самую физику на клиенте и сервере — она всё равно недетерминированная, так зачем мучиться? =)

            А про опыт написания даже простенькой физической либы в стиле ECS я бы с удовольствием почитал — пишите статью!
              0
              или на худой конец использование той же версии PhysX на сервере, что и на клиенте

              Конкретно на этот вариант и надеялся. Я подумывал поэкспериментировать с этим, но пока только мысли — надеялся что у вас что-то подобное, когда спрашивал.

              рандомные нативные краши

              Да, этого крайне много в том же BulletSharp и на исправление уходит тонна времени.

              Не рассматривали физику Unity ни в каком виде, т.к. на сервере у нас нет Unity.

              Да, такой вариант разве что для прототипов может подойти, там много лишнего получается — хотя сейчас можно настраивать PlayerLoop (ScriptBehaviourUpdateOrder.UpdatePlayerLoop), экспериментальная штука, но все равно много лишнего.

              она всё равно недетерминированная, так зачем мучиться? =)

              Тут надо смотреть как быстро накапливается ошибка, насколько частые откаты. В случае детерминизма откаты будут во многом из-за получения станов, замедлений. В случае недетерминизма откаты будут чаще — соответственно больше затраты процессора на ресимуляцию, затраты по сути в самой затратной системе. Жаль Вы про это в статье не написали, буду ждать продолжение.
                0
                Да, такой вариант разве что для прототипов может подойти
                У нас Unity инстансы на сервере вполне нормально работали в MOBA игре.
                  0
                  Тут всё зависит от подхода. Крутить Unity на серверах это просто очень дорого и не стабильно. Учитывая, что создание обычного .net сервера стоит нам как минимум тех же усилий, что борьба с Unity (не забудьте про кручение физики, pathfinding, которые надо закостылить, чтобы запускать много матчей в рамках одного процесса Unity; невозможность Unity работать со стандартными серверными либами вроде RabbitMQ, утечки и плохая утилизация многопоточности и современной .net среды), а в выигрыше будет хорошая утилизация серверного железа и как следствие — косты, то мы решили особо не мудрить.

                  Но тут стоит отдать должное Unity — они отлично понимают, что серверный кейс Unity существует и активно работают над поддержкой этого направления.
                    0
                    Да, дороговато. У нас там было по одному инстансу на ядро. В каждом инстансе 10 комнат на 6 человек (если речь про арены) или 100 комнат на планеты (если речь про деревню игрока, где ситибилдинг часть). В итоге одна машина с 4 ядрами держала 200-300 ccu.
                  0
                  Тут надо смотреть как быстро накапливается ошибка, насколько частые откаты. В случае детерминизма откаты будут во многом из-за получения станов, замедлений. В случае недетерминизма откаты будут чаще — соответственно больше затраты процессора на ресимуляцию, затраты по сути в самой затратной системе. Жаль Вы про это в статье не написали, буду ждать продолжение.


                  К сожалению, детерминизм как раз не решает описанных вами проблем — станлоки по прежнему будут отбрасывать предикшн игрока назад. Фактически основной селлинг поинт детерминизма — это экономия траффика, т.к. в этом случае с сервера перестают слаться снапшоты и шлются только инпуты потому что клиент, при наличии детерминированной симуляции, может полностью локально восстановить состояние мира. В этом случае количество ресимуляций в среднем будет как раз больше, т.к. с детерминизмом и наличием только инпутов откатывать состояние мира нужно постоянно как минимум на размер пинга, или больше, чтобы учесть в состоянии ввод других игроков. Посмотрите приложенную к статье лекцию от NRS. Там ресимуляции происходят постоянно и под них в Injustice и MK11 заложен кусок времени фрейма.
                    0
                    Посмотрел видео, довольно интересно.

                    Но видео рассказывает о Peer to Peer, где каждый клиент обязан симулировать всю физику (до конца так и не понял как они частицы симулируют). У нас простая интерполяция, мы других игроков двигаем не при помощи физики, зачем нам их инпут? У них понятно почему инпут, у них до этой модели вообще как я понял был локстеп, каждый клиент поддерживает симуляцию, у него нет сервера, который поможет. Если другие игроки взаимодействуют с миром — игрок не может это знать, будет рассинхрон — но это будет рассинхрон не из-за ошибки вычислений.
                      +1
                      В том-то и дело, что когда есть детерминизм, то трафик становится копеечным — в этом его основной селлинг поинт. И серверы становятся не нужными, т.к. каждый клиент в состоянии пересчитать состояние всего мира самостоятельно. Фактически сервер может вообще проводить валидацию боёв постфактум и банить читеров постфактум. И более того, затраты кадра на ресимуляцию всегда фиксированы и заранее заложены в кадр, по этому не зависят от случайных миспредиктов и как следствие не порождают спайков FPS.

                      В таких схемах отсутствуют серверные снапшоты как таковые (на самом деле иногда и нет, т.к. тогда всплывают классические проблемы event-sourcing'a вроде невозможности быстро переподключиться в бой), а о перемещении противника можно узнать только из ресимуляции.

                      Если же говорить о детерминизме в нашей схеме — она помогает с расхождениями, да, но тут же возникает соблазн сделать всё вышеописанное, т.к. это реальная экономия ресурсов (если мы конечно говорим о более-менее крупных проектах).
                        +1
                        Большое спасибо за ответ, теперь я Вас понял.

                        У нас был прототип — но делали не на Unity — использовали Unreal. Суть в том, что есть 2d мир и помимо твердых тел есть упругие тела, флюиды (только жидкости если точнее) и ткани (cloth) — физика таких тел представлена частицами (использовали LiquidFun). Что-то похожее на Terraria планировали, выживалку. По итогу физика была ядром механики и очень много съедала времени — довольно много чего испробовали — слать снапшоты было вообще не вариант. Пробовали локстепом сделать и передавать только инпут, пробовали как-то вычислительные шейдеры подключить — но по итогу так и не нашли способ.
                0
                Плюсую верхний коммент про статью о физике на ECS :3
                И не опенсорсная ли она у вас?

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое