大家好! 在本文中,我们将讨论为多人射击游戏的物理引擎工作的个人经验,并将主要关注物理与
ECS的交互作用:我们在工作中涉足了什么耙子,学到了什么,为什么选择了特定的解决方案。

首先,让我们弄清楚为什么需要物理引擎。 没有一个普遍的答案:在每款游戏中,它都有其目的。 一些游戏使用物理引擎来正确模拟世界中对象的行为,以达到沉浸玩家的效果。 在其他情况下,物理是游戏玩法的基础-例如,《愤怒的小鸟》和《红色派系》。 还有一些“沙盒”,其物理定律不同于通常的定律,因此使游戏玩法更加有趣和特别(门户,光速较慢)。
从编程的角度来看,物理引擎可以简化模拟游戏中对象行为的过程。 实际上,它是一个存储对象物理特性描述的库。 在存在物理引擎的情况下,我们不需要开发身体与游戏世界将赖以生存的普遍规律之间的相互作用系统。 这样可以节省大量时间和开发精力。
上图描述了播放器的本质,其组件及其数据,以及与播放器及其组件一起工作的系统。 图中的关键对象是玩家:他可以在太空中移动-变换和移动组件MoveSystem; 身体健康,并且可能死亡-组件健康,损害,损害系统; 死亡出现在重生点之后-该位置的Transform组件,即RespawnSystem; 可能无敌-组成无敌。射击游戏实施游戏物理的特征是什么?
在我们的游戏中,没有复杂的物理交互,但是仍有许多事情需要物理引擎。 最初,我们计划使用它根据预定的法律在世界上移动角色。 通常,这是通过给主体一定的冲动或恒定速度来完成的,然后,使用库的Simulate / Update方法,对其中注册的所有主体进行精确的模拟,向前迈出了一步。
在射击游戏中,3D物理不仅经常用于模拟角色移动,而且还用于正确处理子弹和火箭的弹道,跳跃,角色彼此之间以及环境之间的相互作用。 如果射击者声称自己是现实的并且试图传达射击过程的真实感受,那么他只需要一个物理引擎。 当玩家向目标射击a弹枪时,他希望获得的经验和结果尽可能与他从长期的射击游戏中已经知道的那种结果相类似-某种全新的东西很可能使他感到意外。
但就我们的游戏而言,有许多限制。 由于我们的射手是可移动的,因此它并不意味着角色之间以及与周围世界之间的复杂互动,因此它不需要美丽的弹道,可破坏性,在不平坦的表面上跳跃。 但是出于相同的原因,同时有非常严格的流量要求。 在这种情况下,3D物理将是多余的:它将仅使用其计算资源的一小部分并生成不必要的数据,这在移动网络中以及客户端通过UDP与服务器的持续同步将占用太多空间。 这里值得回顾的是,在我们的网络模型中,仍然存在诸如
预测和对帐之类的东西,这也涉及到客户的结算。 结果,我们认为我们的物理学应尽可能快地工作,以便成功启动并在移动设备上工作,而不会干扰渲染和其他客户端子系统。
因此,3D物理不适用于我们。 但是,这里值得记住的是,即使游戏看起来像是三维的,也不是事实,因为它的物理场也是三维的:一切都决定了物体之间相互作用的本质。 通常2D物理无法涵盖的效果要么是自定义的(即编写的逻辑看上去像三维交互),要么只是被不影响游戏玩法的视觉效果所替代。 在《风暴英雄》,《古代防御》,《英雄联盟》中,二维物理学能够提供游戏的所有游戏功能,而不会影响画面质量或游戏设计师和世界各地艺术家所创造的信誉感。 因此,例如,在这些游戏中有跳跃角色,但是在跳跃高度上没有物理意义,因此可以归结为二维模拟并在角色悬空时设置某种标志,例如_isInTheAir-在计算逻辑时要考虑到这一点。
因此决定使用2D物理学。 我们用Unity编写游戏,但是服务器使用的是Unity无法理解的无Unity的.net。 由于在客户端和服务器之间流传着大部分仿真代码,因此我们开始寻找跨平台的东西-即,用纯C#编写的物理库,而没有使用本机代码来消除崩溃移动平台的危险。 此外,考虑到
射手的工作细节,尤其是服务器上不断倒带以确定玩家的射击位置,对于我们来说,库可以与历史一起工作很重要-也就是说,我们可以廉价地及时查看N帧尸体的位置。 并且,当然,不应该放弃该项目:作者的支持和能够快速修复错误(如果在操作过程中发现任何错误)非常重要。
事实证明,当时很少有库可以满足我们的要求。 实际上,只有一个适合我们
-VolatilePhysics 。
该库值得注意,因为它可以与Unity和无Unity的解决方案一起使用,并且还允许您开箱即用地对对象的过去状态进行快照。 适用于射手逻辑。 另外,该库的便利之处在于,用于控制Simulate()模拟开始的机制使您可以在客户端需要时随时生成它。 另一个功能-能够将其他数据写入身体的功能。 当根据reykast的结果对模拟对象进行寻址时,这很有用-但是,这会大大降低性能。
经过几次测试,并确保客户端和服务器与VolatilePhysics交互良好且没有崩溃,我们选择了它。
我们如何进入图书馆以与ECS正常合作的方式及其结果
与VolatilePhysics合作的第一步是创建VoltWorld的物理世界。 它是一个代理类,主要工作在该类上:调整,模拟有关对象,reykast等的数据。我们将其包装在特殊的外观中,以便将来我们可以将库的实现更改为其他形式。 外观代码如下所示:
查看代码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()的调用,并且库模拟了每帧发生的对象的所有交互。
因此,与物理学的工作可归结为两个部分:一帧中物体在空间中的均匀运动以及拍摄所需的许多石,效果的正确操作以及许多其他事情。 Reykast在身体状态的历史中尤其重要。
根据我们的测试结果,我们很快意识到该库在不同的速度下表现非常差,并且以一定的速度,物体很容易开始穿过墙壁。 在我们的引擎中,没有与连续碰撞检测关联的设置可以解决此问题。 但是当时市场上没有其他解决方案,因此我不得不提出自己的版本,在全球范围内移动物体并将物理数据与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) {
这样的想法是,在每个角色移动之前,我们
都要按照其移动方向制作一个
CircleCast ,以确定其前方是否有障碍物。 之所以需要CircleCast,是因为游戏中角色的投射代表一个圆圈,我们不希望它们陷入不同几何形状之间的角落。 然后,我们考虑速度的增量,并将此值分配给物理世界的对象作为其在一帧中的速度。 下一步是调用物理引擎Update()的模拟方法,该方法将移动我们需要的所有对象,同时记录历史中的旧状态。 引擎内部的模拟完成后,我们将读取此模拟数据,将其复制到ECS的Transform组件中,然后继续使用它,尤其是通过网络发送。
事实证明,这种用角色的移动速度控制的小块数据更新物理的方法,对于解决客户端和服务器上物理的差异非常有效。 而且由于我们的物理学不是确定性的-也就是说,使用相同的输入数据,模拟结果可能会有所不同-已经有很多讨论,关于是否值得使用它,以及行业中是否有人做类似的事情,拥有确定性的物理引擎。 幸运的是,我们在游戏开发者大会上从NetherRealm Studios的开发者那里获得了一份有关其游戏网络组件的出色报告,并意识到这种方法确实在发生。 完全组装好系统并进行几次测试后,我们得到了大约5,000个滴答的50个错误预测,即在5分钟的战斗中。 通过调节机制和玩家位置的视觉插值,可以很容易地对如此多的错过预测进行平准。 在使用您自己的数据频繁手动更新物理过程中发生的错误微不足道,因此,视觉插值可以相当迅速地进行-仅需要这样做,这样就不会在角色模型中发生视觉跳跃。
为了检查客户端和服务器状态是否匹配,我们使用了以下形式的自写类:
如有必要,它可以实现自动化,但我们没有这样做,尽管我们在以后考虑过。
转换比较代码:
查看代码 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平面上-在这种情况下,物理效果很好,但是游戏设计师曾一度说:“我们想要手榴弹!”而且我们认为,什么都不会改变很多,为什么不只用2D数据模拟人体的3D飞行。
他们介绍了一些物体的高度概念。
对于一个废弃的身体来说,高度定律随时间的变化是什么样的,它们通过了八年级的物理课程,因此对弹道学的决定变得微不足道。 但是,解决冲突的方法不再那么简单。 让我们想象一下这种情况:飞行中的手榴弹应该与墙壁碰撞,或者根据其当前高度和墙壁高度飞过墙壁。 我们将仅在二维世界中解决问题,在二维世界中,手榴弹由圆形表示,墙壁由矩形表示。
查看对象的几何形状以解决问题。首先,我们关闭了手榴弹的动态物体与其他静态和动态物体的交互作用。 为了专注于目标,这是必要的。 在我们的任务中,当手榴弹在二维平面上的投影彼此相交时,手榴弹应该能够穿过其他物体并“飞过”墙壁。 在正常的交互中,两个物体不能彼此通过,但是在手榴弹具有自定义运动逻辑和高度的情况下,我们允许它在某些条件下执行此操作。
我们为榴弹引入了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中得到的结果几乎没有区别。
下面给出了计算手榴弹和弹性碰撞轨迹的完整代码:
. 接下来是什么?
, , - , .
ECS . , , JSON, ECS. :

, «». ECS, , . ― ― , , ECS, ECS . , API, , , . , .
- 2D-: , . , : , opensource , - . ECS, , . , , . - , , . ― - .
- , 3D-, , .
, , , . , , ECS .
有用的链接
:
: