Logo

Ljung

.dev

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.

Existing functionality

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:

  1. The max step height is unintuitive and can't be configured.

  2. There is no minimum step height so we can't block arbitrarily low curbs.

  3. Step up does not always happen instantly when pushing against tall curbs.

  4. If the curb surface is even slightly inclined we will always slide up on it.

Let's try to solve those.

Flow

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.

Refactoring

Sweep and Ground Check

Let's revisit our CheckForGround function:

cpp
1bool USimpleMovementComponent::CheckForGround(FHitResult& OutHit, const float Height) const
2{
3  if (!UpdatedCollider)
4  {
5    return false;
6  }
7
8  const FVector Offset = FVector::UpVector * Height;
9  const FVector ColliderLocation = UpdatedCollider->GetComponentLocation();
10  const FVector StartLocation = ColliderLocation;
11  const FVector EndLocation = ColliderLocation - Offset;
12
13  FCollisionQueryParams SweepParams;
14  SweepParams.AddIgnoredActor(UpdatedCollider->GetOwner());
15
16  FCollisionResponseParams ResponseParams;
17  UpdatedCollider->InitSweepCollisionParams(SweepParams, ResponseParams);
18
19  const FCollisionShape SweepShape = UpdatedCollider->GetCollisionShape();
20  const ECollisionChannel CollisionChannel = UpdatedCollider->GetCollisionObjectType();
21
22  bool bDidHit = GetWorld()->SweepSingleByChannel(
23    OutHit, StartLocation, EndLocation, FQuat::Identity, CollisionChannel, SweepShape, SweepParams,
24    ResponseParams);
25
26  return bDidHit;
27}

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):

cpp
1bool SweepWithCollider(FHitResult& OutHit, FVector StartLocation, FVector EndLocation) const;

And let's implement it (basically the same as our current CheckForGround):

cpp
1bool USimpleMovementComponent::SweepWithCollider(FHitResult& OutHit, FVector StartLocation, FVector EndLocation) const
2{
3  if (!UpdatedCollider)
4  {
5    return false;
6  }
7
8  FCollisionQueryParams SweepParams;
9  SweepParams.AddIgnoredActor(UpdatedCollider->GetOwner());
10
11  FCollisionResponseParams ResponseParams;
12  UpdatedCollider->InitSweepCollisionParams(SweepParams, ResponseParams);
13
14  const FCollisionShape SweepShape = UpdatedCollider->GetCollisionShape();
15  const ECollisionChannel CollisionChannel = UpdatedCollider->GetCollisionObjectType();
16
17  bool bDidHit = GetWorld()->SweepSingleByChannel(
18    OutHit, StartLocation, EndLocation, FQuat::Identity, CollisionChannel, SweepShape, SweepParams,
19    ResponseParams);
20
21  return bDidHit;
22}

And modify CheckForGround to just call this new function with the previous parameters:

cpp
1bool USimpleMovementComponent::CheckForGround(FHitResult& OutHit, const float Height) const
2{
3  const FVector Offset = FVector::UpVector * Height;
4  const FVector ColliderLocation = UpdatedCollider->GetComponentLocation();
5  const FVector StartLocation = ColliderLocation;
6  const FVector EndLocation = ColliderLocation - Offset;
7
8  return SweepWithCollider(OutHit, StartLocation, EndLocation);
9}

Functionality should be the same as before.

Slide

The Move function should no longer be responsible for sliding so we need to pop that part out into a separate function:

cpp
1void Slide(const FVector MovementDelta, const FHitResult& Hit);

The implementation is very simply just the slide part of the Move function:

cpp
1void USimpleMovementComponent::Slide(const FVector MovementDelta, const FHitResult& Hit)
2{
3  FHitResult UnusedHit(Hit);
4  SlideAlongSurface(MovementDelta, 1.0f - Hit.Time, Hit.Normal, UnusedHit, true);
5}

Move

The Move function signature has to be slightly modified as well, since it handles the movement delta and Slide is dependent on that:

cpp
1bool Move(FHitResult& OutInitialHit, FVector& OutMovementDelta, const float DeltaTime);

We need to change the implementation to reflect this, and remove the slide call:

cpp
1bool USimpleMovementComponent::Move(FHitResult& OutInitialHit, FVector& OutMovementDelta, const float DeltaTime)
2{
3  const FRotator& Rotation = UpdatedComponent->GetComponentRotation();
4  OutMovementDelta = Velocity * DeltaTime;
5
6  SafeMoveUpdatedComponent(OutMovementDelta, Rotation, true, OutInitialHit);
7
8  if (OutInitialHit.IsValidBlockingHit())
9  {
10    HandleImpact(OutInitialHit, DeltaTime, OutMovementDelta);
11    return true;
12  }
13
14  return false;
15}

We also need to update our Walking and Falling state functions to keep our existing functionality:

cpp
1...
2
3FHitResult Hit;
4FVector MovementDelta;
5bool bDidHit = Move(Hit, MovementDelta, DeltaTime);
6
7if (bDidHit)
8{
9  Slide(MovementDelta, Hit);
10}
11
12...

Step Up

Theory

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:

Implementation

First let's add our max step-up height variable to our header:

cpp
1UPROPERTY(EditAnywhere, Category="Movement")
2float 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:

cpp
1bool CanStepUp(const FHitResult& Hit, FVector& OutStepUpMovementDelta) const;
2void 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:

cpp
1void USimpleMovementComponent::StepUp(const FVector& StepUpMovementDelta)
2{
3  FHitResult StepUpMoveHit;
4  SafeMoveUpdatedComponent(StepUpMovementDelta, UpdatedCollider->GetComponentRotation(), false,
5                           StepUpMoveHit, ETeleportType::TeleportPhysics);
6}

The key here is the teleport flag which ignores collision but still retains velocity since this is still "regular" movement.

CanStepUp

Examining our images, we first need to check if we're even hitting something. If not, there's nothing to step up on anyway:

cpp
1if (!Hit.IsValidBlockingHit())
2{
3  return false;
4}

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:

cpp
1const FVector ColliderLocation = UpdatedCollider->GetComponentLocation();
2const FVector OriginalMovementDelta = Hit.TraceEnd - Hit.TraceStart;
3const 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:

cpp
1const FVector StepUpOffset = FVector::UpVector * (MaxStepUpHeight + 0.1f);
2const 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 our MaxStepUpHeight unable to be stepped upon.

Using these vector locations we can now call our generalized SweepWithCollider function, and return the results:

cpp
1FHitResult StepUpHit;
2const bool bDidHit = SweepWithCollider(StepUpHit, MaxStepUpLocation, DesiredLocation);
3const bool bCanStepUp = bDidHit && !StepUpHit.bStartPenetrating;
4
5if (bCanStepUp)
6{
7  OutStepUpMovementDelta = StepUpHit.Location - ColliderLocation;
8}
9
10return bCanStepUp;

Since OutStepUpMovementDelta is an out parameter we also initialize it at the start of the function:

cpp
1OutStepUpMovementDelta = FVector::ZeroVector;

The full CanStepUp function now looks like this:

cpp
1bool USimpleMovementComponent::CanStepUp(const FHitResult& Hit,
2                                         FVector& OutStepUpMovementDelta) const
3{
4  OutStepUpMovementDelta = FVector::ZeroVector;
5
6  if (!Hit.IsValidBlockingHit())
7  {
8    return false;
9  }
10
11  const FVector ColliderLocation = UpdatedCollider->GetComponentLocation();
12  const FVector OriginalMovementDelta = Hit.TraceEnd - Hit.TraceStart;
13  const FVector DesiredLocation = ColliderLocation + OriginalMovementDelta;
14  const FVector StepUpOffset = FVector::UpVector * (MaxStepUpHeight + 0.1f);
15  const FVector MaxStepUpLocation = DesiredLocation + StepUpOffset;
16
17  FHitResult StepUpHit;
18  const bool bDidHit = SweepWithCollider(StepUpHit, MaxStepUpLocation, DesiredLocation);
19  const bool bCanStepUp = bDidHit && !StepUpHit.bStartPenetrating;
20
21  if (bCanStepUp)
22  {
23    OutStepUpMovementDelta = StepUpHit.Location - ColliderLocation;
24  }
25
26  return bCanStepUp;
27}

Walking State

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:

cpp
1...
2
3FVector MovementDelta;
4bool bDidHit = Move(Hit, MovementDelta, DeltaTime);
5
6if (bDidHit)
7{
8  FVector OutStepUpMovementDelta;
9  bool bCanStepUp = CanStepUp(Hit, OutStepUpMovementDelta);
10
11  if (bCanStepUp)
12  {
13    StepUp(OutStepUpMovementDelta);
14  }
15  else
16  {
17    Slide(MovementDelta, Hit);
18  }
19}
20
21...

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.

Steepness

By taking steepness into account we basically want to do two things:

  1. Disregard our step-up logic for gentle inclines and (defer that for Slide to handle)

  2. 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".

Implementation

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.

cpp
1UPROPERTY(EditAnywhere, Category="Movement")
2float 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:

cpp
1bool 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):

cpp
1bool USimpleMovementComponent::IsWithinStepUpSteepness(const FHitResult& Hit) const
2{
3  const float Dot = Hit.ImpactNormal | FVector::UpVector;
4  const float MaxStepUpWallAngleRad = FMath::DegreesToRadians(MinStepUpSteepness);
5  const float MaxDotValue = FMath::Sin(MaxStepUpWallAngleRad);
6  const bool bIsValidAngle = Dot <= MaxDotValue;
7  return bIsValidAngle;
8}

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:

cpp
1...
2
3const bool bIsValidAngle = IsWithinStepUpSteepness(Hit);
4if (!bIsValidAngle)
5{
6  return false;
7}
8
9...

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.

cpp
1...
2
3}
4else
5{
6  const bool bIsWithinStepUpSteepness = IsWithinStepUpSteepness(Hit);
7  if (bIsWithinStepUpSteepness)
8  {
9    Hit.Normal = Hit.Normal.GetSafeNormal2D();
10  }
11
12  Slide(MovementDelta, Hit);
13}
14
15...

Results

We now have a relatively stable stepping system.

Caveats

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.