Note: Any code shown is made with Unreal Engine in mind, but the general concepts should be applicable to most 3D-engines.
A project using this tutorial series is tracked on GitHub. The revision after this part is 1938330.
Hej!
In this first part of diving into the movement code, we'll be implementing basic forward/backward/strafing movement as well as camera look. We still have some input-handling boilerplate to go through but we'll start with just the bare minimum so no collision or gravity yet.
It is fairly straightforward. The logic goes:
Grab our desired input in a format we can understand
Translate that input into a direction that is relative to the player pawn's rotation
Pass the input to the movement component
Consume the input in the movement component
Derive a world-space delta to apply based on input and movement speed
Move our player pawn by the calculated delta
The first part is probably the more complex as we have to setup our input bindings first.
This section will double down as a crash course in Enhanced Input. Without going into too much detail, the main difference between Enhanced Input and a traditional input binding that you might be familiar with is that Enhanced Input takes common operations that are usually expressed in code and abstracts them into configurable objects. Things like handling raw input, deadzones, hold time, rapid firing etc. can be customized through dropdowns instead of code.
You can read more about it on the docs here.
An abstract action that we want to handle ("move", "jump", "shoot") are configured as Input Action objects. The object that binds concrete input (e.g. the "W" key) to an action is an Input Mapping Context object. There are some extra layers to this but that is what we're going to use in our basic scenario.
We need two input actions: Move, and Look.
Right-click an area of your choice in the Content Drawer and add two actions:
I named them IA_Move
and IA_Look
.
Open IA_Move
and set Value Type
to Axis2D (Vector2D)
.
Do the same for IA_Look
.
Next right-click again and create an Input Mapping Context object. I named mine IMC_Default
.
Open it and add a mapping for IA_Move
. Then add control bindings for whatever inputs you want to handle. For this tutorial I'm adding WASD and Gamepad Left Thumbstick 2D-Axis
. The idea is to make input mappings so that the final value (the Vector2D value type) has X representing forward (positive) and backward (negative) movement, and Y representing right (positive) and left (negative) movement. To accomplish this for WASD we need to add a few swizzles and negates which is out of this scope, but the result should look like this:
(Dead zone is added to prevent the neutral position from drifting for gamepads.)
Add another mapping entry for IA_Look. This entry is much simpler as we can directly use the input values for mouse XY and the right thumbstick for gamepads. The idea here is that the X-axis represents yaw (left-right) and the Y-axis represents pitch (up-down). It should look like this:
We can now move over to the code that will utilize these objects.
First, to access Enhanced Input in C++ we need to add it to our build script. Open your Build.cs (mine is located as Source/Cactus/Cactus.Build.cs
) and add "EnhancedInput" to the public dependencies:
PublicDependencyModuleNames.AddRange(new string[]
{"Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput"});
(You may need to regenerate project files after doing this.)
Next, we need to do the following things:
Setup the Input Mapping Context object in the controller class
Add the input actions as UPROPERTY
fields so we can set them from the editor
Add an empty (for now) Move and Look function
Override SetupPlayerInputComponent
to handle binding the actions to code
The idea (I think) behind mapping contexts is that you can have different contexts depending on what your doing (main gameplay, pause menu, spectator etc.) that can alter how you handle inputs. For our scenario we'll stick with a single one, and we'll let the Player Controller handle it since it seems appropriate.
We need a property for the Input Mapping Context object that we can set from the editor, and we need to handle using it in our BeginPlay
function which is called at the start of the game. In your player controller header file add to the class:
public:
UPROPERTY(EditDefaultsOnly)
class UInputMappingContext* DefaultInputMappingContext;
protected:
virtual void BeginPlay() override;
Then we add the BeginPlay
implementation where we fetch the Enhanced Input subsytem and set our context (we also need to include the relevant header):
#include "CactusPlayerController.h"
#include "EnhancedInputSubsystems.h"
void ACactusPlayerController::BeginPlay()
{
Super::BeginPlay();
if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(
GetLocalPlayer()))
{
Subsystem->ClearAllMappings();
Subsystem->AddMappingContext(DefaultInputMappingContext, 0);
}
}
That's it for the Player Controller class. Don't forget to set the context object (IMC_Default
) in your Player Controller BP!
Next up we need to add two fields of type UInputAction*
for the move action and look action. Inside your player pawn header file add:
UPROPERTY(EditDefaultsOnly, Category="Input")
UInputAction* MoveAction;
UPROPERTY(EditDefaultsOnly, Category="Input")
UInputAction* LookAction;
I've added them just below the component declarations, within the public scope. We also need to have UInputAction
accessible. I like to forward declare them however you can also import the action header file. Above my player pawn class definition add:
class UInputAction;
We need to add two functions, one for Move and one for Look. We'll implement them soon but for now we just need to get the input handling out of the way. In your pawn header add the functions to the class:
private:
void OnInput_Move(const FInputActionValue& Value);
void OnInput_Look(const FInputActionValue& Value);
We also need to forward declare FInputActionValue
:
struct FInputActionValue;
Then add the empty implementations to the cpp file. Optionally you can also log the input value to verify that the bindings work later on:
void ACactusPlayerPawn::OnInput_Move(const FInputActionValue& Value)
{
UE_LOG(LogTemp, Log, TEXT("Move: %s"), *Value.Get<FVector2D>().ToString());
}
void ACactusPlayerPawn::OnInput_Look(const FInputActionValue& Value)
{
UE_LOG(LogTemp, Log, TEXT("Look: %s"), *Value.Get<FVector2D>().ToString());
}
SetupPlayerInputComponent
comes from Pawn and is a protected virtual function that is called when it is time to setup any input bindings (looking at the Unreal source it probably during APawn::PawnClientRestart
).
We need to override it and add our own bindings. The base implementation is empty, but we can still call the Super
variant in case that ever changes. Binding actions is similar to the built-in way except we cast the input component to the Enhanced Input variant, so it accepts our input action objects.
In the pawn header file add:
protected:
virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;
And for the cpp file add:
void ACactusPlayerPawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
UEnhancedInputComponent* PlayerEnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent);
if (PlayerEnhancedInputComponent)
{
PlayerEnhancedInputComponent->BindAction(MoveAction, ETriggerEvent::Triggered, this,
&ACactusPlayerPawn::OnInput_Move);
PlayerEnhancedInputComponent->BindAction(LookAction, ETriggerEvent::Triggered, this,
&ACactusPlayerPawn::OnInput_Look);
}
}
Everything should be ready to accept input now. Don't forget to assign the Input Mapping Context object in the Player Controller BP, and the inputs actions in the Pawn BP. If you added the log statements to the Move and Look function, try pressing WASD and moving the mouse. You should see output similar to this:
The inputs should give these values:
W should give X=1 Y=0
S should give X=-1 Y=0
A should give X=0 Y=-1
D should give X=0 Y=1
Dragging the mouse up should give positive Y
Dragging the mouse down should give negative Y
Dragging the mouse right should give positive X
Dragging the mouse left should give negative X
We'll cover the look function first since it's simple. It involves some math but is unrelated to most things we will deal with later on.
First we add some editor-exposed variables to adjust how our camera look works. In the pawn header add:
UPROPERTY(EditAnywhere, Category="Input")
FVector2D CameraSensitivity;
UPROPERTY(EditAnywhere, Category="Input")
FVector2D CameraPitchRange;
We should also set sensible defaults in our constructor in the cpp file:
ACactusPlayerPawn::ACactusPlayerPawn(): CameraSensitivity(FVector2D(180.0f, 90.0f)),
CameraPitchRange(FVector2D(-80.0f, 80.0f))
{
...
}
Next, we implement the Look function. The logic goes:
Grab our FVector2D value
Multiply it by the camera sensitivity and delta time
Calculate a new pitch (up and down) value, clamped between our range
Set the cameras rotation to use the new pitch
Calculate a new yaw (left and right) value based on actor rotation
Set actor rotation to use new yaw
The code looks like this:
void ACactusPlayerPawn::OnInput_Look(const FInputActionValue& Value)
{
const FVector2D VectorValue = Value.Get<FVector2D>() * CameraSensitivity * GetWorld()->GetDeltaSeconds();
// Update pitch
const FRotator& CameraRotation = CameraComponent->GetComponentRotation();
const float NewPitch = FMath::Clamp(CameraRotation.Pitch + VectorValue.Y, CameraPitchRange.X, CameraPitchRange.Y);
const FRotator NewCameraRotator = FRotator(NewPitch, CameraRotation.Yaw, CameraRotation.Roll);
CameraComponent->SetWorldRotation(NewCameraRotator);
// Update yaw
const FRotator& ActorRotation = GetActorRotation();
const float NewYaw = ActorRotation.Yaw + VectorValue.X;
const FRotator NewActorRotator = FRotator(ActorRotation.Pitch, NewYaw, ActorRotation.Roll);
SetActorRotation(NewActorRotator);
}
You should now be able to look around with the mouse (or gamepad). Feel free to adjust the sensitivity setting in your Pawn BP.
(I've added a cube "arm" to the camera component of our Pawn to help visualize, as well as created a map to test future features).
Next up is the Move function. The Pawn implementation is very minimal; most of the logic lies in the Movement Component.
The Pawn implementation only needs to transform our local-space XY input into world-space for our movement component to consume. The code looks like this:
void ACactusPlayerPawn::OnInput_Move(const FInputActionValue& Value)
{
const FVector2D RawInput = Value.Get<FVector2D>();
const FVector ForwardInput = RawInput.X * GetActorForwardVector();
const FVector RightInput = RawInput.Y * GetActorRightVector();
AddMovementInput(ForwardInput + RightInput);
}
AddMovementInput
is a Pawn function that automatically pipes the input through to the first available Movement Component, which in our case is our USimpleMovementComponent
that we've created in our Pawn constructor.
Let's move on to the Movement Component.
First we need to override its tick function, which is the function that gets called every frame. Add to your Movement Component header file:
public:
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
Then add the implementation. Inside we need to add a few sanity checks before running the main logic:
void USimpleMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (ShouldSkipUpdate(DeltaTime))
{
return;
}
if (!PawnOwner || !UpdatedComponent)
{
return;
}
// Handle movement
}
Now we're ready to start moving.
A Movement Component already has access to the root component it manages (in our case our Pawn Capsule Component) in the form of the variable UpdatedComponent
. The component can be manipulated directly, but the Movement Component also has access to many helpful functions. One important function is MoveUpdatedComponent
which as the name implies handles moving it.
A Movement Component also has a variable called Velocity
which can be used to calculate movement. It is helpful to treat it as actual physics velocity. Since Unreal Units (UU) are equivalent of centimeters, we'll use velocity as UU/s.
When we're working with the velocity variable we also need to call UpdateComponentVelocity()
at the end of a tick to handle the changes internally.
First we need to grab our input provided previously in our Pawn by AddMovementInput()
. This is done by calling ConsumeInputVector()
.
const FVector& Input = ConsumeInputVector().GetClampedToMaxSize2D(1.0f);
Note: the reason we clamp it is because otherwise we the magnitude larger than1 when moving diagonally, which will make us exceed our max speed (by ~1.4x or the square root of 2). This is less pronounced with a gamepad than with WASD because of how the joystick works but still present.
This is actually in many older FPS games, and often considered a useful technique when speedrunning for example, so if you want that you can keep it. Otherwise we simply need to clamp the max size of our input vector.
This input is then used to calculate a directional force, scaled by our desired movement speed.
const FVector DesiredInputForce = Input * MoveSpeed;
MoveSpeed
is just a float that has been added to our Movement Component. The value equals how many Unreal units we move per second (cm/s). It is helpful to expose it as a UPROPERTY
variable so we can change it in the editor. I've also added a constructor to set a default value:
public:
UPROPERTY(EditAnywhere, Category="Movement")
float MoveSpeed;
USimpleMovementComponent();
USimpleMovementComponent::USimpleMovementComponent(): MoveSpeed(600.0f)
{
}
The resulting DesiredInputForce
is then added to our Velocity
, and we calculate a new vector, MovementDelta
which will equal how much we move this tick:
Velocity += DesiredInputForce;
const FVector MovementDelta = Velocity * DeltaTime;
We then call the relevant functions to update our actual location.
const FRotator& Rotation = UpdatedComponent->GetComponentRotation();
MoveUpdatedComponent(MovementDelta, Rotation, false);
UpdateComponentVelocity();
Note that MoveUpdatedComponent
requires a rotation as well, so we just grab our current rotation.
Finally we also need to clear our Velocity
at the start of a tick so that it doesn't accumulate, since we won't be implementing friction/deceleration right now.
Velocity.X = 0;
Velocity.Y = 0;
The final tick function looks like this:
void USimpleMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (ShouldSkipUpdate(DeltaTime))
{
return;
}
if (!PawnOwner || !UpdatedComponent)
{
return;
}
// Reset velocity
Velocity.X = 0;
Velocity.Y = 0;
// Calculate force
const FVector& Input = ConsumeInputVector().GetClampedToMaxSize2D(1.0f);
const FVector DesiredInputForce = Input * MoveSpeed;
Velocity += DesiredInputForce;
const FVector MovementDelta = Velocity * DeltaTime;
// Move
const FRotator& Rotation = UpdatedComponent->GetComponentRotation();
MoveUpdatedComponent(MovementDelta, Rotation, false);
UpdateComponentVelocity();
}
We should be able to move around! However we are currently levitating and we can move through walls 👻.