A project using this tutorial series is tracked on GitHub. The revision after this part is c494c9e.
Hej!
We're now going to look at "stepping up" geometry like stairs. It sounds easy but it's fairly complex to get right. What we'll end up with here is something that will work OK with a few caveats.
Note: I generally recommend keeping the max step height low and instead use simplified collision for complex geometry, like replacing stairs with slopes. Otherwise there can be noticeable "jitter" when stepping up/down.
The solution detailed here works mostly to smooth out rough terrain and limit unintended stepping in certain situations.
As is right now we actually have unintentional step-up functionality due to how the SlideAlongSurface
in Move
works under the hood.
However there are a few notable downsides to this:
The max step height is unintuitive and can't be configured.
There is no minimum step height so we can't block arbitrarily low curbs.
Step up does not always happen instantly when pushing against tall curbs.
If the curb surface is even slightly inclined we will always slide up on it.
Let's try to solve those.
Continuing the trend of flowcharts, right now what we have in a broad sense is this:
Showcasing the internals of the Move
function is important since we must change it a bit. What we will end up with is this (changes to be made highlighted in green):
First, we need to separate out the sliding functionality as we only conditionally need to slide when we shouldn't step up.
Second, we need to separate the "can step up" check from performing the step-up action itself because of branching, so we can't bake everything into a single step with this approach.
Third, we need to do some steepness checks to avoid auto-stepping over low height during the slide as described previously.
Additionally the "can step up" part will perform sweeping similar to (but not exactly the same as) our ground check, therefore we should generalize the functionality to sweep with our collider and make the ground check utilize that instead. We will do that first.
Let's revisit our CheckForGround
function:
bool USimpleMovementComponent::CheckForGround(FHitResult& OutHit, const float Height) const
{
if (!UpdatedCollider)
{
return false;
}
const FVector Offset = FVector::UpVector * Height;
const FVector ColliderLocation = UpdatedCollider->GetComponentLocation();
const FVector StartLocation = ColliderLocation;
const FVector EndLocation = ColliderLocation - Offset;
FCollisionQueryParams SweepParams;
SweepParams.AddIgnoredActor(UpdatedCollider->GetOwner());
FCollisionResponseParams ResponseParams;
UpdatedCollider->InitSweepCollisionParams(SweepParams, ResponseParams);
const FCollisionShape SweepShape = UpdatedCollider->GetCollisionShape();
const ECollisionChannel CollisionChannel = UpdatedCollider->GetCollisionObjectType();
bool bDidHit = GetWorld()->SweepSingleByChannel(
OutHit, StartLocation, EndLocation, FQuat::Identity, CollisionChannel, SweepShape, SweepParams,
ResponseParams);
return bDidHit;
}
The parts specific to checking for ground is only the location parts (lines 8-11), otherwise the rest of the function is generic enough to cover several use-cases.
Add a function like so in the header file (suggested above CheckForGround
):
bool SweepWithCollider(FHitResult& OutHit, FVector StartLocation, FVector EndLocation) const;
And let's implement it (basically the same as our current CheckForGround
):
bool USimpleMovementComponent::SweepWithCollider(FHitResult& OutHit, FVector StartLocation, FVector EndLocation) const
{
if (!UpdatedCollider)
{
return false;
}
FCollisionQueryParams SweepParams;
SweepParams.AddIgnoredActor(UpdatedCollider->GetOwner());
FCollisionResponseParams ResponseParams;
UpdatedCollider->InitSweepCollisionParams(SweepParams, ResponseParams);
const FCollisionShape SweepShape = UpdatedCollider->GetCollisionShape();
const ECollisionChannel CollisionChannel = UpdatedCollider->GetCollisionObjectType();
bool bDidHit = GetWorld()->SweepSingleByChannel(
OutHit, StartLocation, EndLocation, FQuat::Identity, CollisionChannel, SweepShape, SweepParams,
ResponseParams);
return bDidHit;
}
And modify CheckForGround
to just call this new function with the previous parameters:
bool USimpleMovementComponent::CheckForGround(FHitResult& OutHit, const float Height) const
{
const FVector Offset = FVector::UpVector * Height;
const FVector ColliderLocation = UpdatedCollider->GetComponentLocation();
const FVector StartLocation = ColliderLocation;
const FVector EndLocation = ColliderLocation - Offset;
return SweepWithCollider(OutHit, StartLocation, EndLocation);
}
Functionality should be the same as before.
The Move
function should no longer be responsible for sliding so we need to pop that part out into a separate function:
void Slide(const FVector MovementDelta, const FHitResult& Hit);
The implementation is very simply just the slide part of the Move
function:
void USimpleMovementComponent::Slide(const FVector MovementDelta, const FHitResult& Hit)
{
FHitResult UnusedHit(Hit);
SlideAlongSurface(MovementDelta, 1.0f - Hit.Time, Hit.Normal, UnusedHit, true);
}
The Move
function signature has to be slightly modified as well, since it handles the movement delta and Slide is dependent on that:
bool Move(FHitResult& OutInitialHit, FVector& OutMovementDelta, const float DeltaTime);
We need to change the implementation to reflect this, and remove the slide call:
bool USimpleMovementComponent::Move(FHitResult& OutInitialHit, FVector& OutMovementDelta, const float DeltaTime)
{
const FRotator& Rotation = UpdatedComponent->GetComponentRotation();
OutMovementDelta = Velocity * DeltaTime;
SafeMoveUpdatedComponent(OutMovementDelta, Rotation, true, OutInitialHit);
if (OutInitialHit.IsValidBlockingHit())
{
HandleImpact(OutInitialHit, DeltaTime, OutMovementDelta);
return true;
}
return false;
}
We also need to update our Walking and Falling state functions to keep our existing functionality:
...
FHitResult Hit;
FVector MovementDelta;
bool bDidHit = Move(Hit, MovementDelta, DeltaTime);
if (bDidHit)
{
Slide(MovementDelta, Hit);
}
...
Time for the actual step-up functionality. Let's visualize the basic theory.
Imagine a single movement sweep from P0
to P1
(exaggerated here for visualization, with points near feet location for simplicity):
The red X represents where we would hit geometry (disregarding the sweep shape). This will be our trigger for initiating the whole step-up procedure.
Next we want to sweep again a bit up from our desired location (P1
), denoted here by Ps
:
The amount of distance above we check is our max step height, here denoted by M
. Performing the sweep in this scenario will net us another impact point (Ph
):
This is the point where we want to end up! So the final step is to perform a move to that point. We can disregard move-sweeping in this case since we already know the point is "safe", and to avoid hitting the edge on the way:
In cases where the geometry is too tall we will have an initial overlap. In that case we can ignore the sweep and abort the step-up:
First let's add our max step-up height variable to our header:
UPROPERTY(EditAnywhere, Category="Movement")
float MaxStepUpHeight;
We should also set a default value in the constructor as usual. I chose 20.0f
to match our step-down height.
Next we need our two step-up related functions as mentioned earlier:
bool CanStepUp(const FHitResult& Hit, FVector& OutStepUpMovementDelta) const;
void StepUp(const FVector& StepUpMovementDelta);
The OutStepUpMovementDelta
from CanStepUp
represents the movement that results in our character moving to the point Ph
as described in the theory section. The StepUp
function simply takes that delta and applies it in a way that ignores collision along the way. Since it's fairly concise let's implement it first:
void USimpleMovementComponent::StepUp(const FVector& StepUpMovementDelta)
{
FHitResult StepUpMoveHit;
SafeMoveUpdatedComponent(StepUpMovementDelta, UpdatedCollider->GetComponentRotation(), false,
StepUpMoveHit, ETeleportType::TeleportPhysics);
}
The key here is the teleport flag which ignores collision but still retains velocity since this is still "regular" movement.
Examining our images, we first need to check if we're even hitting something. If not, there's nothing to step up on anyway:
if (!Hit.IsValidBlockingHit())
{
return false;
}
Next we need to get our initially desired location. It can be calculated by adding our movement delta to our current location. The movement delta can be calculated from the hit result, by taking Hit.TraceEnd
and subtracting Hit.TraceStart
which is equivalent to the original sweep delta:
const FVector ColliderLocation = UpdatedCollider->GetComponentLocation();
const FVector OriginalMovementDelta = Hit.TraceEnd - Hit.TraceStart;
const FVector DesiredLocation = ColliderLocation + OriginalMovementDelta;
We need to sweep down to this desired location, with an offset based on our max step height. This location is essentially our highest location that we can step up on:
const FVector StepUpOffset = FVector::UpVector * (MaxStepUpHeight + 0.1f);
const FVector MaxStepUpLocation = DesiredLocation + StepUpOffset;
The reason we add a very slight
0.1f
padding is because of rounding errors which can make steps the exact height of ourMaxStepUpHeight
unable to be stepped upon.
Using these vector locations we can now call our generalized SweepWithCollider
function, and return the results:
FHitResult StepUpHit;
const bool bDidHit = SweepWithCollider(StepUpHit, MaxStepUpLocation, DesiredLocation);
const bool bCanStepUp = bDidHit && !StepUpHit.bStartPenetrating;
if (bCanStepUp)
{
OutStepUpMovementDelta = StepUpHit.Location - ColliderLocation;
}
return bCanStepUp;
Since OutStepUpMovementDelta
is an out parameter we also initialize it at the start of the function:
OutStepUpMovementDelta = FVector::ZeroVector;
The full CanStepUp
function now looks like this:
bool USimpleMovementComponent::CanStepUp(const FHitResult& Hit,
FVector& OutStepUpMovementDelta) const
{
OutStepUpMovementDelta = FVector::ZeroVector;
if (!Hit.IsValidBlockingHit())
{
return false;
}
const FVector ColliderLocation = UpdatedCollider->GetComponentLocation();
const FVector OriginalMovementDelta = Hit.TraceEnd - Hit.TraceStart;
const FVector DesiredLocation = ColliderLocation + OriginalMovementDelta;
const FVector StepUpOffset = FVector::UpVector * (MaxStepUpHeight + 0.1f);
const FVector MaxStepUpLocation = DesiredLocation + StepUpOffset;
FHitResult StepUpHit;
const bool bDidHit = SweepWithCollider(StepUpHit, MaxStepUpLocation, DesiredLocation);
const bool bCanStepUp = bDidHit && !StepUpHit.bStartPenetrating;
if (bCanStepUp)
{
OutStepUpMovementDelta = StepUpHit.Location - ColliderLocation;
}
return bCanStepUp;
}
Let's update the walking state to take advantage of these new functions. Implementing the green-highlighted logic from the flowchart earlier (disregarding steepness for now), we replace everything between the velocity addition and our StepDown();
call with:
...
FVector MovementDelta;
bool bDidHit = Move(Hit, MovementDelta, DeltaTime);
if (bDidHit)
{
FVector OutStepUpMovementDelta;
bool bCanStepUp = CanStepUp(Hit, OutStepUpMovementDelta);
if (bCanStepUp)
{
StepUp(OutStepUpMovementDelta);
}
else
{
Slide(MovementDelta, Hit);
}
}
...
At this point we can now run it and we should have passable walking up stairs!
However you may notice some jittering while walking up slopes, and we can unintentionally walk up really sharp inclines Skyrim-horse style:
Which brings us to the steepness factor.
By taking steepness into account we basically want to do two things:
Disregard our step-up logic for gentle inclines and (defer that for Slide
to handle)
Block Slide
from going up sharp inclines if we can't step up normally
For both actions we need to define what "steep" means to us. It is best explained with an image:
We can define an angle, theta (θ), of which any incline gentler (angle >= θ
) is deferred to Slide
, and any angle sharper (angle < θ
) is blocked from sliding up on if we can't step up. In the image the incline 1
is blocked, and the incline 2
is deferred.
To get the angle we can use some vector math and calculate the dot product of the initial hit impact normal and the global up vector. It works similarly to our MaxCeilingStopAngle
.
To recap, a dot product between two vectors gives us how close two vectors are to pointing in the same direction. dot=1
means both vectors are identical, dot=0
means they're perpendicular. Geometrically the dot product is the cosine of the angle between them.
Imagine a sharp incline. The dot will then be close to 0 (red arrow is impact normal and dotted blue is global up):
Conversely a gentle incline will give a dot closer to 1:
To get the angle we use the inverse cosine function FMath::Acos
which gives us an angle of 90° (half π in radians) at dot=0, and 0° at dot=1.
However to make things more workable from a design standpoint we can instead use FMath::Asin
which essentially flips it the other way around, which gives an angle representing how far the surface is from being a perfectly vertical wall. From there we can say "allow any walls up to X degrees inclined from perfectly vertical".
Let's put it all together.
First let's add a variable to control our allowed margin. It will represent the minimum "steepness" from a perfectly vertical wall that we will allow to explicitly step up upon.
UPROPERTY(EditAnywhere, Category="Movement")
float MinStepUpSteepness;
As usual it's good practice to add a default value to our constructor. I find that a value of 10 works quite well.
Next let's add a private function to check the steepness:
bool IsWithinStepUpSteepness(const FHitResult& Hit) const;
The implementation does the dot calculation mentioned above, and returns if this hit is considered "valid" (as in our step-up procedure should act on it):
bool USimpleMovementComponent::IsWithinStepUpSteepness(const FHitResult& Hit) const
{
const float Dot = Hit.ImpactNormal | FVector::UpVector;
const float MaxStepUpWallAngleRad = FMath::DegreesToRadians(MinStepUpSteepness);
const float MaxDotValue = FMath::Sin(MaxStepUpWallAngleRad);
const bool bIsValidAngle = Dot <= MaxDotValue;
return bIsValidAngle;
}
To utilize this function we need to edit our CanStepUp
to check the angle, and we need to edit our walking state to potentially block the "auto step up" from Slide
.
In our CanStepUp
function just before we start doing the sweep check we can add an additional early-out check:
...
const bool bIsValidAngle = IsWithinStepUpSteepness(Hit);
if (!bIsValidAngle)
{
return false;
}
...
And for our walking state, before we call Slide
we can "flatten" the hit normal (Z=0) if we should block. Flattening the hit normal essentially tricks the slide functionality into thinking we hit a perfectly vertical wall, which works pretty well.
...
}
else
{
const bool bIsWithinStepUpSteepness = IsWithinStepUpSteepness(Hit);
if (bIsWithinStepUpSteepness)
{
Hit.Normal = Hit.Normal.GetSafeNormal2D();
}
Slide(MovementDelta, Hit);
}
...
We now have a relatively stable stepping system.
Again, due to the simplicity of the implementation it is best to keep step heights relatively low and instead opt to use as much sloping collision shapes as possible when dealing with stairs and stair-like geometry and use blocking volumes surrounding the playable area.
There are also a few drawbacks or "bugs" with this basic implementation. Notably we can sometimes not step up when expected when moving diagonally against a step height and a wall:
The steepness block (or more specifically the step-up sweep) is also not perfect, as it's still possible to climb steep walls if approaching at a very acute angle:
We might fix these in the future but for now they can be mitigated by proper level design.