BlueRose
文章97
标签28
分类7
UE4 AI相关模块学习笔记

UE4 AI相关模块学习笔记

前言

本文为本人学习UE4 AI相关模块学习笔记,之前因为没有系统的整理过所以没发出。现在因为某一些原因,又因为懒得整理所以就发了。

其他文档推荐:
UE4场景询问系统浅析(EQS与行为树)
http://www.uejoy.com/?p=500

UE4 AIModule源码阅读之AI感知系统
https://zhuanlan.zhihu.com/p/356716030

AI感知系统(AI Perception System)

文档:https://docs.unrealengine.com/4.26/zh-CN/InteractiveExperiences/ArtificialIntelligence/AIPerception/

给角色类添加AIPerception组件并且添加所需要类型的感知,即可实现感知效果。

  • AI伤害:对Event Any Damage、Event Point Damage或Event Radial Damage作出反应
  • AI听觉:可用于检测由 报告噪点事件(Report Noise Event) 产生的声音
  • AI感知报告:在PredictionTime秒内向请求者提供PredictedActor的预计位置。
  • AI视觉:当一个Actor进入视觉半径后,AI感知系统将发出更新信号,并穿过被看到的Actor(举例而言,一个玩家进入该半径,并被具备视觉感知的AI所察觉)。
  • AI团队:这会通知感知组件的拥有者同团队中有人处在附近(发送该事件的游戏代码也会发送半径距离)。
  • AI触觉:配置能够检测到AI与物体发生主动碰撞、,或是与物体发生被动碰撞。。举例而言,在潜入类型的游戏中,您可能希望玩家在不接触敌方AI的情况下偷偷绕过他们。使用此感官可以确定玩家与AI发生接触,并能用不同逻辑做出响应。

除了可以使用绑定FActorPerceptionUpdatedDelegate委托来实现获取目标Actor外,还可以使用GetHostileActorsBySense()(蓝图版本GetCurrentlyPerceivedActors())

处理函数ProcessStimuli()是从UAIPerceptionSystem::Tick()调用的,所以只需要修改Tick频率即可。PrimaryActorTick.TickInterval即可。

UAISense_Prediction一般与UAISense_Sight连用,在UAISense_Sight丢失目标后若干秒(使用计时器),调用RequestControllerPredictionEvent,其中PredictionTime为预测时间长短。之后在处理函数中将事件Location传递给黑板变量。

UPawnSensingComponent

除了UAIPerceptionComponent还可以使用UPawnSensingComponent,有OnSeePawn与OnHearNoise委托。不过UPawnSensingComponent有个问题即它只能“看到”或者“听到”Pawn。

案例代码:

ARPGAICharacter::ARPGAICharacter(const FObjectInitializer& ObjectInitializer): Super(ObjectInitializer.SetDefaultSubobjectClass<ULCMCharacterMovementComponent>(ACharacter::CharacterMovementComponentName))
{
    AIControllerClass = ARPGAIController::StaticClass();
    bUseControllerRotationYaw = true;

    SensingComponent = CreateDefaultSubobject<UPawnSensingComponent>(TEXT("SensingComponent"));
    SensingComponent->SetPeripheralVisionAngle(90.f);
}

void ARPGAICharacter::PossessedBy(AController* NewController)
{
    Super::PossessedBy(NewController);

    if (SensingComponent)
    {
        SensingComponent->OnSeePawn.AddDynamic(this, &ARPGAICharacter::OnSeeTarget);
        SensingComponent->OnHearNoise.AddDynamic(this, &ARPGAICharacter::OnHearNoise);
    }
}

void ARPGAICharacter::OnSeeTarget(APawn* Pawn)
{
    ARPGAIController* AIController = Cast<ARPGAIController>(GetController());
    //Set the seen target on the blackboard
    if (AIController)
    {
        GLog->Log("Oh hello there");
        AIController->SetSeenTarget(Pawn);
    }
}

void ARPGAICharacter::OnHearNoise(APawn* Pawn,const FVector& Location, float Volume)
{
    ARPGAIController* AIController = Cast<ARPGAIController>(GetController());
    //Set the seen target on the blackboard
    if (AIController)
    {
        GLog->Log(FString::Printf(TEXT("Hear Noise:Vec3(%f,%f,%f) Volume:%f"),Location.X,Location.Y,Location.Z,Volume));
        AIController->SetHearTarget(Pawn,Location,Volume);
    }
}

控制类中一般是将这些数据添加到黑板中。对应HearNoise则需要在角色中添加UPawnNoiseEmitterComponent
,之后调用MakeNoise实现。

PawnNoiseEmitterComponent->MakeNoise(this, 1.f, GetActorLocation());

刺激源(Stimuli Source)

该组件封装了场景中可以感知的内容以及可以感知的内容。它通常添加在AI需要感知的Actor中。

调试方法

您可以使用AI调试工具调试AI感知。操作方法是在游戏运行时按下撇号(‘)键,然后按数字键4调出感知信息。

设计思路

对于ARPG游戏来说我们只需AI听觉、AI感知报告、AI视觉与AI团队。
AI听觉:让AI确定TargetLocation,让AI去最后听到声音的位置查看。
AI视觉:让AI发现TargetActor。
AI团队:让AI将自己的TargetActor发送给其他AI。

UE4 AI阻挡AI解决策略

CharacterMovement->bUseRVOAvoidance = true

RAMA的插件

https://nerivec.github.io/old-ue4-wiki/pages/ai-custom-pathing-how-to-use-nav-modifiers-query-filters.html

随机Task实现(蓝图实现)

https://answers.unrealengine.com/questions/658501/random-task-in-ai.html
使用Service节点随机给黑板随机生成一个数字,在下面连接Tasks上加上一个BlackboardBasedCondition限制节点来实现。

BT节点

黑板相关

可以在构造函数中给FBlackboardKeySelector设置过滤

//筛选Actor类型
BlackboardKey.AddObjectFilter(this, GET_MEMBER_NAME_CHECKED(UBTService_DefaultFocus, BlackboardKey), AActor::StaticClass());
//筛选Vector类型
BlackboardKey.AddVectorFilter(this, GET_MEMBER_NAME_CHECKED(UBTService_DefaultFocus, BlackboardKey));
MyVectorKey.AddVectorFilter(this, GET_MEMBER_NAME_CHECKED(UMyBTTask, MyVectorKey));

MyObjectKey.AddObjectFilter(this, GET_MEMBER_NAME_CHECKED(UMyBTTask, MyObjectKey), AActor::StaticClass());

MySpecialActorClassKey.AddClassFilter(this, GET_MEMBER_NAME_CHECKED(UMyBTTask, MySpecialActorClassKey), AMySpecialActor::StaticClass());

MyEnumKey.AddEnumFilter(this, GET_MEMBER_NAME_CHECKED(UMyBTTask, MyEnumKey), StaticEnum<EMyEnum>());

MyEnumKey.AddNativeEnumFilter(this, GET_MEMBER_NAME_CHECKED(UMyBTTask, MyEnumKey), "MyEnum");

MyIntKey.AddIntFilter(this, GET_MEMBER_NAME_CHECKED(UMyBTTask, MyIntKey));

MyFloatKey.AddFloatFilter(this, GET_MEMBER_NAME_CHECKED(UMyBTTask, MyFloatKey));

MyBoolKey.AddBoolFilter(this, GET_MEMBER_NAME_CHECKED(UMyBTTask, MyBoolKey));

MyRotatorKey.AddRotatorFilter(this, GET_MEMBER_NAME_CHECKED(UMyBTTask, MyRotatorKey));

MyStringKey.AddStringFilter(this, GET_MEMBER_NAME_CHECKED(UMyBTTask, MyStringKey));

MyNameKey.AddNameFilter(this, GET_MEMBER_NAME_CHECKED(UMyBTTask, MyNameKey));
//通过行为树组件获取到黑板组件
UBlackboardComponent* MyBlackboard = OwnerComp.GetBlackboardComponent();
//获取黑板键值
UObject* KeyValue = MyBlackboard->GetValue<UBlackboardKeyType_Object>(BlackboardKey.GetSelectedKeyID());
//设置黑板键值
UBlackboardComponent->SetValueAsObject(TargetActor.SelectedKeyName, nullptr);

如果设置了FBlackboardKeySelector变量,还需要在重写InitializeFromAsset(UBehaviorTree& Asset):

void UMyBTTask::InitializeFromAsset(UBehaviorTree& Asset)
{
    Super::InitializeFromAsset(Asset);

    UBlackboardData* BBAsset = GetBlackboardAsset();
    if (ensure(BBAsset))
    {
        MySpecialKey.ResolveSelectedKey(*BBAsset);
    }
}

实例化

Service节点是默认不被实例化的,这代表这个节点会共用内存。实例化Service的两种方法:

第一种: 使用内存块.

优点: 更加节省,不需要实例化节点,小巧方便.
缺点: 内存块的大小是固定的,如果涉及到动态分配内存的类型,例如数组,则无法使用此方法.

在源码BTService.h文件中,可以看到一段二十行的英文注释,关于实例化的描述翻译如下。

由于其中一些可以针对特定的AI进行实例化,因此以下虚拟函数未标记为const:
OnBecomeRelevant(来自UBTAuxiliaryNode)
OnCeaseRelevant(来自UBTAuxiliaryNode)
TickNode(来自UBTAuxiliaryNode)
OnSearchStart
如果未实例化您的节点(默认行为),请不要在这些函数内更改对象的任何属性!
模板节点使用相同的树资产在所有行为树组件之间共享,并且必须将其运行时属性存储在提供的NodeMemory块中(分配大小由GetInstanceMemorySize()确定)

GetInstanceMemorySize()函数的返回值决定了Service节点可以被分配到用来存储自身属性的内存大小。
而此函数是在UBTAuxiliaryNode的父类UBTNode中声明的,并且默认返回值为0!

UBTNode不光是UBTAuxiliaryNode的父类,也是UBTTaskNode(任务节点)的父类。
也就是说,行为树中 Service(服务节点),Decorator(装饰器节点),Task(任务节点)都是默认不被实例化的!
至于如何实例化,我们完全可以直接参考源码提供的BTService_RunEQS的写法。

首先在自定义Service头文件中声明一个结构体,将需要的变量都放结构体里。
在类中声明一个此结构体的变量,并重写GetInstanceMemorySize函数返回结构体所需内存大小。在InitializeMemory函数中可以对结构体变量进行初始化。

第二种: 直接实例化节点.

优点: 可以使用任何复杂类型的属性.
缺点: 会为每个节点创建一个新对象.

这种只要在构造函数中设置标志位 bCreateNodeInstance = true; ,你就可以在头文件中声明任意变量使用了.

更改描述及命名

重写GetStaticDescription函数。

NodeName可以从Details面板修改,也可以在类构造函数中直接 NodeName = “ ”
但这里的NodeName并不会修改在AddService时列表中显示的名称。
列表中的名称是由类名决定的,除了修改类名,也可以用类说明符DisplayName修改。

UClass(DisplayName="xxxx")

蓝图节点为啥会是双本版设计

源码逻辑:
当AIOwner为AIController时,优先调用带AI后缀的函数。(若只实现了不带AI后缀的,也会调用成功)
当AIOwner不为AIController时,只调用不带AI后缀的函数。

void UBTService_BlueprintBase::SetOwner(AActor* InActorOwner)
{
    ActorOwner = InActorOwner;
    AIOwner = Cast<AAIController>(InActorOwner);
}

void UBTService_BlueprintBase::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
    Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
        //判断是否Owner为AIController并且有任一版本函数被实现
    if (AIOwner != nullptr && (ReceiveTickImplementations & FBTNodeBPImplementationHelper::AISpecific))
    {
        ReceiveTickAI(AIOwner, AIOwner->GetPawn(), DeltaSeconds);
    }
        //是否实现了不带AI后缀的函数
    else if (ReceiveTickImplementations & FBTNodeBPImplementationHelper::Generic)
    {
        ReceiveTick(ActorOwner, DeltaSeconds);
    }
}

只有附加在Composites(合成节点)上的Service,才会调用OnSearchStart。

Composites(合成节点)也就是指Sequence,Selector,SimpleParallel节点。

还有种特殊情况:当Service附加在与Root相连的第一个合成节点上时,SearchStart会被触发两次。

Compisiton

复合节点:此类节点定义分支的根以及执行该分支的基本规则。

Decorator

可以理解为条件节点,连接Composite或者Task节点,决定树中的分支、甚至单个节点能否被执行。

c++实现可以参考:

  • UBTDecorator_BlueprintBase
  • UBTDecorator_BlackboardBase
  • UBTDecorator_CheckGameplayTagsOnActor
  • UBTDecorator_Loop

蓝图节点中可重写的函数有:

  • ReceiveTick:通用版本的tick函数。如果条件符合会在TickNode()中调用。
  • ReceiveTickAI:AI版本的tick函数。如果条件符合会在TickNode()中调用。
  • ReceiveExecutionStart:通用版本的Start函数。如果条件符合会在OnNodeActivation()中调用。
  • ReceiveExecutionStartAI:AI版本的Start函数。如果条件符合会在OnNodeActivation()中调用。
  • ReceiveExecutionFinish:通用版本的Finish函数。如果条件符合会在OnNodeDeactivation()中调用。
  • ReceiveExecutionFinishAI:AI版本的Finish函数。如果条件符合会在OnNodeDeactivation()中调用。
  • ReceiveObserverActivated:通用版本的Activated函数。如果条件符合会在OnBecomeRelevant()中调用。
  • ReceiveObserverActivatedAI:AI版本的Activated函数。如果条件符合会在OnBecomeRelevant()中调用。
  • ReceiveObserverDeactivated:通用版本的Deactivated函数。如果条件符合会在OnCeaseRelevant()中调用。
  • ReceiveObserverDeactivatedAI:AI版本的Deactivated函数。如果条件符合会在OnCeaseRelevant()中调用。
  • PerformConditionCheck:通用版本的ConditionCheck函数。如果条件符合会在CalculateRawConditionValueImpl()中调用。
  • PerformConditionCheckAI:AI版本的ConditionCheck函数。如果条件符合会在CalculateRawConditionValueImpl()中调用。

Service

可以理解为并行逻辑节点,连接连接Composite或者Task节点,运行到当前分支时,Service节点将按照之前设定的频率执行。通常是使用这些Service节点检查、更新黑板数据。

c++可以参考:

  • UBTService_BlueprintBase
  • UBTService_BlackboardBase
  • UBTService_RunEQS

蓝图节点中可重写的函数有:

  • ReceiveTick
  • ReceiveTickAI
  • ReceiveSearchStart
  • ReceiveSearchStartAI
  • ReceiveActivation
  • ReceiveActivationAI
  • ReceiveDeactivation
  • ReceiveDeactivationAI

相关事件

  • OnSearchStart:所挂靠的合成节点被激活时调用的函数。
  • TickNode:Tick执行的函数(间隔自定义)。可在构造函数设置 bNotifyTick 来决定是否调用。
  • OnBecomeRelevant:节点被激活时调用(等同于蓝图Service里的Receive Activation) 需要在构造函数中设置 bNotifyBecomeRelevant = true来开启调用。
  • OnCeaseRelevant:节点被激活时调用(等同于蓝图Service里的Receive Deactivation) 需要在构造函数中设置 bNotifyCeaseRelevant = true来开启调用。
  • TickNode,OnBecomeRelevant和OnCeaseRelevant并不是UBTService类的虚函数,而是UBTService的父类UBTAuxiliaryNode的。装饰器UBTDecorator也继承此类。
  • GetInstanceMemorySize:当节点被实例化时分配的内存大小,关于实例化后面会详谈。

  • Tick函数执行间隔 = Random(Interval-RandomDeviation,Interval+RandomDeviation)
    在这也就是 = Random(0.5-0.1 , 0.5+0.1), 每次调用都会计算一次。

  • CallTickonSearchStart:若为True,则会在SearchStart函数触发时,调用一次Tick函数。 若为False,则即使节点已被激活,也要等待一次间隔才能执行第一次Tick函数。
  • RestartTimeronEachActivation:若为True,则每次节点被激活都会从0开始计算间隔。 若为False,则会记录下上一次已经等待的间隔,在下一次激活时沿用。

Task

可以理解为具体执行的任务(逻辑)。此类节点是行为树的叶。它们是可执行的操作,没有输出连接。

UBTTask_BlueprintBase与UBTTask的区别在于
以UBTTask_PlaySound这种简单UBTTask为例,主要实现了ExecuteTask(),其他是显示图标与描述的GetStaticDescription()与GetNodeIconName()

蓝图节点中可重写的函数有:

  • ReceiveTick:通用版本的tick函数。如果条件符合会在TickTask()中调用。
  • ReceiveTickAI:AI版本的tick函数。如果条件符合会在TickTask()中调用。
  • ReceiveExecute:通用版本的Execute函数。如果条件符合会在ExecuteTask()中调用。
  • ReceiveAbort:通用版本的Abort函数。如果条件符合会在AbortTask()中调用。
  • ReceiveExecuteAI:AI版本的Execute函数。如果条件符合会在ExecuteTask()中调用。
  • ReceiveAbortAI:AI版本的Abort函数。如果条件符合会在AbortTask()中调用。

RunBehavior 与 RunBehaviorDynamic

可以加载其他行为树并且运行。

  • RunBehavior的一个限制是在运行时不能改变该子树资源。存在该限制的原因是给子树的根等级装饰器节点提供支持,这些装饰器节点将注入到父树中。此外,在运行时不能修改运行树的结构。
  • RunBehaviorDynamic使用行为树组件上的SetDynamicSubtree 函数即可以在运行时分配子树资源。本函数不会为子树的根等级装饰器节点提供支持。

EQS

大致流程:
通过生成器在场景生成检测点获取当前位置的各种数据,之后再通过条件判断获取想要的查询结果。EQS的一些使用范例包括:找到最近的回复剂或弹药、判断出威胁最大的敌人,或者找到能看到玩家的视线(下面就会显示这样的一个示例)。

在Actor类中使用EQS

结合原生代码使用EQS
虽然EQS查询通常是在行为树中运行,但也可以直接从原生代码使用它。以下示例展示了一个虚构的查询,要在指定区域内为角色或物品寻找安全生成地点:

// 以下名称必须与查询中使用的变量名一致
static const FName SafeZoneIndexName = FName(TEXT("SafeZoneIndex"));
static const FName SafeZoneRadiusName = FName(TEXT("SafeZoneRadius"));

// 运行查询,根据区域索引和安全半径寻找安全的生成点
bool AMyActor::RunPlacementQuery(const UEnvQuery* PlacementQuery)
{
    if (PlacementQuery)
    {
        // 设置查询请求
        FEnvQueryRequest QueryRequest(PlacementQuery, this);

        // 设置查询参数
        QueryRequest.SetIntParam(SafeZoneIndexName, SafeZoneIndexValue);
        QueryRequest.SetFloatParam(SafeZoneRadiusName, SafeZoneRadius);

        // 执行查询
        QueryRequest.Execute(EEnvQueryRunMode::RandomBest25Pct, this, &AFortAthenaMutator_SpawningPolicyBase::OnEQSSpawnLocationFinished);

        // 返回true说明查询已开始
        return true;
    }

    // 返回false说明查询未能开始
    return false;
}