Назад к блогу

Ghost Actor Reappearance: исправление десинхронизации сетевой репликации в Multiplayer для разрушаемых сеточных структур

Опубликовано 21 июня 2026 г.
Ghost Actor Reappearance: исправление десинхронизации сетевой репликации в Multiplayer для разрушаемых сеточных структур

Коротко о главном

В статье рассматривается техническое решение проблемы «ghost builds» — повторного появления разрушенных объектов в сетевых играх из-за рассинхронизации репликации. Автор детально объясняет физику процесса расхождения локального предсказания (client-side prediction) и авторитетного ответа сервера, а также особенности доставки пакетов через UDP. В качестве решения приводится пример реализации буфера предсказания на C++ для Unreal Engine, временно подавляющего обновления свойств до закрытия сетевого канала. Для масштабирования сетевой инфраструктуры без рутины предлагается использовать готовую Backend-платформу horizOn.

Ghost Actor Reappearance: исправление десинхронизации сетевой репликации в Multiplayer для разрушаемых сеточных структур

Игрок замахивается инструментом сбора ресурсов, деревянная стена мгновенно разрушается на его экране, но через 80 миллисекунд она снова появляется с полным запасом здоровья, а затем, спустя еще 400 миллисекунд, исчезает насовсем. Эта визуальная аномалия, широко известная как «ghost build», представляет собой классическое проявление расхождения предсказания на стороне клиента (client-side prediction mismatch) и нарушения порядка репликации (out-of-order replication). В динамичном Multiplayer кратковременные откаты состояния (state rollbacks) разрушают погружение и засоряют экран визуальным мусором. Чтобы решить эту проблему, разработчикам необходимо внедрить надежный фикс рассинхронизации сетевой репликации (multiplayer network replication desync fix), который согласует мгновенный локальный отклик с авторитетной валидацией на сервере (authoritative server validation).

Анатомия «ghost build»: Prediction против Replication

При создании сетевого геймплея разработчикам приходится балансировать между отзывчивостью управления и авторитетностью сервера (server authority). Чтобы передвижение и разрушение объектов казались мгновенными, клиент предсказывает результаты действий еще до получения подтверждения от сервера. Например, когда игрок атакует разрушаемую структуру, клиентская игровая логика сразу же запускает расчет урона, отключает коллизию (collision) и активирует эффекты частиц (particle effects).

«Под капотом» это создает мгновенное расхождение, при котором симуляция на клиенте опережает серверную. В стандартной модели Netcode это расхождение устраняется, как только сервер обрабатывает входящий RPC от клиента и реплицирует новое состояние обратно. Однако если пакет задерживается или tick rate сервера (обычно от 20 до 30 Гц) отстает от частоты кадров клиента (от 60 до 120 FPS), возникает состояние гонки (race condition). Предсказание на клиенте удаляет actor, но следующее обновление репликации от сервера все еще содержит старое состояние этого actor (он жив и имеет запас здоровья).

Это специфическое состояние гонки (race condition) особенно заметно на деревянных конструкциях. По сравнению с камнем или металлом, дерево имеет более низкий порог здоровья (например, 90 HP против 300 HP), то есть разрушается с одного удара. Из-за этого временное окно между действием игрока и подтверждением от сервера становится критически узким. Любая задержка репликации заставляет сетевой драйвер клиента выполнять синхронизацию состояния (state reconciliation), восстанавливая actor, так как сервер все еще сообщает, что тот жив.

Влияние Packet Loss и Tick Rates

При возникновении Packet Loss предсказанное клиентом разрушение зависает в неопределенном состоянии. Если клиент отправляет пакет с уроном, который теряется в сети, сервер никогда его не обработает, хотя клиент считает, что урон был нанесен. В результате клиент продолжает симуляцию, ошибочно полагая, что actor больше нет. Когда сервер присылает следующее обновление состояния, это несоответствие становится очевидным, заставляя клиент снова спавнить actor в мире. Процесс репримирения (reconciliation) создает неприятный для глаз визуальный скачок (visual pop), особенно при уровне Packet Loss в 1.5% – 3%, когда подобные потери происходят регулярно.

Под капотом: Жизненный цикл Actor и закрытие сетевых каналов (Channel Teardown)

Unreal Engine и аналогичные современные Multiplayer-движки синхронизируют присутствие actor в мире с помощью выделенных сетевых каналов связи. Каждому реплицируемому actor назначается свой сетевой канал (actor channel). Когда actor уничтожается на сервере, сервер закрывает этот канал, отправляя клиенту управляющее сообщение о закрытии канала (NetGUID retirement).

Критическая проблема заключается в том, что репликация свойств (property replication) и закрытие канала не идут по одному и тому же пути доставки. Обновления свойств (например, изменения переменной Health у структуры) сериализуются и отправляются в составе обычного пакета репликации actor. Если сервер обработал событие урона, но еще не произвел Garbage Collection для этого actor, он может сериализовать финальное обновление свойств перед тем, как actor будет окончательно помечен на удаление. Если UDP-пакет с обновлением свойств придет раньше пакета с закрытием канала, клиент обновит здоровье actor, тем самым переопределив локально предсказанное разрушение.

Такое поведение тесно связано с другими проблемами синхронизации Netcode, например, с теми, которые мы рассматривали в нашем руководстве по устранению рассинхронизации в multiplayer и исправлению проблем с репликацией RPC в Unreal Engine, ломающих состояния. В той статье мы анализируем, как нарушение порядка выполнения RPC и свойств нарушает логику игрового мира. Аналогично, при работе с позиционированием игроков разработчики часто сталкиваются со схожими расхождениями, что подробно описано в нашем руководстве по [исправлению десинхронизации местоположения игроков в Uefn и Unreal Engine multiplayer](https://horizon.pm/blog/how-to-fix-player-location-desync in-uefn-and-unreal-engine-multiplayer).

Когда клиент обрабатывает пришедший с нарушением порядка пакет репликации, он видит, что на сервере actor еще жив, и принудительно возвращает его в пул активных объектов. В итоге клиенту приходится ждать, пока наконец не придет пакет закрытия канала — зачастую это занимает до 0.4 секунды — чтобы окончательно удалить этот actor.

«Под капотом» пакеты репликации ограничены размером максимального блока передачи (MTU), который обычно составляет 1400 байт. Если пропускная способность игрового соединения ограничена (например, параметр MaxClientRate установлен на 15000 байт/сек), обновления ставятся в очередь и разбиваются на несколько UDP-пакетов. Поскольку управляющее сообщение о закрытии канала отправляется надежным способом (reliable), оно требует подтверждения получения, тогда как обновления свойств часто отправляются ненадежным способом (unreliable). При возникновении сетевых перегрузок или Packet Loss надежное сообщение о закрытии канала может задержаться, пропустив вперед более старые ненадежные пакеты со свойствами, что приведет к рассинхронизации и заставит клиент пересоздать actor.

Реализация буфера предсказания состояний (Predictive State Buffer) на C++

Чтобы устранить эффект ghost builds, необходимо перехватывать входящие обновления репликации на стороне клиента для тех actor, разрушение которых было предсказано. Реализовав буфер client-side prediction, мы можем подавлять принудительное обновление свойств (property reconciliation) в течение определенного временного окна (например, 500 мс), давая пакету закрытия канала от сервера достаточно времени для доставки. Ниже представлена полная рабочая C++ реализация предсказываемого разрушаемого actor. Код переопределяет стандартное поведение репликации и использует локальную метку времени (timestamp) для определения необходимости подавления репликации.

// PredictedDestructibleActor.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PredictedDestructibleActor.generated.h"

UCLASS()
class MULTIPLAYERGAME_API APredictedDestructibleActor : public AActor
{
    GENERATED_BODY()

public:
    APredictedDestructibleActor();

protected:
    virtual void BeginPlay() override;
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
    
    // Server-authoritative health variable
    UPROPERTY(ReplicatedUsing = OnRep_Health)
    float Health;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Destruction")
    float MaxHealth;

    // Triggered when health changes on the client
    UFUNCTION()
    void OnRep_Health();

    // Client-side prediction tracking flags
    bool bClientPredictedDestroyed;
    float ClientPredictionTime;
    
    // Maximum time (in seconds) the client will suppress server updates
    UPROPERTY(EditAnywhere, Category = "Networking")
    float PredictionTimeout;

    // Visual effect helper function
    void TriggerDestructionEffects();

public:
    // Called when the local player destroys the structure client-side
    UFUNCTION(BlueprintCallable, Category = "Destruction")
    void PredictDestruction();

    // Resets the predicted state if the server rejects the destruction
    void ResetPredictionState();

    virtual void Tick(float DeltaTime) override;
};

Ниже представлен соответствующий файл реализации, показывающий, как фильтровать входящие состояния от сервера:

// PredictedDestructibleActor.cpp
#include "PredictedDestructibleActor.h"
#include "Net/UnrealNetwork.h"
#include "Kismet/GameplayStatics.h"

APredictedDestructibleActor::APredictedDestructibleActor()
{
    PrimaryActorTick.bCanEverTick = true;
    bReplicates = true;
    
    // Set a moderate update frequency to balance bandwidth and responsiveness
    NetUpdateFrequency = 33.0f; 
    
    MaxHealth = 100.0f;
    Health = MaxHealth;
    bClientPredictedDestroyed = false;
    ClientPredictionTime = 0.0f;
    PredictionTimeout = 0.5f; // 500ms safety window
}

void APredictedDestructibleActor::BeginPlay()
{
    Super::BeginPlay();
}

void APredictedDestructibleActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(APredictedDestructibleActor, Health);
}

void APredictedDestructibleActor::OnRep_Health()
{
    // If the client has predicted this actor's death, suppress server property updates
    if (bClientPredictedDestroyed)
    {
        return;
    }

    if (Health <= 0.0f)
    {
        TriggerDestructionEffects();
    }
}

void APredictedDestructibleActor::PredictDestruction()
{
    // Prediction only runs on the client that simulated the event
    if (GetNetMode() == NM_Client)
    {
        bClientPredictedDestroyed = true;
        ClientPredictionTime = GetWorld()->GetTimeSeconds();
        
        // Hide the actor and disable collision immediately for responsive local feedback
        SetActorEnableCollision(false);
        SetActorHiddenInGame(true);
        
        // Spawn local particles and audio instantly
        TriggerDestructionEffects();
    }
}

void APredictedDestructibleActor::ResetPredictionState()
{
    bClientPredictedDestroyed = false;
    SetActorEnableCollision(true);
    SetActorHiddenInGame(false);
}

void APredictedDestructibleActor::TriggerDestructionEffects()
{
    // Spawn local visual effects (e.g. wood splinters, dust clouds)
    // and play destruction audio.
}

void APredictedDestructibleActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (GetNetMode() == NM_Client && bClientPredictedDestroyed)
    {
        float CurrentTime = GetWorld()->GetTimeSeconds();
        
        // If the timeout expires and the server hasn't torn down the channel, 
        // the server must have rejected the damage. We must roll back.
        if (CurrentTime - ClientPredictionTime > PredictionTimeout)
        { 
            ResetPredictionState();
        }
    }
}

Использование такого буфера предсказания позволяет предотвратить сброс видимости actor при вызове OnRep_Health. Благодаря этому на стороне клиента actor остается скрытым, а его коллизия отключенной вплоть до прихода пакета закрытия канала. Если же сервер отклонит разрушение (например, из-за несовпадения при валидации Anti-Cheat), истечение таймаута принудительно вызовет откат (rollback), предотвращая вечную рассинхронизацию симуляции.

Обработка Rollbacks и отклонения валидации

Важнейшим компонентом этого исправления репликации является обработка ситуаций, когда сервер отклоняет действие игрока. Если логика валидации на сервере определяет, что игрок не мог нанести удар по конструкции, урон отклоняется. В таком сценарии клиент должен выполнить rollback предсказанного разрушения, чтобы избежать перманентной десинхронизации. Именно поэтому при отсутствии подтверждения от сервера в течение 500 мс таймаут восстанавливает коллизию и видимость actor.

Трудоемкость ручной реализации против специализированных Backend-решений

Хотя предложенное C++ решение устраняет ghosting отдельных actor, его масштабирование на всё игровое окружение — задача непростая. Разработчикам приходится вручную писать код для предсказания и rollback для каждого типа разрушаемых объектов, отслеживать активные буферы предсказания, оптимизировать tick rate для actor и управлять приоритетами сетевой репликации. Для инди-команды разработка и тестирование подобных пограничных случаев (edge cases) может легко занять от 4 до 6 недель чистой сетевой разработки.

Самостоятельная сборка такого решения требует настройки load balancers, шардирования баз данных (database sharding) и создания сложных WebSockets/UDP серверов. С horizOn эти backend-сервисы поставляются уже настроенными, что позволяет вам сфокусироваться на выпуске игры, а не на администрировании сетевой инфраструктуры. Управление лобби в реальном времени и оркестрация сессий от horizOn гарантируют надежную синхронизацию состояний игроков и параметров матча с задержкой менее 50 мс, сводя к минимуму задержки репликации, вызывающие появление ghost builds.

Практические рекомендации по исправлению рассинхронизации сетевой репликации в Multiplayer

При оптимизации сетевого кода для разрушаемых объектов следуйте этим рекомендациям, чтобы поддерживать согласованность игрового мира:

  1. Разделяйте визуальные ресурсы и жизненный цикл Actor: Избегайте зависимости визуального отклика от немедленного вызова AActor::Destroy(). Используйте булевый флаг репликации вроде bIsDead и запускайте локальные системы частиц сразу. Это позволит отключить коллизию на клиенте, не дожидаясь серверных процедур очистки.
  2. Приоритизируйте закрытие каналов перед обновлением свойств: Установите флаг bOnlyRelevantToOwner или увеличьте NetPriority для разрушаемых объектов, чтобы сетевой драйвер обрабатывал обновления об их уничтожении в первую очередь. Это предотвратит их задержку за обычной репликацией фоновых свойств.
  3. Установите временное окно таймаута для активного предсказания: Не позволяйте client-side prediction работать бесконечно. Всегда внедряйте предохранительный таймаут (обычно в 1.5–2 раза больше максимального приемлемого RTT плюс запас на колебания tick rate сервера), чтобы принудительно запустить rollback на клиенте, если сервер отклонит действие. Это спасет от ситуации, когда actor остается скрытым навсегда из-за потери пакета.
  4. Настройте NetUpdateFrequency: Держите частоту обновлений разрушаемых структур на низком уровне (например, 10–15 Гц) в покое. Динамически повышайте частоту обновления до 33 Гц только при получении ими урона, экономя трафик в простое при сохранении высокой отзывчивости в моменты взаимодействия игроков.
  5. Оптимизируйте пайплайн валидации на сервере: Убедитесь, что серверная валидация урона выполняется быстро и легковесно. Если сервер тратит на валидацию попадания больше 100 мс, буфер предсказания на клиенте, скорее всего, закроется по таймауту, вызвав визуальное дергание (jitter). Упростите логику проверки, чтобы снизить задержки обработки.

Резюме и следующие шаги

Устранение десинхронизаций репликации требует глубокого понимания работы сетевого пайплайна вашего движка. Подавляя серверные обновления свойств для тех actor, разрушение которых уже предсказано на клиенте, вы сможете избавиться от эффекта ghost builds и обеспечить игрокам плавный и отзывчивый игровой процесс.

Готовы масштабировать свой multiplayer-backend и минимизировать проблемы с синхронизацией? Попробуйте horizOn бесплатно или изучите API docs, чтобы узнать, как реализовать управление сессиями с низкой задержкой (low-latency session management) в вашем следующем проекте.


Источник: Ghost builds appear shortly after breaking wooden player build structures