Concurrent Advance

Overview

When using Prysm to create your UI, most of the work is done inside the cohtml::View::Advance function call. This is usually where the core Cohtml library produces rendering commands for the page, updates animations and executes JavaScript, among other things. The concurrent Advance feature gives you the ability to move the Advance call to a worker thread, leveraging Unreal Engine’s Task System, thus freeing up the Game thread and increasing the overall performance of your project, which will reduce the cases where the rest of the game may lag. Note that while Advance will happen concurrently to the Game Thread when using this feature, multiple Advance calls will still not happen at the same time, but will instead be serialized to happen one after the other on Worker threads.

There are, however, a few drawbacks to this approach:

  • Most View operations cannot be used during the Advance call, such as sending input, triggering events, synchronizing models, etc. To avoid crashes you must follow some synchronization rules in your project, as explained below. Importantly, this applies to multiple Views as well - for example while ViewA::Advance is running, you can not call ViewB::SynchronizeModels.
  • Most Prysm callbacks (for example events triggered from JavaScript) will be called on the same thread as the Advance, which would be a Worker thread when using the feature. This does not mesh well with Unreal Engine’s limitations for operations that can only happen on the Game Thread. In order to adhere to those limitations, you will have to do some extra synchronization.
  • The Game thread still needs to wait for the Advance call to finish toward the end of every frame, to keep the game and UI in sync. If you have incredibly long Advance calls your Game thread will still get blocked waiting for them to finish.
  • Advance will now happen at an undefined time, so there will be no guarantees about the state of the Game world. In the cases where your UI depends on it, you should be careful with how you synchronize.

Below is a rough outline of how the feature works. There are a few things to point out:

  • The red lines are synchronization points between threads, both of which are explained below.
  • Note that the first Tick group that schedules the Advance call and the synchronization point at the end of World::Tick implicitly determine the part of the frame where UI changes can not be made (in red).
  • Prysm will block and wait for Advance to finish at the end of the World::Tick event after all tick groups have passed.
  • Whenever the Paint task gets executed on the render thread, it will block until Layout finishes. While this is not strictly related to concurrent Advance and rarely comes into play, it’s still a good thing to keep in mind.

How to use

To turn on the feature, use the provided “Use Advance Concurrently” checkbox:

  • For Widgets or Actor components (i.e. in-world Views) just change the option in their respective “Details” page in the Unreal Editor as shown in the screenshot below.
  • For HUDs, you’ll need to utilize either the SetupView method in blueprints/C++ as shown in the screenshot below.
  • For all setups, you can also change the bRunAdvanceConcurrently UPROPERTY in C++.

It is also a good idea to consider the Tick group you want the Advance to be scheduled in. Prysm will give you some defaults to start you off, but to achieve a substantial increase in performance you will need to choose your tick group carefully.

You can also check out the provided “Concurrent Advance” sample maps.

Converting an existing project to use Concurrent Advance

It can be quite hard to restructure an existing project to use the Concurrent Advance feature, so here is a quick guide that is aimed at gradually improving performance in the most controllable way:

  1. One by one, convert your Views to use “Concurrent Advance” and leave their tick groups to their new defaults (LastDemotable). You should run into (and fix) most of the worker thread callback issues here.
  2. Move as much of your UI changing logic to as early in the frame as possible. All SynchronizeModels, TriggerEvent, and other API calls should be moved to the earliest Tick group that can viably execute them.
  3. Figure out the latest tick group at which any of your UI changing logic happens - change your Views to Tick (and schedule Advance) in the next Tick Group. You can find more information on this in the Choosing a tick group section.

Details

Disallowed View operations

View operations/APIs, as used in this article, is a catch-all term that does not neatly correspond to specific functions. It’s important to understand the main idea behind it - changing or interacting with the UI while it is being updated is undefined behavior and should be avoided. There are some APIs that you can easily “guess” fall under this category, such as triggering JavaScript events or resizing the UI, but there are plenty of subtler APIs, which may cause a change/rebuild of the UI under the hood.

For those reasons, it is strongly recommended that interacting with the UI page (i.e. calling View APIs) and updating the UI (i.e. scheduling an Advance call for later) are synchronized implicitly, by doing the interactions in earlier Tick groups and scheduling the Advance call(s) in later Tick groups.

Choosing a Tick group

The chosen tick group will only control when the Game thread schedules the Advance call, not when it is executed. That being said, there is an important trade-off to point out:

  • Scheduling the concurrent Advance calls earlier will give them more time to finish before the end of the frame. This means that earlier tick groups translate to better performance.
  • Scheduling the concurrent Advance calls later means you will have more tick groups available to set up your UI in because you can only use View APIs before that. Regardless of which tick group you choose, all of your UI needs to be set up before it. This means that later tick groups translate to better flexibility and also take into account more state changes in the game for this frame (such as actors moving, physics, etc.), but the Advance tasks will have less time to complete, resulting in blocking the Game Thread to wait on their completion more often.

For Concurrent Advance, the default Tick group during which the Advance will be scheduled is LastDemotable, which is generally speaking the last Tick group in the UWorld::Tick. This is a good tick group to choose in terms of performance, but it is a good default to start using the feature with because it greatly reduces the possibility of crashes in an existing project - your UI will likely already be set up in earlier tick groups for any given project. It is not, however, a good tick group to use in general because it gives you the lowest possible performance - it makes it so you schedule the Advance call and almost immediately block the Game Thread to start waiting for it. It is highly recommended that after you try the feature, you evaluate which tick groups your project uses View APIs in (or changes the UI in general) and schedule the Advance calls in the earliest possible tick group after that.

If you want even finer control, you can also set the tick group to “Manual tick” and call TickComponent yourself every frame to schedule the Advance. This allows you even more fine-grained control, by specifying an exact point at which you are done with your View API calls, regardless of the Tick group.

Dealing with worker thread callbacks

If the Advance calls happen on a worker thread, most Prysm callbacks will happen on that thread as well. This leads to the following problems:

  • In Unreal Engine, some work can not be done on worker threads, such as some types of shader compilation, iterating over actors in the world, and loading UObjects among other things. If you want to do such work in a Prysm callback, you would now have to reschedule it back to the Game Thread, as shown in the example below.
  • Due to Advance happening at an undefined time, the Game world state is undefined, and relying on it or accessing it can lead to unwanted behavior. This can mostly be avoided by choosing the correct Tick group to schedule Advance in, and making sure that all relevant changes to the UI/world are done before that. For other cases, you would need to synchronize manually.

Rescheduling callbacks back to the Game thread example:

// Somewhere in BeginPlay/Constructor/etc.
CohtmlHUD->ReadyForBindings.AddDynamic(this, &MyHUD::BindUI);

// This will be called on a worker thread
void MyHUD::BindUI()
{
    // We post a task back to the Game thread
    AsyncTask(ENamedThreads::GameThread, []()
        {
            // Any work that can only be done on the Game Thread
        });
}

Debugging Concurrently Advancing Views

There are two main ways to improve your debugging experience when using the concurrent Advance feature:

  • Switching the SDK config to “Debug”. This will make it so that any non-synchronized usage of the View APIs immediately throws an exception with better logging and opportunities to inspect what is going wrong. If you do not switch to “Debug”, non-synchronized usage of the View APIs can have a multitude of subtler side effects, such as rendering artifacts, JavaScript not executing correctly, etc. and be much harder to track down.
  • You can use the “Enable Thread Usage logging” option. This will make it so that every View in your scene logs what tick group it’s ticking in when it schedules its Advance calls and from which thread, among other things. This is usually quite useful when trying to understand what is going on in a complex scene.

Mixing concurrent and non-concurrent Views

It is possible to have some of your Views running their Advance calls on the Game Thread and some of them running their Advance calls on Worker threads, as long as you adhere to the following principle. All View Advance calls running on the Game thread must run on earlier Tick Groups than those on Worker Threads.

This means that the following setup is correct.

  1. ViewA ticks on PrePhysics and runs Advance on the Game Thread
  2. ViewB ticks on DuringPhysics and runs Advance on the Game Thread
  3. ViewC ticks on StartPhysics and schedules a concurrent Advance on a Worker thread
  4. ViewD also ticks on StartPhysics and schedules a concurrent Advance on a Worker thread. It can also tick in a different Tick Group, as long as it is later than DuringPhysics.

The following setup is incorrect.

  1. ViewA ticks on PrePhysics and runs Advance on the Game Thread.
  2. ViewB ticks on StartPhysics and schedules a concurrent Advance on a Worker thread
  3. ViewC ticks on DuringPhysics and runs Advance on the Game Thread - this is not safe, because the Advance from 2. might happen at the same time as this one.

Limitations

Here are some limitations of the Concurrent Advance feature, which should hopefully improve over time:

  • The “Text to speech” sample will not run correctly if you switch its View options to Concurrent Advance mode.