Prediksi fisika sisi klien dalam Unity

gambar

TL; DR


Saya membuat demo yang menunjukkan bagaimana menerapkan prediksi sisi klien dari gerakan fisik pemain di Unity - GitHub .

Pendahuluan


Pada awal 2012, saya menulis posting tentang cara menerapkan peramalan di sisi klien dari gerakan fisik pemain di Unity. Berkat Physics.Simulate (), solusi canggung yang saya jelaskan tidak lagi diperlukan. Posting lama masih salah satu yang paling populer di blog saya, tetapi untuk Unity modern informasi ini sudah salah. Karena itu, saya merilis versi 2018.

Apa yang ada di sisi klien?


Dalam permainan multipemain yang kompetitif, menyontek harus dihindari sedapat mungkin. Biasanya ini berarti bahwa model jaringan dengan server otoriter digunakan: klien mengirim informasi yang dimasukkan ke server, dan server mengubah informasi ini menjadi gerakan pemain, dan kemudian mengirimkan potret status pemain ke klien. Dalam hal ini, ada penundaan antara menekan tombol dan menampilkan hasilnya, yang tidak dapat diterima untuk setiap game yang aktif. Prediksi di sisi klien adalah teknik yang sangat populer yang menyembunyikan penundaan, memprediksi apa yang akan terjadi dan segera menunjukkannya kepada pemain. Ketika klien menerima hasil dari server, ia membandingkannya dengan apa yang diprediksi klien, dan jika mereka berbeda, maka ramalan itu salah dan perlu diperbaiki.

Snapshots yang diterima dari server selalu berasal dari masa lalu sehubungan dengan keadaan klien yang diprediksi (misalnya, jika mentransfer data dari klien ke server dan kembali membutuhkan 150 ms, maka setiap snapshot akan ditunda setidaknya 150 ms). Sebagai akibatnya, ketika klien perlu memperbaiki ramalan yang salah, ia harus memutar kembali ke titik ini di masa lalu, dan kemudian mereproduksi semua informasi yang dimasukkan dalam celah untuk kembali ke tempatnya. Jika gerakan pemain dalam permainan didasarkan pada fisika, maka Physics.Simulate () diperlukan untuk mensimulasikan beberapa siklus dalam satu frame. Jika hanya Pengontrol Karakter (atau pemain kapsul, dll.) Yang digunakan saat menggerakkan pemain, maka Anda dapat melakukannya tanpa Fisika. Rangsang () - dan saya berasumsi bahwa kinerjanya akan lebih baik.

Saya akan menggunakan Unity untuk membuat ulang demo jaringan yang disebut "Zen Fisika Jaringan dari Glenn Fiedler ," yang telah lama saya nikmati. Pemain memiliki kubus fisik di mana ia dapat mengerahkan kekuatan, mendorongnya dalam adegan. Demo ini mensimulasikan berbagai kondisi jaringan, termasuk penundaan dan kehilangan paket.

Mulai bekerja


Hal pertama yang harus dilakukan adalah mematikan simulasi fisika otomatis. Meskipun Physics.Simulate () memungkinkan kita memberi tahu sistem fisik kapan harus memulai simulasi, secara default ia melakukan simulasi secara otomatis berdasarkan delta waktu proyek yang tetap. Oleh karena itu, kita akan menonaktifkannya di Edit-> Pengaturan Proyek-> Fisika , hapus centang pada kotak " Simulasi Otomatis ".

Untuk memulai, kami akan membuat implementasi pengguna tunggal yang sederhana. Input diambil sampelnya (w, a, s, d untuk bergerak dan ruang untuk melompat), dan semuanya bermuara pada kekuatan sederhana yang diterapkan pada Rigidbody menggunakan 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); } } } 


Pemain bergerak saat jaringan tidak digunakan

Mengirim input ke server


Sekarang kita perlu mengirim input ke server, yang juga akan mengeksekusi kode gerak ini, membuat snapshot keadaan kubus dan mengirimkannya kembali ke klien.

 // client private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.inputs = inputs; input_msg.tick_number = this.tick_number; this.SendToServer(input_msg); this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); ++this.tick_number; } } 

Tidak ada yang istimewa di sini sejauh ini, satu-satunya hal yang ingin saya perhatikan adalah menambahkan variabel tick_number. Hal ini diperlukan agar ketika server mengirim snapshot dari keadaan kubus kembali ke klien, kita dapat mengetahui kebijaksanaan klien yang sesuai dengan keadaan ini, sehingga kita dapat membandingkan keadaan ini dengan klien yang diprediksi (yang akan kita tambahkan sedikit kemudian).

 // server private void Update() { while (this.HasAvailableInputMessages()) { InputMessage input_msg = this.GetInputMessage(); Rigidbody rigidbody = player.GetComponent<Rigidbody>(); this.AddForcesToPlayer(rigidbody, input_msg.inputs); Physics.Simulate(Time.fixedDeltaTime); StateMessage state_msg; state_msg.position = rigidbody.position; state_msg.rotation = rigidbody.rotation; state_msg.velocity = rigidbody.velocity; state_msg.angular_velocity = rigidbody.angularVelocity; state_msg.tick_number = input_msg.tick_number + 1; this.SendToClient(state_msg); } } 

Semuanya sederhana - server menunggu pesan input, ketika menerimanya, ini mensimulasikan siklus jam. Kemudian dia mengambil snapshot dari keadaan kubus yang dihasilkan dan mengirimkannya kembali ke klien. Anda mungkin memperhatikan bahwa tick_number dalam pesan status lebih besar daripada tick_number dalam pesan input. Ini dilakukan karena secara pribadi secara intuitif lebih nyaman bagi saya untuk memikirkan "keadaan pemain dalam kebijaksanaan 100" sebagai "keadaan pemain pada awal kebijaksanaan 100". Oleh karena itu, keadaan pemain dalam ukuran 100 dalam kombinasi dengan input pemain dalam ukuran 100 menciptakan keadaan baru bagi pemain dalam ukuran 101.

Status n + Input n = Status n + 1


Saya tidak mengatakan bahwa Anda harus mengambilnya dengan cara yang sama, yang utama adalah keteguhan pendekatan.

Harus juga dikatakan bahwa saya tidak mengirim pesan-pesan ini melalui soket nyata, tetapi meniru dengan menulisnya ke antrian, mensimulasikan penundaan dan kehilangan paket. Adegan ini berisi dua kubus fisik - satu untuk klien, yang lain untuk server. Saat memperbarui kubus klien, saya menonaktifkan GameObject dari kubus server, dan sebaliknya.

Namun, saya tidak mensimulasikan bouncing jaringan dan pengiriman paket dalam urutan yang salah, itulah sebabnya saya membuat asumsi bahwa setiap pesan input yang diterima lebih baru daripada yang sebelumnya. Peniruan ini diperlukan untuk menjalankan "klien" dan "server" dengan sangat sederhana dalam satu contoh Unity, sehingga kami dapat menggabungkan server dan kubus klien dalam satu adegan.

Anda juga dapat memperhatikan bahwa jika pesan input dibuang dan tidak mencapai server, maka server mensimulasikan lebih sedikit siklus clock daripada klien, dan karenanya akan menciptakan keadaan yang berbeda. Ini benar, tetapi bahkan jika kami mensimulasikan kelalaian ini, inputnya masih bisa salah, yang juga akan menyebabkan keadaan yang berbeda. Kami akan menangani masalah ini nanti.

Juga harus ditambahkan bahwa dalam contoh ini hanya ada satu klien, yang menyederhanakan pekerjaan. Jika kami memiliki beberapa klien, maka kami akan memerlukan a) saat memanggil Fisika. Mensimulasi () untuk memverifikasi bahwa hanya satu kubus pemain yang diaktifkan di server, atau b) jika server menerima input dari beberapa kubus, simulasikan semuanya bersama-sama.


Tunda 75 ms (150 ms pulang pergi)
0% paket hilang
Kubus kuning - pemain server
Kubus biru - foto terakhir yang diterima oleh klien

Semuanya terlihat baik sejauh ini, tetapi saya sedikit selektif dengan apa yang saya rekam di video untuk menyembunyikan masalah yang agak serius.

Kegagalan Penentuan


Lihatlah sekarang ini:


Aduh ...

Video ini direkam tanpa kehilangan paket, namun simulasi masih bervariasi dengan input yang sama persis. Saya tidak begitu mengerti mengapa ini terjadi - PhysX harus cukup deterministik, jadi saya merasa mengejutkan bahwa simulasi sering kali berbeda. Ini mungkin karena fakta bahwa saya terus-menerus mengaktifkan dan menonaktifkan kubus GameObject, yaitu, ada kemungkinan bahwa masalahnya akan berkurang ketika menggunakan dua contoh Unity yang berbeda. Ini bisa menjadi bug, jika Anda melihatnya di kode di GitHub, maka beri tahu saya.

Namun demikian, perkiraan yang salah adalah fakta penting dalam memperkirakan di sisi klien, jadi mari kita hadapi mereka.

Bisakah saya mundur?


Prosesnya cukup sederhana - ketika klien memprediksi pergerakan, dia menyimpan buffer status (posisi dan rotasi) dan input. Setelah menerima pesan status dari server, ia membandingkan keadaan yang diterima dengan keadaan yang diprediksi dari buffer. Jika mereka berbeda dengan nilai yang terlalu besar, maka kami mendefinisikan kembali keadaan kubus klien di masa lalu, dan kemudian mensimulasikan lagi semua langkah menengah.

 // client private ClientState[] client_state_buffer = new ClientState[1024]; private Inputs[] client_input_buffer = new Inputs[1024]; private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.inputs = inputs; input_msg.tick_number = this.tick_number; this.SendToServer(input_msg); uint buffer_slot = this.tick_number % 1024; this.client_input_buffer[buffer_slot] = inputs; this.client_state_buffer[buffer_slot].position = rigidbody.position; this.client_state_buffer[buffer_slot].rotation = rigidbody.rotation; this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); ++this.tick_number; } while (this.HasAvailableStateMessage()) { StateMessage state_msg = this.GetStateMessage(); uint buffer_slot = state_msg.tick_number % c_client_buffer_size; Vector3 position_error = state_msg.position - this.client_state_buffer[buffer_slot].position; if (position_error.sqrMagnitude > 0.0000001f) { // rewind & replay Rigidbody player_rigidbody = player.GetComponent<Rigidbody>(); player_rigidbody.position = state_msg.position; player_rigidbody.rotation = state_msg.rotation; player_rigidbody.velocity = state_msg.velocity; player_rigidbody.angularVelocity = state_msg.angular_velocity; uint rewind_tick_number = state_msg.tick_number; while (rewind_tick_number < this.tick_number) { buffer_slot = rewind_tick_number % c_client_buffer_size; this.client_input_buffer[buffer_slot] = inputs; this.client_state_buffer[buffer_slot].position = player_rigidbody.position; this.client_state_buffer[buffer_slot].rotation = player_rigidbody.rotation; this.AddForcesToPlayer(player_rigidbody, inputs); Physics.Simulate(Time.fixedDeltaTime); ++rewind_tick_number; } } } } 

Input dan status data yang disangga disimpan dalam buffer melingkar yang sangat sederhana, di mana pengidentifikasi ukuran digunakan sebagai indeks. Dan saya memilih nilai 64 Hz untuk frekuensi clock fisika, yaitu, buffer 1024 elemen memberi kita ruang selama 16 detik, dan ini jauh lebih dari yang kita butuhkan.


Koreksi aktif!

Transfer Input Redundan


Pesan input biasanya sangat kecil - tombol yang ditekan dapat digabungkan menjadi bidang bit yang hanya membutuhkan beberapa byte. Masih ada nomor pengukur dalam pesan kami, yang menempati 4 byte, tetapi kita dapat dengan mudah mengompresnya menggunakan nilai 8-bit dengan carry (mungkin interval 0-255 akan terlalu kecil, kita bisa aman dan meningkatkannya menjadi 9 atau 10 bit). Bagaimanapun, pesan-pesan ini cukup kecil, dan ini berarti bahwa kami dapat mengirim banyak data input di setiap pesan (jika data input sebelumnya hilang). Seberapa jauh ke belakang kita harus kembali? Nah, klien tahu nomor ukuran pesan status terakhir yang ia terima dari server, jadi tidak masuk akal untuk kembali lebih jauh dari ukuran ini. Kita juga perlu memberlakukan batasan pada jumlah data input redundan yang dikirim oleh klien. Saya tidak melakukan ini dalam demo saya, tetapi harus diimplementasikan dalam kode yang sudah selesai.

 while (this.HasAvailableStateMessage()) { StateMessage state_msg = this.GetStateMessage(); this.client_last_received_state_tick = state_msg.tick_number; 

Ini adalah perubahan sederhana, klien hanya menulis nomor ukuran pesan status yang terakhir diterima.

 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); 

Pesan input yang dikirim oleh klien sekarang berisi daftar data input, bukan hanya satu item. Bagian dengan nomor pengukur mendapat nilai baru - sekarang ini adalah nomor pengukur input pertama dalam daftar ini.

 while (this.HasAvailableInputMessages()) { InputMessage input_msg = this.GetInputMessage(); // message contains an array of inputs, calculate what tick the final one is uint max_tick = input_msg.start_tick_number + (uint)input_msg.inputs.Count - 1; // if that tick is greater than or equal to the current tick we're on, then it // has inputs which are new if (max_tick >= server_tick_number) { // there may be some inputs in the array that we've already had, // so figure out where to start uint start_i = server_tick_number > input_msg.start_tick_number ? (server_tick_number - input_msg.start_tick_number) : 0; // run through all relevant inputs, and step player forward Rigidbody rigidbody = player.GetComponent<Rigidbody>(); for (int i = (int)start_i; i < input_msg.inputs.Count; ++i) { this.AddForcesToPlayer(rigidbody, input_msg.inputs[i]); Physics.Simulate(Time.fixedDeltaTime); } server_tick_number = max_tick + 1; } } 

Ketika server menerima pesan input, ia mengetahui jumlah pengukuran input pertama dan jumlah data input dalam pesan. Oleh karena itu, dapat menghitung ukuran input terakhir dalam pesan. Jika ukuran terakhir ini lebih besar dari atau sama dengan nomor ukuran server, maka ia tahu bahwa pesan tersebut mengandung setidaknya satu input yang belum dilihat server. Jika demikian, maka ini mensimulasikan semua data input baru.

Anda mungkin telah memperhatikan bahwa jika kami membatasi jumlah input data yang berlebihan dalam pesan input, maka dengan sejumlah besar pesan input yang hilang, kami akan memiliki celah simulasi antara server dan klien. Artinya, server dapat mensimulasikan ukuran 100, mengirim pesan status untuk mulai mengukur 101, dan kemudian menerima pesan input mulai dari ukuran 105. Dalam kode di atas, server akan pergi ke 105, itu tidak akan mencoba mensimulasikan langkah-langkah antara berdasarkan pada data input terbaru yang diketahui. Apakah Anda membutuhkannya tergantung pada keputusan Anda dan seperti apa permainannya. Secara pribadi, saya tidak akan memaksa server untuk berspekulasi dan memindahkan pemain pada peta karena kondisi jaringan yang buruk. Saya percaya bahwa lebih baik membiarkan pemain di tempatnya sampai koneksi pulih.

Dalam demo "Zen dari Fisika Jaringan" ada fungsi untuk mengirim "gerakan penting" oleh klien, yaitu, ia mengirim data input yang berlebihan hanya jika berbeda dari input yang dikirimkan sebelumnya. Ini bisa disebut kompresi input delta, dan dengan itu Anda dapat mengurangi ukuran pesan input. Namun sejauh ini saya belum melakukannya, karena dalam demo ini tidak ada optimasi pemuatan jaringan.


Sebelum mengirim data input yang berlebihan: ketika 25% paket hilang, gerakan kubus lambat dan berkedut, itu terus dibuang kembali.


Setelah mengirim data input yang berlebihan: dengan kehilangan 25% dari paket, masih ada koreksi berkedut, tetapi kubus bergerak dengan kecepatan yang dapat diterima.

Frekuensi Snapshot Variabel


Dalam demo ini, frekuensi server mengirim foto ke klien bervariasi. Dengan frekuensi yang berkurang, klien akan membutuhkan lebih banyak waktu untuk menerima koreksi dari server. Oleh karena itu, ketika klien keliru dalam ramalan, maka sebelum menerima pesan status dia mungkin menyimpang lebih banyak, yang akan mengarah pada koreksi yang lebih nyata. Dengan snapshot frekuensi tinggi, packet loss jauh kurang penting, sehingga klien tidak perlu menunggu lama untuk menerima snapshot berikutnya.


Frekuensi snapshot 64 Hz


Frekuensi snapshot 16 Hz


Frekuensi snapshot 2 Hz

Jelas, semakin tinggi frekuensi foto, semakin baik, jadi Anda harus mengirimnya sesering mungkin. Tetapi itu juga tergantung pada jumlah traffic tambahan, biayanya, ketersediaan dedicated server, biaya komputasi server, dan sebagainya.

Koreksi perataan


Kami membuat ramalan yang salah dan mendapatkan koreksi tersentak lebih sering dari yang kita inginkan. Tanpa akses yang tepat ke integrasi Unity / PhysX, saya hampir tidak bisa men-debug ramalan yang keliru ini. Saya telah mengatakan ini sebelumnya, tetapi saya ulangi sekali lagi - jika Anda menemukan sesuatu yang berhubungan dengan fisika, yang salah saya, maka beri tahu saya tentang itu.

Saya menghindari solusi untuk masalah ini dengan mengolesi retakan dengan smoothing lama yang baik! Ketika koreksi terjadi, klien cukup menghaluskan posisi dan rotasi pemain ke arah kondisi yang benar untuk beberapa frame. Kubus fisik itu sendiri dikoreksi secara instan (tidak terlihat), tetapi kami memiliki kubus kedua untuk tampilan saja, yang memungkinkan perataan.

 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>(); // capture the current predicted pos for smoothing Vector3 prev_pos = player_rigidbody.position + this.client_pos_error; Quaternion prev_rot = player_rigidbody.rotation * this.client_rot_error; // rewind & replay player_rigidbody.position = state_msg.position; player_rigidbody.rotation = state_msg.rotation; player_rigidbody.velocity = state_msg.velocity; player_rigidbody.angularVelocity = state_msg.angular_velocity; uint rewind_tick_number = state_msg.tick_number; while (rewind_tick_number < this.tick_number) { buffer_slot = rewind_tick_number % c_client_buffer_size; this.client_input_buffer[buffer_slot] = inputs; this.client_state_buffer[buffer_slot].position = player_rigidbody.position; this.client_state_buffer[buffer_slot].rotation = player_rigidbody.rotation; this.AddForcesToPlayer(player_rigidbody, inputs); Physics.Simulate(Time.fixedDeltaTime); ++rewind_tick_number; } // if more than 2ms apart, just snap if ((prev_pos - player_rigidbody.position).sqrMagnitude >= 4.0f) { this.client_pos_error = Vector3.zero; this.client_rot_error = Quaternion.identity; } else { this.client_pos_error = prev_pos - player_rigidbody.position; this.client_rot_error = Quaternion.Inverse(player_rigidbody.rotation) * prev_rot; } } 

Ketika perkiraan yang salah terjadi, klien melacak perbedaan posisi / rotasi setelah koreksi. Jika jarak total koreksi posisi lebih dari 2 meter, maka kubus hanya bergerak dalam sentakan - smoothing masih akan terlihat buruk, jadi biarkan setidaknya kembali ke keadaan yang benar secepat mungkin.

 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; 

Dalam setiap frame, klien melakukan lerp / slerp menuju posisi / rotasi yang benar sebesar 10%, ini adalah pendekatan hukum-kekuatan standar untuk pergerakan rata-rata. Itu tergantung pada frame rate, tetapi untuk keperluan demo kami ini sudah cukup.


Penundaan 250 ms
Kehilangan 10% paket
Tanpa smoothing, koreksi sangat terlihat


Penundaan 250 ms
Kehilangan 10% paket
Dengan smoothing, koreksi jauh lebih sulit untuk diperhatikan.

Hasil akhirnya bekerja dengan sangat baik, saya ingin membuat versi yang benar-benar mengirim paket, daripada meniru mereka. Tetapi setidaknya ini adalah bukti konsep untuk sistem perkiraan sisi klien dengan objek fisik nyata di Unity tanpa perlu plug-in fisik dan sejenisnya.

Source: https://habr.com/ru/post/id432190/


All Articles