Download

Character Movement Optimizations

Sep 2, 2021.Knowledge
Server CPU Usage

  • Clients by default send moves to the server at a pretty high rate. We’ve made the client rates adjustable in INI files (they used to be hardcoded in ReplicateMoveToServer, see INI Settings below). For some games you could go even lower than the conservative engine defaults… more like a send rate of 20Hz-30Hz from clients (which can match the server tick rate, or be slower even). This will result in less bandwidth usage, and fewer ServerMoves for the server to process, which directly lowers CPU usage since that’s where the character sim happens on the server.

You can reduce the client send rate so the server has to process fewer character moves from each client. This is the relevant block in BaseGame.ini which you can tweak in DefaultGame.ini. Defaults are 60Hz, or 45Hz when player count is at or over 10. Fortnite gets away with 20Hz by default, or 15Hz at or above a 24 player threshold. Clients will receive lower network update rates of character location because of this, but client movement prediction and mesh interpolation will smooth out the result. At lower rates like this, using “linear” smoothing is suggested for smoother interpolation results (more on that later).

INI Settings:
BaseGame.ini defaults:

[/Script/Engine.GameNetworkManager]
ClientNetSendMoveDeltaTime=0.0166
ClientNetSendMoveDeltaTimeThrottled=0.0222
ClientNetSendMoveDeltaTimeStationary=0.0166
ClientNetSendMoveThrottleAtNetSpeed=10000
ClientNetSendMoveThrottleOverPlayerCount=10

DefaultGame.ini overrides from engine defaults:
[/Script/Engine.GameNetworkManager]
ClientNetSendMoveDeltaTime=0.05
ClientNetSendMoveDeltaTimeThrottled=0.066
ClientNetSendMoveDeltaTimeStationary=0.0833
ClientNetSendMoveThrottleAtNetSpeed=10000
ClientNetSendMoveThrottleOverPlayerCount=24

  • Consider whether you need any mesh animation at all on the server. Unless you have server authoritative headshots or something, you probably don’t need it (even then, client-side would be better for latency concerns). I mention this because the ServerMove also updates animation typically and you can see it wrapped up under ServerMove in profiles. You can set the MeshComponentUpdate flag on the character meshes to OnlyUpdateWhenRendered, which on a dedicated server does nothing. This will not update the pose ever (and likely avoid Blueprint cost). Perhaps you still need a pose update, but no bone positions, there is also an option for that (bNoSkeletonUpdate or other mesh flags). There is the possibility you need things like montages to still update (for root motion as well), so you can also flip on/off the component tick and mesh flag only when a montage plays, if that is the case.

  • There are a few simple settings which you are probably aware of, that save CPU in limited cased, but might as well mention them here:

set bAlwaysCheckFloor = false, saves floor checks for stationary characters.
lower MaxSimulationIterations and MaxSimulationTimeStep from their defaults. The default is pretty aggressive for high-quality simulation, but you can usually get away with only 2, maybe 4 iterations with longer timesteps. In the case of a network or CPU spike, this avoids double simulation intended to smooth out movement over the longer time delta.

  • Component count: you’ve probably already done this, but really try to get the number of attached components on the character capsule and mesh down. On dedicated servers, you can simply detach anything like particle fx and audio components in PostInitializeComponents(), they are mostly useless on the server. Just double check the positions are not needed by anything like game logic or blueprints, and nothing attached is needed either. This will avoid updating any transforms for those components. Related, also try to disable any collision on attached components if possible, or at least have them ignore the Pawn channel the player capsule uses. This will allow fewer collision tests in the physx sweep phase.

  • Overlap events: similarly, remove bGenerateOverlapEvents as much as possible from any attached components. We generally try to turn it on only on the player capsule itself, never anything attached (or at most 1 other if really necessary), since those will perform a scene query every move as well. See notes below on client performance related to whether you need overlaps on network proxies at all.

Client CPU Usage

For cheaper simulation of proxies on the client, your best bet is to override UCharacterMovementComponent::SimulateMovement() to basically avoid doing any forward prediction (component transform updates and physics queries) of the capsule location for a select number of proxies. Instead you rely on the mesh interpolation between network updates of the capsule location to smooth the visual location. This works best when the interpolation mode is set to ENetworkSmoothingMode::Linear. This does imply changes in behavior if you need a smoother capsule location for some reason for proxies. Typically you could choose whether to interpolate based on distance from the local player, if fidelity is an issue.

When you decide to not call the Super implementation that does everything, you still need to do a little bookkeeping for if you do switch back, and to handle movement mode changes.

void UMyCharacterMovement::SimulateMovement(float DeltaTime)
{
bFullySimulatingProxyMovement = ShouldFullySimulateMovement(DeltaTime);

if (bFullySimulatingProxyMovement)
{
	// Handle transferring floor check request from non-simulated mode.
	if (bFloorUpdateRequestedForAnimation)
	{
		bFloorUpdateRequestedForAnimation = false;
		bForceNextFloorCheck = true;
	}
	else if (!bWasFullySimulating)
	{
		bForceNextFloorCheck = true;
	}

	// Enable encroach checks if allowed.
	CharacterOwner->bClientCheckEncroachmentOnNetUpdate = 

bClientCanEverCheckEncroachmentOnNetUpdate;

	// Run full sim now
	UpdateCharacterStateBeforeMovement(DeltaTime);
	Super::SimulateMovement(DeltaTime);
	UpdateCharacterStateAfterMovement(DeltaTime);
}
else
{
	// Disable encroach checks. Only necessary while simulation is enabled.
	CharacterOwner->bClientCheckEncroachmentOnNetUpdate = false;

	// Update replicated movement mode.

// This does the floor check if changing to walking,
// and clears the floor if not.
if (bNetworkMovementModeChanged)
{
const uint8 RepMode = GetCharacterOwner()->GetReplicatedMovementMode();
ApplyNetworkMovementMode(RepMode);
bNetworkMovementModeChanged = false;
if (IsMovingOnGround())
{
// Sets FramesUntilAnimFloorUpdate based on pawn LOD level,
// and tries to not run them all on the same offset/frame.
QueueAnimFloorUpdate();
}
}
else if (IsMovingOnGround())
{
// If needed find the floor. Slope warping animation system
// needs the floor for proper foot placement (enabled for players).
bool bNewFloorUpdateRequested = false;
if (bUpdatesFloorWhenNotInFullSimulation)
{
bNewFloorUpdateRequested = (bJustTeleported || !Velocity.IsZero());
}
else if (bUpdatesFloorWhenNotInFullSimulationOnlyOnNetUpdate)
{
bNewFloorUpdateRequested = (bJustTeleported);
}

		// New floor update request? Start a countdown,

// offset for each request to keep them on different frames.
if (bNewFloorUpdateRequested)
{
QueueAnimFloorUpdate();
}

		// Handle floor request. If not rendered, the request will

// remain until rendered or timer expires, at which time the update occurs.
if (bFloorUpdateRequestedForAnimation && UpdatedComponent)
{
const USkeletalMeshComponent* Mesh = CharacterOwner->GetMesh();
const bool bRecentlyRendered = (Mesh && Mesh->bRecentlyRendered);
bool bFloorCheckRequestedIfRendered = bRecentlyRendered &&
IsSimulatedFloorCheckRequiredIfRendered();

			FramesUntilAnimFloorUpdate--;
			const bool bNeedsThrottledUpdate = (FramesUntilAnimFloorUpdate <= 0);
			if (bFloorCheckRequestedIfRendered || bNeedsThrottledUpdate)
			{
				FindFloor(UpdatedComponent->GetComponentLocation(), CurrentFloor, /*bZeroDelta=*/ false, NULL);
				bFloorUpdateRequestedForAnimation = false;
			}
		}
	}
	else
	{
		// Not moving on the ground, don't care.
		bFloorUpdateRequestedForAnimation = false;
	}

	// Animations rely on some portion of what's normally done in SimulateMovement().
	UpdateProxyAcceleration();

	// Copy some Super::SimulateMovement() behavior.
	if (MovementMode != MOVE_None)
	{
		HandlePendingLaunch();
	}
	UpdateComponentVelocity();
	ClearAccumulatedForces();
	bNetworkUpdateReceived = false;
	bJustTeleported = false;
	
	LastUpdateLocation = UpdatedComponent ?

UpdatedComponent->GetComponentLocation() : FVector::ZeroVector;
LastUpdateRotation = UpdatedComponent ?
UpdatedComponent->GetComponentQuat() : FQuat::Identity;
LastUpdateVelocity = Velocity;
}
}

You might still need to call FindFloor, if your animations use them for some reason. You can throttle that over time as well if needed. Some of that is abstracted in the example above.

Another important client optimization is to always have UCharacterMovementComponent::SmoothClientPosition() call SmoothClientPosition_Interpolate() every frame to interpolate the timestamps and location/rotation offsets, but only selectively update the component and visual locations (which is more expensive) by calling SmoothClientPosition_UpdateVisuals() less frequently. A strategy to use there would be a LOD system to update less often for characters far away or significantly outside the camera frustum. Whereas the above example of modifying SimulateMovement() avoided moving the capsule every frame (while SimulateMovement was disabled, the capsule only moved during network replication updates), this approach in SmoothClientPosition() updates the MeshComponent location (and attached components) less often if desired. If done to on-screen elements, this will visually cause skipping on the mesh location, so is only advisable for characters very far away, occluded, or out of view angles.

Note: when skipping SmoothClientPosition_UpdateVisuals(), make sure to set bNetworkSmoothingComplete = false on those frames, or subsequent updates may skip SmoothClientPosition() altogether!

You can use significance to have various Pawn LOD levels and turn up or down options based on distance to the player/camera. Fully sim only the closest 0, 5, or 10 etc by platform. The rest can use interpolation between net updates only. The least relevant (or those significantly out of view), can lower SmoothClientPosition_UpdateVisuals() rate to update mesh location less frequently. In combination these strategies can save significant client time when many characters are network relevant to the client.

Note that if you are pursuing the above strategies but still need a smooth location for either the capsule or mesh locations, you can still calculate the smoothed location and rotation easily from the results of SmoothClientLocation_Interpolate(). The location is simply the CapsuleLocation + MeshTranslationOffset from the client smoothing data.

Also consider whether you need bGenerateOverlapEvents at all on simulated proxies. Will that be necessary for anything client-side, or will the server just trigger any important events anyway? You might be able to simply disable all bGenerateOverlapEvents flags on everything on the proxy in BeginPlay() on clients, which will further save on movement cost. There are a couple other flags you can also choose to disable for proxies:

GetCapsuleComponent()->SetGenerateOverlapEvents(false);
GetCapsuleComponent()->SetShouldUpdatePhysicsVolume(false);
GetCharacterMovement()->bComponentShouldUpdatePhysicsVolume = false;

Dormancy

Dormancy can help for high replicated actor counts though it has limitations/nuances. There is essentially 3 levels of savings:

  1. DORM_Initial, “placed in map” actors. These get filtered out at the highest level and basically become free (until they change and flush their dormancy). If you have replicated actors placed in your map, see if you can make them DORM_Initial. FN has a hack where some buildings do need randomization/changing after spawn - this is done deterministically on server/all clients and they are left in DORM_Initial. The changes essentially go behind the replication systems back.

  2. “Dormant on all connections” actors. That is, an actor that has gone dormant and every connection is also filtered at a high level and becomes almost free (I think there is still a TMap lookup, which does add up). But in practice for large maps and high connection counts - this isn’t that useful. For example, a pickup spawned in FNBR in some corner of the map. It will almost certainly never become relevant for all 100 connections, so that pickup actor will never hit the “dormant on all connections” filter. It will need to be checked per connection.

  3. “Dormant on a connection” still, dormancy is tracked per connection and once the actor has replicated its last set of properties to a connection, it will never delta those properties again until dormancy is flushed. It will also never be prioritized/sorted, etc. This is still a big win.

Stat Captures

You can generate stat captures/profiles using (run on the server):

stat dumpframe -ms=0.01 -root=Stat_Net
This won’t be helpful for hitches/spikes but will give a quick overview of steady state of the net driver cpu cost.

1 Like