Skip to main content

Using C++20 with Unreal

This article describes using some C++ 20 features such as concepts and lambdas with template parameters.

Introduction

This article describes using some C++ 20 features such as concepts and lambdas with template parameters.

The code example used here comes from a Mass processor which is configured to find certain entities, which have specific tags and fragments, and then update those fragments.

The parts of a typical processor look like this:

void UMassMovingAvoidanceProcessor::ConfigureQueries()
{
EntityQuery.AddRequirement<FMassForceFragment>(EMassFragmentAccess::ReadWrite);
EntityQuery.AddRequirement<FMassNavigationEdgesFragment>(EMassFragmentAccess::ReadOnly);
EntityQuery.AddRequirement<FMassMoveTargetFragment>(EMassFragmentAccess::ReadOnly);
EntityQuery.AddRequirement<FTransformFragment>(EMassFragmentAccess::ReadOnly);
EntityQuery.AddRequirement<FMassVelocityFragment>(EMassFragmentAccess::ReadOnly);
EntityQuery.AddRequirement<FAgentRadiusFragment>(EMassFragmentAccess::ReadOnly);
EntityQuery.AddTagRequirement<FMassMediumLODTag>(EMassFragmentPresence::None);
EntityQuery.AddTagRequirement<FMassLowLODTag>(EMassFragmentPresence::None);
EntityQuery.AddTagRequirement<FMassOffLODTag>(EMassFragmentPresence::None);
EntityQuery.AddConstSharedRequirement<FMassMovingAvoidanceParameters>(EMassFragmentPresence::All);
EntityQuery.AddConstSharedRequirement<FMassMovementParameters>(EMassFragmentPresence::All);
}
void UMassMovingAvoidanceProcessor::Execute(FMassEntityManager& EntityManager, FMassExecutionContext& Context)
{
...
EntityQuery.ForEachEntityChunk(EntityManager, Context, [this, &EntityManager](FMassExecutionContext& Context)
{
...
const TArrayView<FMassForceFragment> ForceList = Context.GetMutableFragmentView<FMassForceFragment>();
const TConstArrayView<FMassNavigationEdgesFragment> NavEdgesList = Context.GetFragmentView<FMassNavigationEdgesFragment>();
const TConstArrayView<FTransformFragment> LocationList = Context.GetFragmentView<FTransformFragment>();
const TConstArrayView<FMassVelocityFragment> VelocityList = Context.GetFragmentView<FMassVelocityFragment>();
const TConstArrayView<FAgentRadiusFragment> RadiusList = Context.GetFragmentView<FAgentRadiusFragment>();
const TConstArrayView<FMassMoveTargetFragment> MoveTargetList = Context.GetFragmentView<FMassMoveTargetFragment>();
const FMassMovingAvoidanceParameters& MovingAvoidanceParams = Context.GetConstSharedFragment<FMassMovingAvoidanceParameters>();
const FMassMovementParameters& MovementParams = Context.GetConstSharedFragment<FMassMovementParameters>();

for (int32 EntityIndex = 0; EntityIndex < NumEntities; ++EntityIndex)
{
...
const FMassNavigationEdgesFragment& NavEdges = NavEdgesList[EntityIndex];
const FTransformFragment& Location = LocationList[EntityIndex];
const FMassVelocityFragment& Velocity = VelocityList[EntityIndex];
const FAgentRadiusFragment& Radius = RadiusList[EntityIndex];
FMassForceFragment& Force = ForceList[EntityIndex];

The ConfigureQueries() function defines a query which runs against all the existing entities, and then the Execute() function updates the entities which match the query.

Access to the fragments can be ReadOnly or ReadWrite, and there are a range of access options including:

  • All - the tag or fragment must be present
  • None - the tag or fragment must not be present
  • Any - the tag or fragment is optional

Inside the Execute() function there are calls like this:

const TArrayView<FMassForceFragment> ForceList = Context.GetMutableFragmentView<FMassForceFragment>();
const TConstArrayView<FMassNavigationEdgesFragment> NavEdgesList = Context.GetFragmentView<FMassNavigationEdgesFragment>()

to access the data for each specified fragment. This is complicated by the fact that:

  • ReadOnly and ReadWrite fragments return different types, such as TConstArrayView for readonly data and TArrayView for mutable data
  • if the fragment is shared and optional it will return a pointer, if it is not optional it will return a reference
  • if the fragment is optional a returned array view might have no elements
  • shared fragments return only one data element (so don't use an array view) and return a reference or a pointer
  • const shared fragments return only one const data element (so don't use an array view) and return a reference or a pointer

For each entity there are lines like this:

const FMassNavigationEdgesFragment& NavEdges = NavEdgesList[EntityIndex];
const FTransformFragment& Location = LocationList[EntityIndex];

these lines extract the data specific to each individual entity.

Why use templates

The problem with all this is it is repetitive and error prone. Some coding errors will be detected at compile time, some not until runtime. In this article we look at ways of replacing the above code with templates to reduce repetition.

The code here is developed and tested using Visual Studio 2022 setup as described here

Design

The basic structure of a mass processor looks like this:

UCLASS()
class MASSAIBEHAVIOR_API UMassLookAtProcessor : public UMassProcessor
{
GENERATED_BODY()

UMassLookAtProcessor();

protected:

virtual void ConfigureQueries() override;
virtual void Execute(FMassEntityManager& EntityManager, FMassExecutionContext& Context) override;

... properties ...

FMassEntityQuery EntityQuery_Conditional;
};

We create a processor by deriving a new class from UMassProcessor. We cannot make the derived class a template because the Unreal Header Tool (UHT) does not parse template headers and won't allow this.

We cannot template another class derived from our processor (UMassLookAtProcessor in the above example) because our processor needs to be a UCLASS in order to be registered properly. If we don't make it a UCLASS it will compile ok but it won't run. It is also difficult to template a processor class and use multiple inheritance because we cannot make the inheritance from UObject to UMassProcessor virtual without changing the engine source code.

So instead of templating the class, we can replace the FMassEntityQuery object with a class called FExtendedMassQuery and template that object instead. This also makes it simpler to have more than one templated query in a single class.

The FExtendedMassQuery class takes as template parameters a collection of FFragmentRequirement structs each of which holds the tag or fragment and its access and presence requirements. We can use it like so:

FExtendedMassQuery
<
FFragmentRequirement< FMassActiveZombieTag, EMassFragmentAccess::ReadWrite, EMassFragmentPresence::All >,
FFragmentRequirement< FMassPathSubsetFragment, EMassFragmentAccess::ReadOnly, EMassFragmentPresence::All >,
FFragmentRequirement< FMassNavigationEdgesFragment, EMassFragmentAccess::ReadWrite, EMassFragmentPresence::All >
>
EntityQuery;

FFragmentRequirement

FFragmentRequirement specifies the access and presence requirements for a fragment, like this:

template <  typename T, 
EMassFragmentAccess InAccess = EMassFragmentAccess::ReadOnly,
EMassFragmentPresence InPresence = EMassFragmentPresence::All >
requires IsDerivedFromFragmentOrTag< T >
struct FFragmentRequirement
{
using FragmentType = T;
static constexpr EMassFragmentAccess Access = InAccess;
static constexpr EMassFragmentPresence Presence = InPresence;
};

Here we define the FFragmentRequirement struct and use a concept to make sure the FragmentType property is indeed a tag or fragment. The concept is defined like this:

template < typename F >
concept IsDerivedFromFragmentOrTag =
TIsDerivedFrom< F, FMassFragment >::IsDerived ||
TIsDerivedFrom< F, FMassSharedFragment >::IsDerived ||
TIsDerivedFrom< F, FMassConstSharedFragment >::IsDerived ||
TIsDerivedFrom< F, FMassTag >::IsDerived;

We also define concepts to express various access and presence requirements:

template < typename T >
concept IsNormalFragment = TIsDerivedFrom< typename T::FragmentType, FMassFragment >::IsDerived;

template < typename T >
concept IsSharedFragment = TIsDerivedFrom< typename T::FragmentType, FMassSharedFragment >::IsDerived;

template < typename T >
concept IsConstSharedFragment = TIsDerivedFrom< typename T::FragmentType, FMassConstSharedFragment >::IsDerived;

template < typename T >
concept IsMassTag = TIsDerivedFrom< typename T::FragmentType, FMassTag >::IsDerived;

template < typename T >
concept IsMassFragment = IsNormalFragment< T > || IsSharedFragment< T > || IsConstSharedFragment< T >;

template < typename T >
concept IsMassFragmentOrTag = IsMassFragment< T > || IsMassTag< T >;

template < typename T >
concept IsOptional = T::Presence == EMassFragmentPresence::Any || T::Presence == EMassFragmentPresence::Optional;

template < typename T >
concept IsPresenceNone = T::Presence == EMassFragmentPresence::None;

template < typename T >
concept IsReadWrite = T::Access == EMassFragmentAccess::ReadWrite;

Templating the Query Object

The FExtendedMassQuery object is declared like this:

template < typename... Requirements >
struct FExtendedMassQuery : public FMassEntityQuery

The "typename..." declares a template parameter pack which allows us to have any number of requirements.

Replacing ConfigureQueries()

Every mass processor must implement the ConfigureQueries() method because it is pure virtual in the base UMassProcessor class. We can just override it can leave it empty like this:

virtual void ConfigureQueries() override {};

What ConfigureQueries() does it set the parameters on the EntityQuery object and register it. We can do this in the FExtendedMassQuery constructor like so:

FExtendedMassQuery( UMassProcessor* Processor )
: FMassEntityQuery( *Processor )
{
( AddSingleRequirement< Requirements >(), ... );
}

The highlighted line uses a fold expression to call the AddSingleRequirement() function for each element in the Requirements parameter pack.

AddSingleRequirement() is declared like this:

template < typename T >
void AddSingleRequirement()
{
// add a requirement to the member query
if constexpr ( IsNormalFragment< T > )
{
AddRequirement< typename T::FragmentType >( T::Access, T::Presence );
}
else if constexpr ( IsSharedFragment< T > )
{
AddSharedRequirement< typename T::FragmentType >( T::Access, T::Presence );
}
else if constexpr ( IsConstSharedFragment< T > )
{
AddConstSharedRequirement< typename T::FragmentType >( T::Presence );
}
else if constexpr ( IsMassTag< T > )
{
AddTagRequirement< typename T::FragmentType >( T::Presence );
}
}

It uses if constexpr together with concepts such as IsNormalFragment to add the correct requirement based on the fragment type and the access and presence values.

Data Types

Each fragment requirement might have an associated data type which we want to access. These are:

TypeOptionalAccessData TypeExample
FragmentfalseReadWritereferenceFTransformFragment&
FragmentfalseReadOnlyconst referenceconst FTransformFragment&
FragmenttrueReadWritepointerFTransformFragment*
FragmenttrueReadOnlyconst pointerconst FTransformFragment*

We can assemble a list of types like this:

using ViewDataTypes = decltype( std::tuple_cat( GetFragmentType< Requirements >()... ) );

where GetFragmentType() is coded like this:

template < typename T >
requires IsMassFragment< T >
static constexpr auto GetFragmentType()
{
if constexpr ( IsPresenceNone< T > )
{
return std::tuple<>();
}
else if constexpr ( IsOptional< T > )
{
if constexpr ( IsReadWrite< T > )
{
return std::tuple< std::add_pointer_t< typename T::FragmentType > >();
}
else
{
return std::tuple< std::add_const_t< std::add_pointer_t< typename T::FragmentType > > >();
}
}
else if constexpr ( !IsReadWrite< T > )
{
return std::tuple< std::add_const_t< typename T::FragmentType > >();
}
else
{
return std::tuple< typename T::FragmentType >();
}
}

template < typename T >
requires IsMassTag< T >
static constexpr auto GetFragmentType()
{
return std::tuple<>();
}

Note that we don't return a type for tags or when Presence=None. This means often there are fewer values in the ViewDataTypes list than there are requirements.

Selecting the Data

Instead of doing this:

EntityQuery.ForEachEntityChunk(EntityManager, Context, [this, &EntityManager](FMassExecutionContext& Context)
{
const TArrayView<FMassForceFragment> ForceList = Context.GetMutableFragmentView<FMassForceFragment>();
const TConstArrayView<FMassNavigationEdgesFragment> NavEdgesList = Context.GetFragmentView<FMassNavigationEdgesFragment>();
const TConstArrayView<FTransformFragment> LocationList = Context.GetFragmentView<FTransformFragment>();

we can use another fold expression to call the approriate function for each requirement and concatenate all the returned values into one tuple:

void Execute( FMassEntityManager& EntityManager, FMassExecutionContext& Context, auto&& Func )
{
ForEachEntityChunk( EntityManager,
Context,
[&]( FMassExecutionContext& Context )
{
auto FragmentViews = std::tuple_cat( GetView< Requirements >( Context )... );

This calls GetView() once for each requirement.

GetView() returns a reference or a pointer for requirements where we want the data, and nothing (std::make_tuple<>()) for tags or fragments where Presence=None:

template < class... >
struct False : std::bool_constant< false >
{
};

template < typename T >
auto GetView( FMassExecutionContext& InContext )
{
// add a requirement to the member query
if constexpr ( IsPresenceNone< T > )
{
return std::make_tuple<>();
}
else if constexpr ( IsNormalFragment< T > && IsReadWrite< T > )
{
return std::make_tuple( InContext.GetMutableFragmentView< typename T::FragmentType >() );
}
else if constexpr ( IsNormalFragment< T > && !IsReadWrite< T > )
{
return std::make_tuple( InContext.GetFragmentView< typename T::FragmentType >() );
}
else if constexpr ( IsConstSharedFragment< T > && !IsOptional< T > )
{
return std::make_tuple( InContext.GetConstSharedFragment< T::FragmentType >() );
}
else if constexpr ( IsConstSharedFragment< T > && IsOptional< T > )
{
return std::make_tuple( InContext.GetConstSharedFragmentPtr< T::FragmentType >() );
}
else if constexpr ( IsMassTag< T > )
{
return std::tuple<>();
}
else
{
static_assert( False< int >{}, "no matching GetView call" );
}
}

The line:

auto FragmentViews = std::tuple_cat( GetView< Requirements >( Context )... );

is executed once for each Entity chunk which contains a number of entities, after that we need to process each entity and extract its data from the FragmentViews. We are not copying any data here, we are just arranging references and pointers into the existing data.

Once we have assembled the views and pointers in the FragmentViews tuple, for each entity we can extract that entity's data. This is more complex because we need to index into the FragmentViews tuple to get the right array view or pointer for each requirement, so we are iterating through the FragmentViews and the ViewDataTypes together.

We do this using a std::make_index_sequence which is basically a list of integers 0, 1, 2 ... for the length of ViewDataTypes. We do this like so:

using IndexSequence = std::make_index_sequence< std::tuple_size_v< ViewDataTypes > >;

std::apply( [&]( auto&... Views )
{
int32 NumEntities = Context.GetNumEntities();
for ( int32 i = 0; i < NumEntities; ++i )
{
auto FragmentsForEntity = ExpandWithIndex( IndexSequence{}, Context, FragmentViews, i );
const FMassEntityHandle Entity = Context.GetEntity( i );
Func( Entity, FragmentsForEntity );
}
},
FragmentViews );

In the above code we get the fragments for each entity and pass them to a function (called Func) which implements the body of the processor.

This line:

auto FragmentsForEntity = ExpandWithIndex( IndexSequence{}, Context, FragmentViews, i );

calls this function:

template < std::size_t... Is >
auto ExpandWithIndex( std::index_sequence< Is... >, FMassExecutionContext& Context, auto&& Views, const size_t EntityIndex )
{
return std::tuple_cat( GetFragmentFromView< std::tuple_element_t< Is, ViewDataTypes > >( Context, std::get< Is >( Views ), EntityIndex )... );
}

What this does it call GetFragmentFromView() with a template argument which is the type of data (pointer, reference etc.) which is associated with a FragmentView and an argument which is the array view or other data for that requirement.

The GetFragmentFromView() method is shown below. It uses "requires" expressions to differentiate between pointer and non-pointer, and const and non-const types. We return either std::tuple or std::tie, using std::tie when we know the data is there so we can return a reference to it.

template < typename FinalType >
auto GetFragmentFromView( FMassExecutionContext& InContext, TArrayView< FinalType >& View, const size_t EntityIndex )
{
return std::tie( View[EntityIndex] );
}

template < typename FinalType >
requires std::is_pointer_v< FinalType >
auto GetFragmentFromView( FMassExecutionContext& InContext, TArrayView< std::remove_pointer_t< FinalType > >& View, const size_t EntityIndex )
{
return std::tuple( View.Num() > EntityIndex ? &View[EntityIndex] : nullptr );
}

template < typename FinalType >
requires std::is_pointer_v< FinalType >
auto GetFragmentFromView( FMassExecutionContext& InContext, TArrayView< const std::remove_pointer_t< FinalType > >& View, const size_t EntityIndex )
{
return std::tuple( View.Num() > EntityIndex ? &View[EntityIndex] : nullptr );
}

template < typename FinalType >
auto GetFragmentFromView( FMassExecutionContext& InContext, TArrayView< const FinalType >& View, const size_t EntityIndex )
{
return std::tie( View[EntityIndex] );
}

template < typename FinalType >
requires std::is_pointer_v< FinalType > && std::is_const_v< FinalType >
auto GetFragmentFromView( FMassExecutionContext& InContext, const std::remove_pointer_t< FinalType >* View, const size_t EntityIndex )
{
return std::tie( View );
}

Processor Body

The entire FExtendedMassQuery::Execute() function looks like this:

void Execute( FMassEntityManager& EntityManager, FMassExecutionContext& Context, auto&& Func )
{

ForEachEntityChunk( EntityManager,
Context,
[&]( FMassExecutionContext& Context )
{
auto FragmentViews = std::tuple_cat( GetView< Requirements >( Context )... );

using IndexSequence = std::make_index_sequence< std::tuple_size_v< ViewDataTypes > >;

std::apply( [&]( auto&... Views )
{
int32 NumEntities = Context.GetNumEntities();

for ( int32 i = 0; i < NumEntities; ++i )
{
auto FragmentsForEntity = ExpandWithIndex( IndexSequence{}, Context, FragmentViews, i );

const FMassEntityHandle Entity = Context.GetEntity( i );

Func( Entity, FragmentsForEntity );
}
},
FragmentViews );
} );
}

It is called by a processor like this:

void UMassCreatePathEdgesProcessor::Execute( FMassEntityManager& EntityManager, FMassExecutionContext& Context )
{
EntityQuery.Execute( EntityManager,
Context,
[&Context]( const FMassEntityHandle Entity, auto& FragmentsForEntity )
{
const auto& [PathSubset, Edges] = FragmentsForEntity;

// do processor code
}
}

The caller passes FExtendedMassQuery::Execute() a lambda which is called for each entity and passed the entity handle and a variable called FragmentsForEntity, which is unpacked using structure binding like this:

const auto& [PathSubset, Edges] = FragmentsForEntity;

The number of variable names specified in the [] must be number of values appropiate for the requirements:

FExtendedMassQuery< 
FFragmentRequirement< FMassActiveZombieTag, EMassFragmentAccess::ReadWrite, EMassFragmentPresence::All >,
FFragmentRequirement< FMassPathSubsetFragment, EMassFragmentAccess::ReadOnly, EMassFragmentPresence::All >,
FFragmentRequirement< FTransformFragment, EMassFragmentAccess::ReadOnly, EMassFragmentPresence::All >
> EntityQuery;

There will be a variable for each requirement except there won't be one for tags and when Presence = EMassFragmentPresence::None.

The variables will have the types found in the ViewDataTypes declaration. They will be references or pointers depending on whether they are required or optional.