Stopping actor deletion when outside of Network Cull Distance on clients?

TLDR: When an actor goes outside of NetCullDistance is it possible to stop it being deleted on the clients?

Design:
Base building multiplayer game. Many of the same actor types (Foundations, floors, walls etc). I would like to utilize UHierarchicalInstancedStaticMeshComponent (HISM) for each type to allow for optimized rendering. To achieve this I have an actor (Base) which contains many HISM for each type. The syncing of the HISM across the server would be handled through FFastArray (FFastArraySerializer and FFastArraySerializerItem) to contain data representing each element contained for a single HISM for say “floors”. All of this works great.

The Issue:
When I go outside of the NetCullDistance all of the information contained in Base that is not replicated would be lost meaning a lot of recalculating things. I could decouple some of this but it still leaves the problem with the implementation of a FFastArray being redundant. As it does not just send changes across - it sends the entire array which will become costly in a complete system.

Further Research:

  • Using bAlwaysRelevant is kind of the behaviour I am after but I don’t want it to keep receiving updates from the server constantly as there will be many of these bases (potentially hundreds) in the game.
  • I read that if an object is in the world and not spawned it is not deleted. So I could create a base pool and utilize this approach but it would have draw backs with the base pool reaching its limit. Also the wasted resources on objects in the pool that are not used.
  • Currently I am researching the Replication Graph and the system to replace it Iris. I have yet to find an exact solution as these are new to me.
  • I am currently looking into the mechanism that deletes actors when they are no longer relevant but going through the source code is tough as Actor, ActorReplication etc are complex.
  • Using AI it suggests or maybe hallucinates that this is possible using IsNetRelevantFor. But most things it suggests seem to be just a custom way of implementing a bAlwaysRelevant. I have yet to to implement any of what it suggests as it seems wrong.
  • I am open to changing the unreal source code to not delete the actor but I feel that a lot of the code needs to stay the consistent and that it wouldn’t be as simple as - if the class is of type ABase don’t destroy it.

Of course I understand many people might say your design approach is wrong - unreal does not work this way etc. But I’m wondering if anyone has done anything about keeping an object alive outside the NetCullDistance. My approach seems optimal if only the server/client relationship did not delete the object when outside of the NetCullDistance.

Thank you to anyone who takes the time to read this or comment. I never ask for help but I feel out of options on this one and really wanted this to work.

NOW SOLVED SEE MY LAST POST

I don’t know much about ReplicationGraph or Iris, but I believe you can do some things with the default replication system.

First, enable bAlwaysRelevant to avoid channel close > actor destroyed > channel re-opened > actor recreated > ALL properties re-replicated. Then, try to manipulate the system to reduce/cancel updates that you deem unnecessary. For reference, stuff happens in UNetDriver::ServerReplicateActors.

There is NetUpdateFrequency which could be used to reduce the frequency at which actor is considered for replication. However after checking out code, it seems that the frequency check is a global check (per actor), and cannot be adjusted per-connection (per-player). So if a player is close to the actor and needs fast updates, everyone will receive fast updates as well.

Once the list of relevant actors to consider has been built, there is a delegate OnPreConsiderListUpdateOverride that can be bound to further manipulate the list. Again it is still global and not per-connection.

Then, the per-connection checks and processings begin.
By default, NetDriver does the following :

  • PrioritizeActors : basically sort actors list by priority calculated via Actor->GetNetPriority, which can be overridden, so it is a potentially interesting hook point for you, as it can be tweaked per-connection.
  • ProcessPrioritizedActorsRange : replicate relevant actors and close non-relevant actors.
  • MarkRelevantActors : actors that could not be replicated this frame are delayed to next frame.

This whole per-connection process can also be overriden by binding the OnProcessConsiderListOverride delegate, which also seems to be an interesting hook point. When the delegate is bound the default code is not executed so you have to re-implement it yourself. But the code block is small and all important methods are ENGINE_API marked so it should be easily done.

So with this I can think of a coupe approaches to achieve what you are looking for.


Idea 1 : override GetNetPriority in your actor class, and keep far actors with a very low priority. By default, the calculation factors in NetPriority and the time since last replication, such that any actor eventually becomes prioritized after a while. You could change that behavior, so that far actors of your class are never prioritized, regardless of time passed.

virtual float AMyActor::GetNetPriority(const FVector& ViewPos, const FVector& ViewDir, AActor* Viewer, AActor* ViewTarget, UActorChannel* InChannel, float Time, bool bLowBandwidth)
{
    if (!IsWithinNetRelevancyDistance(ViewPos))
        return 0.f;
    else
        return Super::GetNetPriority(ViewPos, ViewDir, Viewer, ViewTarget, InChannel, Time, bLowBandwidth);
}

Idea 2 : override OnProcessConsiderListOverride, and filter the actors list to skip updating undesired actors, without closing their channel or destroying them.

    // do this in some singleton class (GameMode or a World subsystem)
    GetWorld()->GetNetDriver()->OnProcessConsiderListOverride.BindUFunction(this, &AMySingleton::OnProcessConsiderListOverride);

void AMySingleton::OnProcessConsiderListOverride(const ConsiderListUpdateParams& UpdateParams, int32& OutUpdated, const TArray<FNetworkObjectInfo*>& ConsiderList)
{
    // Get connection viewer (already stored in ReplicationViewers by calling code)
    TArray<FNetViewer>& Viewers = GetWorld()->GetWorldSettings()->ReplicationViewers;
    const FNetViewer& Viewer = Viewers[0];

    // Filter
    TArray<FNetworkObjectInfo*> MyList;
    MyList.Reserve(ConsiderList.Num());
    for (FNetworkObjectInfo* Item : ConsiderList)
    {
        if (Item->Actor->IsA<AMyActor>() && !Item->Actor->IsWithinNetRelevancyDistance(Viewer.ViewLocation))
            continue;  //filter out these

        MyList.Emplace(Item);
    }

    // Copy default behavior with filtered list
    const auto& Driver = GetWorld()->GetNetDriver();
    const auto& Connection = UpdateParams.Connection;
    FActorPriority* PriorityList = NULL;
    FActorPriority** PriorityActors = NULL;
    const int32 FinalSortedCount = Driver->ServerReplicateActors_PrioritizeActors(Connection, Viewers, MyList, UpdateParams.bCPUSaturated, PriorityList, PriorityActors);
    TInterval<int32> ActorsIndexRange(0, FinalSortedCount);
    const int32 LastProcessedActor = Driver->ServerReplicateActors_ProcessPrioritizedActorsRange(Connection, Viewers, PriorityActors, ActorsIndexRange, OutUpdated);
    Driver->ServerReplicateActors_MarkRelevantActors(Connection, Viewers, LastProcessedActor, FinalSortedCount, PriorityActors);
}
1 Like

All hope is not lost! Thank you so so so much for your time and insight @Chatouille.

Yeah I figured there could be two approaches with or without bAlwaysRelevant. I like your idea of approach it with and managing the distance and priority myself. This will avoid managing the lifetime of the actor.

NetUpdateFrequency I still need to look into. I would have it on the lower to zero end of “Frequency”. Thank you for pointing out its global and not per connection this is good for my understanding. Along these lines I would also be looking into dormancy and forced updates? If an object is dormant I think it can still send the initial state.

I will still look into OnPreConsiderListUpdateOverride even if it global. I need to look inside NetDriver for what you have suggested. I will try implementing both your solutions.

Idea 1:
Just thoughts going into it - Would I have to manually trigger it to be prioritized when I want it to send an update or could ForceNetUpdate be used to by pass this? or can properties like FFastArray be replicated without the owning actor being replicated (probably not)?

Idea 2:
Thank your for including the code with the filter and default behaviour. This is an excellent suggestion you really are a wizard!

Issues with bAlwaysRelevant:
My only issue with having things marked bAlwaysRelevant is my hunch - say as an example - I have 100 bases - each with 100 of each type of object (floor, walls etc) would they then be all sent at once when you connect to the server? Using unreal engine built in replication I have no way to manually control the first sync without a huge amount of workarounds to get the initial state with a loading bar etc and not just have the game stall and server fall over. So its vital in the solution I also handle that scenario along with how the actor works outside NetCullDistance (or now a custom distance check and priority handling).

Replication Graph and Iris:
I feel there could be useful solution in there somewhere but its just hard to find information on stuff that is still in beta.

I was just about to go to bed so I’ll let you know how I get on with your suggestions tomorrow.

@Chatouille I was attempting Idea 2.

I’m using unreal engine 5.0.3. (not tied to this version as I’m just prototyping and waiting to choose a version.)

Was unable to find information on OnProcessConsiderListOverride. Searching through UNetDriver I can’t find any member. Is this something internal that’s not exposed? Googling it returns nothing but this thread and chatgpt knows nothing about it and states its never seen this function in any of the unreal engine versions up until mid 2023.

I found it in 5.3 source code but have no idea when it was added sorry.

After double checking, it looks like the delegate and methods are protected, so using them will be a bit more difficult unfortunately. Sounds like you’d have to create your own UNetDriver subclass, but then you could override the relevant methods directly, which makes the delegate kinda redundant…

I’ve seen your other post but not had the time to dig into it yet. I can already say that in the traditional replication framework, ForceNetUpdate will not override the Priority sorting, so if you call ForceNetUpdate and return 0 in GetNetPriority, chances are it’s not gonna update. ForceNetUpdate only overrides the Frequency setting. Regardless, I think the method 1 is pretty solid anyways, and you could easily add a variable(+method) similar to ForceNetUpdate, that you’d check in your GetNetPriority function if you want to force-update. The problem about initial replication and AlwaysRelevant is a good point that will require some more digging. The first idea I can come up with is to override IsNetRelevantFor to keep the actor irrelevant until it’s supposed to be relevant, but only if the actor has not been relevant yet, so also gotta keep track of who it’s been replicated to… :roll_eyes:
It’s a bit convoluted but I don’t see any reason why it wouldn’t work…

UPROPERTY()
TSet<AActor*> RelevanceSet;

bool AMyActor::IsNetRelevantFor(const AActor* RealViewer, const AActor* ViewTarget, const FVector& SrcLocation) const
{
    if (!RelevanceSet.Contains(RealViewer))
    {
        if (!IsWithinNetRelevancyDistance(ViewLocation))
            return false;  //never been relevant yet

        RelevanceSet.Emplace(RealViewer, true);
    }
    return true; //always relevant
}

Ideally though, I’d like to dig into the netdriver code to see if it’s possible to access the replication channels to check if one already exists for that player or not.

Alternatively, again, subclassing the NetDriver might make things easier. I hadn’t suggested it at first, but if you do it, you could also attempt to override the method and simply not close the channel if the actor is an actor of your specific class…

1 Like

Hey @Chatouille! I figured you were busy and can’t believe tbh you can take the time at all to message me back! :smiley:

LOL. “Fixme: this should be a setting” ■■■■ right that should be a setting ffs! Seems like who ever did the original comments sees the issue and lack of feature I’m after.

Thanks to you and the last code you shared - I’ve had a thought that will solve-maybe-probably-not my problem (I’ve yet to attempt this)

Forcing bNetStartup in the actor to be true (Imitating a map actor) although I don’t know what the full address will mean in the context of being spawned over existing in the world:

/** If true, this actor was loaded directly from the map, and for networking purposes can be addressed by its full path name */
UPROPERTY()
uint8 bNetStartup:1

If I can learn what the full path name represent (I’m assuming maybe a reference to where it is on disk?)… Might be worth a shot. It is public so should definitely be able to be set true. I feel this route is more what I’m after in terms of design - net cull distance and relevance. I can definitely look into making a subclass of NetDriver but would love to check a boolean to make this all work xD

Also I am still open to suggestion 1 its just I’m thinking if there is any possible way to avoid big for loops every server tick that is just for this. I’m going to look at every use of bNetStartup and try to see if it will be possible to not make the thing blow up if I am able to set it true! Then when that doesn’t work i’ll likely attempt a custom NetDriver or change the default NetDriver. Thanks again for your help you have 100% helped me on think about this from both sides and given me the exact line I feel I am after: the “Fixme” part.

Last edit of this post!

I think I might modify default Actor and NetDriver to include a boolean like “bDoNotCloseNetChannel” and in the fix me add that to the if !Actor->bDoNotCloseNetChannel
Busy over the next couple of days myself with work so will update my progress when I make some!

Yaaaas! So I solved it!!! Using:

#1:

bNetStartup = true

2# Use of IsNetRelevantFor

bool ABase::IsNetRelevantFor(const AActor* RealViewer, const AActor* ViewTarget, const FVector& SrcLocation) const
{
    // Attempt to get the PlayerController
    const APlayerController* PlayerController = nullptr;

    if (const APawn* Pawn = Cast<APawn>(RealViewer))
    {
        PlayerController = Cast<APlayerController>(Pawn->GetController());
    }
    else
    {
        PlayerController = Cast<APlayerController>(RealViewer);
    }

    // Ensure we have a valid PlayerController
    if (PlayerController)
    {
        // Cast to custom player controller
        const AMyPlayerController* MyPC = Cast<AMyPlayerController>(PlayerController);

        if (MyPC && MyPC->bClientReady)
        {
            // Client is ready, use normal relevance logic
            return Super::IsNetRelevantFor(RealViewer, ViewTarget, SrcLocation);
        }
        else
        {
            // Client is not ready, actor is not relevant
            return false;
        }
    }

    // If we couldn't get a PlayerController, assume client is not ready
    return false;
}

3# Spawning it with the correct name and naming it at the point of spawning it:

// Set up spawn parameters
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
SpawnParams.Name = FName(*FString::Printf(TEXT("Base_0")));

// Spawn the ABase actor
Base = GetWorld()->SpawnActor<ABase>(ABase::StaticClass(), FVector(0.f, 0.f, 142013.f), FRotator::ZeroRotator, SpawnParams);

4# Manually controlling relevancy only once the object is spawned:

// Trigger the player controller to notify the server that the client is ready
APlayerController* PlayerController = GetWorld()->GetFirstPlayerController();
if (PlayerController)
{
    AMyPlayerController* MyPC = Cast<AMyPlayerController>(PlayerController);
    if (MyPC)
    {
        // Notify the server that the client is ready
        MyPC->ClientSignalReady();
    }
}

Thanks for your help @Chatouille - Mr. Tickles! <3