Powrót do Bloga

Optymalizacja skalowania gier mobilnych: Projektowanie architektury miast dla ponad miliona Concurrent Players

Opublikowano 21 maja 2026
Optymalizacja skalowania gier mobilnych: Projektowanie architektury miast dla ponad miliona Concurrent Players

W skrócie

Artykuł szczegółowo opisuje strategie optymalizacji skalowania mobilnych gier MMO, koncentrując się na architekturze miast obsługujących ponad milion graczy. Autor omawia kluczowe techniki, takie jak Spatial Hashing, inteligentne Interest Management oraz asynchroniczny streaming assetów, niezbędne do uniknięcia krytycznych błędów pamięci na urządzeniach mobilnych. Przewodnik dostarcza praktycznych przykładów implementacji w C# i C++ oraz promuje nowoczesne podejście do rozproszonego Backend w celu zapewnienia płynnej rozgrywki przy ekstremalnym obciążeniu sieciowym.

Każdy deweloper gier Multiplayer zna ten moment, w którym architektura gry mobilnej zaczyna pękać. Projektujesz rozległe, piękne środowisko miejskie. Testujesz je lokalnie z 10 symulowanymi klientami, a build działa w idealnych 60 FPS. Następnie wdrażasz go do środowiska produkcyjnego, gdzie 1000 graczy jednocześnie tłoczy się na centralnym placu. W ciągu kilku sekund budżetowe urządzenia z Androidem crashują z powodu wyjątków Out-Of-Memory (OOM), iOS Jetsam agresywnie ubija aplikację, a obciążenie CPU Twojego Dedicated Server skacze do 100%, gdy próbuje on przeliczyć replikację sieciową dla tysięcy nakładających się na siebie encji.

Budując mobilne MMO lub wielkoskalowy otwarty świat zaprojektowany dla milionów aktywnych użytkowników, nie możesz polegać na domyślnych ustawieniach silnika. Hardware mobilny ma rygorystyczne ograniczenia termiczne (thermal throttling) i twarde limity pamięci (często ograniczające grę do mniej niż 2 GB użytkowej pamięci RAM na urządzeniach średniej klasy). Jednocześnie Twój serwer musi obsługiwać gęste skupiska graczy bez uginania się pod obciążeniem.

Osiągnięcie prawdziwej optymalizacji skalowania gry mobilnej wymaga podejścia opartego na trzech filarach: agresywnym podziale przestrzennym (spatial partitioning) na serwerze, bezwzględnym zarządzaniu pamięcią na kliencie oraz rozproszonej architekturze Backend, która udźwignie ogromny wolumen połączeń. W tym tutorialu krok po kroku wyjaśnimy, jak zaprojektować wielkoskalowe miasta na platformy mobilne.

Krok 1: Server-Side Spatial Partitioning

Fundamentalnym wrogiem wydajności serwera w grach Massive Multiplayer jest problem O(N²). Jeśli Twój serwer iteruje przez każdego gracza, aby sprawdzić jego dystans względem wszystkich innych graczy w celu ustalenia, kto potrzebuje aktualizacji sieciowych, obliczenia skalują się katastrofalnie. 100 graczy wymaga 10 000 sprawdzeń dystansu na Tick. 1000 graczy wymaga już 1 000 000 sprawdzeń. Przy Tick Rate serwera wynoszącym 30 Hz, daje to 30 milionów sprawdzeń na sekundę.

Aby to rozwiązać, musimy zaimplementować Spatial Hashing (lub systemy Grid/Quadtree). Dzieląc miasto na logiczną siatkę, gracze sprawdzają Network Relevance tylko względem encji znajdujących się w ich aktualnej komórce oraz bezpośrednio sąsiadujących komórkach. Redukuje to koszmar O(N²) do wyszukiwania w siatce o złożoności O(1), po którym następuje mocno ograniczone sprawdzenie lokalne.

Implementacja Spatial Hash Grid (Przykład C#)

Oto wysoce wydajna implementacja 2D Spatial Hash Grid w C#, którą możesz zaadaptować w Unity, Godot (przez C#) lub autorskim serwerze Backend do zarządzania bliskością encji bez iterowania przez cały stan świata.

using System.Collections.Generic;
using UnityEngine;

public class SpatialHashGrid
{
    private readonly float _cellSize;
    private readonly Dictionary<Vector2Int, HashSet<uint>> _grid;

    public SpatialHashGrid(float cellSize = 50f)
    {
        _cellSize = cellSize;
        _grid = new Dictionary<Vector2Int, HashSet<uint>>();
    }

    // Konwersja pozycji świata na współrzędne siatki
    private Vector2Int GetCellCoordinate(Vector3 position)
    {
        return new Vector2Int(
            Mathf.FloorToInt(position.x / _cellSize),
            Mathf.FloorToInt(position.z / _cellSize)
        );
    }

    // Dodawanie lub aktualizacja pozycji gracza w siatce
    public void UpdateEntityPosition(uint entityId, Vector3 oldPosition, Vector3 newPosition)
    {
        Vector2Int oldCell = GetCellCoordinate(oldPosition);
        Vector2Int newCell = GetCellCoordinate(newPosition);

        if (oldCell != newCell)
        {
            if (_grid.ContainsKey(oldCell))
            {
                _grid[oldCell].Remove(entityId);
            }
            
            if (!_grid.ContainsKey(newCell))
            {
                _grid[newCell] = new HashSet<uint>();
            }
            _grid[newCell].Add(entityId);
        }
    }

    // Pobieranie wszystkich encji w najbliższym sąsiedztwie (9 komórek)
    public List<uint> GetEntitiesInProximity(Vector3 position)
    {
        List<uint> nearbyEntities = new List<uint>();
        Vector2Int centerCell = GetCellCoordinate(position);

        // Iteracja przez siatkę 3x3 wokół gracza
        for (int x = -1; x <= 1; x++)
        {
            for (int y = -1; y <= 1; y++)
            {
                Vector2Int cellToCheck = new Vector2Int(centerCell.x + x, centerCell.y + y);
                if (_grid.TryGetValue(cellToCheck, out HashSet<uint> entitiesInCell))
                {
                    nearbyEntities.AddRange(entitiesInCell);
                }
            }
        }

        return nearbyEntities;
    }
}

Kierując logikę replikacji sieciowej przez GetEntitiesInProximity, Twój serwer oblicza dokładne dystanse tylko dla kilkunastu graczy znajdujących się blisko siebie, co drastycznie redukuje obciążenie CPU i pozwala pojedynczej instancji serwera na swobodną obsługę tysięcy Concurrent Players.

Krok 2: Network Interest Management

Nawet jeśli Spatial Hashing rozwiąże problem wąskiego gardła CPU na serwerze, wciąż pozostaje kwestia Bandwidth. Sieci mobilne (4G/5G) są z natury niestabilne, podatne na wysoki Jitter i mają ścisłe limity przepustowości. Wysyłanie danych o 50 pobliskich graczach w każdym Ticku przepełni bufor gniazda (socket buffer) klienta mobilnego, prowadząc do ekstremalnych Desyncs.

Interest Management (lub Network Relevancy) to praktyka priorytetyzacji tego, co jest wysyłane przez sieć. Gracz oddalony o 2 metry, biorący udział w wymianie ognia, wymaga 30 aktualizacji na sekundę. Gracz oddalony o 40 metrów, idący inną ulicą, potrzebuje tylko 2 aktualizacji na sekundę.

Nadpisywanie Network Relevancy (Przykład C++ w Unreal Engine)

W Unreal Engine możesz przejąć nad tym kontrolę, nadpisując funkcję IsNetRelevantFor. Pozwala to na agresywne odcinanie ruchu sieciowego w oparciu o linię wzroku (line-of-sight) i progi odległości.

bool ACityPlayerCharacter::IsNetRelevantFor(const AActor* RealViewer, const AActor* ViewTarget, const FVector& SrcLocation) const
{
    // 1. Zawsze istotne dla nas samych
    if (RealViewer == this || ViewTarget == this)
    {
        return true;
    }

    // 2. Obliczanie kwadratu odległości (szybsze niż dokładny dystans)
    const float DistanceSquared = FVector::DistSquared(SrcLocation, GetActorLocation());

    // 3. Bezwzględny dystans odcięcia (np. 10 000 jednostek = 100 metrów)
    const float MaxRelevancyDistSq = 100000000.0f; 
    if (DistanceSquared > MaxRelevancyDistSq)
    {
        return false;
    }

    // 4. Dynamiczna częstotliwość aktualizacji sieciowych w zależności od dystansu
    // Jeśli są daleko, obniżamy częstotliwość wysyłania danych
    if (DistanceSquared > 25000000.0f) // 50 metrów
    {
        NetUpdateFrequency = 2.0f; // 2 aktualizacje na sekundę
    }
    else
    {
        NetUpdateFrequency = 30.0f; // 30 aktualizacji na sekundę
    }

    return Super::IsNetRelevantFor(RealViewer, ViewTarget, SrcLocation);
}

Dynamicznie skalując NetUpdateFrequency na podstawie dystansu, możesz zredukować wychodzący Bandwidth serwera o ponad 70%, oszczędzając pakiet danych gracza i zapobiegając skokom opóźnień (latency spikes).

Krok 3: Limity pamięci klienta i streaming assetów

Serwery mają mnóstwo pamięci RAM; telefony komórkowe nie. iPhone 13 posiada 4 GB pamięci zunifikowanej. System operacyjny iOS zazwyczaj rezerwuje z tego około 1,5 GB do 2 GB. Twoja gra musi zmieścić się całkowicie w pozostałych 2 GB. Jeśli załadujesz całe wielkoskalowe miasto do pamięci naraz, system operacyjny natychmiast zamknie aplikację.

Aby przetrwać w tym środowisku, Twoje miasto musi być podzielone na chunki i streamowane asynchronicznie.

  • Hierarchical Level of Detail (HLODs): Zamiast renderować 50 pojedynczych budynków w odległym kwartale miasta (co daje 3000 Draw Calls), musisz upiec cały ten kwartał w jeden statyczny mesh z ujednoliconym atlasem tekstur. Redukuje to liczbę Draw Calls dla odległej geometrii z tysięcy do dokładnie jednego.
  • Systemy Addressable Assets: Nigdy nie używaj twardych referencji w głównych assetach danych. Jeśli gracz pojawia się w Dystrykcie A, klient powinien użyć asynchronicznego ładowania (np. Addressables w Unity lub PrimaryAssetLabels w Unreal), aby pobrać lub załadować tylko tekstury i meshe wymagane dla Dystryktu A. Dystrykt B musi zostać rygorystycznie usunięty z RAM.
  • Texture Compression: Na urządzeniach mobilnych polegaj wyłącznie na ASTC (Adaptive Scalable Texture Compression). Pozwala to na bardzo zmienne rozmiary bloków, dając precyzyjną kontrolę nad stosunkiem zajętości pamięci do jakości wizualnej dla każdej tekstury z osobna.

Krok 4: Rozproszona architektura Backend i sharding serwerów

Ogromna metropolia nie może działać na jednej fizycznej maszynie. Projektując miasto o skali MMO, świat musi być fizycznie podzielony na wiele instancji serwerowych (Shardy lub Nody). Gdy gracz przechodzi przez most z węzła Downtown do węzła Slums, jego połączenie klienckie i stan świata muszą zostać płynnie przekazane (hand-off) między dwoma całkowicie różnymi procesami serwerowymi.

Samodzielna budowa takiego rozwiązania wymaga skonfigurowania klastrów Kubernetes orkiestrowanych przez systemy takie jak Agones, shardingu bazy danych z wykorzystaniem Redis do przekazywania stanu gracza między węzłami oraz dedykowanych load balancerów UDP dla płynnego przełączania połączeń. Solidne zaprojektowanie tego mechanizmu, tak aby gracze nie tracili przedmiotów podczas przejścia, to ogromne przedsięwzięcie – zazwyczaj 4-6 miesięcy dedykowanej pracy DevOps dla doświadczonego zespołu inżynierów.

Jeśli nie obsłużysz poprawnie kolejek RPC i zapisów do bazy danych podczas tych przejść, nieuchronnie dojdziesz do korupcji stanu. Omówiliśmy wcześniej mechanikę naprawiania problemów z replikacją RPC w Unreal Engine psujących stany, a te same zasady mają bezpośrednie zastosowanie do przekazywania przestrzennego między węzłami serwerowymi.

W tym miejscu błyszczą rozwiązania platformowe. Dzięki horizOn te wysokowydajne usługi Backend, synchronizacja bazy danych w czasie rzeczywistym i orkiestracja Dedicated Servers są wstępnie skonfigurowane. Zamiast tracić budżet na projektowanie i debugowanie reguł sieciowych w Kubernetes, możesz skupić się wyłącznie na budowaniu pętli rozgrywki i optymalizacji klienta.

Best Practices w budowaniu mobilnych miast

Aby zapewnić bezbłędne skalowanie miasta dla milionów użytkowników przy zachowaniu wysokiej liczby klatek na budżetowych urządzeniach, trzymaj się rygorystycznie tych zasad architektury:

  1. Agresywny Instance Pooling: Nigdy nie używaj Instantiate() ani SpawnActor dla obiektów tymczasowych, takich jak pojazdy, piesi czy pociski podczas rozgrywki. Mobilne procesory bardzo słabo radzą sobie z alokacją pamięci i Garbage Collection. Przygotuj pule obiektów (Object Pools) podczas ekranu ładowania i cyklicznie je wykorzystuj.
  2. Atlasowanie tekstur dla kwartałów miast: Draw Calls są głównym zabójcą mobilnych procesorów graficznych (które opierają się na Tile-Based Deferred Rendering). Połącz tekstury wszystkich generycznych rekwizytów ulicznych (kosze na śmieci, ławki, latarnie) w jeden duży atlas tekstur. Pozwala to silnikowi na wsadowe renderowanie setek rekwizytów w jednym Draw Call.
  3. Rygorystyczne budżety wielokątów na chunk: Wymuszaj twarde limity. Pojedynczy chunk mobilnego miasta (np. obszar 100x100 metrów) powinien idealnie mieścić się w granicach 300 000 widocznych trójkątów. Polegaj mocno na mapach normalnych (normal maps) zamiast na surowej geometrii do symulowania detali architektonicznych.
  4. Implementacja hibernacji po stronie serwera: Uruchamianie Dedicated Server dla ogromnego miasta, w którym 80% mapy jest obecnie puste, to najprostsza droga do bankructwa studia. Potrzebujesz agresywnego zarządzania instancjami, czerpiąc inspirację z propozycji optymalizacji hibernacji serwerów Fortnite, aby wygaszać bezczynne współrzędne siatki i wybudzać je natychmiast, gdy gracz się zbliży.
  5. Oddzielenie kolizji od Visual Mesh: Nigdy nie używaj złożonych meshy wizualnych do obliczeń kolizji po stronie serwera. Serwer powinien postrzegać miasto tylko jako serię prymitywnych kształtów Low-Poly (boxy, kapsuły, sfery). Utrzymuje to minimalne zużycie pamięci serwera i sprawia, że obliczenia fizyki zajmują ułamki milisekund.

Typowe pułapki, których należy unikać

  • Pułapka zalewania RPC (RPC Flooding): Deweloperzy często wyzwalają Remote Procedure Calls (RPC) z serwera do klienta dla efektów wizualnych (np. iskry przy zderzeniu samochodów). Nie rób tego. Serwer powinien jedynie replikować stan samochodu (np. bIsCrashed = true). Klient powinien niezależnie zaobserwować tę zmianę stanu poprzez OnRep/Property Hook i lokalnie wyzwolić efekt VFX iskier. Oszczędza to ogromne ilości Bandwidth.
  • Wycieki pamięci przy zmianie stref: Podczas wyładowywania chunka miasta na urządzeniu mobilnym upewnij się, że aktywnie wymuszasz Garbage Collection lub ręcznie zwalniasz Asset Bundles. Jeśli zostawisz choćby kilka megabajtów osieroconych tekstur przy każdym przejściu między strefami, gra nieuchronnie scrashuje po 20 minutach rozgrywki.

Podsumowanie

Osiągnięcie prawdziwej optymalizacji skalowania gry mobilnej to sztuka balansowania. Wymaga walki o każdy megabajt pamięci RAM klienta, ścisłego regulowania Network Relevancy i rozpraszania obciążenia serwera na skalowalne węzły Backend. Implementując Spatial Hashing, dynamiczne częstotliwości aktualizacji i asynchroniczny streaming assetów, możesz budować potężne, żyjące miasta, które działają płynnie nawet na kilkuletnim sprzęcie mobilnym.

Jednak budowa skalowalnej infrastruktury do obsługi tysięcy jednoczesnych połączeń i zarządzania płynnymi przejściami między serwerami jest często trudniejsza niż stworzenie samej gry. Chcesz skalować swój Multiplayer Backend bez koszmaru DevOps? Wypróbuj horizOn za darmo lub sprawdź dokumentację API, aby zobaczyć, jak obsługujemy architekturę wysokiej współbieżności "out of the box".


Źródło: Designing Large-Scale Mobile Game Cities: Production, Optimization, & Worldbuilding Expertise