Powrót do Bloga

Optymalizacja RPC w Unreal Engine: Jak przestać zalewać sieć w każdym ticku

Opublikowano 4 maja 2026
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:

  1. 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.
  2. 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).
  3. 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.
  4. 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).
  5. Profiluj wcześnie i często: Używaj komendy net.DumpRelevantActors oraz narzędzia Network Profiler (NetworkProfiler.exe znajdują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.


Źródło: Network: How not to send all PRC every tick?