Ghost optimization
Optimize your ghosts to improve the performance of your game.
Importance scaling
The server operates with a fixed bandwidth target and sends a single snapshot packet of customizable size on every network tick. It fills this packet with the ghosts with the highest importance, determined by a priority queue of ghost chunks (rebuilt each tick). Therefore, importance is determined at the ghost chunk level, not on each instance individually.
Several factors determine the importance of each ghost chunk:
- You can specify the base
GhostAuthoringComponent.Importance
per ghost type.- Netcode for Entities multiplies this base importance value by
ticksSinceLastSent
(notticksSinceLastAcked
), as well as other modifiers such asGhostSendSystemData.IrrelevantImportanceDownScale
andGhostSendSystemData.FirstSendImportanceMultiplier
.
- Netcode for Entities multiplies this base importance value by
- You can also supply your own method to scale importance on a per-chunk, per-connection basis, using
GhostImportance.BatchScaleImportanceFunction
. For example, this allows you to deprioritize far away ghosts, in favor of nearby ones. GhostAuthoringComponent.MaxSendRate
doesn't directly impact importance values. It's a pre-pass that prevents a ghost chunk from being added to the priority queue at all (for this tick).
Once a packet has reached the bandwidth target, the server sends it. The remaining ghost entities aren't sent on this tick, but they are more likely to be in the next snapshot because of ticksSinceLastSent
scaling.
Note
Ghost group children do not support relevancy (nor importance, MaxSendRate, static-optimization etc.) until they've left the group, refer to the ghost groups page for more information.
Set up ghost importance scaling
The following is an example of how to set up the built-in distance-based importance scaling in Netcode for Entities. If you want to use a custom importance implementation, you can reuse parts of the built-in solution or replace it with your own.
GhostImportance
GhostImportance
is the configuration component for setting up importance scaling. GhostSendSystem
invokes the BatchScaleImportanceFunction
only if the GhostConnectionComponentType
and GhostImportanceDataType
are created.
You can set the following fields on GhostImportance
:
BatchScaleImportanceFunction
allows you to write and assign a custom scaling function (to scale the importance, with chunk granularity).GhostConnectionComponentType
is the type added per connection, allowing you to store per-connection data that's needed in the scaling calculation.GhostImportanceDataType
is an optional singleton component, allowing you to pass in any of your own static data necessary in the scaling calculation.GhostImportancePerChunkDataType
is the shared component added per chunk, storing any chunk-specific data used in the scaling calculation.
Order of operations
First, the function pointer is invoked by the GhostSendSystem
for each chunk and returns the importance scaling for the entities contained within that chunk. The signature of the method is of the delegate type GhostImportance.ScaleImportanceDelegate
and the parameters are IntPtr
s, which point to instances of the three types of data described above.
You must add a GhostConnectionComponentType
component to each connection to determine which tile the connection should prioritize. The GhostSendSystem
then passes this per-connection information to the BatchScaleImportanceFunction
function.
The GhostImportanceDataType
is global, static, singleton data that configures how chunks are constructed. It's optional, and IntPtr.Zero
is passed if it's not found. GhostSendSystem
fetches this singleton data and passes it to the importance scaling function.
Note
The GhostImportanceDataType
must be added to the same entity as the GhostImportance
singleton. If it isn't, an exception is thrown in the Editor.
GhostImportancePerChunkDataType
is then added to each ghost, essentially forcing it into a specific chunk. The GhostSendSystem
expects the type to be a shared component. This ensures that the elements in the same chunk are all grouped together by the entity system. A user-created system is required to update each entity's chunk to regroup them (example below). It's important to think about how entity transfer between chunks actually works (namely the performance implications) because regularly changing an entity's chunk is not efficient.
Distance-based importance
The built-in form of importance scaling in Netcode for Entities is distance-based (GhostDistanceImportance.Scale
) and uses tiling to group entities into spatial chunks. The GhostDistanceData
component describes the size and borders of the tiles entities are grouped into.
Distance-based importance in Asteroids
The Asteroids sample project uses Netcode for Entities' default scaling implementation. The LoadLevelSystem
sets up an entity to act as a singleton with GhostDistanceData
and GhostImportance
added:
var gridSingleton = state.EntityManager.CreateSingleton(new GhostDistanceData
{
TileSize = new int3(tileSize, tileSize, 256),
TileCenter = new int3(0, 0, 128),
TileBorderWidth = new float3(16f, 16f, 16f),
});
state.EntityManager.AddComponentData(gridSingleton, new GhostImportance
{
BatchScaleImportanceFunction = GhostDistanceImportance.ScaleFunctionPointer,
GhostConnectionComponentType = ComponentType.ReadOnly<GhostConnectionPosition>(),
GhostImportanceDataType = ComponentType.ReadOnly<GhostDistanceData>(),
GhostImportancePerChunkDataType = ComponentType.ReadOnly<GhostDistancePartitionShared>(),
});
Note
Again, you must add both singleton components to the same entity.
The GhostDistancePartitioningSystem
then splits all the ghosts in the world into chunks, based on the tile size defined above. Using the configurable component GhostConnectionPosition
and the Entities concept of chunks, Netcode for Entities can create spatial partitions that enable the fast culling of entire sets of entities based on distance to the connection's character controller (or other notable object).
GhostConnectionPosition
stores the position of a player's entity (Ship.prefab
in the Asteroids example), which is passed into the Scale
function via the GhostSendSystem
, allowing each connection to determine which tiles (chunks) that connection should prioritize.
In Asteroids, this component is added to the connection entity when the (Asteroids-specific) RpcLevelLoaded
RPC is invoked:
[BurstCompile(DisableDirectCall = true)]
[AOT.MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))]
private static void InvokeExecute(ref RpcExecutor.Parameters parameters)
{
var rpcData = default(RpcLevelLoaded);
rpcData.Deserialize(ref parameters.Reader, parameters.DeserializerState, ref rpcData);
parameters.CommandBuffer.AddComponent(parameters.JobIndex, parameters.Connection, new PlayerStateComponentData());
parameters.CommandBuffer.AddComponent(parameters.JobIndex, parameters.Connection, default(NetworkStreamInGame));
parameters.CommandBuffer.AddComponent(parameters.JobIndex, parameters.Connection, default(GhostConnectionPosition)); // <-- Here.
}
Which is then updated via the Asteroids server system UpdateConnectionPositionSystemJob
:
[BurstCompile]
partial struct UpdateConnectionPositionSystemJob : IJobEntity
{
[ReadOnly] public ComponentLookup<LocalTransform> transformFromEntity;
public void Execute(ref GhostConnectionPosition conPos, in CommandTarget target)
{
if (!transformFromEntity.HasComponent(target.targetEntity))
return;
conPos = new GhostConnectionPosition
{
Position = transformFromEntity[target.targetEntity].Position
};
}
}
Create a custom importance scaling function
Every component and function used in importance scaling is configurable. To create a custom importance scaling function, you need to do three things:
- Define the three components above (a per-connection component, an optional singleton config component, and a per-chunk shared component), and set them in the
GhostImportance
singleton. - Define your own scaling function and set it via the
GhostImportance
singleton. - Define your own version of a
GhostDistancePartitioningSystem
which moves your entities between chunks (via writing to the shared component).
Ghost relevancy
Ghost relevancy, also known as ghost filtering, is a server feature that allows you to define under what conditions a specific ghost entity is replicated on a client. You can use this to:
- Define a maximum replication distance for ghosts so that they only spawn when near a player.
- Create a server-side, anti-cheat fog of war that prevents clients from knowing about ghosts that they shouldn't be able to see.
- Only allow specific clients to be notified of a ghost's state, such as an item being dropped in a hidden information game.
- Create client-specific ghosts, such as NPCs that are only visible to a player when they've completed some quest condition.
- Temporarily pause all replication on a client while that client is in a specific state, such as when a player has died and is waiting to respawn.
Use ghost relevancy to avoid replicating entities that the player can neither see nor interact with.
Note
Ghost group children do not support relevancy (nor importance, MaxSendRate, static-optimization etc.) until they've left the group, refer to the ghost groups page for more information.
The GhostRelevancy
singleton component has the following controls:
GhostRelevancyMode
defines the behavior of the relevancy subsystem:- Disabled: The default setting. No relevancy is applied under any circumstances.
- SetIsRelevant: Only ghosts added to the relevancy set (
GhostRelevancySet
) are considered relevant to that client and serialized for the specified connection (where possible: eventual consistency and importance scaling rules still apply).- If you have this setting as the default, then no ghosts will be replicated to any client unless they're in the
GhostRelevancySet
. This can be useful when it's rare or impossible for a player to be viewing the entire world.
- If you have this setting as the default, then no ghosts will be replicated to any client unless they're in the
- SetIsIrrelevant: Ghosts added to relevancy set (
GhostRelevancySet
) are considered not relevant to that client and won't be serialized for the specified connection. In other words, use this mode if you want to specifically ignore entities for a given client.
GhostRelevancySet
stores the connection-ghost pairs. The behavior of the set is defined byGhostRelevancyMode
.DefaultRelevancyQuery
is a global rule denoting that all ghost chunks matching this query are always considered relevant to all connections (unless you've added the ghosts in said chunk to theGhostRelevancySet
). This is useful for creating general relevancy rules (for example: the entities in charge of tracking player scores are always relevant).GhostRelevancySet
takes precedence over this rule. Refer to the Asteroids sample for an example implementation.
var relevancy = SystemAPI.GetSingletonRW<GhostRelevancy>();
relevancy.ValueRW.DefaultRelevancyQuery = GetEntityQuery(typeof(AsteroidScore));
Note
If a ghost has been replicated to a client and is then set to not be relevant to that client, the client will be notified that the entity has been destroyed, and will replicate that change locally. This misnomer can be confusing, as the entity being despawned does not imply the server entity was destroyed.
For example: despawning an enemy monster in a MOBA because it became hidden in the fog of war shouldn't trigger a death animation (nor S/VFX). Thus, use some other data to notify what kind of entity-destruction state your entity has entered (such as enabling an IsDead
/IsCorpse
component).
Relevancy fast-path via importance scaling
You can merge the ghost relevancy calculation with the batched importance scaling function pointer (assuming relevancy can be expressed via the same data as importance scaling).
As shown in the GhostDistanceImportance.BatchScaleWithRelevancy
sample code, enabling this fast-path requires the following steps:
- Enabling relevancy via
SystemAPI.GetSingletonRW<GhostRelevancy>().ValueRW.GhostRelevancyMode = GhostRelevancyMode.SetIsRelevant;
(orSetIsIrrelevant
). - Setting the
PrioChunk.isRelevant
flag for each chunk (this flag ignores theSetIsRelevant
vsSetIsIrrelevant
distinction, so settingisRelevant = true
will cause the chunk to be relevant, regardless of which mode we're in).
...
data.priority = basePriority;
data.isRelevant = distSq <= 16; // Any chunks greater than 4 tiles from the player will be irrelevant (unless explicitly added to the `GhostRelevancySet`).
When using this fast-path, there is no need to write ghost instances into the global GhostRelevancySet
unless they would not be added via the ghost importance function isRelevant
flag.
For example; a map marker ghost far outside the practical BatchScaleWithRelevancy
radius, but that you still want to replicate.
Note
PrioChunk.isRelevant
has lower precedence than the per-entity GhostRelevancySet
.
Preserialize ghosts
By default, all ghosts are serialized once per connection on the server. This is done on demand and each ghost is only serialized when it's actually sent to a client. This serialization process can be expensive in terms of CPU, especially when the server has many connections and many ghosts. To reduce this cost, you can use preserialization.
Preserialization is a feature that allows you to serialize ghost data once and reuse it for all connections on the server. You can enable preserialization in two ways:
- Enabling
UsePreserialization
in theGhostAuthoringComponent
inspector on your ghost prefab. This causes all ghosts of this type to use preserialization. - Adding the
PreSerializedGhost
component to the ghost entity in the server world. This causes only this specific ghost to use preserialization.
When preserialization is enabled the server only serializes the ghost once for all connections. However, preserialized ghosts are serialized regularly on every tick, even if the ghost isn't going to be sent to any client. As a result, preserialization is only recommended for ghosts that are frequently sent to multiple clients (otherwise the CPU cost might be higher than the default behavior of serializing ghosts on demand).
Optimization Mode
Optimization Mode is a setting available on the GhostAuthoringComponent
that changes how often Netcode for Entities resends the GhostField
on a spawned entity. It has two modes: Dynamic and Static.
- Dynamic: This is the default setting. Use this when you expect the ghost to change often. The ghost is optimized for a small snapshot size when both changing and not changing.
- Static: Use this when you expect the ghost to change infrequently. The ghost isn't optimized for a small snapshot size when changing, but isn't sent at all when it's not changing.
For example, if you spawn objects that never move, set the Optimization Mode to Static to ensure that Netcode for Entities doesn't resynchronize their Transform.
When a GhostField
changes, Netcode for Entities sends the changes regardless of the Optimization Mode. It just optimizes the number and size of the snapshots sent.
Limitations with static-optimized ghosts
- Static-optimized ghosts are forced to enable
UseSingleBaseline
. - Static optimization isn't supported for ghosts involved in a ghost group (neither the root, nor ghost group children), nor for ghosts containing any replicated child components. In both of these cases, ghosts are treated as Dynamic at runtime.
- Ghosts that are both static-optimized and interpolated won't run
GhostField
extrapolation (SmoothingAction.InterpolateAndExtrapolate
is forced intoSmoothingAction.Interpolate
).