Optymalizacja RPC w Unreal Engine: Jak przestać zalewać sieć w każdym ticku
W skrócie
Artykuł szczegółowo omawia techniki optymalizacji RPC w Unreal Engine, skupiając się na eliminacji problemu zalewania sieci przez funkcję Tick(). Przedstawiono praktyczną implementację wzorca akumulatora w C++, który pozwala na precyzyjne sterowanie częstotliwością wysyłania danych oraz wykorzystanie kwantyzacji wektorów. Dzięki tym metodom deweloperzy mogą znacząco zredukować obciążenie serwerów i poprawić płynność rozgrywki w trybie Multiplayer.
Każdy deweloper gier Multiplayer prędzej czy później napotyka to samo wąskie gardło sieciowe: klient działający w 144 klatkach na sekundę postanawia wysyłać swój własny stan ruchu do serwera w każdym pojedynczym ticku. W ciągu kilku sekund kolejka sieciowa serwera zostaje całkowicie zalana nadmiarowymi Remote Procedure Calls (RPCs), co powoduje ekstremalne lagi, utratę pakietów i nieuniknione rozłączenie. Twój klient w zasadzie przeprowadza atak Distributed Denial of Service (DDoS) na Twoją własną infrastrukturę serwerową.
Ten scenariusz reprezentuje jedną z najczęstszych pułapek w architekturze gier Multiplayer. Gdy deweloperzy muszą przesyłać niestandardowe dane wejściowe gracza, złożone stany fizyki pojazdów lub mechanikę szybkiego strzelania, umieszczenie RPC wewnątrz funkcji Tick() wydaje się logicznym wyborem dla zapewnienia płynności. Jednak warstwa sieciowa Unreal Engine nie odrzuca automatycznie pośrednich RPC. Jeśli Twoja gra wypycha RPC w każdym ticku, wszystkie z nich trafiają do kolejki i są transmitowane.
W przypadku aktualizacji ruchu i pozycji prawie nigdy nie potrzebujesz 143 pośrednich klatek; potrzebujesz tylko absolutnie najnowszego stanu do replikacji do innych klientów. W tym kompleksowym przewodniku zagłębimy się w temat unreal engine rpc optimization, pokazując dokładnie, jak dławić te oparte na ticku wywołania sieciowe, implementować inteligentną akumulację stanu i drastycznie zredukować narzut pasma w Multiplayer.
Zagrożenie płynące z zdarzeń sieciowych powiązanych z Tickiem
Przed wdrożeniem rozwiązania kluczowe jest zrozumienie anatomii problemu. Kiedy deklarujesz RPC w Unreal Engine, niezależnie od tego, czy jest to Server, Client, czy NetMulticast, instruujesz sterownik sieciowy silnika, aby zserializował parametry funkcji i wypchnął je do kolejki pakietów wychodzących.
Problem z kolejkowaniem
Unreal Engine grupuje wychodzące RPC w pakiety na podstawie NetUpdateFrequency połączenia i limitów przepustowości. Jeśli klient wywołuje Server RPC w każdym ticku przy wysokiej liczbie klatek, silnik spróbuje przetworzyć każde z tych wywołań.
Jeśli RPC jest oznaczone jako Reliable, sytuacja jest katastrofalna. Reliable RPC gwarantują dostarczenie i kolejność wykonywania. Kanał sieciowy szybko się zapełni, a jeśli bufor przepełni się, połączenie zostanie przymusowo zamknięte przez silnik, co skutkuje rozłączeniem gracza.
Jeśli RPC jest oznaczone jako Unreliable, silnik będzie upuszczać pakiety, gdy kolejka się zapełni. Choć zapobiega to twardemu rozłączeniu, prowadzi do potężnego efektu rubber-banding. Serwer może otrzymać klatkę 1, klatkę 2, upuścić klatki 3-100, a następnie przetworzyć klatkę 101. Rezultatem jest nieobliczalny, szarpany ruch, który rujnuje doświadczenie z gry. Jest to częsta przyczyna problemów, gdy zespoły próbują naprawić błąd replikacji RPC w Unreal Engine psujący stany.
Matematyka przepustowości
Przyjrzyjmy się konkretnym liczbom. Wyobraź sobie, że wysyłasz prosty wektor (12 bajtów) i rotator (12 bajtów) via Server RPC. Z narzutem nagłówka RPC, szacujemy to na 32 bajty na wywołanie.
- Przy 30 FPS:
30 * 32 bajty = 960 bajtów/sekundę(około 1 KB/s na klienta). - Przy 144 FPS:
144 * 32 bajty = 4,608 bajtów/sekundę(około 4.6 KB/s na klienta). - Przy 240 FPS:
240 * 32 bajty = 7,680 bajtów/sekundę.
Pomnóż to przez 64 graczy w battle royale, a Twój serwer nagle przetwarza prawie pół megabajta czystego narzutu RPC w każdej sekundzie – tylko dla podstawowego śledzenia ruchu. To się nie skaluje.
Krok 1: Przełamanie zależności od Ticka za pomocą wzorca akumulatora
Najskuteczniejszą strategią dla unreal engine rpc optimization jest oddzielenie częstotliwości wysyłania sieciowego od częstotliwości renderowania klatek przez klienta. Zamiast wypychać RPC w Tick(), powinieneś aktualizować lokalną zmienną w każdym ticku, a następnie użyć timera, aby wysłać (flush) te dane do serwera w stałym, przewidywalnym interwale (np. 10 lub 20 razy na sekundę).
Nazywamy to Wzorcem Akumulatora (Accumulator Pattern). Klient stale gromadzi najnowszy stan, ale transmituje go tylko wtedy, gdy otwiera się bramka sieciowa.
Identyfikacja docelowej częstotliwości
Nie potrzebujesz 144 aktualizacji na sekundę dla płynnego doświadczenia w Multiplayer. Większość nowoczesnych shooterów kompetencyjnych ma serwery z tickrate na poziomie 30Hz lub 60Hz. Dlatego wysyłanie aktualizacji od klienta 15 do 30 razy na sekundę jest zazwyczaj wystarczające, pod warunkiem, że używasz poprawnego client-side prediction i server-side interpolation.
Redukując częstotliwość wysyłania z nieograniczonego 144Hz do limitowanego 20Hz, natychmiast zmniejszasz ruch sieciowy o ponad 85% dla tej konkretnej akcji.
Krok 2: Implementacja Rate-Limitera w C++
Zobaczmy, jak skutecznie zaimplementować to w C++. Stworzymy system, w którym klient śledzi swoją pożądaną lokalizację i rotację w każdym ticku, ale wysyła RPC Server_UpdateTransform tylko w oparciu o zdefiniowaną częstotliwość wysyłania sieciowego.
Plik nagłówkowy (.h)
Najpierw definiujemy nasze zmienne i funkcje w naszej własnej klasie APawn lub ACharacter. Potrzebujemy uchwytu timera (timer handle), częstotliwości aktualizacji oraz zmiennych do przechowywania niewysłanych danych.
UCLASS()
class MYGAME_API AMyCustomPawn : public APawn
{
GENERATED_BODY()
public:
AMyCustomPawn();
virtual void Tick(float DeltaTime) override;
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
protected:
virtual void BeginPlay() override;
// RPC do wysyłania danych do serwera. Oznaczone jako Unreliable dla szybkich, ciągłych aktualizacji.
UFUNCTION(Server, Unreliable, WithValidation)
void Server_SendTransformUpdate(FVector NewLocation, FRotator NewRotation);
private:
// Timer handle dla naszego network flush
FTimerHandle NetworkUpdateTimerHandle;
// Ile razy na sekundę chcemy wysyłać aktualizacje do serwera
UPROPERTY(EditDefaultsOnly, Category = "Network")
float NetworkSendRate;
// Flaga do śledzenia, czy mamy nowe dane, które nie zostały jeszcze wysłane
bool bHasPendingNetworkUpdate;
// Zakumulowane dane czekające na wysłanie
FVector PendingLocation;
FRotator PendingRotation;
// Funkcja wywoływana przez timer do wysyłania danych
void FlushNetworkUpdate();
};
Plik źródłowy (.cpp)
Teraz implementujemy logikę. Konfigurujemy timer w BeginPlay, aktualizujemy nasze zmienne oczekujące w Tick i pozwalamy timerowi obsłużyć faktyczną transmisję sieciową.
#include "MyCustomPawn.h"
#include "TimerManager.h"
AMyCustomPawn::AMyCustomPawn()
{
PrimaryActorTick.bCanEverTick = true;
// Domyślnie wysyłamy 20 aktualizacji na sekundę
NetworkSendRate = 20.0f;
bHasPendingNetworkUpdate = false;
}
void AMyCustomPawn::BeginPlay()
{
Super::BeginPlay();
// Tylko lokalnie sterowany klient powinien uruchamiać timer wysyłania sieciowego
if (IsLocallyControlled())
{
float UpdateInterval = 1.0f / NetworkSendRate; // np. 1.0 / 20.0 = 0.05 sekundy
GetWorld()->GetTimerManager().SetTimer(
NetworkUpdateTimerHandle,
this,
&AMyCustomPawn::FlushNetworkUpdate,
UpdateInterval,
true // Zapętlenie ciągłe
);
}
}
void AMyCustomPawn::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// Tutaj uruchom swoją niestandardową logikę ruchu po stronie klienta
// np. FVector NewLoc = ...; FRotator NewRot = ...;
// SetActorLocationAndRotation(NewLoc, NewRot);
if (IsLocallyControlled())
{
// Zamiast wywoływać RPC tutaj, po prostu przechowujemy najnowszy stan
PendingLocation = GetActorLocation();
PendingRotation = GetActorRotation();
// Oznaczamy, że mamy świeże dane czekające na wysłanie
bHasPendingNetworkUpdate = true;
}
}
void AMyCustomPawn::FlushNetworkUpdate()
{
// Jeśli nie ma nowych danych (np. gracz stoi w miejscu), nie marnuj pasma
if (!bHasPendingNetworkUpdate)
{
return;
}
// Wyślij ostatni zakumulowany stan do serwera
Server_SendTransformUpdate(PendingLocation, PendingRotation);
// Zresetuj flagę, aż następny tick ponownie zmodyfikuje stan
bHasPendingNetworkUpdate = false;
}
bool AMyCustomPawn::Server_SendTransformUpdate_Validate(FVector NewLocation, FRotator NewRotation)
{
// Tutaj dodaj walidację anti-cheat. Czy lokalizacja jest sensowna?
return true;
}
void AMyCustomPawn::Server_SendTransformUpdate_Implementation(FVector NewLocation, FRotator NewRotation)
{
// Serwer otrzymuje dane z ograniczeniem częstotliwości i aplikuje je
SetActorLocationAndRotation(NewLocation, NewRotation);
// Uwaga: Serwer następnie replikowałby to do innych klientów,
// zazwyczaj poprzez standardowe właściwości Replicated, a NIE przez Multicast.
}
Dlaczego ta architektura działa
Ta konfiguracja elegancko rozwiązuje problem zalewania sieci. Niezależnie od tego, czy klient działa w 30 FPS czy 300 FPS, serwer ma gwarancję otrzymania dokładnie NetworkSendRate aktualizacji na sekundę (zakładając brak utraty pakietów).
Co więcej, zaimplementowaliśmy sprawdzenie wyjścia (!bHasPendingNetworkUpdate). Jeśli gracz odejdzie od klawiatury, aby zrobić sobie kawę, klient całkowicie przestaje wysyłać RPC, zwalniając krytyczne pasmo dla aktywnych graczy. To ogromny zysk dla utrzymania stałej wydajności serwera.
Krok 3: Obsługa interpolacji stanu na innych klientach
Gdy zredukujesz częstotliwość wysyłania sieciowego, ruch na serwerze – a w konsekwencji na innych połączonych klientach – stanie się skokowy. Jeśli wysyłasz aktualizacje z częstotliwością 10Hz, postać będzie widocznie teleportować się 10 razy na sekundę na monitorze 60 FPS.
Aby to naprawić, nie możesz po prostu przeskoczyć postacią do nowej lokalizacji. Musisz użyć interpolacji. Gdy serwer replikuje NewLocation do simulated proxies (innych klientów obserwujących gracza), klienci ci muszą płynnie wykonywać FMath::VInterpTo z ich obecnej pozycji do replikowanej pozycji docelowej w czasie.
Zapewnia to, że nawet przy bardzo agresywnym limicie (jak 5 lub 10 aktualizacji na sekundę), wizualna reprezentacja pozostaje idealnie płynna. Jeśli zmagasz się z nieprawidłowym przeskakiwaniem postaci podczas interpolacji, warto sprawdzić jak naprawić desynchronizację lokalizacji gracza w UEFN i Unreal Engine multiplayer.
Krok 4: Batching struktur dla złożonych RPC
Jeśli Twoja gra wymaga przesyłania wielu różnych zmiennych, nie wysyłaj wielu oddzielnych RPC. Każde RPC ma bazowy narzut nagłówka (zazwyczaj minimum około 1-2 bajtów, ale w praktyce więcej, biorąc pod uwagę serializację payloadu).
Jeśli wywołasz Server_SendHealth(), Server_SendArmor() i Server_SendPosition() w tym samym cyklu wysyłania, płacisz koszt nagłówka trzykrotnie.
Zamiast tego stwórz dedykowaną strukturę dla swoich danych sieciowych.
USTRUCT()
struct FPlayerNetworkState
{
GENERATED_BODY()
UPROPERTY()
FVector Location;
UPROPERTY()
FRotator Rotation;
UPROPERTY()
uint8 CurrentWeaponIndex;
UPROPERTY()
bool bIsCrouching;
};
Przekaż tę pojedynczą strukturę przez swoje oparte na timerze RPC. System refleksji Unreal Engine spakuje te zmienne efektywnie w pojedynczy payload pakietu, minimalizując ślad bajtowy Twojego połączenia.
5 dobrych praktyk optymalizacji RPC w Unreal Engine
Aby upewnić się, że Twoja gra skaluje się od testów lokalnych do tysięcy graczy jednocześnie, przyjmij te fundamentalne zasady architektury sieciowej:
- Nigdy nie wysyłaj RPC w Ticku bez bramkowania: Traktuj to jako twardą zasadę. Jeśli RPC znajduje się wewnątrz
Tick(), musi być chronione przez sprawdzenie czasu (np.if (TimeSinceLastRPC > 0.1f)) lub zarządzane przez zapętlony timer. - Priorytetyzuj Unreliable nad Reliable: Dla danych, które aktualizują się w sposób ciągły (ruch, rozglądanie się, broń ciągła), zawsze używaj Unreliable RPC. Jeśli pakiet upadnie, następny pakiet przybywający ułamek sekundy później i tak go nadpisze. Reliable RPC powinny być ściśle zarezerwowane dla absolutnych zmian stanu (np. wystrzał broni, podniesienie przedmiotu, śmierć gracza).
- Używaj kwantyzacji dla floatów i wektorów: Wysyłając dane
FVector, rzadko potrzebujesz pełnej precyzji zmiennoprzecinkowej. Unreal Engine pozwala na kwantyzację wektorów w RPC (np.FVector_NetQuantize100), co zaokrągla wartości do dwóch miejsc po przecinku i drastycznie obniża pasmo wymagane do ich wysłania. - Preferuj standardową replikację dla danych w dół: O ile klienci muszą używać RPC do wysyłania danych do serwera, o tyle serwer rzadko powinien używać Multicast RPC do wysyłania ciągłych danych w dół. Serwer powinien aktualizować zmienną
UPROPERTY(Replicated), pozwalając wbudowanemu menedżerowi replikacji Unreal Engine automatycznie obsłużyć optymalizację pasma, priorytetyzację i sortowanie istotności (relevancy). - Profiluj wcześnie i często: Używaj komendy
net.DumpRelevantActorsoraz narzędzia Network Profiler (NetworkProfiler.exeznajdującego się w plikach binarnych silnika), aby wizualizować dokładnie, ile bajtów Twoje RPC konsumują w każdej klatce. Nigdy nie zgaduj zysków z optymalizacji; mierz je empirycznie.
Obsługa infrastruktury i skalowanie Backend
Opanowanie zawiłości netcode'u Unreal Engine to ogromne przedsięwzięcie. Spędzasz godziny na dostrajaniu timerów, kwantyzacji wektorów i mitygowaniu desynchronizacji tylko po to, by Twoje Dedicated Servers działały płynnie bez przekraczania limitów pasma.
Gdy Twój kod w grze jest już zoptymalizowany, wciąż musisz wdrożyć i skalować te serwery globalnie. Samodzielne budowanie tego wymaga skonfigurowania menedżerów floty, load balancerów, shardingu baz danych i zarządzania certyfikatami SSL – to łatwo 4-6 tygodni intensywnej pracy nad infrastrukturą, która odciąga Cię od projektowania gry.
Dzięki horizOn, te usługi Backend są wstępnie skonfigurowane specjalnie dla deweloperów gier. Otrzymujesz skalowalny hosting Dedicated Servers, synchronizację bazy danych w czasie rzeczywistym i solidną analitykę prosto z pudełka, co pozwala Ci wydać grę zamiast walczyć z infrastrukturą.
Przemyślenia końcowe
Kluczem do unreal engine rpc optimization jest uświadomienie sobie, że przepustowość sieci to skończony, bardzo zmienny zasób. Nie możesz traktować warstwy sieciowej jak standardowego bufora klatek. Odchodząc od wykonywania sterowanego przez Tick i przyjmując Wzorzec Akumulatora, zyskujesz pełną kontrolę nad wyjściem danych Twojej gry. Redukujesz obciążenie serwera, mitygujesz utratę pakietów i tworzysz drastycznie płynniejsze doświadczenie dla graczy z niestabilnymi połączeniami internetowymi.
Pamiętaj, że optymalizacja gry to proces ciągły. Przestań polegać na domyślnych zachowaniach silnika, aby ratowały Cię przed zalewem sieciowym. Przejmij jawną kontrolę nad przepływem danych. Zaimplementuj te limity w swoim obecnym prototypie, monitoruj metryki przed i po użyciu Network Profiler i obserwuj, jak wydajność Twojego serwera szybuje w górę.
Gotowy na skalowanie swojego nowo zoptymalizowanego backendu Multiplayer? Wypróbuj horizOn za darmo lub sprawdź dokumentację API, aby zobaczyć, jak prosta może być profesjonalna infrastruktura gier.