Sergi Santana

Custom Perception System in Unreal Engine

The advantages of rolling out your own system

10 February 2024

In 2023 I enrolled for a master's degree on game development where I spent a whole year working with 6 other developers making a game in Unreal Engine. The game is a sci-fi action-adventure game with stealth elements where the players control an alien creature trying to escape a lab spaceship the humans own.

To make the main character more interesting we decided to play with its movement. It can move through floors, walls and ceilings as well as use webs to move quickly around rooms and suspend itself mid-air. This decision increased the complexity of the project and we had to rethink some other decisions that would've been trivial otherwise. One of them being the behavior of enemies.

Issues using Unreal Engine's perception system

Since our character can move through ceilings we wanted to give it a bit of an advantage over moving through the floor to encourage using the full range of motion. UE's system didn't allow the flexibility to do the following:

  1. Take height into consideration: it's not a deal-breaker, but it's annoying. The code became messy; we would add the difference in height between player and soldier to our awareness increase formula so it'd grow slower or not at all. It also wasn't intuitive at all for designers.
  2. Set different settings per soldier: we had soldiers in corridors and rooms of all sizes and heights, each fine tuned to different situations. The workaround would've been to have multiple blueprints of the same soldier which made it increasingly more tedious to modify the scene for designers.
  3. Change settings at run time: we wanted to make soldiers a lot more perceptive once they became alerted, encouraging the players to use stealth and hide. This was outright impossible and we didn't find any workaround.

I'm writing this by heart, so I might be missing some stuff, but it was enough for us to write our own perception system. Besides, every time I struggled with any of these issues I would put some time into thinking how the new system would look like, so by the time we decided to make the switch it turned out to be pretty easy to build.

Our custom perception system

The new system was easier to visualize and edit

Each soldier has a set of cones of vision which are defined by height, angle, minimum radius and maximum radius. Every update each soldiers checks if the player falls into any of the cone and, if it does, throws a raycast to check if the player is in their line of sight.

It's simple, but effective. With this new system some of the design decisions were easy to add, these being the most relevant ones:

I haven't properly tested if this method is more resource intensive than UE's system, but my guess is that it can't be much slower. After all our system is simpler. We don't have to account for all kinds of AIs populating the world and more than one player interacting with them.

Overall I'm happy how it turned out. It's not always obvious when it's a good idea to roll your own code for something the engine already provides, but as problems piled up it felt right to do so. Here's the code for the check, it's as straightforward as you'd imagine:

bool ASoldierAIController::IsPlayerInSight() {
    ASpider *PlayerCharacter = GetPlayerCharacter();
    if (!PlayerCharacter) { return false; }
    ASLSoldier *SoldierCharacter = Cast<ASLSoldier>(GetInstigator());
    if (!SoldierCharacter) { return false; }

    FVector SoldierPosition = SoldierCharacter->GetActorLocation();
    FVector PlayerPosition  = PlayerCharacter ->GetActorLocation();

    // If the Soldier is alerted we don't care about the cones
    if (bIsAlerted) {
      return IsPlayerInSightRaycast(SoldierPosition, PlayerPosition);
    }

    FVector SoldierPositionNoZ = FVector(SoldierPosition.X, SoldierPosition.Y, 0);
    FVector  PlayerPositionNoZ = FVector( PlayerPosition.X,  PlayerPosition.Y, 0);
    FVector SoldierToPlayer = PlayerPositionNoZ - SoldierPositionNoZ;
    float DistanceSqr = SoldierToPlayer.SquaredLength();

    // Check if the player is too far away
    if (DistanceSqr > MaxSightRadius*MaxSightRadius) { return false; }

    float ZDistance = FMath::Abs(SoldierPosition.Z - PlayerPosition.Z);
    FVector SoldierForward = SoldierCharacter->GetActorForwardVector();
    SoldierForward .Normalize();
    SoldierToPlayer.Normalize();
    float DotResult = FVector::DotProduct(SoldierForward, SoldierToPlayer);

    for (FUSLAICone Cone : SoldierCharacter->ConesOfVision) {
        // Check if player is close enough
        float MaxRadiusSqr = Cone.MaxSightRadius * Cone.MaxSightRadius;
        float MinRadiusSqr = Cone.MinSightRadius * Cone.MinSightRadius;
        if (DistanceSqr > MaxRadiusSqr) continue;
        if (DistanceSqr < MinRadiusSqr) continue;

        // Check if player is at the right height
        if (ZDistance > Cone.SightHeight) continue;

        // Check if player is in front
        float CosPeripherialVision =
            FMath::Cos(FMath::DegreesToRadians(Cone.PeripherialVision/2));
        if (DotResult <= CosPeripherialVision) continue;

        // At this point we know the player is inside the cone,
        // so we raycast to check if it can be seen and return
        return IsPlayerInSightRaycast(SoldierPosition, PlayerPosition);
    }

    return false;
}

bool ASoldierAIController::IsPlayerInSightRaycast(
    FVector SoldierPosition, FVector PlayerPosition)
{
    FHitResult HitResult;
    FCollisionQueryParams TraceParams;
    TraceParams.AddIgnoredActor(GetInstigator());
    bool bHit = GetWorld()->LineTraceSingleByChannel(
        HitResult, SoldierPosition, PlayerPosition,
        SL_ECC_SoldierAI, TraceParams
    );
    return (bHit && (HitResult.GetActor() == GetPlayerCharacter()));
}