
在
上一篇文章中,我的同事谈到了我们如何在移动多人射击游戏中使用二维物理引擎。 现在,我想分享一下我们如何抛弃以前所做的一切并从头开始-换句话说,我们如何将游戏从2D世界转移到3D。
一切始于这样一个事实,即制作人和领先的游戏设计师一旦来到我们的程序员部门,并给我们带来了挑战:必须在狭窄空间内进行射击的移动式PvP自上而下射击游戏必须转换为在开放区域射击的第三人称射击游戏。 在这种情况下,希望卡看起来不是这样:

依此类推:

技术要求如下:
- 地图尺寸-100×100米;
- 高差-40米;
- 支持隧道,桥梁;
- 向不同高度的目标射击;
- 与静态几何体发生碰撞(我们与游戏中的其他角色没有碰撞);
- 自由落体物理学;
- 手榴弹投掷物理。
展望未来,我可以说我们的游戏看起来不像上一个屏幕截图:事实证明,这是第一个选项和第二个选项之间的交叉。
选项一:分层结构
提出第一个想法是不更改物理引擎,而只是添加几层“层数”级别。 原来这是建筑物的平面图:

使用这种方法,我们不需要从根本上重做客户端或服务器应用程序,并且通常看来,以这种方式可以很简单地解决任务。 但是,在尝试实现它时,我们遇到了几个关键问题:
- 在与关卡设计师澄清了细节之后,我们得出的结论是,这种方案中的“楼层”数量可以令人印象深刻:有些地图位于平缓坡度和丘陵的开放区域。
- 从一层射击到另一层时,命中率的计算成为一项艰巨的任务。 问题情况的示例如下图所示:在这里,玩家1可以进入玩家3,但不能进入玩家2,因为射击路径会阻塞第2层,尽管玩家2和玩家3都在同一层。

简而言之,我们很快放弃了将空间划分为2D层的想法-决定我们将完全替换物理引擎来采取行动。
这导致我们需要选择这种引擎并将其构建到现有的客户端和服务器应用程序中。
选项二:选择一个就绪库
由于游戏客户端是用Unity编写的,因此我们决定考虑使用默认内置于Unity中的物理引擎PhysX的可能性。 总的来说,他完全满足了我们游戏设计师的要求,以支持游戏中的3D物理,但是仍然存在一个重大问题。 事实是我们的服务器应用程序是使用C#编写的,而没有使用Unity。
可以选择在服务器上使用C ++库(例如,相同的PhysX),但我们并未认真考虑:由于使用本机代码,因此这种方法很可能导致服务器崩溃。 另外,Interop操作的生产率低下以及PhysX组件(仅在Unity下)的独特性(在其他环境中使用除外)也感到尴尬。
另外,在尝试实现此想法时,发现了其他问题:
- 缺乏在Linux上使用IL2CPP构建Unity的支持,这非常关键,因为在最新版本之一中,我们将游戏服务器切换到了.Net Core 2.1并将其部署在Linux机器上;
- 缺乏在Unity上对服务器进行性能分析的便捷工具;
- Unity应用程序的性能低下:我们只需要一个物理引擎,而不需要Unity中所有可用的功能。
此外,与我们的项目同时,该公司还在开发另一款多人PvP游戏原型。 它的开发人员使用Unity服务器,我们对建议的方法有很多负面反馈。 尤其值得一提的是,Unity服务器运行非常“流”,必须每隔几个小时重新启动一次。
这些问题的结合使我们也放弃了这个想法。 然后,我们决定将游戏服务器保留在.Net Core 2.1上,并选择使用C#编写的另一个开放式物理引擎代替之前使用的VolatilePhysics。 也就是说,我们需要一个C#引擎,因为我们在使用C ++编写的引擎时会担心意外崩溃。
结果,选择了以下引擎进行测试:
对我们来说,主要标准是引擎的性能,将其集成到Unity中及其支持的可能性:如果我们发现其中的任何错误,都不应放弃它。
因此,我们测试了Bepu Physics v1,Bepu Physics v2和Jitter Physics引擎的性能,其中Bepu Physics v2被证明是效率最高的。 此外,他是这三位中唯一继续积极发展的人。
但是,Bepu Physics v2不满足Unity的最后一个集成标准:该库使用SIMD操作和System.Numerics,并且由于使用IL2CPP的移动设备上的程序集中不存在SIMD支持,因此Bepu优化的所有好处都丧失了。 iPhone 5S上的iOS版本中的演示场景非常慢。 我们无法在移动设备上使用此解决方案。
在这里应该解释为什么我们通常对使用物理引擎感兴趣。 在我以前的
一篇文章中,我谈到了我们如何实现游戏的网络部分以及本地预测玩家行为的方式。 简而言之,在客户端和服务器(ECS系统)上执行相同的代码。 客户无需等待服务器的响应即可立即响应玩家的行为-发生了所谓的预测。 当服务器发出响应时,客户端将使用接收到的响应检查世界的预测状态,如果不匹配(错误预测),则基于服务器的响应,对玩家看到的内容进行协调。
主要思想是我们在客户端和服务器上都执行相同的代码,并且错误预测的情况非常少见。 但是,在移动设备上工作时,我们发现没有物理C#引擎能够满足我们的要求:例如,它无法在iPhone 5S上提供稳定的30 fps。
选项三,最终:两个不同的引擎
然后,我们决定进行实验:在客户端和服务器上使用两个不同的物理引擎。 我们认为在我们的情况下这可能行得通:我们的游戏中有一个非常简单的碰撞物理,此外,它是由我们作为单独的ECS系统实施的,而不是物理引擎的一部分。 物理引擎所需的一切就是在3D空间中制作reykast和扫播的能力。
因此,我们决定在客户端上使用内置的物理Unity-PhysX-在服务器上使用Bepu Physics v2。
首先,我们重点介绍了使用物理引擎的界面:
查看代码using System; using System.Collections.Generic; using System.Numerics; namespace Prototype.Common.Physics { public interface IPhysicsWorld : IDisposable { bool HasBody(uint id); void SetCurrentSimulationTick(int tick); void Update(); RayCastHit RayCast(Vector3 origin, Vector3 direction, float distance, CollisionLayer layer, int ticksBehind = 0, List<uint> ignoreIds = null); RayCastHit SphereCast(Vector3 origin, Vector3 direction, float distance, float radius, CollisionLayer layer, int ticksBehind = 0, List<uint> ignoreIds = null); RayCastHit CapsuleCast(Vector3 origin, Vector3 direction, float distance, float radius, float height, CollisionLayer layer, int ticksBehind = 0, List<uint> ignoreIds = null); void CapsuleOverlap(Vector3 origin, float radius, float height, BodyMobilityField bodyMobilityField, CollisionLayer layer, List<Overlap> overlaps, int ticksBehind = 0); void RemoveOrphanedDynamicBodies(WorldState.TableSet currentWorld); void UpdateBody(uint id, Vector3 position, float angle); void CreateStaticCapsule(Vector3 origin, Quaternion rotation, float radius, float height, uint id, CollisionLayer layer); void CreateDynamicCapsule(Vector3 origin, Quaternion rotation, float radius, float height, uint id, CollisionLayer layer); void CreateStaticBox(Vector3 origin, Quaternion rotation, Vector3 size, uint id, CollisionLayer layer); void CreateDynamicBox(Vector3 origin, Quaternion rotation, Vector3 size, uint id, CollisionLayer layer); } }
客户端和服务器对该接口有不同的实现:如前所述,在服务器上,我们将实现与Bepu一起使用,在客户端-Unity。
在这里值得一提的是在服务器上处理物理问题的细微差别。
由于客户端延迟(滞后)从服务器接收世界更新的事实,玩家看到的世界与他在服务器上看到的世界有些不同:他现在和过去都看到了自己。 因此,事实证明播放器在本地射击位于其他服务器上的目标。 因此,由于我们使用本地玩家动作预测系统,因此我们需要补偿在服务器上射击时的延迟。

为了补偿它们,我们需要在服务器上存储最近N毫秒的世界历史,还需要处理历史中的物体,包括它们的物理性质。 就是说,我们的系统必须能够“过去”计算碰撞,rakcast和扫掠。 通常,物理引擎不知道如何执行此操作,使用PhysX的Bepu也不例外。 因此,我们必须自己实现此类功能。
由于我们以每秒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中,有许多适用于物理的读写系统:
- InitPhysicsWorldSystem;
- SpawnPhysicsDynamicsBodiesSystem;
- DestroyPhysicsDynamicsBodiesSystem;
- UpdatePhysicsTransformsSystem;
- MovePhysicsSystem,
以及许多只读系统,例如用于计算射击命中,手榴弹爆炸等的系统。
在世界模拟的每个滴答中,首先执行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;
之后,SpawnPhysicsDynamicsBodiesSystem和DestroyPhysicsDynamicsBodiesSystem系统根据上一次ECS滴答中的更改方式在物理引擎中创建或删除对象。 然后,UpdatePhysicsTransformsSystem根据ECS中的数据更新所有动态实体的位置。
一旦ECS和物理引擎中的数据同步,我们就可以计算对象的运动。 完成所有读写操作后,用于计算游戏逻辑(射击,爆炸,战争迷雾...)的只读系统开始起作用。
适用于Bepu Physics的完整SimulationSlice实现代码:
查看代码 using System; using System.Collections.Generic; using System.Numerics; using BepuPhysics; using BepuPhysics.Collidables; using BepuUtilities.Memory; using Quaternion = BepuUtilities.Quaternion; namespace Prototype.Physics { public partial class BepupPhysicsWorld { private unsafe partial class SimulationSlice : IDisposable { private readonly Dictionary<int, StaticBody> _staticHandlerToBody = new Dictionary<int, StaticBody>(); private readonly Dictionary<int, DynamicBody> _dynamicHandlerToBody = new Dictionary<int, DynamicBody>(); private readonly Dictionary<uint, int> _staticIdToHandler = new Dictionary<uint, int>(); private readonly Dictionary<uint, int> _dynamicIdToHandler = new Dictionary<uint, int>(); private readonly List<uint> _staticIds = new List<uint>(); private readonly List<uint> _dynamicIds = new List<uint>(); private readonly BufferPool _bufferPool; private readonly Simulation _simulation; public SimulationSlice(BufferPool bufferPool) { _bufferPool = bufferPool; _simulation = Simulation.Create(_bufferPool, new NarrowPhaseCallbacks(), new PoseIntegratorCallbacks(new Vector3(0, -9.81f, 0))); } public RayCastHit RayCast(Vector3 origin, Vector3 direction, float distance, CollisionLayer layer, List<uint> ignoreIds=null) { direction = direction.Normalized(); BepupRayCastHitHandler handler = new BepupRayCastHitHandler(_staticHandlerToBody, _dynamicHandlerToBody, layer, ignoreIds); _simulation.RayCast(origin, direction, distance, ref handler); var result = handler.RayCastHit; if (result.IsValid) { var collidableReference = handler.CollidableReference; if (handler.CollidableReference.Mobility == CollidableMobility.Static) { _simulation.Statics.GetDescription(collidableReference.Handle, out var description); result.HitEntityId = _staticHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = description.Pose.Position; } else { _simulation.Bodies.GetDescription(collidableReference.Handle, out var description); result.HitEntityId = _dynamicHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = description.Pose.Position; } } return result; } public RayCastHit SphereCast(Vector3 origin, Vector3 direction, float distance, float radius, CollisionLayer layer, List<uint> ignoreIds = null) { direction = direction.Normalized(); SweepCastHitHandler handler = new SweepCastHitHandler(_staticHandlerToBody, _dynamicHandlerToBody, layer, ignoreIds); _simulation.Sweep(new Sphere(radius), new RigidPose(origin, Quaternion.Identity), new BodyVelocity(direction.Normalized()), distance, _bufferPool, ref handler); var result = handler.RayCastHit; if (result.IsValid) { var collidableReference = handler.CollidableReference; if (handler.CollidableReference.Mobility == CollidableMobility.Static) { _simulation.Statics.GetDescription(collidableReference.Handle, out var description); result.HitEntityId = _staticHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = description.Pose.Position; } else { var reference = new BodyReference(collidableReference.Handle, _simulation.Bodies); result.HitEntityId = _dynamicHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = reference.Pose.Position; } } return result; } public RayCastHit CapsuleCast(Vector3 origin, Vector3 direction, float distance, float radius, float height, CollisionLayer layer, List<uint> ignoreIds = null) { direction = direction.Normalized(); var length = height - 2 * radius; SweepCastHitHandler handler = new SweepCastHitHandler(_staticHandlerToBody, _dynamicHandlerToBody, layer, ignoreIds); _simulation.Sweep(new Capsule(radius, length), new RigidPose(origin, Quaternion.Identity), new BodyVelocity(direction.Normalized()), distance, _bufferPool, ref handler); var result = handler.RayCastHit; if (result.IsValid) { var collidableReference = handler.CollidableReference; if (handler.CollidableReference.Mobility == CollidableMobility.Static) { _simulation.Statics.GetDescription(collidableReference.Handle, out var description); result.HitEntityId = _staticHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = description.Pose.Position; } else { var reference = new BodyReference(collidableReference.Handle, _simulation.Bodies); result.HitEntityId = _dynamicHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = reference.Pose.Position; } } return result; } public void CapsuleOverlap(Vector3 origin, float radius, float height, BodyMobilityField bodyMobilityField, CollisionLayer layer, List<Overlap> overlaps) { var length = height - 2 * radius; var handler = new BepupOverlapHitHandler( bodyMobilityField, layer, _staticHandlerToBody, _dynamicHandlerToBody, overlaps); _simulation.Sweep( new Capsule(radius, length), new RigidPose(origin, Quaternion.Identity), new BodyVelocity(Vector3.Zero), 0, _bufferPool, ref handler); } public void CreateDynamicBox(Vector3 origin, Quaternion rotation, Vector3 size, uint id, CollisionLayer layer) { var shape = new Box(size.X, size.Y, size.Z); var pose = new RigidPose() { Position = origin, Orientation = rotation }; var handler = CreateDynamic(shape, pose, false, id, layer); var body = _dynamicHandlerToBody[handler]; body.Box = shape; _dynamicHandlerToBody[handler] = body; } public void CreateStaticBox(Vector3 origin, Quaternion rotation, Vector3 size, uint id, CollisionLayer layer) { var shape = new Box(size.X, size.Y, size.Z); var pose = new RigidPose() { Position = origin, Orientation = rotation }; var handler =CreateStatic(shape, pose, false, id, layer); var body = _staticHandlerToBody[handler]; body.Box = shape; _staticHandlerToBody[handler] = body; } public void CreateStaticCapsule(Vector3 origin, Quaternion rotation, float radius, float height, uint id, CollisionLayer layer) { var length = height - 2 * radius; var shape = new Capsule(radius, length); var pose = new RigidPose() { Position = origin, Orientation = rotation }; var handler =CreateStatic(shape, pose, true, id, layer); var body = _staticHandlerToBody[handler]; body.Capsule = shape; _staticHandlerToBody[handler] = body; } public void CreateDynamicCapsule(Vector3 origin, Quaternion rotation, float radius, float height, uint id, CollisionLayer layer) { var length = height - 2 * radius; var shape = new Capsule(radius, length); var pose = new RigidPose() { Position = origin, Orientation = rotation }; var handler = CreateDynamic(shape, pose, true, id, layer); var body = _dynamicHandlerToBody[handler]; body.Capsule = shape; _dynamicHandlerToBody[handler] = body; } private int CreateDynamic<TShape>(TShape shape, RigidPose pose, bool isCapsule, uint id, CollisionLayer collisionLayer) where TShape : struct, IShape { var activity = new BodyActivityDescription() { SleepThreshold = -1 }; var collidable = new CollidableDescription() { Shape = _simulation.Shapes.Add(shape), SpeculativeMargin = 0.1f, }; var capsuleDescription = BodyDescription.CreateKinematic(pose, collidable, activity); var handler = _simulation.Bodies.Add(capsuleDescription); _dynamicIds.Add(id); _dynamicIdToHandler.Add(id, handler); _dynamicHandlerToBody.Add(handler, new DynamicBody { BodyReference = new BodyReference(handler, _simulation.Bodies), Id = id, IsCapsule = isCapsule, CollisionLayer = collisionLayer }); return handler; } private int CreateStatic<TShape>(TShape shape, RigidPose pose, bool isCapsule, uint id, CollisionLayer collisionLayer) where TShape : struct, IShape { var capsuleDescription = new StaticDescription() { Pose = pose, Collidable = new CollidableDescription() { Shape = _simulation.Shapes.Add(shape), SpeculativeMargin = 0.1f, } }; var handler = _simulation.Statics.Add(capsuleDescription); _staticIds.Add(id); _staticIdToHandler.Add(id, handler); _staticHandlerToBody.Add(handler, new StaticBody { Description = capsuleDescription, Id = id, IsCapsule = isCapsule, CollisionLayer = collisionLayer }); return handler; } public void RemoveOrphanedDynamicBodies(TableSet currentWorld) { var toDel = stackalloc uint[_dynamicIds.Count]; var toDelIndex = 0; foreach (var i in _dynamicIdToHandler) { if (currentWorld.DynamicPhysicsBody.HasCmp(i.Key)) { continue; } toDel[toDelIndex] = i.Key; toDelIndex++; } for (int i = 0; i < toDelIndex; i++) { var id = toDel[i]; var handler = _dynamicIdToHandler[id]; _simulation.Bodies.Remove(handler); _dynamicHandlerToBody.Remove(handler); _dynamicIds.Remove(id); _dynamicIdToHandler.Remove(id); } } public bool HasBody(uint id) { return _staticIdToHandler.ContainsKey(id) || _dynamicIdToHandler.ContainsKey(id); } public void RestoreBodiesFromPreviousTick(SimulationSlice previous) { var oldStaticCount = previous._staticIds.Count;
另外,除了在服务器上实现历史记录之外,我们还需要在客户端上实现物理历史记录。 我们的Unity客户端具有服务器仿真模式(我们称为本地仿真),在该模式下,服务器代码与客户端一起运行。 我们使用此模式来快速制作游戏功能原型。
与Bepu一样,PhysX也没有历史记录支持。 在这里,我们对服务器上的每个历史记录使用了几次物理模拟,使用了相同的想法。 但是,Unity在使用物理引擎时强加了自己的细节。 但是,应该注意的是,我们的项目是在Unity 2018.4(LTS)上开发的,某些API可能会在较新的版本中更改,因此不会出现像我们这样的问题。
问题在于,Unity不允许创建单独的物理模拟(或用PhysX术语表示为场景),因此我们在Unity上的物理历史中将每个滴答实现为单独的场景。
在此类情况下编写了包装器类-UnityPhysicsHistorySlice:
public UnityPhysicsHistorySlice(SphereCastDelegate sphereCastDelegate, OverlapSphereNonAlloc overlapSphere, CapsuleCastDelegate capsuleCast, OverlapCapsuleNonAlloc overlapCapsule, string name) { _scene = SceneManager.CreateScene(name, new CreateSceneParameters() { localPhysicsMode = LocalPhysicsMode.Physics3D }); _physicsScene = _scene.GetPhysicsScene(); _sphereCast = sphereCastDelegate; _capsuleCast = capsuleCast; _overlapSphere = overlapSphere; _overlapCapsule = overlapCapsule; _boxPool = new PhysicsSceneObjectsPool<BoxCollider>(_scene, "box", 0); _capsulePool = new PhysicsSceneObjectsPool<UnityEngine.CapsuleCollider>(_scene, "sphere", 0); }
Unity的第二个问题是,所有与物理学有关的工作都是通过Physics静态类完成的,该类的API不允许您在特定场景中执行rakecast和scancast。 该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中的代码没有太大不同。
因此,我们获得了两种游戏物理实现:在客户端和服务器上。
下一步是测试。
客户的“健康状况”最重要的指标之一是服务器误判次数的参数。 在切换到不同的物理引擎之前,该指标在1-2%范围内变化-也就是说,在持续9000滴答声(或5分钟)的战斗中,我们被误认为是90-180滴答声。 我们在软休息室中通过游戏的多个发行版获得了这些结果。 切换到不同的引擎后,我们预计该指标会强劲增长-甚至可能是几十倍-毕竟,现在我们在客户端和服务器上执行了不同的代码,而且不同算法的计算错误会迅速累积,这似乎是合乎逻辑的。 实际上,事实证明,差异参数仅增长0.2-0.5%,平均每场战斗2-2.5%,这完全适合我们。
我们研究的大多数引擎和技术在客户端和服务器上都使用相同的代码。 但是,我们关于使用不同物理引擎的可能性的假设得到了证实。 差异率如此之小增长的主要原因是我们使用ECS系统之一来计算物体在空间中的运动和碰撞。 客户端和服务器上的代码均相同。 从物理引擎上,我们需要快速计算瑞克广播和扫视广播,并且在实践中,对于我们的两个引擎而言,这些操作的结果相差无几。
读什么
总之,与往常一样,这里有一些相关链接: