[C++/UE] UE5中AI開發的一點功能梳理(五) – AI Perception 感知系統 Sense部分

感知系統AI Perception

  • 使用的AI Controller -> Add Component
    • AIPerception => 刺激源接收器
  • AIPerception Stimuli Source => 刺激源發生器
    • 在非AI Actor上添加並注冊對應事件,從而被AI所「感知」到
      • 增加具體要被哪些感官所能感知到
    • 所有Pawn都會默認加上Sight,所以多用於添加其他類型的刺激或者非Pawn的Actor也能發生刺激的場景上

AI Perception使用及參數說明

  • 在AI Perception組件中,可以配置相應的Sense Config項
Sight 視覺感知
  • Detection by Affiliation 決定誰能觸發事件(敵人/路人/友軍)
    • Affiliation目前只能在C++裡定義
    • 在blueprint裡可以勾上Dectect Neutrals(路人)來檢測所有Actor
  • Auto Success Range from Last Seen Location 對象必然會被重新看見的距離
    • if value > 0,對象如果離上次被看見的距離 < value,則仍然算是被看見
    • if value < 0,每次失蹤後都要重新做一次完整的視覺感知
    • 例子:解決恐怖遊戲的當面入櫃情況
  • 具體視野計算
    • Sight Radius / Lose Sight Radius 視野/ 失蹤距離,會被轉化成Square值
//AISense_Sight.cpp
UAISense_Sight::FDigestedSightProperties::FDigestedSightProperties(const UAISenseConfig_Sight& SenseConfig)
{
    SightRadiusSq = FMath::Square(SenseConfig.SightRadius + SenseConfig.PointOfViewBackwardOffset);
    LoseSightRadiusSq = FMath::Square(SenseConfig.LoseSightRadius + SenseConfig.PointOfViewBackwardOffset);
}
    • Peripheral Vision Half Angle Degrees 可視範圍的半角,會被轉成弧度加入計算
    • Point of View Backward Offset 如果不接近於0,就會用來調整視野的起點位置
      • == 0:起點位置 = AI自身的位置
      • != 0:起點位置 = 正後方方向normalized * Point of View Backward Offset
    • Near clipping Radius:從新起點計算的近平面半徑,會被轉化為Square值
//AISense_Sight.cpp
UAISense_Sight::FDigestedSightProperties::FDigestedSightProperties(const UAISenseConfig_Sight& SenseConfig)
{
    NearClippingRadiusSq = FMath::Square(SenseConfig.NearClippingRadius);
}
    • 具體視野示意圖如下(視野檢測會調用CheckIsTargetInSightCone):
//AIHelpers.cpp
//----------------------------------------------------------------------//
// CheckIsTargetInSightCone
//                     F
//                   *****  
//              *             *
//          *                     *
//       *                           *
//     *                               *
//   *                                   * 
//    \                                 /
//     \                               /
//      \                             /
//       \             X             /
//        \                         /
//         \          ***          /
//          \     *    N    *     /
//           \ *               * /
//            N                 N
//            
//           
//           
//           
//
// 
//                     B 
//
// X = StartLocation
// B = Backward offset
// N = Near Clipping Radius (from the StartLocation adjusted by Backward offset)
// F = Far Clipping Radius (from the StartLocation adjusted by Backward offset)
//----------------------------------------------------------------------//
bool CheckIsTargetInSightCone(const FVector& StartLocation, const FVector& ConeDirectionNormal, float PeripheralVisionAngleCos,
    float ConeDirectionBackwardOffset, float NearClippingRadiusSq, float const FarClippingRadiusSq, const FVector& TargetLocation)
{
    const FVector BaseLocation = FMath::IsNearlyZero(ConeDirectionBackwardOffset) ? StartLocation : StartLocation - ConeDirectionNormal * ConeDirectionBackwardOffset;
    const FVector ActorToTarget = TargetLocation - BaseLocation;
    const FVector::FReal DistToTargetSq = ActorToTarget.SizeSquared();
    if (DistToTargetSq <= FarClippingRadiusSq && DistToTargetSq >= NearClippingRadiusSq)
    {
        // Will return true if squared distance to Target is smaller than SMALL_NUMBER
        if (DistToTargetSq < SMALL_NUMBER)
        {
            return true;
        }
        
        // Calculate the normal here instead of calling GetUnsafeNormal as we already have the DistToTargetSq (optim)
        const FVector DirectionToTargetNormal = ActorToTarget * FMath::InvSqrt(DistToTargetSq);

        return FVector::DotProduct(DirectionToTargetNormal, ConeDirectionNormal) > PeripheralVisionAngleCos;
    }

    return false;
}

//AISense_Sight.cpp
UAISense_Sight::EVisibilityResult UAISense_Sight::ComputeVisibility(UWorld* World, FAISightQuery& SightQuery, FPerceptionListener& Listener, const AActor* ListenerActor, FAISightTarget& Target, AActor* TargetActor, const FDigestedSightProperties& PropDigest, float& OutStimulusStrength, FVector& OutSeenLocation, int32& OutNumberOfLoSChecksPerformed, int32& OutNumberOfAsyncLosCheckRequested) const
{
    //...
    const FVector TargetLocation = TargetActor->GetActorLocation();
    const float SightRadiusSq = SightQuery.GetLastResult() ? PropDigest.LoseSightRadiusSq : PropDigest.SightRadiusSq;
    if (!FAISystem::CheckIsTargetInSightCone(Listener.CachedLocation, Listener.CachedDirection, PropDigest.PeripheralVisionAngleCos, PropDigest.PointOfViewBackwardOffset, PropDigest.NearClippingRadiusSq, SightRadiusSq, TargetLocation))
    {
        return EVisibilityResult::NotVisible;
    }
    //...
}
Hearing 聽覺感知
  • 響應Report Noise Event
    • blueprint裡可以拖出相關節點
    • Hearing Range 聽覺距離
    • Detection by Affiliation 決定誰能觸發事件(敵人/路人/友軍)
      • Affiliation目前只能在C++裡定義
      • 在blueprint裡可以勾上Dectect Neutrals(路人)來檢測所有Actor
Touch 接觸感知
  • 應在被甚麼碰到/碰到甚麼時觸發
  • 響應Report Touch Event
    • blueprint裡可以拖出相關節點
Team Sense 隊友感知
  • 同陣營接近的時候會通知
    • 需要配合C++食用
AI Prediction Sense 預測感知
  • 目的是為了讓AI發現玩家後,在跟蹤的過程中跟丟之後,依然會對玩家的可能移動結果進行一個以時間為單位的推算,並得出一個新的目標位置
    • 需要配合其他Sense使用,如以下視野檢測:
  • 當Stimulus Successfully Sensed為false時(看不見了),接入Request Pawn/Character Prediction Event節點,請求做一次Prediction Sense
  • 然後會觸發Perception Update事件,再在該事件裡增加對Prediction Sense的處理
Damage Sense 傷害感知
  • 響應 Report Damage Event / Apply Any Damage / Apply Radial Damage / Apply Point Damage
    • blueprint裡可以拖出相關節點

共同參數

  • Dominant Sense => 最優先感知,應該是已配置的感知其中之一
  • StartsEnabled:該感知是否要手動啟動,true為不需要
    • 手動啟動流程:調用SetSenseEnabled,指定具體的Sense Class
  • MaxAge:感知持續時間,0代表永久持續
    • 單位是秒
    • Age會每幀按一定的增長率遞增,一旦 Age >= MaxAge(ExpirationAge),感知就會「過期」
    • 增長率可以在UAIPerceptionSystem的初始化列表裡修改
//AIPerceptionSystem
UAIPerceptionSystem::UAIPerceptionSystem(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
    , PerceptionAgingRate(0.3f)
    , bHandlePawnNotification(false)
    , NextStimuliAgingTick(0.)
    , CurrentTime(0.)
{
    StimuliSourceEndPlayDelegate.BindDynamic(this, &UAIPerceptionSystem::OnPerceptionStimuliSourceEndPlay);
}

void UAIPerceptionSystem::Tick(float DeltaSeconds)
{
    SCOPE_CYCLE_COUNTER(STAT_AI_PerceptionSys);
    SCOPE_CYCLE_COUNTER(STAT_AI_Overall);
    CSV_SCOPED_TIMING_STAT_EXCLUSIVE(AIPerception);
    // if no new stimuli
    // and it's not time to remove stimuli from "know events"

    UWorld* World = GEngine->GetWorldFromContextObjectChecked(GetOuter());
    check(World);

    if (World->bPlayersOnly == false)
    {
        // cache it
        CurrentTime = World->GetTimeSeconds();      
        if (SourcesToRegister.Num() > 0)
        {
            PerformSourceRegistration();
        }
        bool bSomeListenersNeedUpdateDueToStimuliAging = false;
        if (NextStimuliAgingTick <= CurrentTime)
        {
            constexpr double Precision = 1./64.;
            const float AgingDt = FloatCastChecked<float>(CurrentTime - NextStimuliAgingTick, Precision);
            bSomeListenersNeedUpdateDueToStimuliAging = AgeStimuli(PerceptionAgingRate + AgingDt);
            NextStimuliAgingTick = CurrentTime + PerceptionAgingRate;
        }
        //...
    }
}

bool UAIPerceptionSystem::AgeStimuli(const float Amount)
{
    ensure(Amount >= 0.f);
    bool bTagged = false;

    for (AIPerception::FListenerMap::TIterator ListenerIt(ListenerContainer); ListenerIt; ++ListenerIt)
    {
        FPerceptionListener& Listener = ListenerIt->Value;
        if (Listener.Listener.IsValid())
        {
            // AgeStimuli will return true if this listener requires an update after stimuli aging
            if (Listener.Listener->AgeStimuli(Amount))
            {
                Listener.MarkForStimulusProcessing();
                bTagged = true;
            }
        }
    }
    return bTagged;
}

//AIPerceptionComponent.cpp
bool UAIPerceptionComponent::AgeStimuli(const float ConstPerceptionAgingRate)
{
    bool bExpiredStimuli = false;

    for (FActorPerceptionContainer::TIterator It(PerceptualData); It; ++It)
    {
        FActorPerceptionInfo& ActorPerceptionInfo = It->Value;

        for (FAIStimulus& Stimulus : ActorPerceptionInfo.LastSensedStimuli)
        {
            // Age the stimulus. If it is active but has just expired, mark it as such
            if (Stimulus.AgeStimulus(ConstPerceptionAgingRate) == false
                && (Stimulus.IsActive() || Stimulus.WantsToNotifyOnlyOnPerceptionChange())
                && Stimulus.IsExpired() == false)
            {
                AActor* TargetActor = ActorPerceptionInfo.Target.Get();
                if (TargetActor)
                {
                    Stimulus.MarkExpired();
                    RegisterStimulus(TargetActor, Stimulus);
                    bExpiredStimuli = true;
                }
            }
        }
    }

    return bExpiredStimuli;
}

//AIPerceptionTypes.h
/** @return false when this stimulus is no longer valid, when it is Expired */
FORCEINLINE bool AgeStimulus(float ConstPerceptionAgingRate) 
{ 
    Age += ConstPerceptionAgingRate; 
    return Age < ExpirationAge;
}