A world that ticks
In the last chapter, we covered the idea of the Update pattern. When using the Update pattern, we design with the philosophy that lots of different types of classes need to execute code on each frame. To achieve this, we will use an update function or, in the case of Unreal, a Tick Event. This pattern provides a fast way to make things happen, which is why it has a certain gravity, pulling many learning resources into leaning on it heavily. The volume of learning materials doesn’t make this a good idea, but it does allow systems to be propped up quickly, thus making learning from resources like this easy to understand. The problem lies in the seeming loss of understanding, over the cost of relying on this method. When we place any nodes or lines of code under the purview of tick, we need to remember it runs once per frame. On modern machines this equates to an average of 60 times a second, but it can vary depending on hardware, which is an important consideration for developers. For small games on modern hardware, the odd variable set or transform change may be fine; however, this is significantly more concerning if you consider the impact of a loop. This should make it clear that, as powerful as tick may seem, a web of problems lies beneath its surface.
To illustrate the problem, let’s look at the AGuardTower_CH5_1
actor in the Chapter 5
folder. We have followed best practice with the header file, using forward declared classes for member component definitions and the most limited property specifiers that we can. You will probably notice though that the only function, bar the constructor, is Tick
. In this function, we carry out all our searchlight functionality. Stepping through the tick function on the searchlight, we have the following:
- A call to the parent Tick
- A sphere trace that gets reference to any actor that falls within the rough area of the light
- A check to see whether the detected actor is of the player pawn type
- Code branches to rotate the searchlight in every direction when the player has not been found
The GuardTower
body file contains a tick
function which looks like this:
GuardTower_CH5_1.cpp Tick function
void AGuardTower_CH5_1::Tick(float DeltaTime) { Super::Tick(DeltaTime); FVector startLocation = _Arrow->GetComponentLocation(); FVector endLocation = _Arrow->GetComponentLocation() + (_Arrow->GetForwardVector() * _DetectionRange); FHitResult hit; UKismetSystemLibrary::SphereTraceSingle(GetWorld(),startLocation, endLocation,_DetectionRadius, UEngineTypes:: ConvertToTraceType(ECC_Visibility), false, {}, EDrawDebugTrace::ForOneFrame,hit, true); ACharacter* otherCasted = Cast<AEliteUnit>(hit.GetActor()); _EnemySpotted = (otherCasted != nullptr); if (!_EnemySpotted) { if (_RotateForward) { _LightPivot->AddLocalRotation(FRotator(0.0, 0.2, 0.0)); if (FMath::IsNearlyEqual (_LightPivot->GetRelativeRotation().Yaw, 40.f)) { _RotateForward = false; } } else { _LightPivot->AddLocalRotation(FRotator(0.0, -0.2, 0.0)); if (FMath::IsNearlyEqual (_LightPivot->GetRelativeRotation().Yaw, -40.f)) { _RotateForward = true; } } } }
This code does run, and you can see that if you drag an instance of BP_GuardTower
into the level provided in the Chapter 5 folder. However, there are some issues, so let’s break down these problems in order:
- With all the logic being on tick, we are performing some costly actions often. A sphere trace on its own is not too costly, but if we perform that sphere trace once per frame, per guard tower in a scene, the computational cost can add up quickly.
- Quite a few getter functions are repeatedly used without caching. The world pointer could have been cached on
BeginPlay
, as that is unlikely to change, and multiple calls to the arrow component’s location could be done once. These are only minor improvements. - Casting is nowhere near as costly as it used to be in the early days of Unreal Engine. That said, when casting to a class, the target class must be loaded to ensure a match. This must be kept in mind when casting to a large class, as it can inflate the size of the process in RAM. Alternatives could include casting to an interface if you only need some functionality, checking tags if it is just a validity check, or, even better, trying to flip the communication on its head and remove the need to cast.
- We then have a pattern that we will refer to as gated polling. On tick, check to see whether a block of code needs to run; if so, run it. This gated polling pattern is repeated a couple of times for different blocks of code. The alarm bells should now be ringing but maybe for the wrong reason. Yes, we have a repeated pattern of code, and we previously talked about avoiding repetition; that isn’t the real issue here. Any time we process something that may or may not need to happen, we guarantee a wasted check on some frames when it fails. We should consider how we can avoid the check and only process the code when it does need to happen.
- Lastly, we have a compound problem with each branch of the gated poll. Yes, we are hardcoding values, both the rotation limit and the speed of rotation. Turning the hardcoded
0.2
and40
into float member variables called_RotationSpeed
and_RotationLimit
respectively is a simple fix for the first part. AddingEditAnywhere
to theUPROPERTY()
block above each new variable will allow designers to not only balance the values in the actor but also create varied instances in their levels. This still leaves an issue with our code. Tick is not constant, yet we are using a constant value for rotation. This is whereDeltaTime
comes in. It is an argument of tick passed in as the time since the last frame rendered. Multiplying values by this will cause them to be applied evenly across 1 real-world second.
It should be pretty clear that there is a lot of room for improvement with this code, but how much room? Back in Chapter 3, we discussed Big O notation, but to get a higher resolution analysis, we need to calculate the T(n) or time efficiency of the algorithm. This should provide us with a baseline to prove that we can quantifiably do better.
The first sphere trace section contains roughly three assignments, six function calls, three arithmetic operations, and the internal time of a sphere trace. The cast section is two function calls, one test, and one assignment. In the if
block, both branches are basically the same number of executions, so we will only count the top branch, giving us two tests, four function calls, and one assignment. Altogether, this makes roughly 23 + 1 * sphere trace executions per frame, with no overhead beyond a standard setup. Over a second at 60 frames per second (FPS), we would be running 1380 + 60 * sphere trace executions.
Important note
You can find the full working solutions for the following two sections under the _2
and _3
versions of the GuardTower_CH5
class, but try to follow along with this section, making changes to the _1
version to get the most out of the chapter.
Now that we’ve reviewed the problems with using Tick for the guard tower, let’s begin to look at how we can fix it, removing our reliance on Tick and writing better code.