Climbing Detection

Detection Strategies

The simplest way would be to fire a raycast from the players forward vector and look for surface collision that way, but this has some immediate shortcomings when thinking about walls with overhangs and/or convex or concave corners and trying to know if you can climb in any cardinal direction.

A workaround could be firing multiple raycasts from different positions on the body perhaps low, medium, and high positions, or adding additional ones offset by the input direction.

What we really want is "an area of detection around the player" which can be solved with multiple raycasts, but there is a much easier way to accomplish this by using a Shape Sweep.

Instead of individual rays, SweepMultiByChannel allows us to pass in a shape that can be used for uniform collision checks which just makes this problem much easier to solve.

Surface Detection

We'll need to override BeginPlay() for some setup, and TickComponent() so we can sweep for wall hits. We need a few properties which will define our sweep shape, as well as a way to store the collision hits, and query params for the sweep.

.h

// ZCCharacterMovementComponent.h

private:
	virtual void BeginPlay() override;
	virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
	
	void SweepAndStoreWallHits();
	
	UPROPERTY(Category = "Character Movement: Climbing", EditAnywhere)
	int CollisionCapsulRadius = 50;
	
	UPROPERTY(Category = "Character Movement: Climbing", EditAnywhere)
	int CollisionCapsulHalfHeight = 72;
	
	UPROPERTY(Category = "Character Movement: Climbing", EditAnywhere)
	int CollisionCapsulForwardOffset = 20;
	
	TArray<FHitResult> CurrentWallHits;
	FCollisionQueryParams ClimbQueryParams;

The sweep should happen every Tick, and we don't want the sweep to trigger for our own Character

.cpp

// ZCCharacterMovementComponent.cpp

void UZCCharacterMovementComponent::BeginPlay()
{
	Super::BeginPlay();

	ClimbQueryParams.AddIgnoredActor(GetOwner());
}

void UZCCharacterMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

	SweepAndStoreWallHits();
}

I've been saying Sweep but you might think we don't actually need the shape to sweep anywhere, we just want to detect collisions in one static location.

However due to a bug if the Start and End position of SweepMultiByChannel() is the same I've noticed detection doesn't work properly, so instead we will sweep, just very closely out in front of us.

.cpp

// ZCCharacterMovementComponent.cpp

void UZCCharacterMovementComponent::SweepAndStoreWallHits()
{
	const FCollisionShape CollisionShape = FCollisionShape::MakeCapsule(CollisionCapsulRadius, CollisionCapsulHalfHeight);

	const FVector StartOffset = UpdatedComponent->GetForwardVector() * CollisionCapsulForwardOffset;

	const FVector Start = UpdatedComponent->GetComponentLocation() + StartOffset;	// a bit in front
	const FVector End = Start + UpdatedComponent->GetForwardVector();				// using the same start/end location for a sweep doesn't trigger hits on Landscapes

	if (UWorld* World = GetWorld())
	{
		World->SweepMultiByChannel(CurrentWallHits , Start, End, FQuat::Identity, ECC_WorldStatic, CollisionShape, ClimbQueryParams);
	}		
}
White Capsule represents sweep shape, Blue Spheres are collisions

Evaluating Surfaces

First let's create a dedicated function for determining if we can start climbing given any of the wall hits that we'll come back to this later

.h

// ZCCharacterMovementComponent.h

private:
// ...

bool CanStartClimbing() const;

.cpp

// ZCCharacterMovementComponent.cpp

bool UZCCharacterMovementComponent::CanStartClimbing() const
{
	// I don't actually do anything yet
	return false;
}

In games like Zelda BOTW and Genshin when the character runs into a wall they automatically start climbing if:

  • Horizontal check passes - the angle of intent is within some threshold (i.e. are we running into the wall head on or is our shoulder just scraping it)

  • Vertical check passes - the wall's angle is within a Climbable Threshold (i.e. not too steep, and not too shallow)

Horizontal Check - Angle of Intent

This check is very straight forward. We just want to make sure that the angle between the characters Forward Vector and the surface is less than some angle threshold

Is the player reasonably intending to run into the wall, i.e. intending to climb or not

.h

// ZCCharacterMovementComponent.h

private:
// ...

bool AngleOfIntentCheck(const FHitResult& WallHit) const;

UPROPERTY(Category = "Character Movement: Climbing", EditAnywhere)
float MinLookDegreesToStartClimbing = 25;

.cpp

// ZCCharacterMovementComponent.cpp

bool UZCCharacterMovementComponent::AngleOfIntentCheck(const FHitResult& WallHit) const
{
	// 2D normal because we don't care about the Z (up) component for this check
	const FVector WallSurfaceNormal = WallHit.Normal.GetSafeNormal2D();
	const FVector PlayerForward = UpdatedComponent->GetForwardVector();
	
	// inverse wall normal so the vectors are pointing same direction keeping result positive
	const float LookAngleCos = PlayerForward.Dot(-WallHorizontalNormal);
	const float LookAngleDiff = FMath::RadiansToDegrees(FMath::Acos(LookAngleCos));
	
	return LookAngleDiff <= MinLookDegreesToStartClimbing;
}

Optimization Note

It's better practice to convert any angles used as threshold checks to Radians on startup so we don't have to eat the conversion cost every tick.

Additionally in this case we could pre-compute the Arc Cosine of the angle threshold if we know this is all it will be used for so we only have to compare the Cosines but I left it more verbose for simplicity.

Lastly update our CanStartClimbing() function to check angle of intent

// ZCCharacterMovementComponent.cpp

bool UZCCharacterMovementComponent::CanStartClimbing() const
{
	for (const FHitResult& WallHit : CurrentWallHits)
	{
		if (AngleOfIntentCheck(WallHit))
		{
			// We only need to find one valid hit
			return true;
		}
	}

	return false;
}
We only consider the player intending to climb if they're looking at the wall

Vertical Check - Surface Angle

There are a few requirements for a surface to be climbable. Let's create a function to handle the vertical checking.

.h

// ZCCharacterMovementComponent.h

bool IsClimbableSurface(const FHitResult& WallHit) const;

Ceiling and Floor check

The easiest check is just to make sure that our surface isn't perpendicular with what our game considers flat.

We do this by ignoring the Up component of the Normal, i.e. the Z component, in the 2D vector and compare it with the 3D vector.

Note, this only works because we assume the Up direction in our game is FVector::UpVector

If your game has unique gravity then instead project the 2D vector onto a place who's normal is opposite of gravity

We'll add the check into the CanStartClimbing() function and implement IsClimbableSurface

.cpp

// ZCCharacterMovementComponent.cpp

bool UZCCharacterMovementComponent::CanStartClimbing() const
{
	for (const FHitResult& WallHit : CurrentWallHits)
	{
		if (AngleOfIntentCheck(WallHit) && IsClimbableSurface(WallHit))
		{
			// We only need to find one valid hit
			return true;
		}
	}

	return false;
}


bool UZCCharacterMovementComponent::IsClimbableSurface(const FHitResult& WallHit) const
{
	const FVector WallHorizontalNormal = WallHit.Normal.GetSafeNormal2D();
	const float VerticalAngleCos = FVector::DotProduct(WallHit.Normal, WallHorizontalNormal);

	// Check if the surface is too flat
	const bool bIsCeilingOrFloor = FMath::IsNearlyZero(VerticalAngleCos);

	return !bIsCeilingOrFloor;
}
Blue Line is the 3D Normal, Purple Line is the 2D normal

Surface Height Check

There is another case we need to account for which is whether the surface is high enough to actually climb, as right now the game will indicate low ledges as climbable which wouldn't make any sense.

we shouldn't be able to climb this (perhaps a vault would be nice but certainly not climb)

It seems reasonable that we should only be able to climb surfaces that are as tall (or taller) than our character, so lets add in a height check using the character's Eye Height as a reference for our "vertical threshold".

It would make sense if the length of the ray was equal to our collision edge, otherwise if we fire too far away we would incorrectly evaluate edges

Add a new function to handle the Eye Height check, and update IsClimbableSurface to use it

.h

// ZCCharacterMovementComponent.h

bool EyeHeightTrace(const float TraceDistance) const;

.cpp

// ZCCharacterMovementComponent.cpp

bool UZCCharacterMovementComponent::IsClimbableSurface(const FHitResult& WallHit) const
{
	// ...

	// Check if surface is high enough
	const float CollisionEdge = CollisionCapsulRadius + CollisionCapsulForwardOffset;
	const bool bIsHighEnough = EyeHeightTrace(CollisionEdge);

	return bIsHighEnough && !bIsCeilingOrFloor;
}

bool UZCCharacterMovementComponent::EyeHeightTrace(const float TraceDistance) const
{
	FHitResult UpperEdgeHit;
	
	// UpdatedComponent is at the location of the MovementComponent (middle of the character)
	const FVector ComponentOrigin = UpdatedComponent->GetComponentLocation();
	
	// raise it up
	const FVector EyeHeight = ComponentOrigin + (UpdatedComponent->GetUpVector() * GetCharacterOwner()->BaseEyeHeight);
	
	// extend it out
	const FVector End = EyeHeight + (UpdatedComponent->GetForwardVector() * TraceDistance);

	return GetWorld()->LineTraceSingleByChannel(UpperEdgeHit, EyeHeight, End, ECC_WorldStatic, ClimbQueryParams);
}
Left (incorrect): Too long of a raycast. Right (correct): Raycast only to collision edge

Resizing Eye Height length based off steepness

However using a static length raycast only works with perpendicular and steep surfaces. It falls short (pun intended) with longer shallow surfaces.

We should be able to climb this but our Eye Height raycast isn't reaching far enough to tell

To solve this, we can make the length of the Eye Height raycast dependent on the surface angle meaning the more shallow the surface, the longer the raycast.

Update IsClimbableSurface() to increase where we consider our CollisionEdge by a steepness multiplier which uses the cosine of the surface angle as our scaling factor since it's a value between [0,1]

.cpp

// ZCCharacterMovementComponent.cpp

bool UZCCharacterMovementComponent::IsClimbableSurface(const FHitResult& WallHit) const
{
	// ...
	
	// Check if surface is high enough
	const float CollisionEdge = CollisionCapsulRadius + CollisionCapsulForwardOffset;
	// magic number '6' here makes it work with all angles we should be able to climb
	const float SteepnessMultiplier = 1 + (1 - VerticalAngleCos) * 6;
	const float TraceLength = CollisionEdge * SteepnessMultiplier;
	const bool bIsHighEnough = EyeHeightTrace(TraceLength);

	// ...
}

Good, now we can properly detect all angles, except there is one last issue.

It's possible, based off how high the capsule collider is off the ground, and what your game considers its Max Walkable Angle for us to incorrectly evaluate a walkable surface as climbable

Luckily the CharacterMovementComponent has a built in accessor for getting the Max Walkable Angle via GetWalkableFloorAngle(). Let's add that as the last check

.cpp

// ZCCharacterMovementComponent.cpp

bool UZCCharacterMovementComponent::IsClimbableSurface(const FHitResult& WallHit) const
{
	// ...
	
	// GetWalkableFloorAngle returns the value in degrees for whatever reason
	const float bIsNonWalkable = FMath::Acos(VerticalAngleCos) < FMath::DegreesToRadians(GetWalkableFloorAngle());

	return bIsHighEnough && bIsNonWalkable && !bIsCeilingOrFloor;
}

Setting Custom Movement Mode

Now we're all setup to determine if we can climb something.

To actually perform the climb we need to tell our custom CharacterMovementComponent to SetMovementMode do a new custom mode.

CharacterMovementComponent::SetMovementMode takes an additional parameter when specifying MOVE_Custom so the developer can implement numerous amounts of custom modes.

We only have 1 custom mode, Climbing, but if we needed we could add them to our new enum

// In its own file, or within an existing .h file, your choice

UENUM(BlueprintType)
enum ECustomMovementMode
{
	CMOVE_Climbing		UMETA(DisplayName="Climbing"),
	CMOVE_MAX			UMETA(Hidden)
};

Then when the movement updates we can check if we CanStartClimbing() in which case we can set our new custom climbing mode

.h

// ZCCharacterMovementComponent.h

private:
    // ...
    
    virtual void OnMovementUpdated(float DeltaSeconds, const FVector& OldLocation, const FVector& OldVelocity) override;

.cpp

// ZCCharacterMovementComponent.cpp

void UZCCharacterMovementComponent::OnMovementUpdated(float DeltaSeconds, const FVector& OldLocation, const FVector& OldVelocity)
{
	if (CanStartClimbing())
		SetMovementMode(EMovementMode::MOVE_Custom, ECustomMovementMode::CMOVE_Climbing);

	Super::OnMovementUpdated(DeltaSeconds, OldLocation, OldVelocity);
}

Now whenever we are in range of a climbable surface the movement mode will be set to Climbing.

Last updated