TL ؛ د
لقد قمت بإنشاء عرض توضيحي يوضح كيفية تطبيق التنبؤ من جانب العميل للحركة البدنية للاعب في Unity -
GitHub .
مقدمة
في أوائل عام 2012 ، كتبت
منشورًا حول كيفية تنفيذ التنبؤ على جانب العميل في الحركة البدنية للاعب في Unity. بفضل
Physics.Simulate () ، لم تعد هناك حاجة إلى هذا الحل البديل
الخبيث الذي وصفته. لا يزال المنشور القديم واحدًا من أكثر المقالات شيوعًا على مدونتي ، لكن بالنسبة للوحدة الحديثة هذه المعلومات غير صحيحة بالفعل. لذلك ، أنا إطلاق الإصدار 2018.
ما هو على جانب العميل؟
في الألعاب متعددة اللاعبين المنافسة ، يجب تجنب الغش كلما كان ذلك ممكنًا. عادةً ما يعني هذا استخدام نموذج شبكة مع خادم استبدادي: يرسل العملاء المعلومات المدخلة إلى الخادم ، ويقوم الخادم بتحويل هذه المعلومات إلى حركة لاعب ، ثم يرسل لقطة من حالة اللاعب إلى العميل. في هذه الحالة ، هناك تأخير بين الضغط على المفتاح وعرض النتيجة ، وهو أمر غير مقبول لأي ألعاب نشطة. التنبؤ من جانب العميل هو أسلوب شائع للغاية يخفي التأخير ، ويتنبأ بما ستكون عليه الحركة الناتجة ويعرضها على الفور للاعب. عندما يتلقى العميل النتائج من الخادم ، يقوم بمقارنتها بما تنبأ به العميل ، وإذا كانت مختلفة ، فسيكون التوقع خاطئًا ويحتاج إلى تصحيح.
تأتي اللقطات التي يتم تلقيها من الخادم دائمًا من الماضي فيما يتعلق بالحالة المتوقعة للعميل (على سبيل المثال ، إذا استغرق نقل البيانات من العميل إلى الخادم 150 مللي ثانية ، فسيتم تأخير كل لقطة بمقدار 150 مللي ثانية على الأقل). نتيجة لذلك ، عندما يحتاج العميل إلى تصحيح التوقعات الخاطئة ، يجب عليه العودة إلى هذه النقطة في الماضي ، ثم إعادة إنتاج جميع المعلومات التي تم إدخالها في الفجوة من أجل العودة إلى مكانه. إذا كانت حركة اللاعب في اللعبة تعتمد على الفيزياء ، فعندئذ ستكون هناك حاجة إلى Physics.Simulate () لمحاكاة عدة دورات في إطار واحد. إذا تم استخدام "التحكم في الأحرف" (أو كبسولة الكبسولات ، إلخ) فقط عند نقل المشغل ، فيمكنك الاستغناء عن Physics.Simulate () - وأفترض أن الأداء سيكون أفضل.
سأستخدم الوحدة لإعادة إنشاء عرض توضيحي للشبكة يسمى
" جلين فيدلر زن في الفيزياء الشبكية" ، والذي استمتعت به منذ فترة طويلة. يمتلك اللاعب مكعبًا جسديًا يمكنه ممارسة القوة به ، ويدفعه إلى مكان الحادث. يحاكي العرض التوضيحي ظروف الشبكة المختلفة ، بما في ذلك التأخير وفقدان الحزمة.
الحصول على العمل
أول شيء فعله هو إيقاف محاكاة الفيزياء التلقائية. على الرغم من أن Physics.Simulate () يسمح لنا بإخبار النظام الفعلي بموعد بدء المحاكاة ، فإنه يقوم افتراضيًا بإجراء المحاكاة تلقائيًا استنادًا إلى دلتا وقت المشروع الثابت. لذلك ، سنقوم بتعطيله في
تحرير-> إعدادات المشروع-> الفيزياء عن طريق إلغاء تحديد مربع "
المحاكاة التلقائية ".
للبدء ، سنقوم بإنشاء تطبيق مستخدم واحد بسيط. يتم أخذ عينات من المدخلات (w ، a ، s ، d للحركة ومساحة للقفز) ، وكل ذلك يعود إلى القوى البسيطة المطبقة على Rigidbody باستخدام AddForce ().
public class Logic : MonoBehaviour { public GameObject player; private float timer; private void Start() { this.timer = 0.0f; } private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs; inputs.up = Input.GetKey(KeyCode.W); inputs.down = Input.GetKey(KeyCode.S); inputs.left = Input.GetKey(KeyCode.A); inputs.right = Input.GetKey(KeyCode.D); inputs.jump = Input.GetKey(KeyCode.Space); this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); } } }
يتحرك اللاعب أثناء عدم استخدام الشبكةإرسال المدخلات إلى الخادم
نحتاج الآن إلى إرسال الإدخال إلى الخادم ، والذي سيؤدي أيضًا إلى تنفيذ رمز الحركة هذا ، ووضع لقطة لحالة المكعب وإرساله مرة أخرى إلى العميل.
لا يوجد شيء مميز هنا حتى الآن ، الشيء الوحيد الذي أود الانتباه إليه هو إضافة متغير tick_number. هناك حاجة لذلك عندما يرسل الخادم لقطات لحالة المكعب مرة أخرى إلى العميل ، يمكننا معرفة براعة العميل يتوافق مع هذه الحالة ، حتى نتمكن من مقارنة هذه الحالة مع العميل المتوقع (والتي سنضيفها لاحقًا).
كل شيء بسيط - الخادم ينتظر رسائل الإدخال ، وعندما يستقبلها ، فإنه يحاكي دورة الساعة. ثم يأخذ لقطة للحالة الناتجة للمكعب ويرسلها مرة أخرى إلى العميل. قد تلاحظ أن tick_number في رسالة الحالة أكبر من tick_number في رسالة الإدخال. يتم ذلك لأنه من المناسب بالنسبة لي شخصياً أن أفكر في "حالة اللاعب في اللباقة 100" باعتبارها "حالة اللاعب في
بداية اللباقة 100". لذلك ، فإن حالة اللاعب في المقياس 100 بالاقتران مع مدخلات اللاعب في المقياس 100 تخلق حالة جديدة للاعب في المقياس 101.
الحالة n + الإدخال n = الحالة n + 1
أنا لا أقول أنه يجب عليك أن تأخذ الأمر بنفس الطريقة ، الشيء الرئيسي هو ثبات النهج.
يجب أن يقال أيضًا أنني لا
أرسل هذه الرسائل عبر مأخذ توصيل حقيقي ، لكن تقليدها عن طريق كتابتها إلى قائمة الانتظار ، مع محاكاة تأخير الحزمة وفقدانها. يحتوي المشهد على مكعبين فعليين - أحدهما للعميل والآخر للخادم. عند تحديث مكعب العميل ، أقوم بتعطيل GameObject من مكعب الخادم والعكس صحيح.
ومع ذلك ، لا أحاكي ارتداد الشبكة وتسليم الحزمة بالترتيب الخاطئ ، ولهذا السبب افترض أن كل رسالة إدخال تم تلقيها أحدث من سابقتها. هناك حاجة إلى هذا التقليد من أجل تنفيذ ببساطة "العميل" و "الخادم" في مثيل واحد للوحدة ، حتى نتمكن من دمج مكعبات الخادم والعميل في مشهد واحد.
يمكنك أيضًا ملاحظة أنه إذا تم تجاهل رسالة الإدخال ولم تصل إلى الخادم ، فسيحاكي الخادم دورات ساعة أقل من العميل ، وبالتالي سينشئ حالة مختلفة. هذا صحيح ، لكن حتى لو قمنا بمثل هذه الإغفالات ، فقد تظل المدخلات غير صحيحة ، الأمر الذي قد يؤدي أيضًا إلى حالة مختلفة. سوف نتعامل مع هذه المشكلة في وقت لاحق.
يجب أيضًا إضافة أنه في هذا المثال ، يوجد عميل واحد فقط ، مما يبسط العمل. إذا كان لدينا العديد من العملاء ، فسنحتاج إلى) عند الاتصال بـ Physics.Simulate () للتحقق من تمكين مكعب لاعب واحد فقط على الخادم ، أو ب) إذا تلقى الخادم مدخلات من عدة مكعبات ، فقم بمحاكاتها جميعًا معًا.
التأخير 75 مللي ثانية (150 رحلة ذهابًا وإيابًا)
0 ٪ حزم المفقودة
مكعب أصفر - لاعب الخادم
المكعب الأزرق - آخر لقطة تلقاها العميليبدو كل شيء جيدًا حتى الآن ، لكنني كنت انتقائيًا قليلاً مع ما قمت بتسجيله على الفيديو لإخفاء مشكلة خطيرة إلى حد ما.
فشل التصميم
ألقِ نظرة الآن على هذا:
أوتش ...تم تسجيل هذا الفيديو دون فقد الحزم ، ومع ذلك ، لا تزال عمليات المحاكاة تختلف باختلاف الإدخال نفسه. لا أفهم تمامًا سبب حدوث ذلك - يجب أن يكون PhysX محددًا تمامًا ، لذلك أجد أنه من المذهل أن تتباين عمليات المحاكاة كثيرًا. قد يكون هذا بسبب حقيقة أنني أقوم باستمرار بتمكين وتعطيل مكعبات GameObject ، أي أنه من المحتمل أن تنخفض المشكلة عند استخدام مثيلين مختلفين للوحدة. يمكن أن يكون خطأ ، إذا كنت ترى ذلك في الكود على جيثب ، فأعلمني بذلك.
مهما كان الأمر ، فإن التنبؤات الخاطئة هي حقيقة أساسية في التنبؤ من جانب العميل ، لذلك دعونا نتعامل معها.
هل يمكنني الترجيع؟
العملية بسيطة للغاية - عندما يتنبأ العميل بالحركة ، فإنه يحفظ مخزنًا مؤقتًا للحالة (الموضع والدوران) والمدخلات. بعد تلقي رسالة حالة من الخادم ، تقارن الحالة المستلمة بالحالة المتوقعة من المخزن المؤقت. إذا كانت تختلف بقيمة كبيرة جدًا ، فإننا نعيد تحديد حالة مكعب العميل في الماضي ، ثم نعيد محاكاة جميع التدابير الوسيطة مرة أخرى.
يتم تخزين بيانات المدخلات والحالة المخزّنة في مخزن مؤقت دائري بسيط للغاية ، حيث يتم استخدام معرف المقياس كفهرس. لقد اخترت قيمة 64 هرتز لتردد الساعة للفيزياء ، أي أن وجود 1024 عنصرًا مؤقتًا يمنحنا مساحة لمدة 16 ثانية ، وهذا أكثر بكثير مما نحتاجه.
التصحيح على!نقل المدخلات الزائدة
عادة ما تكون رسائل الإدخال صغيرة جدًا - يمكن دمج الأزرار المضغوطة في حقل بت لا يتطلب سوى بضع بايت. لا يزال هناك عدد من المقاييس في رسالتنا ، يشغل 4 بايتات ، لكن يمكننا بسهولة ضغطها باستخدام قيمة 8 بت بحمل (ربما سيكون الفاصل الزمني 0-255 صغيرًا جدًا ، ويمكن أن نكون آمنين ونزيده إلى 9 أو 10 بتات). بصرف النظر عن ذلك ، تكون هذه الرسائل صغيرة جدًا ، وهذا يعني أنه يمكننا إرسال الكثير من بيانات الإدخال في كل رسالة (في حالة فقد بيانات الإدخال السابقة). إلى أي مدى يجب أن نعود؟ حسنًا ، يعرف العميل رقم المقياس لرسالة الحالة الأخيرة التي تلقاها من الخادم ، لذلك لا معنى للعودة إلى أبعد من هذا التدبير. نحتاج أيضًا إلى فرض حد على كمية بيانات الإدخال الزائدة التي يرسلها العميل. لم أفعل ذلك في العرض التوضيحي الخاص بي ، لكن يجب تنفيذه في التعليمات البرمجية النهائية.
while (this.HasAvailableStateMessage()) { StateMessage state_msg = this.GetStateMessage(); this.client_last_received_state_tick = state_msg.tick_number;
هذا تغيير بسيط ، حيث يكتب العميل ببساطة رقم قياس آخر رسالة حالة تم تلقيها.
Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.start_tick_number = this.client_last_received_state_tick; input_msg.inputs = new List<Inputs>(); for (uint tick = this.client_last_received_state_tick; tick <= this.tick_number; ++tick) { input_msg.inputs.Add(this.client_input_buffer[tick % 1024]); } this.SendToServer(input_msg);
تحتوي رسالة الإدخال التي أرسلها العميل الآن على قائمة ببيانات الإدخال ، وليس فقط عنصر واحد. يحصل الجزء الذي يحتوي على رقم القياس على قيمة جديدة - الآن هذا هو رقم قياس الإدخال الأول في هذه القائمة.
while (this.HasAvailableInputMessages()) { InputMessage input_msg = this.GetInputMessage();
عندما يتلقى الخادم رسالة إدخال ، فإنه يعرف رقم قياس الإدخال الأول ومقدار بيانات الإدخال في الرسالة. لذلك ، يمكن حساب قياس الإدخال الأخير في الرسالة. إذا كان هذا الإجراء الأخير أكبر من أو يساوي رقم قياس الخادم ، فعندئذٍ يعلم أن الرسالة تحتوي على إدخال واحد على الأقل لم يره الخادم حتى الآن. إذا كان الأمر كذلك ، فإنه يحاكي جميع بيانات الإدخال الجديدة.
ربما لاحظت أننا
إذا قمنا بتحديد كمية بيانات الإدخال الزائدة في رسالة الإدخال ، ثم مع وجود عدد كبير بما فيه الكفاية من رسائل الإدخال المفقودة ، فستكون لدينا فجوة في المحاكاة بين الخادم والعميل. بمعنى أنه يمكن للخادم محاكاة المقياس 100 ، وإرسال رسالة الحالة لبدء التدبير 101 ، ثم تلقي رسالة إدخال تبدأ من المقياس 105. في الرمز أعلاه ، سيذهب الخادم إلى 105 ، ولن يحاول محاكاة المقاييس الوسيطة استنادًا إلى أحدث بيانات الإدخال المعروفة. سواء كنت في حاجة إليها يعتمد على قرارك وما يجب أن تكون عليه اللعبة. شخصيا ، لن أجبر الخادم على التكهن وتحريك اللاعب على الخريطة بسبب الحالة السيئة للشبكة. أعتقد أنه من الأفضل ترك اللاعب في مكانه حتى تتم استعادة الاتصال.
في العرض التوضيحي "Zen of الفيزياء المتصلة بالشبكة" ، توجد وظيفة لإرسال "خطوات مهمة" من قبل العميل ، أي أنه لا يرسل بيانات إدخال زائدة إلا عندما يختلف عن المدخلات المرسلة مسبقًا. هذا يمكن أن يسمى ضغط دلتا الإدخال ، ومعه يمكنك تقليل حجم رسائل الإدخال. لكن حتى الآن لم أفعل ذلك ، لأنه في هذا العرض التوضيحي لا يوجد تحسين لتحميل الشبكة.
قبل إرسال بيانات المدخلات الزائدة: عند فقد 25٪ من الحزم ، تكون حركة المكعب بطيئة وخز ، ولا يزال يتم إرجاعها.بعد إرسال بيانات المدخلات الزائدة: مع فقدان 25 ٪ من الحزم ، لا يزال هناك تصحيح الوخز ، ولكن المكعبات تتحرك بسرعة مقبولة.لقطة متغيرة التردد
في هذا العرض التوضيحي ، يختلف التردد الذي يرسل به الخادم لقطات للعميل. مع التردد المنخفض ، سيحتاج العميل إلى مزيد من الوقت لتلقي التصحيح من الخادم. لذلك ، عندما يكون العميل مخطئًا في التنبؤ ، فإنه قبل أن يتلقى رسالة الحالة ، قد ينحرف أكثر ، مما سيؤدي إلى تصحيح أكثر ملاحظة. مع ارتفاع وتيرة اللقطات ، فإن فقدان الحزمة أقل أهمية بكثير ، لذلك لا يتعين على العميل الانتظار لفترة طويلة حتى يتم استلام اللقطة التالية.
لقطة تردد 64 هرتزلقطة لقطة 16 هرتزلقطة لقطة 2 هرتزمن الواضح أنه كلما زاد معدل تكرار اللقطات ، كان ذلك أفضل ، لذلك يجب عليك إرسالها كلما كان ذلك ممكنًا. ولكنه يعتمد أيضًا على مقدار الحركة الإضافية وتكلفتها وتوافر الخوادم المخصصة وتكاليف الحوسبة للخوادم وما إلى ذلك.
تجانس تصحيح
نحن ننشئ تنبؤات غير صحيحة ونحصل على تصحيحات متقلبة أكثر مما نود. بدون الوصول المناسب إلى تكامل الوحدة / PhysX ، بالكاد يمكنني تصحيح هذه التوقعات الخاطئة. لقد قلت هذا من قبل ، ولكني أكرر مرة أخرى - إذا وجدت شيئًا ما يتعلق بالفيزياء ، وهو ما أخطئ فيه ، فأعلمني بذلك.
لقد تحايلت على حل هذه المشكلة من خلال التمويه على الشقوق بتجانس قديم جيد! عند حدوث تصحيح ، يقوم العميل ببساطة بسلاسة موضع اللاعب وتناوبه في اتجاه الحالة الصحيحة لعدة إطارات. يتم تصحيح المكعب المادي نفسه على الفور (إنه غير مرئي) ، ولكن لدينا مكعب ثاني للعرض فقط ، والذي يسمح بالتجانس.
Vector3 position_error = state_msg.position - predicted_state.position; float rotation_error = 1.f - Quaternion.Dot(state_msg.rotation, predicted_state.rotation); if (position_error.sqrMagnitude > 0.0000001f || rotation_error > 0.00001f) { Rigidbody player_rigidbody = player.GetComponent<Rigidbody>();
عند حدوث توقعات خاطئة ، يتعقب العميل فرق الموضع / الدوران بعد التصحيح. إذا كانت المسافة الكلية لتصحيح الموضع أكثر من مترين ، فإن المكعب يتحرك ببساطة في رعشة - سيبقى التنعيم سيئًا ، لذا دعه على الأقل يعود إلى الحالة الصحيحة في أسرع وقت ممكن.
this.client_pos_error *= 0.9f; this.client_rot_error = Quaternion.Slerp(this.client_rot_error, Quaternion.identity, 0.1f); this.smoothed_client_player.transform.position = player_rigidbody.position + this.client_pos_error; this.smoothed_client_player.transform.rotation = player_rigidbody.rotation * this.client_rot_error;
في كل إطار ، ينفذ العميل lerp / slerp باتجاه الموضع / الدوران الصحيحين بنسبة 10٪ ، وهذا هو نهج قانون القوة القياسي في حساب متوسط الحركة. يعتمد ذلك على معدل الإطار ، ولكن لأغراض العرض التوضيحي الخاص بنا ، هذا يكفي تمامًا.
250 مللي ثانية تأخير
فقدت 10 ٪ من الحزم
بدون تجانس ، يكون التصحيح ملحوظًا للغاية250 مللي ثانية تأخير
فقدت 10 ٪ من الحزم
مع تجانس ، يصعب ملاحظة التصحيح.تعمل النتيجة النهائية بشكل جيد ، أريد إنشاء نسخة ترسل الحزم بالفعل ، بدلاً من تقليدها. ولكن على الأقل هذا دليل على مفهوم نظام التنبؤ من جانب العميل مع كائنات مادية حقيقية في Unity دون الحاجة إلى المكونات الإضافية المادية وما شابه ذلك.