Overcoming Stuttery Pawn AI Movement

Hello all! I have been bashing my head against the issue of AI-controlled pawn movement for a hot minute now, and while I am starting to see some results, they’re hardly ideal. After learning that I needed to implement a custom movement component in order to enable the BehaviorTree MoveTo function to work ( background on all that here: SelfActor Not Getting Set - #2 by Dante5050 ) I then wanted to get the pathing working on a Vehicle subclass. Through a series of trial-and-error with changes to the damping values (shown on the bottom right properties tab in the video), modifications to the maximum speed of the movement component, and a SetActorRotation call from within my custom behavior tree task that gets the next path point, I have managed to achieve what is shown in the video above. Obviously, this is not ideally how a vehicle should look when moving around a path. Are there any tips on how to better control a pawn with a non-standard mesh to do what I’m trying to get it to do? Ideally I’d rather not rely on damping at all, but I also want to keep physics enabled so it will at least try to stay on the ground on its own (and down the road if it gets flung off by an explosion I’d like it to be handled with the expected physics code). Is rebasing my classes to Character the only realistic path forward here?

Target Nav Movement Component
Relevant code:

FVector DesiredMovementThisFrame = RequestedVelocity.GetClampedToMaxSize(1.0f) * DeltaTime * 1000.0f;

(I tried using GetMaxVelocity instead of hardcoding a scalar at the end but it always returns zero and there doesn’t appear to be any way to set it?)

Behavior Tree Task Get Path Points
Relevant code:

	// orient target to face new location
	FRotator LookDir = FVector(PathRef->Locations[index] - Target->GetTransform().GetLocation()).ToOrientationRotator().Clamp();
	Target->SetActorRotation(LookDir);

(sets the rotation towards the new path point before beginning to MoveTo it)

Isn’t it possible that there is some kind of collision issue? How do you have the car set up? Is it a skeletal mesh? If so check the physics asset and disable collision for the part that might interfere with others to see if it makes any difference (Usually the big part for the chassis might interfere with the wheels, so you can try disabling collision for the chassis)


My default visual component for the vehicle is just a static mesh, and I am pulling one of those from the content pack I’m using, so there aren’t any separate bone components that have collision I could disable. The collision settings I do have available are shown in the bottom right, but none of them stand out as something that would lead to the weird interactions I’m seeing with flat terrain. By comparison, when I use a sphere component as the static mesh for the vehicle, it slides along the ground fine, so I do agree it has something to do with this mesh’s collision with the floor, but idk how to address that…

Alright, spent today trying to move away from a custom nav movement component and towards working with a default FloatingPawnMovementComponent instead, as well as using a new Behavior Tree task that should do all the work instead of relying on a seperate MoveTo call. By doing this, the vehicle no longer bounces around when attempting to move, but its speed is kneecapped despite manually setting the MaxSpeed property to a max int in the vehicle’s constructor. Any ideas why it won’t go fast now?

BTTask_MoveVehicle.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/Tasks/BTTask_BlackboardBase.h"
#include "AIController.h"
#include "Components/SplineComponent.h"
#include "Vehicle.h"
#include "PointPath.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Vector.h"
#include "BTTask_MoveVehicle.generated.h"

/**
 * 
 */
UCLASS()
class OVERLORD_API UBTTask_MoveVehicle : public UBTTask_BlackboardBase
{
	GENERATED_BODY()
	
public:

	// constructor
	UBTTask_MoveVehicle(const FObjectInitializer& ObjectInitializer);

	// main task execution code
	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;

	// flag to set up initial goal on first task execution
	bool FirstExecute = true;

	// position along spline set as our current goal
	FVector GoalPos = FVector();

	// ammount to increment goal distance by when updated
	float GoalDifference = 0.0f;

	// distance along spline at which goal position is located
	float GoalDistance = 0.0f;

	// length from goal at which to update the position along the spline
	float UpdateLength = 50.0f;

	// angle at which to start re-orienting vehicle towards goal
	float UpdateAngle = 10.0f;
};

BTTask_MoveVehicle.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "BTTask_MoveVehicle.h"

UBTTask_MoveVehicle::UBTTask_MoveVehicle(const FObjectInitializer& ObjectInitializer)
{
	bCreateNodeInstance = true;
	NodeName = "Move Vehicle";
}

EBTNodeResult::Type UBTTask_MoveVehicle::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	// get the blackboard
	const UBlackboardComponent* OwnerBlackboard = OwnerComp.GetBlackboardComponent();
	// get the controller tied to the tree we're being run by
	AAIController* OwnerController = OwnerComp.GetAIOwner();
	// abort if either don't return valid
	if (!OwnerController || !OwnerBlackboard) {
		return EBTNodeResult::Failed;
	}

	// grab Vehicle that should have PointPath SmartObject tied to it
	AVehicle* Vehicle = Cast<AVehicle>(OwnerController->GetPawn());
	if (!Vehicle) {
		return EBTNodeResult::Failed;
	}

	// cast PointPath SmartObject from Vehicle's SmartObjectVar
	APointPath* PathRef = Cast<APointPath>(Vehicle->SmartObject);
	// abort if no SmartObject set or SmartObject is not PointPath
	if (!PathRef || PathRef->Locations.Num() < 1) {
		return EBTNodeResult::Failed;
	}

	// set main vars on first execute with a path in hand
	if (FirstExecute) {
		// amount to increment the position along the spline 
		GoalDifference = PathRef->Path->GetSplineLength() / 20;
		// set initial goal 2 goal-diffs away 
		GoalDistance = GoalDifference * 2;
		// set initial goal world position
		GoalPos = PathRef->Path->GetWorldLocationAtDistanceAlongSpline(GoalDistance);
		// disable the flag
		FirstExecute = false;
	}
	else {
		// calc length from goal world pos to vehicle world pos
		float LengthFromGoal = FVector(GoalPos - Vehicle->GetActorTransform().GetLocation()).Length();

		// if close enough, update the goal world pos
		if (LengthFromGoal <= UpdateLength) {
			// increment goal spline distance
			GoalDistance += GoalDifference;
			// if exceeding length of spline, reset to beginning of spline
			if (GoalDistance >= PathRef->Path->GetSplineLength()) {
				GoalDistance = 0.0f;
			}
			// set goal world pos
			GoalPos = PathRef->Path->GetWorldLocationAtDistanceAlongSpline(GoalDistance);
		}
	}

	// calc angle between forward of vehicle and to-goal vector
	float GoalAngle = FMath::RadiansToDegrees(FMath::Acos(FVector::DotProduct(GoalPos - Vehicle->GetActorTransform().GetLocation(), Vehicle->GetActorForwardVector()) / 
		FVector(GoalPos - Vehicle->GetActorTransform().GetLocation()).Length() * Vehicle->GetActorForwardVector().Length()));

	// determine next acceleration input vector based on above angle
	FVector Acceleration;
	if (GoalAngle > UpdateAngle) {
		Acceleration = FVector(GoalPos - Vehicle->GetActorTransform().GetLocation()).GetClampedToMaxSize(Vehicle->AccelLimit);
	}
	else {
		Acceleration = Vehicle->GetActorForwardVector().GetClampedToSize(Vehicle->AccelLimit, Vehicle->AccelLimit);
	}

	// vehicles cannot accelerate upward or downward on their own
	//Acceleration.Z = 0;

	// rotate vehicle towards acceleration
	FQuat VehicleRot = Vehicle->GetActorQuat();
	FQuat GoalRot = Acceleration.ToOrientationQuat();
	if (VehicleRot != GoalRot) {
		Vehicle->VisualMesh->SetWorldRotation(FQuat::Slerp(VehicleRot, GoalRot, 0.4));
	}

	// apply acceleration to vehicle for next movement update
	Vehicle->GetMovementComponent()->AddInputVector(Acceleration, true);

	return EBTNodeResult::Succeeded;
}

Vehicle.cpp

	// set up the movement component
	VehicleMovement = CreateDefaultSubobject<UFloatingPawnMovement>(TEXT("Movement"));
	VehicleMovement->UpdatedComponent = RootComponent;
	VehicleMovement->MaxSpeed = SpeedLimit;
	VehicleMovement->Acceleration = AccelLimit;
	VehicleMovement->Deceleration = AccelLimit;

Turns out the MaxSpeed, Acceleration, and Deceleration were being reset to default values, effectively ignoring my constructor code. By applying the desired values in the editor and calling the AddInputVector function in the Vehicle’s Tick function instead of the Task the Vehicle was able to move at faster speeds. However, when it hit the slope of the small hill, it still started vibrating furiously as long as gravity was enabled. I did my best to limit forced rotation to be around the Z axis so the car wouldn’t be trying to point its nose into the slope, but even these changes didn’t alleviate the issue. I’m at my wit’s end on how to handle this at this point. I’ve tried poking around at an implementation using a WheeledVehicleMovementComponent, but since I’m on UE5 the old PhysX based documentation is useless, and searching “UChaosVehicleMovementComponent” yields less than a page’s worth of results on Google. Right now I can’t even get a custom class to construct with one, with a crash caused by null pointer access:

WheeledVehicle.cpp

	WheeledVehicleMovement = CreateDefaultSubobject<UChaosVehicleMovementComponent>(TEXT("WheeledMovement"));

	WheeledVehicleMovement->UpdatedComponent = RootComponent; // WVM is nullptr here

I have always instantiated components with CreateDefaultSubobject before without issue, but i will admit I do not understand why that is the way to do things in UE5. Is there another way I should be instantiating this?

Alright, last update before I put this down for a bit bc I cannot stay stuck on this forever. I swapped to ChaosWheeledVehicleMovementComponent, and tried my best to mimic the implementation laid out in this tutorial: Make a Vehicle with C++ in Unreal Engine 4 - YouTube . I have my custom vehicle implement a skeleton and then set the movement component’s updated component as that skeleton component. The skeleton used is a Sedan from the Vehicle Variety Pack Volume 2 in Props - UE Marketplace , so as close to what the video uses while being compatible with UE5. I can verify that the behavior tree task does apply the throttle as I’d expect it to, but the vehicle still refuses to move, likely due to the Wheels of the movement component not being set properly, ie everything is zeroed. The bone names in the vehicle blueprint match the names in the skeleton asset, yet for whatever reason the don’t seem to be connected when the game actually runs. In the video, he’s inheriting from a WheeledVehicle that has this connection between the movement component and the mesh already established, but I theoretically should be able to recreate that connection myself by manually setting the updated component, right?

WheeledVehicle.cpp

#include "WheeledVehicle.h"

AWheeledVehicle::AWheeledVehicle()
{
	SkeletonComponent = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Skeleton"));

	SkeletonComponent->SetSkeletalMesh(Skeleton);

	RootComponent = SkeletonComponent;

	//VisualMesh->DestroyComponent();

	WheeledVehicleMovement = CreateDefaultSubobject<UChaosWheeledVehicleMovementComponent>(TEXT("WheeledVehicleMovement"));

	WheeledVehicleMovement->UpdatedComponent = SkeletonComponent;

	// Torque setup
	WheeledVehicleMovement->EngineSetup.MaxRPM = 5700.0f;
	WheeledVehicleMovement->EngineSetup.TorqueCurve.GetRichCurve()->Reset();
	WheeledVehicleMovement->EngineSetup.TorqueCurve.GetRichCurve()->AddKey(0.0f, 400.0f);
	WheeledVehicleMovement->EngineSetup.TorqueCurve.GetRichCurve()->AddKey(1890.0f, 500.0f);
	WheeledVehicleMovement->EngineSetup.TorqueCurve.GetRichCurve()->AddKey(5370.0f, 400.0f);

	// Steering setup
	WheeledVehicleMovement->SteeringSetup.SteeringCurve.GetRichCurve()->Reset();
	WheeledVehicleMovement->SteeringSetup.SteeringCurve.GetRichCurve()->AddKey(0.0f, 1.0f);
	WheeledVehicleMovement->SteeringSetup.SteeringCurve.GetRichCurve()->AddKey(40.0f, 0.7f);
	WheeledVehicleMovement->SteeringSetup.SteeringCurve.GetRichCurve()->AddKey(120.0f, 0.6f);

	// Differential setup
	WheeledVehicleMovement->DifferentialSetup.DifferentialType = EVehicleDifferential::AllWheelDrive;
	WheeledVehicleMovement->DifferentialSetup.FrontRearSplit = 0.65;

	// Automatic Gearbox setup
	WheeledVehicleMovement->TransmissionSetup.bUseAutomaticGears = true;
	WheeledVehicleMovement->TransmissionSetup.GearChangeTime = 0.15f;
	WheeledVehicleMovement->TransmissionSetup.TransmissionEfficiency = 1.0f;
}

void AWheeledVehicle::BeginPlay()
{
	Super::BeginPlay();
}

void AWheeledVehicle::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
}

void AWheeledVehicle::SetThrottle(float Val)
{
	WheeledVehicleMovement->SetThrottleInput(Val);
}

void AWheeledVehicle::SetSteering(float Val)
{
	WheeledVehicleMovement->SetSteeringInput(Val);
}

void AWheeledVehicle::SetBreaking(bool Breaking)
{
	WheeledVehicleMovement->SetHandbrakeInput(Breaking);
}

float AWheeledVehicle::GetSpeed()
{
	return WheeledVehicleMovement->GetForwardSpeed();
}

float AWheeledVehicle::GetThrottle()
{
	return WheeledVehicleMovement->GetThrottleInput();
}

float AWheeledVehicle::GetSteering()
{
	return WheeledVehicleMovement->GetSteeringInput();
}

bool AWheeledVehicle::GetBreaking()
{
	return WheeledVehicleMovement->GetHandbrakeInput();
}

FVector AWheeledVehicle::GetWheelLocation(UINT WheelNum)
{
	if (WheeledVehicleMovement->Wheels.Num() > (int)WheelNum)
	{
		return WheeledVehicleMovement->Wheels[(int)WheelNum]->Location; // always 0,0,0
	}
	return FVector();
}

BTTask_MoveVehicle.cpp

	FVector VehFrontLeft = Vehicle->GetWheelLocation(0);
	float GoalLeftDistance = FVector(GoalPos - VehFrontLeft).Length();
	FVector VehFrontRight = Vehicle->GetWheelLocation(1);
	float GoalRightDistance = FVector(GoalPos - VehFrontRight).Length();

	// determine turn
	if (GoalLeftDistance < GoalRightDistance) {
		Vehicle->SetSteering(FMath::Lerp(Vehicle->GetSteering(), -0.6f, 0.3f));
	}
	else if (GoalRightDistance < GoalLeftDistance) {
		Vehicle->SetSteering(FMath::Lerp(Vehicle->GetSteering(), 0.6f, 0.3f));
	}

	// determine acceleration
	if (Vehicle->GetSpeed() < Vehicle->MaxSpeed) {
		Vehicle->SetThrottle(FMath::Lerp(Vehicle->GetThrottle(), 1.0f, 0.3f));
	}
	else {
		Vehicle->SetThrottle(0.0f);
	}