Overview
This article introduces how to implement a process in C++ that tilts a character according to the slope of the ground.
If you want to implement it in Blueprint, please refer to this article:
UE4 Aligning Characters with Ground Slope
Environment
- Rider 2024.2.6
- Unreal Engine 5.4
References
- https://www.youtube.com/watch?v=1ICBWJ7srxQ
- Tilt a character according to the ground slope in UE4
- Cross product: https://en.wikipedia.org/wiki/Cross_product
- Dot product: https://en.wikipedia.org/wiki/Dot_product
- Roll, Pitch, Yaw
Main Content
When a character walks on a slope, it looks like this if the tilt is not adjusted.
The character's head is buried in the slope, making it look unnatural.
Now, let's implement this in C++.
Steps
- Perform a raycast downward from the character.
- If the raycast hits the ground (slope), obtain the normal of the ground (slope).
- Use the obtained normal to calculate the tilt of the ground (slope).
- Rotate the character according to the tilt of the ground (slope).
Implementing the AlignFloor()
Function in the Player Class
The AlignFloor()
function will be called every 0.1 seconds using a timer (it can also be called in Tick, but for optimization, it's set to 0.1 seconds. Visually, I don’t think a frequency of 0.1 seconds will be noticeable).
PlayerCharacter.h1private: 2 void AlignFloor() const; 3 4 FTimerHandle AlignFloorTimerHandle;
PlayerCharacter.cpp1 2void APlayerCharacter::BeginPlay() 3{ 4 Super::BeginPlay(); 5 6 GetWorldTimerManager().SetTimer(AlignFloorTimerHandle, this, &APlayerCharacter::AlignFloor, 0.1f, true); 7} 8 9void APlayerCharacter::AlignFloor() const 10{ 11 const FVector MeshLocation = GetMesh()->GetComponentLocation() + 1.f * FVector::UpVector; 12 const FVector MeshDownLocation = MeshLocation - 1000.f * FVector::UpVector; 13 FHitResult HitResult; 14 FCollisionQueryParams CollisionQueryParams; 15 CollisionQueryParams.AddIgnoredActor(this); 16 const bool IsHit = GetWorld()->LineTraceSingleByChannel(HitResult, MeshLocation, MeshDownLocation, ECC_WorldStatic, 17 CollisionQueryParams); 18 if (IsHit) 19 { 20 FVector FloorNormal = HitResult.ImpactNormal; 21 FVector RightVector = GetActorRightVector(); 22 FVector UpVector = GetActorUpVector(); 23 float SlopePitch; 24 float SlopeRoll; 25 UKismetMathLibrary::GetSlopeDegreeAngles(RightVector, FloorNormal, UpVector, SlopePitch, SlopeRoll); 26 SlopePitch = -SlopePitch; 27 const float MeshYaw = GetMesh()->GetComponentRotation().Yaw; 28 const float MeshPitch = GetMesh()->GetComponentRotation().Pitch; 29 const FRotator FloorRotation = FRotator(MeshPitch, MeshYaw, SlopePitch); 30 GetMesh()->SetWorldRotation(FloorRotation); 31 } 32}
This completes the implementation of tilting the character according to the slope of the ground!
Explanation
Performing a Raycast Downward from the Character
PlayerCharacter.cpp1void APlayerCharacter::AlignFloor() const 2{ 3 const FVector MeshLocation = GetMesh()->GetComponentLocation() + 1.f * FVector::UpVector; 4 const FVector MeshDownLocation = MeshLocation - 1000.f * FVector::UpVector; 5 FHitResult HitResult; 6 FCollisionQueryParams CollisionQueryParams; 7 CollisionQueryParams.AddIgnoredActor(this); 8 const bool IsHit = GetWorld()->LineTraceSingleByChannel(HitResult, MeshLocation, MeshDownLocation, ECC_WorldStatic, 9 CollisionQueryParams); 10 if (IsHit) 11 { 12 FVector FloorNormal = HitResult.ImpactNormal; 13 FVector RightVector = GetActorRightVector(); 14 FVector UpVector = GetActorUpVector(); 15 float SlopePitch; 16 float SlopeRoll; 17 UKismetMathLibrary::GetSlopeDegreeAngles(RightVector, FloorNormal, UpVector, SlopePitch, SlopeRoll); 18 SlopePitch = -SlopePitch; 19 const float MeshYaw = GetMesh()->GetComponentRotation().Yaw; 20 const float MeshPitch = GetMesh()->GetComponentRotation().Pitch; 21 const FRotator FloorRotation = FRotator(MeshPitch, MeshYaw, SlopePitch); 22 GetMesh()->SetWorldRotation(FloorRotation); 23 } 24}
In this part, a raycast check is performed downward from slightly above the character.
const FVector MeshLocation = GetMesh()->GetComponentLocation() + 1.f * FVector::UpVector;
The + 1.f
is to ensure there is a distance from the ground. Without this, the character would be positioned at the same height as the ground, and the raycast might not hit the ground correctly (this actually occurred in my environment).
If the raycast hits the ground, obtain the ground normal (FloorNormal).
Use the obtained normal to calculate the tilt of the ground.
PlayerCharacter.cpp1void APlayerCharacter::AlignFloor() const 2{ 3 const FVector MeshLocation = GetMesh()->GetComponentLocation() + 1.f * FVector::UpVector; 4 const FVector MeshDownLocation = MeshLocation - 1000.f * FVector::UpVector; 5 FHitResult HitResult; 6 FCollisionQueryParams CollisionQueryParams; 7 CollisionQueryParams.AddIgnoredActor(this); 8 const bool IsHit = GetWorld()->LineTraceSingleByChannel(HitResult, MeshLocation, MeshDownLocation, ECC_WorldStatic, 9 CollisionQueryParams); 10 if (IsHit) 11 { 12 FVector FloorNormal = HitResult.ImpactNormal; 13 FVector RightVector = GetActorRightVector(); 14 FVector UpVector = GetActorUpVector(); 15 float SlopePitch; 16 float SlopeRoll; 17 UKismetMathLibrary::GetSlopeDegreeAngles(RightVector, FloorNormal, UpVector, SlopePitch, SlopeRoll); 18 SlopePitch = -SlopePitch; 19 const float MeshYaw = GetMesh()->GetComponentRotation().Yaw; 20 const float MeshPitch = GetMesh()->GetComponentRotation().Pitch; 21 const FRotator FloorRotation = FRotator(MeshPitch, MeshYaw, SlopePitch); 22 GetMesh()->SetWorldRotation(FloorRotation); 23 } 24}
This section includes some mathematical content, so please read on only if you are interested. If you're not, feel free to skip ahead.
Consider a scenario where the character is a mouse standing on a slope. The raycast hits the slope, and the normal is obtained.
Obtaining the Ground (Slope) Normal
FVector FloorNormal = HitResult.ImpactNormal;
What is a normal?
A line that is perpendicular to the tangent plane at a given point on a curved surface.
Calculating the Slope Angle
Obtain the character's right vector and up vector.
1 //... 2 FVector RightVector = GetActorRightVector(); 3 FVector UpVector = GetActorUpVector(); 4 //...
Use the function from UKismetMathLibrary
to obtain the slope angle (SlopePitch).
UKismetMathLibrary::GetSlopeDegreeAngles(RightVector, FloorNormal, UpVector, SlopePitch, SlopeRoll);
Upon inspecting the internal workings of this function, the following calculations are performed.
KismetMathLibary.cpp1void UKismetMathLibrary::GetSlopeDegreeAngles(const FVector& MyRightYAxis, const FVector& FloorNormal, const FVector& UpVector, float& OutSlopePitchDegreeAngle, float& OutSlopeRollDegreeAngle) 2{ 3 const FVector FloorZAxis = FloorNormal; 4 const FVector FloorXAxis = MyRightYAxis ^ FloorZAxis; 5 const FVector FloorYAxis = FloorZAxis ^ FloorXAxis; 6 7 OutSlopePitchDegreeAngle = 90.f - FMath::RadiansToDegrees(FMath::Acos(FloorXAxis | UpVector)); 8 OutSlopeRollDegreeAngle = 90.f - FMath::RadiansToDegrees(FMath::Acos(FloorYAxis | UpVector)); 9}
Using a diagram for explanation might make it easier to understand.
For example, the right side view of a mouse standing on a slope is shown below.
△ The triangle represents the mouse.
FloorZ
is the normal of the slope (normal vector). FloorX
is calculated using the cross product of the mouse's right vector and FloorZ
(the normal vector), resulting in the upward direction vector of the slope.
The caret (^) in FVector denotes the cross product operator.
The result of the cross product of two vectors is a vector that is perpendicular to both of them.
For more on cross product: Wikipedia - Cross Product
Next, calculating the cross product of FloorZ
and FloorX
gives us the "mouse's right direction vector."
const FVector FloorYAxis = FloorZAxis ^ FloorXAxis;
By calculating the dot product between the slope's upward direction vector (FloorX
) and the mouse's upward vector (Up
), and taking the Acos of the result, we can determine angle a. Subtracting this angle from 90 degrees gives us the slope angle (SlopePitch
).
OutSlopePitchDegreeAngle = 90.f - FMath::RadiansToDegrees(FMath::Acos(FloorXAxis | UpVector));
The
|
in FVector denotes the dot product operator.For more on dot product: Wikipedia - Dot Product
The dot product between vectors u and v, with angle θ between them, can be expressed as:
u⋅v=∣u∣∣v∣cosθ
Rotating the Character According to the Slope's Angle
PlayerCharacter.cpp1void APlayerCharacter::AlignFloor() const 2{ 3 const FVector MeshLocation = GetMesh()->GetComponentLocation() + 1.f * FVector::UpVector; 4 const FVector MeshDownLocation = MeshLocation - 1000.f * FVector::UpVector; 5 FHitResult HitResult; 6 FCollisionQueryParams CollisionQueryParams; 7 CollisionQueryParams.AddIgnoredActor(this); 8 const bool IsHit = GetWorld()->LineTraceSingleByChannel(HitResult, MeshLocation, MeshDownLocation, ECC_WorldStatic, 9 CollisionQueryParams); 10 if (IsHit) 11 { 12 FVector FloorNormal = HitResult.ImpactNormal; 13 FVector RightVector = GetActorRightVector(); 14 FVector UpVector = GetActorUpVector(); 15 float SlopePitch; 16 float SlopeRoll; 17 UKismetMathLibrary::GetSlopeDegreeAngles(RightVector, FloorNormal, UpVector, SlopePitch, SlopeRoll); 18 SlopePitch = -SlopePitch; 19 const float MeshYaw = GetMesh()->GetComponentRotation().Yaw; 20 const float MeshPitch = GetMesh()->GetComponentRotation().Pitch; 21 const FRotator FloorRotation = FRotator(MeshPitch, MeshYaw, SlopePitch); 22 GetMesh()->SetWorldRotation(FloorRotation); 23 } 24}
By rotating the mouse's Mesh roll in the opposite direction, the character naturally adjusts to the slope's tilt.
Result
You can check how the character properly responds to the slope in the demo below.
Finally
In this article, we explained how to rotate a character according to the ground's slope. If you notice any mistakes, please let us know in the comments.