Steering with Mass Framework

How to process steering with Mass Framework. Mass Framework is a ECS for managing crowds and traffic in Unreal 5.0 and above.

Processing Steering with MassAI built-in resources

  • Steering Trait

    Built-in MassAI Trait located At UE5/Engine/Plugins/AI/MassAI/Source/MassNavigation

    UCLASS(meta = (DisplayName = "Steering"))
    class MASSNAVIGATION_API UMassSteeringTrait : public UMassEntityTraitBase
    {
    	GENERATED_BODY()
    
    protected:
    	virtual void BuildTemplate(FMassEntityTemplateBuildContext& BuildContext, const UWorld& World) const override;
    
    	UPROPERTY(Category="Steering", EditAnywhere, meta=(EditInline))
    	FMassMovingSteeringParameters MovingSteering;
    
    	UPROPERTY(Category="Steering", EditAnywhere, meta=(EditInline))
    	FMassStandingSteeringParameters StandingSteering;
    };
    void UMassSteeringTrait::BuildTemplate(FMassEntityTemplateBuildContext& BuildContext, const UWorld& World) const
    {
    	FMassEntityManager& EntityManager = UE::Mass::Utils::GetEntityManagerChecked(World);
    
    	BuildContext.RequireFragment<FAgentRadiusFragment>();
    	BuildContext.RequireFragment<FTransformFragment>();
    	BuildContext.RequireFragment<FMassVelocityFragment>();
    	BuildContext.RequireFragment<FMassForceFragment>();
    
    	BuildContext.AddFragment<FMassMoveTargetFragment>();
    	BuildContext.AddFragment<FMassSteeringFragment>();
    	BuildContext.AddFragment<FMassStandingSteeringFragment>();
    	BuildContext.AddFragment<FMassGhostLocationFragment>();
    
    	const FConstSharedStruct MovingSteeringFragment = EntityManager.GetOrCreateConstSharedFragment(MovingSteering);
    	BuildContext.AddConstSharedFragment(MovingSteeringFragment);
    
    	const FConstSharedStruct StandingSteeringFragment = EntityManager.GetOrCreateConstSharedFragment(StandingSteering);
    	BuildContext.AddConstSharedFragment(StandingSteeringFragment);
    }
  • Fragments for Steering

    As you can see, the built-in Steering Trait of MassAI plugin works with the 8 following fragments:

    Required Fragments

    Required fragments are identical as fragments for Movement Trait

    Located at UE5/Engine/Plugins/Runtime/MassGameplay/Source/MassMovement

    USTRUCT()
    struct MASSMOVEMENT_API FMassVelocityFragment : public FMassFragment
    {
    	GENERATED_BODY()
    
    	FVector Value = FVector::ZeroVector;
    };

    Located at UE5/Engine/Plugins/Runtime/MassGameplay/Source/MassMovement

    USTRUCT()
    struct MASSMOVEMENT_API FMassForceFragment : public FMassFragment
    {
    	GENERATED_BODY()
    
    	FVector Value = FVector::ZeroVector;
    };

    Located at UE5/Engine/Plugins/Runtime/MassGameplay/Source/MassCommon

    USTRUCT()
    struct MASSCOMMON_API FAgentRadiusFragment : public FMassFragment
    {
    	GENERATED_BODY()
    	UPROPERTY(EditAnywhere, Category = "")
    	float Radius = 40.f;
    };

    Located at UE5/Engine/Plugins/Runtime/MassGameplay/Source/MassCommon

    USTRUCT()
    struct MASSCOMMON_API FTransformFragment : public FMassFragment
    {
    	GENERATED_BODY()
    
    	const FTransform& GetTransform() const { return Transform; }
    	void SetTransform(const FTransform& InTransform) { Transform = InTransform; }
    	FTransform& GetMutableTransform() { return Transform; }
    
    protected:
    	UPROPERTY(Transient)
    	FTransform Transform;
    };

    Added fragments

    Located At UE5/Engine/Plugins/AI/MassAI/Source/MassNavigation

    /** Move target. */
    USTRUCT()
    struct MASSNAVIGATION_API FMassMoveTargetFragment : public FMassFragment
    {
    	GENERATED_BODY()
    
    	FMassMoveTargetFragment() : bNetDirty(false), bOffBoundaries(false), bSteeringFallingBehind(false) {}
    
    	/** To setup current action from the authoritative world */
    	void CreateNewAction(const EMassMovementAction InAction, const UWorld& InWorld);
    
    	/** To setup current action from replicated data */
    	void CreateReplicatedAction(const EMassMovementAction InAction, const uint16 InActionID, const double InWorldStartTime, const double InServerStartTime);
    
    	void MarkNetDirty() { bNetDirty = true; }
    	bool GetNetDirty() const { return bNetDirty; }
    	void ResetNetDirty() { bNetDirty = false; }
    
    public:
    ...

    Located At UE5/Engine/Plugins/AI/MassAI/Source/MassNavigation

    USTRUCT()
    struct MASSNAVIGATION_API FMassSteeringFragment : public FMassFragment
    {
    	GENERATED_BODY()
    
    	void Reset()
    	{
    		DesiredVelocity = FVector::ZeroVector;
    	}
    
    	/** Cached desired velocity from steering. Note: not used for moving the entity. */
    	FVector DesiredVelocity = FVector::ZeroVector;
    };

    Located At UE5/Engine/Plugins/AI/MassAI/Source/MassNavigation

    USTRUCT()
    struct MASSNAVIGATION_API FMassStandingSteeringFragment : public FMassFragment
    {
    	GENERATED_BODY()
    
    	/** Selected steer target based on ghost, updates periodically. */
    	FVector TargetLocation = FVector::ZeroVector;
    
    	/** Used during target update to see when the target movement stops */
    	float TrackedTargetSpeed = 0.0f;
    
    	/** Cooldown between target updates */
    	float TargetSelectionCooldown = 0.0f;
    
    	/** True if the target is being updated */
    	bool bIsUpdatingTarget = false;
    
    	/** True if we just entered from move action */
    	bool bEnteredFromMoveAction = false;
    };

    Ghost location used for standing navigation.

    Located At UE5/Engine/Plugins/AI/MassAI/Source/MassNavigation

    USTRUCT()
    struct MASSNAVIGATION_API FMassGhostLocationFragment : public FMassFragment
    {
    	GENERATED_BODY()
    
    	bool IsValid(const uint16 CurrentActionID) const
    	{
    		return LastSeenActionID == CurrentActionID;
    	}
    
    	/** The action ID the ghost was initialized for */
    	uint16 LastSeenActionID = 0;
    
    	/** Location of the ghost */
    	FVector Location = FVector::ZeroVector;
    	
    	/** Velocity of the ghost */
    	FVector Velocity = FVector::ZeroVector;
    };
  • Steering Processor

    Built-in MassAI Processor located at UE5/Engine/Plugins/AI/MassAI/Source/MassNavigation

    /** 
    * Processor for updating steering towards MoveTarget.
    */
    UCLASS()
    class MASSNAVIGATION_API UMassSteerToMoveTargetProcessor : public UMassProcessor
    {
    	GENERATED_BODY()
    
    protected:
    	UMassSteerToMoveTargetProcessor();
    	
    	virtual void ConfigureQueries() override;
    	virtual void Execute(FMassEntityManager& EntityManager, FMassExecutionContext& Context) override;
    
    	FMassEntityQuery EntityQuery;
    };
    UMassSteerToMoveTargetProcessor::UMassSteerToMoveTargetProcessor()
    	: EntityQuery(*this)
    {
    	ExecutionFlags = int32(EProcessorExecutionFlags::All);
    	ExecutionOrder.ExecuteAfter.Add(UE::Mass::ProcessorGroupNames::Tasks);
    	ExecutionOrder.ExecuteBefore.Add(UE::Mass::ProcessorGroupNames::Avoidance);
    }
    
    void UMassSteerToMoveTargetProcessor::ConfigureQueries()
    {
    	EntityQuery.AddRequirement<FTransformFragment>(EMassFragmentAccess::ReadOnly);
    	EntityQuery.AddRequirement<FMassMoveTargetFragment>(EMassFragmentAccess::ReadWrite);
    	EntityQuery.AddRequirement<FMassSteeringFragment>(EMassFragmentAccess::ReadWrite);
    	EntityQuery.AddRequirement<FMassStandingSteeringFragment>(EMassFragmentAccess::ReadWrite);
    	EntityQuery.AddRequirement<FMassGhostLocationFragment>(EMassFragmentAccess::ReadWrite);
    	EntityQuery.AddRequirement<FMassForceFragment>(EMassFragmentAccess::ReadWrite);
    	EntityQuery.AddRequirement<FMassVelocityFragment>(EMassFragmentAccess::ReadWrite);
    	EntityQuery.AddConstSharedRequirement<FMassMovementParameters>(EMassFragmentPresence::All);
    	EntityQuery.AddConstSharedRequirement<FMassMovingSteeringParameters>(EMassFragmentPresence::All);
    	EntityQuery.AddConstSharedRequirement<FMassStandingSteeringParameters>(EMassFragmentPresence::All);
    
    	// No need for Off LOD to do steering, applying move target directly
    	EntityQuery.AddTagRequirement<FMassOffLODTag>(EMassFragmentPresence::None);
    }
    
    void UMassSteerToMoveTargetProcessor::Execute(FMassEntityManager& EntityManager, FMassExecutionContext& Context)
    {
    	EntityQuery.ForEachEntityChunk(EntityManager, Context, [this](FMassExecutionContext& Context)
    	{
    		const int32 NumEntities = Context.GetNumEntities();
    		const TConstArrayView<FTransformFragment> TransformList = Context.GetFragmentView<FTransformFragment>();
    		const TArrayView<FMassMoveTargetFragment> MoveTargetList = Context.GetMutableFragmentView<FMassMoveTargetFragment>();
    		const TArrayView<FMassVelocityFragment> VelocityList = Context.GetMutableFragmentView<FMassVelocityFragment>();
    		const TArrayView<FMassForceFragment> ForceList = Context.GetMutableFragmentView<FMassForceFragment>();
    		const TArrayView<FMassSteeringFragment> SteeringList = Context.GetMutableFragmentView<FMassSteeringFragment>();
    		const TArrayView<FMassStandingSteeringFragment> StandingSteeringList = Context.GetMutableFragmentView<FMassStandingSteeringFragment>();
    		const TArrayView<FMassGhostLocationFragment> GhostList = Context.GetMutableFragmentView<FMassGhostLocationFragment>();
    		const FMassMovementParameters& MovementParams = Context.GetConstSharedFragment<FMassMovementParameters>();
    		const FMassMovingSteeringParameters& MovingSteeringParams = Context.GetConstSharedFragment<FMassMovingSteeringParameters>();
    		const FMassStandingSteeringParameters& StandingSteeringParams = Context.GetConstSharedFragment<FMassStandingSteeringParameters>();
    
    		const FVector::FReal SteerK = 1. / MovingSteeringParams.ReactionTime;
    		const float DeltaTime = Context.GetDeltaTimeSeconds();
    
    		for (int32 EntityIndex = 0; EntityIndex < NumEntities; ++EntityIndex)
    		{
    			const FTransformFragment& TransformFragment = TransformList[EntityIndex];
    			FMassSteeringFragment& Steering = SteeringList[EntityIndex];
    			FMassStandingSteeringFragment& StandingSteering = StandingSteeringList[EntityIndex];
    			FMassGhostLocationFragment& Ghost = GhostList[EntityIndex];
    			FMassMoveTargetFragment& MoveTarget = MoveTargetList[EntityIndex];
    			FMassForceFragment& Force = ForceList[EntityIndex];
    			FMassVelocityFragment& Velocity = VelocityList[EntityIndex];
    			const FMassEntityHandle Entity = Context.GetEntity(EntityIndex);
    
    			const FTransform& Transform = TransformFragment.GetTransform();;
    
    			// Calculate velocity for steering.
    			const FVector CurrentLocation = Transform.GetLocation();
    			const FVector CurrentForward = Transform.GetRotation().GetForwardVector();
    
    			const FVector::FReal LookAheadDistance = FMath::Max(1.0f, MovingSteeringParams.LookAheadTime * MoveTarget.DesiredSpeed.Get());
    
    			if (MoveTarget.GetCurrentAction() == EMassMovementAction::Move)
    			{
    				// Tune down avoidance and speed when arriving at goal.
    				FVector::FReal ArrivalFade = 1.;
    				if (MoveTarget.IntentAtGoal == EMassMovementAction::Stand)
    				{
    					ArrivalFade = FMath::Clamp(MoveTarget.DistanceToGoal / LookAheadDistance, 0., 1.);
    				}
    				const FVector::FReal SteeringPredictionDistance = LookAheadDistance * ArrivalFade;
    
    				// Steer towards and along the move target.
    				const FVector TargetSide = FVector::CrossProduct(MoveTarget.Forward, FVector::UpVector);
    				const FVector Delta = CurrentLocation - MoveTarget.Center;
    
    				const FVector::FReal ForwardOffset = FVector::DotProduct(MoveTarget.Forward, Delta);
    
    				// Calculate steering direction. When far away from the line defined by TargetPosition and TargetTangent,
    				// the steering direction is towards the line, the close we get, the more it aligns with the line.
    				const FVector::FReal SidewaysOffset = FVector::DotProduct(TargetSide, Delta);
    				const FVector::FReal SteerForward = FMath::Sqrt(FMath::Max(0., FMath::Square(SteeringPredictionDistance) - FMath::Square(SidewaysOffset)));
    
    				// The Max() here makes the steering directions behind the TargetPosition to steer towards it directly.
    				FVector SteerTarget = MoveTarget.Center + MoveTarget.Forward * FMath::Clamp(ForwardOffset + SteerForward, 0., SteeringPredictionDistance);
    
    				FVector SteerDirection = SteerTarget - CurrentLocation;
    				SteerDirection.Z = 0.;
    				const FVector::FReal DistanceToSteerTarget = SteerDirection.Length();
    				if (DistanceToSteerTarget > KINDA_SMALL_NUMBER)
    				{
    					SteerDirection *= 1. / DistanceToSteerTarget;
    				}
    				
    				const FVector::FReal DirSpeedScale = UE::MassNavigation::CalcDirectionalSpeedScale(CurrentForward, SteerDirection);
    				FVector::FReal DesiredSpeed = MoveTarget.DesiredSpeed.Get() * DirSpeedScale;
    
    				// Control speed based relation to the forward axis of the move target.
    				FVector::FReal CatchupDesiredSpeed = DesiredSpeed;
    				if (ForwardOffset < 0.)
    				{
    					// Falling behind, catch up
    					const FVector::FReal T = FMath::Min(-ForwardOffset / LookAheadDistance, 1.);
    					CatchupDesiredSpeed = FMath::Lerp(DesiredSpeed, MovementParams.MaxSpeed, T);
    				}
    				else if (ForwardOffset > 0.)
    				{
    					// Ahead, slow down.
    					const FVector::FReal T = FMath::Min(ForwardOffset / LookAheadDistance, 1.);
    					CatchupDesiredSpeed = FMath::Lerp(DesiredSpeed, DesiredSpeed * 0., 1. - FMath::Square(1. - T));
    				}
    
    				// Control speed based on distance to move target. This allows to catch up even if speed above reaches zero.
    				const FVector::FReal DeviantSpeed = FMath::Min(FMath::Abs(SidewaysOffset) / LookAheadDistance, 1.) * DesiredSpeed;
    
    				DesiredSpeed = FMath::Max(CatchupDesiredSpeed, DeviantSpeed);
    
    				// Slow down towards the end of path.
    				if (MoveTarget.IntentAtGoal == EMassMovementAction::Stand)
    				{
    					const FVector::FReal NormalizedDistanceToSteerTarget = FMath::Clamp(DistanceToSteerTarget / LookAheadDistance, 0., 1.);
    					DesiredSpeed *= UE::MassNavigation::ArrivalSpeedEnvelope(FMath::Max(ArrivalFade, NormalizedDistanceToSteerTarget));
    				}
    
    				MoveTarget.bSteeringFallingBehind = ForwardOffset < -LookAheadDistance * 0.8;
    
    				// @todo: This current completely overrides steering, we probably should have one processor that resets the steering at the beginning of the frame.
    				Steering.DesiredVelocity = SteerDirection * DesiredSpeed;
    				Force.Value = SteerK * (Steering.DesiredVelocity - Velocity.Value); // Goal force
    			}
    			else if (MoveTarget.GetCurrentAction() == EMassMovementAction::Stand)
    			{
    				// Calculate unique target move threshold so that different agents react a bit differently.
    				const FVector::FReal PerEntityScale = UE::RandomSequence::FRand(Entity.Index);
    				const FVector::FReal TargetMoveThreshold = StandingSteeringParams.TargetMoveThreshold * (1. - StandingSteeringParams.TargetMoveThresholdVariance + PerEntityScale * StandingSteeringParams.TargetMoveThresholdVariance * 2.);
    				
    				if (Ghost.LastSeenActionID != MoveTarget.GetCurrentActionID())
    				{
    					// Reset when action changes. @todo: should reset only when move->stand?
    					Ghost.Location = MoveTarget.Center;
    					Ghost.Velocity = FVector::ZeroVector;
    					Ghost.LastSeenActionID = MoveTarget.GetCurrentActionID();
    
    					StandingSteering.TargetLocation = MoveTarget.Center;
    					StandingSteering.TrackedTargetSpeed = 0.0f;
    					StandingSteering.bIsUpdatingTarget = false;
    					StandingSteering.TargetSelectionCooldown = StandingSteeringParams.TargetSelectionCooldown * FMath::RandRange(1.f - StandingSteeringParams.TargetSelectionCooldownVariance, 1.f + StandingSteeringParams.TargetSelectionCooldownVariance);
    					StandingSteering.bEnteredFromMoveAction = MoveTarget.GetPreviousAction() == EMassMovementAction::Move;
    				}
    
    				StandingSteering.TargetSelectionCooldown = FMath::Max(0.0f, StandingSteering.TargetSelectionCooldown - DeltaTime);
    
    				if (!StandingSteering.bIsUpdatingTarget)
    				{
    					// Update the move target if enough time has passed and the target has moved. 
    					if (StandingSteering.TargetSelectionCooldown <= 0.0f
    						&& FVector::DistSquared(StandingSteering.TargetLocation, Ghost.Location) > FMath::Square(TargetMoveThreshold))
    					{
    						StandingSteering.TargetLocation = Ghost.Location;
    						StandingSteering.TrackedTargetSpeed = 0.0f;
    						StandingSteering.bIsUpdatingTarget = true;
    						StandingSteering.bEnteredFromMoveAction = false;
    					}
    				}
    				else
    				{
    					// Updating target
    					StandingSteering.TargetLocation = Ghost.Location;
    					const FVector::FReal GhostSpeed = Ghost.Velocity.Length();
    
    					if (GhostSpeed > (StandingSteering.TrackedTargetSpeed * StandingSteeringParams.TargetSpeedHysteresisScale))
    					{
    						const FVector::FReal TrackedTargetSpeed = FMath::Max(StandingSteering.TrackedTargetSpeed, GhostSpeed);
    						StandingSteering.TrackedTargetSpeed = static_cast(TrackedTargetSpeed);
    					}
    					else
    					{
    						// Speed is dropping, we have found the peak change, stop updating the target and start cooldown.
    						StandingSteering.TargetSelectionCooldown = StandingSteeringParams.TargetSelectionCooldown * FMath::RandRange(1.0f - StandingSteeringParams.TargetSelectionCooldownVariance, 1.0f + StandingSteeringParams.TargetSelectionCooldownVariance);
    						StandingSteering.bIsUpdatingTarget = false;
    					}
    				}
    				
    				// Move directly towards the move target when standing.
    				FVector SteerDirection = FVector::ZeroVector;
    				FVector::FReal DesiredSpeed = 0.;
    
    				FVector Delta = StandingSteering.TargetLocation - CurrentLocation;
    				Delta.Z = 0.;
    				const FVector::FReal Distance = Delta.Size();
    				if (Distance > StandingSteeringParams.DeadZoneRadius)
    				{
    					SteerDirection = Delta / Distance;
    					if (StandingSteering.bEnteredFromMoveAction)
    					{
    						// If the current steering target is from approaching a move target, use the same speed logic as movement to ensure smooth transition.
    						const FVector::FReal Range = FMath::Max(1., LookAheadDistance - StandingSteeringParams.DeadZoneRadius);
    						const FVector::FReal SpeedFade = FMath::Clamp((Distance - StandingSteeringParams.DeadZoneRadius) / Range, 0., 1.);
    						DesiredSpeed = MoveTarget.DesiredSpeed.Get() * UE::MassNavigation::CalcDirectionalSpeedScale(CurrentForward, SteerDirection) * UE::MassNavigation::ArrivalSpeedEnvelope(SpeedFade);
    					}
    					else
    					{
    						const FVector::FReal Range = FMath::Max(1., LookAheadDistance - StandingSteeringParams.DeadZoneRadius);
    						const FVector::FReal SpeedFade = FMath::Clamp((Distance - StandingSteeringParams.DeadZoneRadius) / Range, 0., 1.);
    						// Not using the directional scaling so that the steps we take to avoid are done quickly, and the behavior is reactive.
    						DesiredSpeed = MoveTarget.DesiredSpeed.Get() * UE::MassNavigation::ArrivalSpeedEnvelope(SpeedFade);
    					}
    					
    					// @todo: This current completely overrides steering, we probably should have one processor that resets the steering at the beginning of the frame.
    					Steering.DesiredVelocity = SteerDirection * DesiredSpeed;
    					Force.Value = SteerK * (Steering.DesiredVelocity - Velocity.Value); // Goal force
    
    				}
    				else
    				{
    					// When reached destination, clamp small velocities to zero to avoid tiny drifting.
    					if (Velocity.Value.SquaredLength() < FMath::Square(StandingSteeringParams.LowSpeedThreshold))
    					{
    						Velocity.Value = FVector::ZeroVector;
    						Force.Value = FVector::ZeroVector;
    					}
    				}
    
    				MoveTarget.bSteeringFallingBehind = false;
    			}
    			else if (MoveTarget.GetCurrentAction() == EMassMovementAction::Animate)
    			{
    				// Stop all movement when animating.
    				Steering.Reset();
    				MoveTarget.bSteeringFallingBehind = false;
    				Force.Value = FVector::ZeroVector;
    				Velocity.Value = FVector::ZeroVector;
    			}			
    		}
    	});
    }