
في
مقال سابق ، تحدث زميلي عن كيفية استخدامنا لمحرك الفيزياء ثنائي الأبعاد في لعبة إطلاق النار على اللاعبين المتعددين. والآن أريد أن أشارك كيف ألقينا كل شيء فعلناه من قبل وبدأنا من نقطة الصفر - وبعبارة أخرى ، كيف نقلنا لعبتنا من العالم ثنائي الأبعاد إلى ثلاثي الأبعاد.
بدأ كل شيء بحقيقة أنه بمجرد وصول منتج ومصمم رائد للعبة إلى قسم المبرمجين لدينا ووضعنا أمام تحدٍ: تم إطلاق مطلق النار المحمول PvP Top-Down مع إطلاق النار في المساحات الضيقة إلى شخص ثالث يقوم بإطلاق النار في مناطق مفتوحة. في هذه الحالة ، من المستحسن ألا تبدو البطاقة كما يلي:

و هكذا:

المتطلبات الفنية كانت كما يلي:
- حجم الخريطة - 100 × 100 متر ؛
- فرق الارتفاع - 40 متر ؛
- دعم الأنفاق والجسور ؛
- إطلاق النار على أهداف على ارتفاعات مختلفة ؛
- التصادمات مع الهندسة الثابتة (ليس لدينا تصادمات مع شخصيات أخرى في اللعبة) ؛
- فيزياء السقوط الحر ؛
- قنبلة رمي الفيزياء.
بالنظر إلى المستقبل ، أستطيع أن أقول إن لعبتنا لم تشبه لقطة الشاشة الأخيرة: فقد تبين أنها تقاطع بين الخيارين الأول والثاني.
الخيار الأول: هيكل الطبقات
تم اقتراح الفكرة الأولى بعدم تغيير محرك الفيزياء ، ولكن ببساطة لإضافة عدة طبقات من مستويات "عدد الطوابق". اتضح شيء من خطط الكلمة في المبنى:

مع هذا النهج ، لم نكن بحاجة إلى إعادة تطبيق العميل أو الخادم بشكل جذري ، وبصفة عامة ، يبدو أنه بهذه الطريقة تم حل المهمة ببساطة شديدة. ومع ذلك ، عند محاولة تنفيذه ، واجهنا العديد من المشاكل الحرجة:
- بعد توضيح التفاصيل مع مصممي المستويات ، توصلنا إلى استنتاج مفاده أن عدد "الطوابق" في مثل هذا المخطط يمكن أن يكون مثيرًا للإعجاب: بعض الخرائط تقع في منطقة مفتوحة ذات منحدرات وتلال لطيفة.
- أصبح حساب الزيارات عند إطلاق النار من طبقة إلى أخرى مهمة غير تافهة. يوضح الشكل التالي مثالاً عن مشكلة ما: هنا يمكن للاعب 1 الدخول إلى اللاعب 3 ، وليس اللاعب 2 ، نظرًا لأن طبقة مسار الالتقاط تساوي 2 ، على الرغم من وجود كل من اللاعب 2 واللاعب 3 في نفس الطبقة.

باختصار ، لقد تخلينا عن فكرة تقسيم الفضاء إلى طبقات ثنائية الأبعاد - وقررنا أن نتصرف من خلال استبدال المحرك الفعلي تمامًا.
مما أدى بنا إلى ضرورة اختيار هذا المحرك بالذات وبنائه في تطبيقات العميل والخادم الحالية.
الخيار الثاني: حدد مكتبة جاهزة
نظرًا لأن عميل اللعبة مكتوب في Unity ، فقد قررنا النظر في إمكانية استخدام المحرك الفعلي المدمج في Unity افتراضيًا - PhysX. بشكل عام ، استوفى متطلبات مصممي الألعاب لدينا تمامًا لدعم الفيزياء ثلاثية الأبعاد في اللعبة ، ولكن لا تزال هناك مشكلة كبيرة. وتألفت في حقيقة أن تطبيق الخادم الخاص بنا قد كتب في C # دون استخدام Unity.
كان هناك خيار لاستخدام مكتبة C ++ على خادم - على سبيل المثال ، نفس PhysX - لكننا لم نأخذها في الاعتبار بشكل جدي: بسبب استخدام الكود الأصلي ، كان هناك احتمال كبير لتعطل الخادم مع هذا النهج. تشعر بالحرج أيضًا بسبب انخفاض إنتاجية عمليات Interop وتفرد مجموعة PhysX في ظل وحدة Unity ، باستثناء استخدامها في بيئة أخرى.
بالإضافة إلى ذلك ، في محاولة لتنفيذ هذه الفكرة ، تم اكتشاف مشاكل أخرى:
- عدم وجود دعم لبناء Unity مع IL2CPP على Linux ، والذي اتضح أنه أمر بالغ الأهمية ، لأننا في أحد أحدث الإصدارات قمنا بتبديل خوادم الألعاب الخاصة بنا إلى .Net Core 2.1 ونشرها على أجهزة Linux ؛
- عدم وجود أدوات ملائمة لملقمات ملفات التعريف على الوحدة ؛
- أداء منخفض لتطبيق Unity: كنا بحاجة فقط إلى محرك فعلي ، وليس كل الوظائف المتاحة في Unity.
بالإضافة إلى ذلك ، بالتوازي مع مشروعنا ، كانت الشركة تقوم بتطوير لعبة PvP متعددة النماذج أخرى. استخدم مطوروها خوادم الوحدة ، وحصلنا على الكثير من ردود الفعل السلبية بشأن النهج المقترح. على وجه الخصوص ، كانت إحدى الشكاوى أن خوادم الوحدة كانت "متدفقة" جدًا وكان يجب إعادة تشغيلها كل بضع ساعات.
مزيج من هذه المشاكل جعلتنا نتخلى عن هذه الفكرة أيضا. ثم قررنا ترك خوادم اللعبة على .Net Core 2.1 واختيار بدلاً من VolatilePhysics ، والتي استخدمناها سابقًا ، محرك مادي آخر مفتوح مكتوب بلغة C #. أي أننا كنا بحاجة إلى محرك C # ، لأننا كنا خائفين من حوادث غير متوقعة عند استخدام محركات مكتوبة في C ++.
نتيجة لذلك ، تم اختيار المحركات التالية للاختبارات:
كانت المعايير الرئيسية بالنسبة لنا هي أداء المحرك ، وإمكانية اندماجه في الوحدة ودعمه: لا ينبغي التخلي عنه في حال وجدنا أي أخطاء فيه.
لذلك ، قمنا باختبار محركات Bepu Physics v1 و Bepu Physics v2 و Jitter Physics من أجل الأداء ، ومن بينها Bepu Physics v2 أثبتت أنها الأكثر إنتاجية. بالإضافة إلى ذلك ، فهو الوحيد من بين هؤلاء الثلاثة الذين يواصلون التطوير بنشاط.
ومع ذلك ، لم تفي Bepu Physics v2 بمعايير التكامل المتبقية الأخيرة مع Unity: تستخدم هذه المكتبة عمليات SIMD و System.Numerics ، وبما أنه لا يوجد دعم SIMD في التجميعات على الأجهزة المحمولة مع IL2CPP ، فقد تم فقد جميع فوائد تحسينات Bepu. كان المشهد التجريبي في الإنشاء على iOS على iPhone 5S بطيئًا للغاية. لم نتمكن من استخدام هذا الحل على الأجهزة المحمولة.
هنا يجب توضيح سبب اهتمامنا بشكل عام باستخدام محرك مادي. في أحد مقالاتي السابقة
، تحدثت عن كيفية تطبيقنا لجزء الشبكة من اللعبة وكيف يعمل التنبؤ المحلي بتصرفات اللاعب. باختصار ، يتم تنفيذ نفس الرمز على العميل والخادم - نظام ECS. يستجيب العميل لإجراءات اللاعب فورًا ، دون انتظار استجابة من الخادم - يحدث التنبؤ المزعوم. عندما تأتي استجابة من الخادم ، يقوم العميل بفحص الحالة المتوقعة في العالم بالحالة المستلمة ، وإذا لم تتطابق (سوء التقدير) ، وبناءً على استجابة الخادم ، يتم إجراء تصحيح (تسوية) لما يراه اللاعب.
الفكرة الرئيسية هي أننا ننفذ نفس الكود على كلٍ من العميل والخادم ، وأن حالات سوء التقدير نادرة للغاية. ومع ذلك ، لم تحقق أي من محركات C # الفعلية التي وجدناها متطلباتنا عند العمل على الأجهزة المحمولة: على سبيل المثال ، لا يمكنها توفير 30 إطارًا في الثانية ثابتًا على iPhone 5S.
الخيار الثالث ، النهائي: محركان مختلفان
ثم قررنا تجربة: استخدام اثنين من المحركات المادية المختلفة على العميل والخادم. لقد اعتقدنا أنه في حالتنا هذا يمكن أن ينجح: لدينا فيزياء تصادم بسيطة إلى حد ما في لعبتنا ، علاوة على ذلك ، تم تنفيذها من قبلنا كنظام ECS منفصل ولم يكن جزءًا من المحرك المادي. كان كل ما نحتاجه من المحرك المادي هو القدرة على جعل reykast و sweepcasts في مساحة ثلاثية الأبعاد.
نتيجة لذلك ، قررنا استخدام الوحدة الفيزيائية المدمجة - 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 ، وعلى العميل - الوحدة.
هنا تجدر الإشارة إلى الفروق الدقيقة للعمل مع الفيزياء لدينا على الخادم.
نظرًا لحقيقة أن العميل يتلقى تحديثات العالم من الخادم مع تأخير (تأخر) ، يرى اللاعب العالم مختلفًا تمامًا عما يراه على الخادم: إنه يرى نفسه في الوقت الحاضر ، وبقية العالم في الماضي. لهذا السبب ، اتضح أن اللاعب يطلق النار محليًا على هدف موجود على الخادم في مكان آخر. لذلك ، نظرًا لأننا نستخدم نظام التنبؤ بإجراءات اللاعب المحلي ، نحتاج إلى التعويض عن التأخر عند التصوير على الخادم.

من أجل تعويضهم ، نحتاج إلى تخزين تاريخ العالم على الخادم لآخر N مللي ثانية ، وكذلك لنكون قادرين على العمل مع كائنات من التاريخ ، بما في ذلك الفيزياء الخاصة بهم. وهذا يعني أن نظامنا يجب أن يكون قادرًا على حساب التصادمات والبث المباشر والبث المباشر "في الماضي". وكقاعدة عامة ، لا تعرف المحركات الفيزيائية كيفية القيام بذلك ، وليس Bepu مع PhysX استثناءً. لذلك ، كان علينا أن ننفذ هذه الوظيفة لوحدنا.
نظرًا لأننا قمنا بمحاكاة اللعبة بتردد ثابت قدره 30 علامة في الثانية الواحدة ، كان علينا حفظ بيانات العالم المادي لكل علامة. كانت الفكرة هي عدم إنشاء مثيل واحد من المحاكاة في المحرك الفعلي ، ولكن N - لكل علامة تم تخزينها في السجل - واستخدام المخزن المؤقت الدوري لهذه المحاكاة لتخزينها في التاريخ:
private readonly SimulationSlice[] _simulationHistory = new SimulationSlice[PhysicsConfigs.HistoryLength]; public BepupPhysicsWorld() { _currentSimulationTick = 1; for (int i = 0; i < PhysicsConfigs.HistoryLength; i++) { _simulationHistory[i] = new SimulationSlice(_bufferPool); } }
في ECS لدينا ، هناك عدد من أنظمة القراءة والكتابة التي تعمل مع الفيزياء:
- 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; }
تستعيد طريقة RestoreBodiesFrom PreviousTick موضع الكائنات في المحرك الفعلي في وقت العلامة السابقة من البيانات المخزنة في السجل:
عرض الكود public void RestoreBodiesFromPreviousTick(SimulationSlice previous) { var oldStaticCount = previous._staticIds.Count;
بعد ذلك ، تقوم أنظمة SpawnPhysicsDynamicsBodiesSystem و DestroyPhysicsDynamicsBodiesSystem بإنشاء أو حذف كائنات في المحرك الفعلي وفقًا لكيفية تغييرها في علامة ECS الأخيرة. ثم يقوم UpdatePhysicsTransformsSystem بتحديث موضع جميع الهيئات الديناميكية وفقًا للبيانات الموجودة في ECS.
بمجرد أن تتم مزامنة البيانات الموجودة في ECS ومحرك الفيزياء ، نحسب حركة الكائنات. عند الانتهاء من جميع عمليات القراءة والكتابة ، يتم تشغيل أنظمة للقراءة فقط لحساب منطق اللعبة (اللقطات والانفجارات وضباب الحرب ...).
رمز تنفيذ SimulationSlice الكامل لفيزياء Bepu:
عرض الكود 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;
بالإضافة إلى ذلك ، بالإضافة إلى تنفيذ السجل على الخادم ، نحتاج إلى تطبيق تاريخ الفيزياء على العميل. لدى عميل الوحدة لدينا وضع محاكاة للخادم - نسميها المحاكاة المحلية - حيث يتم تشغيل رمز الخادم مع العميل. نحن نستخدم هذا الوضع لنماذج أولية سريعة لميزات اللعبة.
مثل Bepu ، PhysX لا يوجد لديه دعم التاريخ. هنا استخدمنا نفس الفكرة باستخدام العديد من عمليات المحاكاة المادية لكل علامة في التاريخ كما في الخادم. ومع ذلك ، تفرض الوحدة تفاصيلها الخاصة على العمل مع المحركات المادية. ومع ذلك ، تجدر الإشارة هنا إلى أن مشروعنا قد تم تطويره على Unity 2018.4 (LTS) ، وأن بعض واجهات برمجة التطبيقات قد تتغير في الإصدارات الأحدث ، لذلك لن تكون هناك مشاكل مثلنا.
كانت المشكلة أن الوحدة لم تسمح بإنشاء محاكاة مادية منفصلة (أو ، في مصطلحات PhysX ، مشهد) ، لذلك قمنا بتنفيذ كل علامة في تاريخ الفيزياء على الوحدة كمشهد منفصل.
تمت كتابة فئة غلاف على مثل هذه المشاهد - 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 الساكنة ، والتي لا تسمح لك واجهة برمجة التطبيقات الخاصة بها بتنفيذ برامج rakecasts وعمليات المسح في مشهد معين. واجهة برمجة التطبيقات هذه تعمل فقط مع مشهد نشط واحد. ومع ذلك ، يسمح لك محرك PhysX بالعمل مع العديد من المشاهد في نفس الوقت ، ما عليك سوى استدعاء الطرق الصحيحة. لحسن الحظ ، قامت الوحدة بإخفاء مثل هذه الأساليب خلف واجهة فئة 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 الخاصة بنا. هذا الرمز هو نفسه على كل من العميل وعلى الخادم. من المحرك الفعلي ، كنا بحاجة إلى حساب سريع لبرامج rakecasts و sweepcasts ، ولم تختلف نتائج هذه العمليات في الواقع عن اثنين من محركاتنا.
ماذا تقرأ
في الختام ، كالعادة ، إليك بعض الروابط ذات الصلة: