前言
本文为本人学习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的插件
nav修改Volume
随机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;
}