Ağ gecikme telafisi algoritmasıyla mobil bir tetikçi için balistik hesaplamaların mekaniğini nasıl geliştirdik?

Ağ gecikme telafisi algoritmasıyla mobil bir tetikçi için balistik hesaplamaların mekaniğini nasıl geliştirdik?

Merhaba, ben Pixonic'ten sunucu geliştiricisi Nikita Brizhak. Bugün mobil çok oyunculu oyundaki gecikmeyi telafi etmekten bahsetmek istiyorum.

Sunucu gecikme telafisi hakkında Rusça da dahil olmak üzere birçok makale yazıldı. Bu teknoloji 90'ların sonlarından beri çok oyunculu FPS'nin oluşturulmasında aktif olarak kullanıldığı için bu şaşırtıcı değil. Örneğin ilk kullananlardan biri olan QuakeWorld modunu hatırlayabilirsiniz.

Bunu aynı zamanda mobil çok oyunculu nişancı oyunumuz Dino Squad'da da kullanıyoruz.

Bu makalede amacım daha önce binlerce kez yazılanları tekrarlamak değil, teknoloji yığınımızı ve temel oynanış özelliklerimizi dikkate alarak oyunumuzda gecikme telafisini nasıl uyguladığımızı anlatmaktır.

Korteksimiz ve teknolojimiz hakkında birkaç söz.

Dino Squad bir ağ mobil PvP nişancı oyunudur. Oyuncular çeşitli silahlarla donatılmış dinozorları kontrol eder ve 6'ya 6 takım halinde birbirleriyle savaşırlar.

Hem istemci hem de sunucu Unity'yi temel alır. Mimari, atıcılar için oldukça klasiktir: Sunucu otoriterdir ve istemci tahmini, istemciler üzerinde çalışır. Oyun simülasyonu şirket içi ECS kullanılarak yazılır ve hem sunucuda hem de istemcide kullanılır.

Gecikme telafisini ilk kez duyuyorsanız, burada konuya kısa bir bakış atacağız.

Çok oyunculu FPS oyunlarında maç genellikle uzak bir sunucuda simüle edilir. Oyuncular girdilerini (basılan tuşlarla ilgili bilgileri) sunucuya gönderir ve buna yanıt olarak sunucu, alınan verileri dikkate alarak onlara güncellenmiş bir oyun durumu gönderir. Bu etkileşim şemasıyla ileri tuşuna basılması ile oyuncu karakterinin ekranda hareket ettiği an arasındaki gecikme her zaman ping'den daha büyük olacaktır.

Yerel ağlarda bu gecikme (halk arasında giriş gecikmesi olarak adlandırılır) fark edilmeyebilir, İnternet üzerinden oynarken bir karakteri kontrol ederken "buz üzerinde kayıyor" hissi yaratır. Bu sorun, bir oyuncunun pinginin 200 ms olduğu durumun hala mükemmel bir bağlantı olarak kabul edildiği mobil ağlar için iki kat önemlidir. Genellikle ping 350, 500 veya 1000 ms olabilir. O zaman giriş gecikmesi olan hızlı bir nişancı oyunu oynamak neredeyse imkansız hale gelir.

Bu sorunun çözümü istemci tarafı simülasyon tahminidir. Burada istemci, sunucudan yanıt beklemeden girdiyi oyuncu karakterine kendisi uygular. Cevap alındığında ise sadece sonuçları karşılaştırıyor ve rakiplerin pozisyonlarını güncelliyor. Bu durumda bir tuşa basılması ile sonucun ekranda görüntülenmesi arasındaki gecikme minimum düzeydedir.

Buradaki nüansı anlamak önemlidir: İstemci her zaman kendisini son girişine göre çizer ve düşmanları, sunucudan gelen verilerden önceki duruma göre ağ gecikmesiyle çizer. Yani oyuncu bir düşmana ateş ederken onu kendisine göre geçmişte görür. Müşteri tahmini hakkında daha fazla bilgi daha önce yazdık.

Böylece, müşteri tahmini bir sorunu çözer, ancak başka bir sorun yaratır: Bir oyuncu, sunucuda aynı noktaya ateş ederken düşmanın geçmişte olduğu noktaya ateş ederse, düşman artık o yerde olmayabilir. Sunucu gecikme telafisi bu sorunu çözmeye çalışır. Bir silah ateşlendiğinde sunucu, oyuncunun atış anında yerel olarak gördüğü oyun durumunu geri yükler ve gerçekten düşmanı vurup vuramayacağını kontrol eder. Cevap "evet" ise, o noktada düşman artık sunucuda olmasa bile vuruş sayılır.

Bu bilgiyle donanmış olarak Dino Squad'da sunucu gecikme telafisini uygulamaya başladık. Her şeyden önce, müşterinin gördüklerini sunucuya nasıl geri yükleyeceğimizi anlamamız gerekiyordu. Peki tam olarak neyin onarılması gerekiyor? Oyunumuzda silahlardan ve yeteneklerden gelen isabetler, ışın yayınları ve katmanlar, yani düşmanın fiziksel çarpıştırıcılarıyla etkileşimler aracılığıyla hesaplanır. Buna göre oyuncunun yerel olarak "gördüğü" bu çarpıştırıcıların konumunu sunucuda yeniden oluşturmamız gerekiyordu. O zamanlar Unity 2018.x sürümünü kullanıyorduk. Oradaki fizik API'si statiktir, fiziksel dünya tek bir kopya halinde mevcuttur. Durumunu kaydetmenin ve ardından kutudan geri yüklemenin bir yolu yoktur. Peki ne yapmalı?

Çözüm yüzeydeydi; tüm unsurları bizim tarafımızdan diğer sorunları çözmek için zaten kullanılmıştı:

  1. Her müşteri için, tuşlara bastığında rakipleri ne zaman gördüğünü bilmemiz gerekiyor. Bu bilgiyi zaten girdi paketine yazdık ve bunu müşteri tahminini ayarlamak için kullandık.
  2. Oyun durumlarının geçmişini saklayabilmemiz gerekiyor. Rakiplerimizin (ve dolayısıyla çarpıştırıcılarının) pozisyonlarını burada tutacağız. Sunucuda zaten bir durum geçmişimiz vardı, bunu oluşturmak için kullandık deltalar. Doğru zamanı bildiğimiz için tarihteki doğru durumu kolaylıkla bulabiliriz.
  3. Artık geçmişteki oyun durumu elimizde olduğuna göre, oyuncu verilerini fiziksel dünyanın durumuyla senkronize edebilmemiz gerekiyor. Mevcut çarpıştırıcılar - taşıyın, eksik olanları - yaratın, gereksiz olanları - yok edin. Bu mantık da zaten yazılmıştı ve birkaç ECS sisteminden oluşuyordu. Tek bir Unity sürecinde birden fazla oyun odasını bir arada tutmak için kullandık. Ve fiziksel dünya her süreç için bir olduğundan, odalar arasında yeniden kullanılması gerekiyordu. Simülasyonun her tıklamasından önce, fiziksel dünyanın durumunu "sıfırladık" ve mevcut odanın verileriyle yeniden başlattık, akıllı bir havuzlama sistemi aracılığıyla Unity oyun nesnelerini mümkün olduğunca yeniden kullanmaya çalıştık. Geriye kalan tek şey geçmişteki oyun durumu için aynı mantığı çağırmaktı.

Tüm bu unsurları bir araya getirerek fiziksel dünyanın durumunu doğru ana döndürebilecek bir “zaman makinesi” elde ettik. Kodun basit olduğu ortaya çıktı:

public class TimeMachine : ITimeMachine
{
     //История игровых состояний
     private readonly IGameStateHistory _history;

     //Текущее игровое состояние на сервере
     private readonly ExecutableSystem[] _systems;

     //Набор систем, расставляющих коллайдеры в физическом мире 
     //по данным из игрового состояния
     private readonly GameState _presentState;

     public TimeMachine(IGameStateHistory history, GameState presentState, ExecutableSystem[] timeInitSystems)
     {
         _history = history; 
         _presentState = presentState;
         _systems = timeInitSystems;  
     }

     public GameState TravelToTime(int tick)
     {
         var pastState = tick == _presentState.Time ? _presentState : _history.Get(tick);
         foreach (var system in _systems)
         {
             system.Execute(pastState);
         }
         return pastState;
     }
}

Geriye kalan tek şey, atışları ve yetenekleri kolayca telafi etmek için bu makinenin nasıl kullanılacağını bulmaktı.

En basit durumda, mekanikler tek bir isabet taramasına dayandığında her şey açık görünüyor: Oyuncunun ateş etmeden önce fiziksel dünyayı istenen duruma geri döndürmesi, bir raycast yapması, isabeti veya kaçırmayı sayması gerekiyor ve dünyayı ilk durumuna döndürün.

Ancak Dino Squad'da bu türden çok az mekanik var! Oyundaki silahların çoğu, çeşitli simülasyon tikleri (bazı durumlarda düzinelerce tik) için uçan uzun ömürlü mermiler olan mermiler yaratır. Bunlarla ne yapmalı, saat kaçta uçmalılar?

В eski makale Half-Life ağ yığını hakkında Valve'daki adamlar aynı soruyu sordular ve cevapları şuydu: mermi gecikme telafisi sorunludur ve bundan kaçınmak daha iyidir.

Bu seçeneğe sahip değildik: Mermi tabanlı silahlar oyun tasarımının önemli bir özelliğiydi. Bu yüzden bir şeyler bulmamız gerekiyordu. Biraz beyin fırtınası yaptıktan sonra işe yarayacak gibi görünen iki seçeneği formüle ettik:

1. Mermiyi, onu yaratan oyuncunun zamanına bağlarız. Sunucu simülasyonunun her tıklaması, her oyuncunun her mermisi için fiziksel dünyayı istemci durumuna geri döndürüyoruz ve gerekli hesaplamaları gerçekleştiriyoruz. Bu yaklaşım, sunucu üzerinde dağıtılmış bir yüke ve mermilerin öngörülebilir uçuş süresine sahip olmayı mümkün kıldı. Tahmin edilebilirlik bizim için özellikle önemliydi, çünkü düşman mermileri de dahil olmak üzere tüm mermilerin müşteri üzerinde tahmin edilmesi mümkündü.

Ağ gecikme telafisi algoritmasıyla mobil bir tetikçi için balistik hesaplamaların mekaniğini nasıl geliştirdik?
Resimde, 30 numaralı işaretteki oyuncu önceden bir füzeyi ateşliyor: düşmanın hangi yöne doğru koştuğunu görüyor ve füzenin yaklaşık hızını biliyor. Yerel olarak 33. tikte hedefi vurduğunu görüyor. Gecikme telafisi sayesinde sunucuda da görünecek

2. Her şeyi ilk seçenekte olduğu gibi yapıyoruz, ancak mermi simülasyonunun bir tıklamasını saydıktan sonra durmuyoruz, ancak aynı sunucu tıklaması içinde uçuşunu simüle etmeye devam ediyoruz ve her seferinde zamanını sunucuya yaklaştırıyoruz. tek tek işaretleniyor ve çarpıştırıcı konumları güncelleniyor. Bunu iki şeyden biri gerçekleşene kadar yapıyoruz:

  • Kurşunun süresi doldu. Bu, hesaplamaların bittiği anlamına geliyor; bir ıska ya da isabet sayabiliriz. Ve bu da atışın yapıldığı saatin aynısı! Bu bizim için hem artı hem de eksi oldu. Artı - çünkü atış yapan oyuncu için bu, vuruş ile düşmanın sağlığındaki azalma arasındaki gecikmeyi önemli ölçüde azalttı. Dezavantajı ise aynı etkinin rakipler oyuncuya ateş ettiğinde de gözlemlenmesiydi: Görünüşe göre düşman yalnızca yavaş bir roket ateşledi ve hasar zaten sayılmıştı.
  • Mermi sunucu zamanına ulaştı. Bu durumda simülasyonu bir sonraki sunucu tıklamasında herhangi bir gecikme telafisi olmaksızın devam edecektir. Yavaş mermiler için bu, ilk seçeneğe kıyasla fizik geri dönüşlerinin sayısını teorik olarak azaltabilir. Aynı zamanda, simülasyon üzerindeki eşit olmayan yük de arttı: Sunucu ya boştaydı ya da bir sunucu işaretinde birkaç mermi için bir düzine simülasyon işaretini hesaplıyordu.

Ağ gecikme telafisi algoritmasıyla mobil bir tetikçi için balistik hesaplamaların mekaniğini nasıl geliştirdik?
Önceki resimdeki senaryonun aynısı, ancak ikinci şemaya göre hesaplanmıştır. Füze, atışın gerçekleştiği aynı anda sunucu saatine "yakalandı" ve isabet, bir sonraki tıklama kadar erken sayılabilir. 31. tıklamada bu durumda gecikme telafisi artık uygulanmaz

Bizim uygulamamızda, bu iki yaklaşım sadece birkaç kod satırında farklıydı, bu yüzden ikisini de yarattık ve uzun bir süre paralel olarak var oldular. Silahın mekaniğine ve merminin hızına bağlı olarak her dinozor için şu veya bu seçeneği seçtik. Burada dönüm noktası mekaniklerin oyunda “şöyle bir zamanda düşmana bu kadar çok vurursan, falan bonus al” tarzının ortaya çıkmasıydı. Oyuncunun düşmana vurduğu zamanın önemli bir rol oynadığı herhangi bir mekanik, ikinci yaklaşımla çalışmayı reddetti. Böylece ilk seçeneği tercih ettik ve bu artık oyundaki tüm silahlar ve tüm aktif yetenekler için geçerli.

Ayrı olarak, performans konusunu gündeme getirmeye değer. Eğer tüm bunların işleri yavaşlatacağını düşündüyseniz cevap veriyorum: Öyledir. Unity, çarpıştırıcıları hareket ettirme ve açıp kapatma konusunda oldukça yavaştır. Dino Squad'da "en kötü" durumda, savaşta aynı anda birkaç yüz mermi mevcut olabilir. Her mermiyi ayrı ayrı saymak için çarpıştırıcıları hareket ettirmek karşılanamaz bir lükstür. Bu nedenle fizik "geri alma" sayısını en aza indirmemiz kesinlikle gerekliydi. Bunu yapmak için ECS'de oyuncunun zamanını kaydettiğimiz ayrı bir bileşen oluşturduk. Gecikme telafisi gerektiren tüm varlıklara (mermiler, yetenekler vb.) ekledik. Bu tür varlıkları işlemeye başlamadan önce, bu zamana kadar onları kümeleyip birlikte işliyoruz ve her küme için fiziksel dünyayı bir kez geri alıyoruz.

Bu aşamada genel olarak çalışan bir sistemimiz var. Biraz basitleştirilmiş bir biçimde kodu:

public sealed class LagCompensationSystemGroup : ExecutableSystem
{
     //Машина времени
     private readonly ITimeMachine _timeMachine;

     //Набор систем лагкомпенсации
     private readonly LagCompensationSystem[] _systems;
     
     //Наша реализация кластеризатора
     private readonly TimeTravelMap _travelMap = new TimeTravelMap();

    public LagCompensationSystemGroup(ITimeMachine timeMachine, 
        LagCompensationSystem[] lagCompensationSystems)
     {
         _timeMachine = timeMachine;
         _systems = lagCompensationSystems;
     }

     public override void Execute(GameState gs)
     {
         //На вход кластеризатор принимает текущее игровое состояние,
         //а на выход выдает набор «корзин». В каждой корзине лежат энтити,
         //которым для лагкомпенсации нужно одно и то же время из истории.
         var buckets = _travelMap.RefillBuckets(gs);

         for (int bucketIndex = 0; bucketIndex < buckets.Count; bucketIndex++)
         {
             ProcessBucket(gs, buckets[bucketIndex]);
         }

         //В конце лагкомпенсации мы восстанавливаем физический мир 
         //в исходное состояние
         _timeMachine.TravelToTime(gs.Time);
     }

     private void ProcessBucket(GameState presentState, TimeTravelMap.Bucket bucket)
     {
         //Откатываем время один раз для каждой корзины
         var pastState = _timeMachine.TravelToTime(bucket.Time);

         foreach (var system in _systems)
         {
               system.PastState = pastState;
               system.PresentState = presentState;

               foreach (var entity in bucket)
               {
                   system.Execute(entity);
               }
          }
     }
}

Geriye kalan tek şey ayrıntıları yapılandırmaktı:

1. Maksimum hareket mesafesinin zaman içinde ne kadar sınırlanacağını anlayın.

Mobil ağların zayıf olduğu koşullarda oyunu mümkün olduğunca erişilebilir hale getirmek bizim için önemliydi, bu nedenle hikayeyi 30 tıklama marjıyla (20 Hz tıklama hızıyla) sınırladık. Bu, oyuncuların çok yüksek pinglerde bile rakiplere vurmasına olanak tanır.

2. Hangi nesnelerin zaman içinde hareket ettirilebileceğini ve hangilerinin hareket ettirilemeyeceğini belirleyin.

Elbette rakiplerimizi harekete geçiriyoruz. Ancak örneğin kurulabilir enerji kalkanları öyle değil. Çevrimiçi nişancı oyunlarında sıklıkla yapıldığı gibi, savunma yeteneğine öncelik vermenin daha iyi olacağına karar verdik. Oyuncu halihazırda bir kalkan yerleştirmişse, geçmişten gelen gecikme telafili mermiler bunun içinden uçmamalıdır.

3. Dinozorların yeteneklerini telafi etmenin gerekli olup olmadığına karar verin: ısırma, kuyruk vuruşu vb. Neye ihtiyaç olduğuna karar verdik ve bunları mermilerle aynı kurallara göre işledik.

4. Gecikme telafisi yapılan oyuncunun çarpıştırıcılarıyla ne yapılacağını belirleyin. İyi bir anlamda, konumları geçmişe kaymamalıdır: Oyuncu kendisini şu anda sunucuda olduğu anda görmelidir. Ancak şut çeken oyuncunun çarpıştırıcılarını da geri alıyoruz ve bunun birkaç nedeni var.

Birincisi, kümelenmeyi geliştirir: yakın pinglere sahip tüm oyuncular için aynı fiziksel durumu kullanabiliriz.

İkinci olarak, tüm ışın yayınlarında ve örtüşmelerde, yeteneklere veya mermilere sahip olan oyuncunun çarpıştırıcılarını her zaman hariç tutuyoruz. Dino Squad'da oyuncular, nişancı standartlarına göre oldukça standart dışı geometriye sahip dinozorları kontrol ediyor. Oyuncu alışılmadık bir açıyla atış yapsa ve merminin yörüngesi oyuncunun dinozor çarpıştırıcısından geçse bile mermi bunu görmezden gelecektir.

Üçüncüsü, gecikme telafisi başlamadan önce bile ECS'den gelen verileri kullanarak dinozorun silahının konumlarını veya yeteneğin uygulama noktasını hesaplıyoruz.

Sonuç olarak, gecikme telafili oyuncunun çarpıştırıcılarının gerçek konumu bizim için önemli değil, bu nedenle daha verimli ve aynı zamanda daha basit bir yol izledik.

Ağ gecikmesi kolayca ortadan kaldırılamaz, yalnızca maskelenebilir. Diğer herhangi bir gizleme yöntemi gibi, sunucu gecikme telafisinin de kendi ödünleri vardır. Ateş edilen oyuncu pahasına ateş eden oyuncunun oyun deneyimini geliştirir. Ancak Dino Squad için buradaki seçim açıktı.

Elbette tüm bunların, hem programcılar hem de oyun tasarımcıları için bir bütün olarak sunucu kodunun artan karmaşıklığıyla ödenmesi gerekiyordu. Daha önce simülasyon basit bir sıralı sistem çağrısıysa, gecikme telafisi ile iç içe döngüler ve dallar ortaya çıktı. Ayrıca çalışmayı uygun hale getirmek için çok çaba harcadık.

Unity, 2019 sürümünde (ve belki biraz daha erken bir tarihte) bağımsız fiziksel sahneler için tam destek ekledi. Tüm odalarda ortak olan fiziksel dünyadan hızlı bir şekilde kurtulmak istediğimiz için bunları güncellemeden hemen sonra sunucuya uyguladık.

Her oyun odasına kendi fiziksel sahnesini verdik ve böylece simülasyonu hesaplamadan önce sahneyi komşu odanın verilerinden "temizleme" ihtiyacını ortadan kaldırdık. İlk olarak, üretkenlikte önemli bir artış sağladı. İkincisi, programcının yeni oyun öğeleri eklerken sahne temizleme kodunda bir hata yapması durumunda ortaya çıkan bir dizi hatadan kurtulmayı mümkün kıldı. Bu tür hataları ayıklamak zordu ve genellikle bir odadaki sahnedeki fiziksel nesnelerin durumunun başka bir odaya "akması" ile sonuçlanıyordu.

Ayrıca fiziksel sahnelerin fiziksel dünyanın tarihini depolamak için kullanılıp kullanılamayacağı konusunda da bazı araştırmalar yaptık. Yani, şartlı olarak her odaya bir sahne değil, 30 sahne ayırın ve bunlardan hikayenin depolanacağı döngüsel bir tampon yapın. Genel olarak seçeneğin işe yaradığı ortaya çıktı, ancak biz bunu uygulamadık: üretkenlikte çılgın bir artış göstermedi, ancak oldukça riskli değişiklikler gerektiriyordu. Bu kadar çok sahneyle uzun süre çalışırken sunucunun nasıl davranacağını tahmin etmek zordu. Bu nedenle şu kurala uyduk: “Kırılmamışsa, tamir etme'.

Kaynak: habr.com

Yorum ekle