Lag compensation: 'vurdum ama saymadı' nereden geliyor
Nişan alma oyunlarında gecikme telafisini sunucu tarafında nasıl kurduğumu, hangi sayılarla ayarladığımı ve pencereyi nerede kestiğimi anlatıyorum.
Playtestte kaybolan üç isabet
Şubat ayında 32 kişilik bir nişancı prototipinde iç playtest yapıyorduk. Frankfurt'tan bağlanan test kullanıcısının RTT değeri 78 ms idi ve her turdan sonra aynı şeyi yazıyordu: kafasına iki el bastım, hiçbiri saymadı. Sunucu kayıtları onu doğruluyordu; atış anında istemci ekranında hedefin gövdesi nişangâhın tam ortasındaydı, sunucunun o an bildiği dünyada ise hedef 1,4 metre soldaydı.
Bu multiplayer oyun geliştirme tarafında bir hata değil, gecikmenin doğal sonucudur. Oyuncu her zaman geçmişi görür, sunucu ise şimdiyi bilir. Aradaki farkı kapatmayan her nişan alma oyunu, yüksek pingli oyuncuyu sistematik olarak cezalandırır. Ceza ping ile birlikte büyür ve oyuncu bunu ağ sorunu değil, kendi beceriksizliği gibi hisseder.
İşin can sıkıcı yanı, bunun bug gibi görünmemesidir. Kimse çökme raporu açmaz, kimse tekrar üretim adımı yazmaz; oyuncular sadece oyunun kötü hissettirdiğini söyleyip bırakır. Ölçmeye başlayana kadar elinizde şikâyet metninden başka veri olmaz.
Bu yüzden ilk iş şikâyeti sayıya çevirmekti. Sunucuya, her atışta ışının hangi aktörlerden geçtiğini yazdıran küçük bir kayıt ekledik. 40 ms üzeri gecikmeli oyuncuların atışlarının yüzde 12'si sunucuda hiçbir karakterle kesişmiyordu; aynı oran yerel ağdan bağlanan oyuncularda yüzde 2 idi. Aradaki on puan, tamamen telafi edilmemiş gecikmeydi.
Sunucu hitbox geçmişini neden tutmalı
Çözümün adı lag compensation, yani gecikme telafisi. Sunucu her karakterin hitbox'larını her tick'te bir halka tampona yazıyor; biz bu kayda FHitboxSnapshot dedik ve yönetimini LagCompensationComponent.cpp içine topladık. Atış paketi geldiğinde sunucu, o oyuncunun ekranında dünyanın nasıl göründüğü ana kadar hitbox'ları geri sarıyor, ışın testini orada yapıyor ve hemen bugüne dönüyor.
Tampon 64 tick tutuyor; 60 Hz simülasyonda bu 1066 ms'lik geçmiş demek. Oyuncu başına snapshot maliyeti 18 kapsül çarpı 32 bayt, yani 576 bayt; 32 oyuncu ve 64 tick ile toplam 1,1 MB. Sunucu başına bir megabayt, kaçan isabetlerin bedelinin yanında hiçbir şey.
Geri sarma sırasında tüm dünyayı değil, sadece atışın ışını ile kesişebilecek aktörleri taşıyoruz. Süpürülmüş FBoxSphereBounds ile yapılan ön filtre, 32 oyunculu maçta geri sarılan aktör sayısını ortalama 2,3'e düşürdü. Herkesi saran ilk sürümde bu iş atış başına 0,9 ms yiyordu, filtreden sonra 0,08 ms'ye indi.
Snapshot'ı hangi anda aldığınız da sonucu değiştiriyor. İlk sürümde kaydı tick'in başında, yani animasyon güncellenmeden önceki pozdan alıyorduk; koşan bir hedefte kol ve baş kapsülleri bir kare geride kalıyordu. Kaydı PostUpdateWork tick grubuna taşımak, hızlı hareket eden hedeflerde 6-9 cm'lik sistematik sapmayı ortadan kaldırdı.
RTT ve interpolasyon gecikmesi hesabı
Ne kadar geri saracağınızı ping değil, iki bileşenin toplamı belirler: tek yön gecikmesi ve istemcinin interpolasyon tamponu. Kullandığımız formül rewind = RTT / 2 + interpolationDelay. İkinci terimi unutmak, bu konuda gördüğüm en yaygın hata.
İstemci, sunucudan gelen anlık görüntüleri pürüzsüz gösterebilmek için onları bilerek geciktirir. 20 Hz snapshot gönderiyorsanız güvenli tampon iki pakettir, yani 100 ms. 78 ms RTT'li test kullanıcımızda doğru geri sarma miktarı 39 + 100 = 139 ms çıkıyordu; ilk sürümde sadece 39 ms sarıyorduk ve kaçan isabetlerin kaynağı tam olarak buydu.
Bu değeri istemcinin bildirmesine güvenmeyin. Kötü niyetli bir istemci kendi interpolationDelay değerini şişirip daha da geçmişte vurmayı talep edebilir. Biz sunucuda kendi ölçtüğümüz RTT'yi ve istemcinin abone olduğu snapshot hızından türettiğimiz sabit tamponu kullanıyoruz; istemciden gelen tek bilgi atışın tick numarası, o da kıskaçlanıyor.
RTT sabit bir sayı değil, dalgalanan bir seri. Tek ölçüm yerine son 20 pong'un yüzde 25-75 aralığındaki ortalamasını alıyoruz; tek bir tepe değeri geri sarmayı 300 ms'ye fırlatıp isabetleri anlamsızlaştırıyordu. Jitter'ı 15 ms'yi aşan bağlantılarda sonucu yukarı değil aşağı yuvarlıyoruz, çünkü fazla geri sarmanın bedelini atışı yiyen oyuncu ödüyor.
Geri sarma penceresi nerede bitmeli?
Üst sınırı 200 ms'ye çektik. Bu eşiğin altında kalan oyuncularda isabet kaydı, yani hit registration, beklendiği gibi çalışıyor; üstüne çıktığınızda gecikme avantaja dönüşüyor ve köşeden çekilmiş, duvarın arkasına geçmiş bir hedefi vurmak mümkün hale geliyor.
Köşede öldüm şikâyeti, telafi edilen oyuncunun kazancının doğrudan bedelidir. Sıfır telafi yüksek pingli oyuncuyu, sınırsız telafi düşük pingli oyuncuyu cezalandırır. 200 ms bizde iki şikâyet eğrisinin kesiştiği yerdi: 250 ms'de köşe şikâyetleri ikiye katlandı, 150 ms'de yurt dışından bağlanan oyuncuların isabet oranı yüzde 6 düştü. Bu sayıyı bir yapılandırma değeri olarak tutun; harita ölçeği ve mermi hızı değiştikçe yeniden ölçmeniz gerekiyor.
Pencere aynı zamanda bir hile yüzeyidir. Tick numarasını istemciden alıyorsanız, değiştirilmiş bir istemci üç saniye önceki bir tick'i gönderip hedefin eski konumunu vurmayı deneyebilir. ClampRewindTick() içinde hem mutlak sınırı hem de o istemcinin son 32 paketinden hesapladığımız hareketli ortalamayı kontrol ediyoruz; sapma 60 ms'yi aşarsa istek reddediliyor ve olay kayda geçiyor.
Bir de ölüm sonrası atış meselesi var. Geri sarılmış dünyada hedef hayatta, şimdiki dünyada 40 ms önce ölmüş olabilir. Biz bunu kabul ediyoruz: paket sunucuya ulaştığında atıcı hayattaysa vuruş geçerli sayılıyor. Aksi kural, yüksek pingli oyuncunun her mermisini görünmez bir zar atışına çevirir.
Client prediction ve reconciliation ile ilişkisi
Gecikme telafisi tek başına ayakta durmaz; istemci tahmini ile aynı zaman ekseninde olmak zorundadır. İstemci kendi hareketini tick N için tahmin ederken sunucu tick N-8'i işliyorsa, geri sarmada kullanılan tick de bu farkı bilmek zorundadır. Bu farkı sabit sanıp koda gömerseniz, sunucu yükü arttığı anda telafi sessizce kayar ve kimse nedenini bulamaz.
Bizde ikisi tek bir sayaç üzerinden yürüyor. UPredictedMovementComponent her girdiye bir tick numarası basıyor, aynı numara atış paketine de gidiyor ve sunucu server reconciliation sırasında bu numarayı hem hareket doğrulaması hem hitbox geri sarması için kullanıyor. İki ayrı zaman kaynağı tuttuğumuz ilk denemede aradaki bir-iki tick'lik kayma, hızlı yan hareketlerde 30 cm'lik isabet hatası üretiyordu.
Bunu rollback netcode ile karıştırmamak gerekir. Rollback tüm simülasyonu geri alıp yeniden oynatır ve dövüş oyunlarında bunun bedeli ödenebilir. Nişan alma oyununda sadece hitbox'ları geri alırız; fizik, mermiler ve oyun durumu ileri akmaya devam eder. 32 oyuncuda tam rollback denediğimizde sunucu tick süresi 4,1 ms'den 19 ms'ye çıktığı için konu orada kapandı.
Sunucunun oyuncuya ne zaman cevap verdiği de bu zincirin parçası. Reddedilen bir atışta istemcide çoktan oynatılmış mermi izini geri alamazsınız; biz vuruş onayını ayrı ve küçük bir paketle gönderip isabet göstergesini yalnızca sunucu onayında çiziyoruz. 78 ms'lik bağlantıda bu, göstergenin 40 ms geç gelmesi demek, ama yalan söyleyen bir göstergeden çok daha az şikâyet üretiyor.
Nereden başlamalı, neyi ölçmeli
İlk iş, çıplak gözle bakılabilen bir teşhis aracı yazmak. Vuruş anında hem şimdiki hem geri sarılmış hitbox'ları çizen bir DrawDebugCapsule katmanı, tahminlerimden fazlasını gösterdi: kaçan isabetlerin yaklaşık yüzde 70'i interpolasyon tamponunu hesaba katmamaktan, kalanı hitbox'ların animasyon pozuna bir kare geç bağlanmasından geliyordu.
Sonrası yapay gecikme altında test etmek. clumsy ile 60, 120 ve 250 ms tek yön gecikme ekleyip aynı sabit atış senaryosunu 200 kez çalıştırıyoruz; üç profilde de isabet oranı yüzde 3'lük bir bantta kalıyorsa telafi çalışıyor demektir. Bu testi sürüm öncesi kontrol listesine koyun, çünkü hareket koduna atılan her el onu bozabiliyor.
Reddedilen her geri sarma isteğini oyuncu kimliğiyle birlikte saklayın. Bizde bu kayıttan ayda ortalama dört-beş hesap çıkıyor ve hiçbiri istemci tarafı tespitle yakalanmamıştı. Pencereyi sınırlamak sadece adaleti korumuyor, aynı zamanda hile tespitini tahmine değil veriye bağlıyor.
Son olarak şunu not edeyim: telafi penceresini oyuncudan gizlemek yerine anlatmak daha iyi sonuç verdi. Ölüm ekranında rakibin gecikmesini gösteren tek satırı eklediğimizden beri köşe şikâyetleri azalmadı ama tonu değişti; insanlar bunun hile olduğunu düşünmeyi bıraktı.