Unreal BehaviorTree 源码分析

虚幻引擎的行为树不同于传统行为树,它新增了Decorator和Service两个功能组件,而且不再是每次都从根节点开始Tick。要搞懂它们内在执行逻辑需要从源码入手,从开始执行到搜索节点,再到各种节点的声明周期回调。

开始行为树

开始时主要做两件事情:

  • 构建Blackboard组件
  • 构建BehaviorTree组件

1
2
3
4
5
6
7
8
bool AAIController::RunBehaviorTree(UBehaviorTree* BTAsset)
{
UseBlackboard(BTAsset->BlackboardAsset, BlackboardComp);

BTComp = NewObject<UBehaviorTreeComponent>(this, TEXT("BTComponent"));
BTComp->RegisterComponent();
BrainComponent = BTComp;
BTComp->StartTree(*BTAsset, EBTExecutionMode::Looped);
StartTree开始,调几层后,走到下面这里,BehaviorTreeManager后面再讲,然后核心是PushInstance
1
2
3
4
5
6
7
8
void UBehaviorTreeComponent::ProcessPendingInitialize()
{
UBehaviorTreeManager* BTManager = UBehaviorTreeManager::GetCurrent(GetWorld());
if (BTManager)
{
BTManager->AddActiveComponent(*this);
}
const bool bPushed = PushInstance(*TreeStartInfo.Asset);
为啥名字叫PushInstance那么怪?因为这个函数不只是用来在开始行为树的时候跑的,而是在一棵树里执行另一棵子树的时候也会跑到。每棵树执行的时候都是一个Instance,而同一时间只能执行一棵树,一棵树执行完又会返回上一棵树。执行一棵新树就像往栈新增元素一样,所以就称之为PushInstance

检查

加载资源之前,主要有两个检查,一个是规定Blackboard必须是同一种资源。另一个是要检查父节点。

如上面所说,执行子树的时候也会跑进来,所以要检查一下执行子树的父节点是否允许执行子树。调用父节点的CanPushSubtree

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool UBehaviorTreeComponent::PushInstance(UBehaviorTree& TreeAsset)
{
if (TreeAsset.BlackboardAsset && BlackboardComp && !BlackboardComp->IsCompatibleWith(TreeAsset.BlackboardAsset))
{
return false;
}

const UBTNode* ActiveNode = GetActiveNode();
const UBTCompositeNode* ActiveParent = ActiveNode ? ActiveNode->GetParentNode() : NULL;
const bool bIsAllowed = ActiveParent->CanPushSubtree(*this, ParentMemory, ChildIdx);
if (!bIsAllowed)
{
return false;
}
CanPushSubtree默认返回true,只有并行节点SimpleParallel进行了重写,规定其主任务不能够执行子树。

加载资源

UBehaviorTreeManager就是专门用来加载资源的。之所以专门搞一个全局Manager,就是为了能缓存资源的template,以便于下次加载直接读template。所谓的template就是名为FBehaviorTreeTemplateInfo的结构。

1
2
3
4
5
6
bool UBehaviorTreeComponent::PushInstance(UBehaviorTree& TreeAsset)
{
UBTCompositeNode* RootNode = NULL;
uint16 InstanceMemorySize = 0;
UBehaviorTreeManager* BTManager = UBehaviorTreeManager::GetCurrent(GetWorld());
const bool bLoaded = BTManager->LoadTree(TreeAsset, RootNode, InstanceMemorySize);
加载完资源后,就会返回行为树的根节点到RootNode

从下面的代码可以看出,RootNode其实是从template里面拿出来的,说明它作为一个单例对象来使用。其实也意味着所有Node其实都是单例对象的。但作为单例,它又怎么存数据呢,这个后面会讲到。

1
2
3
4
5
6
7
8
bool UBehaviorTreeManager::LoadTree(UBehaviorTree& Asset, UBTCompositeNode*& Root, uint16& InstanceMemorySize)
{
FBehaviorTreeTemplateInfo TemplateInfo;
TemplateInfo.Asset = &Asset;
TemplateInfo.Template = Cast<UBTCompositeNode>(StaticDuplicateObject(Asset.RootNode, this));

LoadedTemplates.Add(TemplateInfo);
Root = TemplateInfo.Template;

静态初始化

LoadTree里面还有些逻辑是用来做初始化的,主要干几件事情:

  • InitializeNodeHelper 构造Service、Decorator等对象
  • InitializeExecutionOrder 预先算好每个节点的下一个节点是谁
  • InitializeNode 存几个信息变量

代码有点多,其实不需要全部搞懂,就简单的认为是对所有节点进行预计算和初始化的。之所以叫静态初始化是它对静态资源做初始化,每一种行为树资源只要第一次加载时做一次即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bool UBehaviorTreeManager::LoadTree(UBehaviorTree& Asset, UBTCompositeNode*& Root, uint16& InstanceMemorySize)
{
InitializeNodeHelper(NULL, TemplateInfo.Template, 0, ExecutionIndex, InitList, Asset, this);

for (int32 Index = 0; Index < InitList.Num() - 1; Index++)
{
InitList[Index].Node->InitializeExecutionOrder(InitList[Index + 1].Node);
}

InitList.Sort(FNodeInitializationData::FMemorySort());
for (int32 Index = 0; Index < InitList.Num(); Index++)
{
InitList[Index].Node->InitializeNode(InitList[Index].ParentNode, InitList[Index].ExecutionIndex, InitList[Index].SpecialDataSize + MemoryOffset, InitList[Index].TreeDepth);
MemoryOffset += InitList[Index].DataSize; // 每个节点的自定义内存偏移,后面讲
}

节点的自定义内存结构

前面说过,节点默认是单例的。意味着你往单例里面存储的东西会一直留着,比如说有三个角色的行为树都用了同一种节点,那么这种节点里面的类成员属性是所有角色共享的。所以虚幻引擎提供了一个自定义结构体的方式,让你能够给每个角色都存点自己的东西。

注意,如果你用的是蓝图节点,那么不需要担心单例问题,因为暴露到蓝图里的类通过bCreateNodeInstance属性强行不使用单例。

1
2
3
UBTService_BlueprintBase::UBTService_BlueprintBase(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
bCreateNodeInstance = true;
我们说回C++,假设我们要使用自定义结构,使用方法会类似于下面这样,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
FSTRUCT()
struct FMyTaskMemory
{
GENERATED_BODY()
UPROPERTY()
AMyCharacter* ControlledCharactor = nullptr;
}

UCLASS()
class UBTMyTask
{
GENERATAED_BODY()

virtual uint16 GetInstanceMemorySize() const override
{
return sizeof(FMyTaskMemory);
}

virtual void InitializeMemory(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, EBTMemoryInit::Type InitType) const override
{
FMyTaskMemory* MemberMemory = CastInstanceNodeMemory<FMyTaskMemory>(NodeMemory);
MemberMemory->ControlledCharactor = StaticCast<AMyCharacter>(OwnerComp->GetOwner()->GetControlledPawn());
}

virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override
{
FMyTaskMemory* MemberMemory = CastInstanceNodeMemory<FMyTaskMemory>(NodeMemory);
}
}
NodeMemory是一个指针,它将指向一块GetInstanceMemorySize()返回的大小的内存块。至于内存块是什么时候构建的,存到哪里,下一节说明。

实例初始化

不同Controller加载同一种行为树,除了静态部分外,肯定有动态的部分。PushInstance会构造一个FBehaviorTreeInstance结构。记录一些基本信息。然后调用Initialize

1
2
3
4
5
6
7
FBehaviorTreeInstance NewInstance;
NewInstance.InstanceIdIndex = UpdateInstanceId(&TreeAsset, ActiveNode, InstanceStack.Num() - 1);
NewInstance.RootNode = RootNode;
NewInstance.ActiveNode = NULL;
NewInstance.ActiveNodeType = EBTActiveNode::Composite;

NewInstance.Initialize(*this, *RootNode, NodeInstanceIndex, bFirstTime ? EBTMemoryInit::Initialize : EBTMemoryInit::RestoreSubtree);
Initialize里面会调每个节点的初始化函数。具体有哪些功能后面在讲。

然后就是构建树实例的中每个节点的自定义内存块,其中InstanceMemorySize就是LoadTree返回的那个,表示这棵树所有内存块的大小总和。

1
2
3
4
5
6
7
8
// initialize memory and node instances
FBehaviorTreeInstanceId& InstanceInfo = KnownInstances[NewInstance.InstanceIdIndex];
const bool bFirstTime = (InstanceInfo.InstanceMemory.Num() != InstanceMemorySize);
if (bFirstTime)
{
InstanceInfo.InstanceMemory.AddZeroed(InstanceMemorySize);
InstanceInfo.RootNode = RootNode;
}
上面提到又是单例,又是实例的,可能有点晕。这里补充说明一下,执行一棵树的时候,树是一个新的实例,而节点则是不一定,节点默认是单例的,所有树共享同一个对象。所以InstanceInfo指的是树的实例信息。

这个内存块还有个有意思的点是,它是一棵树的所有节点内存块是连续的,因为它是一次性申请的。而当想找到属于它自己节点的内存块时,则是通过UBTNode::MemoryOffset属性进行偏移的。

1
2
3
4
5
template<typename T>
T* UBTNode::GetNodeMemory(FBehaviorTreeInstance& BTInstance) const
{
return (T*)(BTInstance.GetInstanceMemory().GetData() + MemoryOffset);
}
给MemoryOffset赋值的地方上面的静态初始化章节就已经透露过了。

因为你可能会递归执行多棵行为树,比如树A执行树B,树B再执行树C,树C执行完又回到树B。就像一个栈,所以就用InstanceStack存起来。

1
2
InstanceStack.Push(NewInstance);
ActiveInstanceIdx = InstanceStack.Num() - 1;

执行Service

从这一步开始,就是开始执行树的逻辑了。首先是执行当前节点的所有Service。NotifyParentActivation会启动Service的Tick。

1
2
3
4
5
6
7
8
9
10
for (int32 ServiceIndex = 0; ServiceIndex < RootNode->Services.Num(); ServiceIndex++)
{
UBTService* ServiceNode = RootNode->Services[ServiceIndex];
uint8* NodeMemory = (uint8*)ServiceNode->GetNodeMemory<uint8>(InstanceStack[ActiveInstanceIdx]);

ServiceNode->NotifyParentActivation(SearchData);

InstanceStack[ActiveInstanceIdx].AddToActiveAuxNodes(ServiceNode);
ServiceNode->WrappedOnBecomeRelevant(*this, NodeMemory);
}
广播代理,通知其他系统自己开始执行一棵新的行为树。
1
FBehaviorTreeDelegates::OnTreeStarted.Broadcast(*this, TreeAsset);

发起执行任务节点的请求

RequestExecution找到该子树的当前需要执行的任务节点,然后发起请求。注意我的用词,是发起请求,而不是正式执行。

1
RequestExecution(RootNode, ActiveInstanceIdx, RootNode, 0, EBTNodeResult::InProgress);

这个函数有三个重载,其实最终还是会调到第一个去,

1
2
3
4
5
void RequestExecution(const UBTCompositeNode* RequestedOn, int32 InstanceIdx, 
const UBTNode* RequestedBy, int32 RequestedByChildIndex,
EBTNodeResult::Type ContinueWithResult, bool bStoreForDebugger = true);
void RequestExecution(const UBTDecorator* RequestedBy) { check(RequestedBy); RequestBranchEvaluation(*RequestedBy); }
void RequestExecution(EBTNodeResult::Type ContinueWithResult) { RequestBranchEvaluation(ContinueWithResult); }

这个函数参数很多,且理解参数的含义是重要的。

1
2
3
4
5
6
7
void UBehaviorTreeComponent::RequestExecution(
const UBTCompositeNode* RequestedOn,
int32 InstanceIdx,
const UBTNode* RequestedBy,
int32 RequestedByChildIndex,
EBTNodeResult::Type ContinueWithResult,
bool bStoreForDebugger)

  • RequestedOn 要求传入一个组合节点。因为任务节点肯定是在组合节点下的,告诉代码你要找的是哪个组合节点下的任务节点。
  • InstanceIdx 树在InstanceStack中的下标。
  • RequestedBy 可以传入一个UBTNode,表示发起者。可能是组合节点或者修饰器(Decorator)。
  • RequestedByChildIndex 传入发起者是第几个子节点。
  • ContinueWithResult 表示任务节点执行完后,应该怎么办。看源码的话会发现它的作用很窄,可以先不深究。
  • bStoreForDebugger 用来debug的,也可以先不管。

这个函数逻辑很长。总结起来就是给名为ExecutionRequest的成员属性填充信息。

1
2
3
4
class AIMODULE_API UBehaviorTreeComponent : public UBrainComponent
{
/** execution request, search will be performed when current task finish execution/aborting */
FBTNodeExecutionInfo ExecutionRequest;
FBTNodeExecutionInfo结构如下,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct FBTNodeExecutionInfo
{
/** index of first task allowed to be executed */
FBTNodeIndex SearchStart;
/** index of last task allowed to be executed */
FBTNodeIndex SearchEnd;
/** node to be executed */
const UBTCompositeNode* ExecuteNode;
/** subtree index */
uint16 ExecuteInstanceIdx;
/** result used for resuming execution */
TEnumAsByte<EBTNodeResult::Type> ContinueWithResult;
/** if set, tree will try to execute next child of composite instead of forcing branch containing SearchStart */
uint8 bTryNextChild : 1;
/** if set, request was not instigated by finishing task/initialization but is a restart (e.g. decorator) */
uint8 bIsRestart : 1;
};
最后的bIsRestart看起来有点难理解,但其实没有用到,可以先不管。ContinueWithResult也有点难理解,它是分了多个使用场景的,后面再细讲。

SearchStartSearchEnd在不同的调用情况下传值是不同的。它规定了一个范围。一般情况是不需要范围的,因为组合节点本身的规则已经足够你找到下一个节点了。

而组合节点本身无法知道的规则,就是Decorator的Observer Aborts了,它可以在设置终止自身或低优先级节点,所以需要通过范围来限制。

Tick

执行任务节点是在Tick里面做的,但Tick里面也做了很多事情,所以先讲讲Tick。

尽管是Tick,但也不是每帧都执行的,它有自己的执行时间间隔,没到时间的时候就之间return掉。

1
2
3
4
5
6
7
8
9
10
11
void UBehaviorTreeComponent::TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction *ThisTickFunction)
{
NextTickDeltaTime -= DeltaTime;
if (NextTickDeltaTime > 0.0f)
{
AccumulatedTickDeltaTime += DeltaTime;
ScheduleNextTick(NextTickDeltaTime);
return;
}
DeltaTime += AccumulatedTickDeltaTime;
AccumulatedTickDeltaTime = 0.0f;

执行辅助节点的Tick

Unreal把装饰节点(Decorator)和服务节点(Service)定义为辅助节点(Auxiliary)。TickComponent每次到时间后都会把所有子树的所有辅助节点的Tick逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
void UBehaviorTreeComponent::TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction *ThisTickFunction)
{
FBTSuspendBranchActionsScoped ScopedSuspend(*this, EBTBranchAction::Changing_Topology_Actions);
for (int32 InstanceIndex = 0; InstanceIndex < InstanceStack.Num(); InstanceIndex++)
{
FBehaviorTreeInstance& InstanceInfo = InstanceStack[InstanceIndex];
InstanceInfo.ExecuteOnEachAuxNode([&InstanceInfo, this, &bDoneSomething, DeltaTime, &NextNeededDeltaTime](const UBTAuxiliaryNode& AuxNode)
{
uint8* NodeMemory = AuxNode.GetNodeMemory<uint8>(InstanceInfo);
SCOPE_CYCLE_UOBJECT(AuxNode, &AuxNode);
bDoneSomething |= AuxNode.WrappedTickNode(*this, NodeMemory, DeltaTime, NextNeededDeltaTime);
});
}
AuxNode.WrappedTickNode执行逻辑就会执行到蓝图里面的UBTService_BlueprintBase::ReceiveTick事件。

这个执行阶段需要领会,这意味着辅助节点的Tick是在处理请求之前完成的。把握这一点将有助于解决实际开发中遇到的时序问题。

处理请求

然后就开始执行ProcessExecutionRequest来处理请求。

1
2
3
void UBehaviorTreeComponent::TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction *ThisTickFunction)
{
ProcessExecutionRequest();

备份

处理请求过程中,有一个名为SearchData的结构体成员属性,会存储搜索过程中的一些信息。搜索过程中会改变其中的字段,如果搜索失败,则需要回滚这些字段,所以使用了几个Rollback开头的字段来备份。

1
2
3
4
5
6
7
void UBehaviorTreeComponent::ProcessExecutionRequest()
{
SearchData.RollbackInstanceIdx = ActiveInstanceIdx;
SearchData.RollbackDeactivatedBranchStart = SearchData.DeactivatedBranchStart;
SearchData.RollbackDeactivatedBranchEnd = SearchData.DeactivatedBranchEnd;

CopyInstanceMemoryToPersistent();
CopyInstanceMemoryToPersistent则是对节点的自定义内存数据进行备份。

反激活

正式搜索前,还会有一个反激活逻辑,将调用DeactivateUpTo函数。当我们要找下一个任务节点时,说明当前节点即将执行完毕。而所谓反激活就是指当前节点执行完毕之后需要做一些事情,所以有了这个反激活的阶段。

1
2
3
4
5
void UBehaviorTreeComponent::ProcessExecutionRequest()
{
if (InstanceStack[ActiveInstanceIdx].ActiveNode != ExecutionRequest.ExecuteNode)
{
const bool bDeactivated = DeactivateUpTo(ExecutionRequest.ExecuteNode, ExecutionRequest.ExecuteInstanceIdx, NodeResult, LastDeactivatedChildIndex);
DeactivateUpTo该函数内部会向上进行父节点链路遍历,每个父节点都调用它的UBTCompositeNode::OnChildDeactivation函数。函数本身的功能是这样,但实际上根据传入参数,调用起来的结果就只是反激活了当前活跃的任务节点。

OnChildDeactivation其内部又会找到其指定子节点,然后触发子节点的Service、本身、以及Decorator的反激活逻辑。

如果该子节点是一个Task节点,则会先将其附带的Service存起来,然后在应用新的任务节点的时候才执行。至于为什么要特别进行这种设计,个人感觉是Epic自己的需求导致的,旁人没有遇到这样的需求,很难解释为什么要这么设计。

当执行反激活时,会调用到蓝图的相关事件。 对于Service是ReceiveDeactivation事件,对于Decorator是ReceiveExecutionFinish事件。

目前我还没用过这个反激活功能,也没想到有什么使用场景,所以暂不能举出实际的使用例子。

决定搜索范围

虽然我们的FBTNodeExecutionInfo已经计算出了SearchStartSearchEnd。但其bTryNextChild将决定我们能否按照这个范围来搜索。

看看bTryNextChild这个属性是怎么赋值的,

1
2
const bool bSwitchToHigherPriority = (ContinueWithResult == EBTNodeResult::Aborted);
ExecutionRequest.bTryNextChild = !bSwitchToHigherPriority;
然后是使用的地方,
1
2
3
4
5
6
7
8
9
10
11
12
void UBehaviorTreeComponent::ProcessExecutionRequest()
{
if (!ExecutionRequest.bTryNextChild)
{
SearchData.SearchStart = ExecutionRequest.SearchStart;
SearchData.SearchEnd = ExecutionRequest.SearchEnd;
}
else
{
SearchData.SearchStart = FBTNodeIndex();
SearchData.SearchEnd = FBTNodeIndex();
}
顾名思义,ContinueWithResult被设置为Aborted,意味着当前节点是被中断的,我们的搜索范围可能是受到限制的。否则,就是搜索没有被限制,可以单纯按照组合节点本身的规则去搜索。

搜索

TestNode表示当前搜索到的节点,NextTask表示目标任务节点,不为NULL说明搜索成功了。其中BTSpecialChild::ReturnToParent表示找不到子节点,需要回到父节点找的意思。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
void UBehaviorTreeComponent::ProcessExecutionRequest()
{
while (TestNode && NextTask == NULL)
{
const int32 ChildBranchIdx = TestNode->FindChildToExecute(SearchData, NodeResult);
const UBTNode* StoreNode = TestNode;

if (SearchData.bPostponeSearch)
{
TestNode = NULL;
bIsSearchValid = false;
}
else if (ChildBranchIdx == BTSpecialChild::ReturnToParent)
{
const UBTCompositeNode* ChildNode = TestNode;
TestNode = TestNode->GetParentNode();

// does it want to move up the tree?
if (TestNode == NULL)
{
// special case for leaving instance: deactivate root manually
ChildNode->OnNodeDeactivation(SearchData, NodeResult);

// don't remove top instance from stack, so it could be looped
if (ActiveInstanceIdx > 0)
{
SearchData.PendingNotifies.Add(FBehaviorTreeSearchUpdateNotify(ActiveInstanceIdx, NodeResult));

// and leave subtree
ActiveInstanceIdx--;
}
}

if (TestNode)
{
TestNode->OnChildDeactivation(SearchData, *ChildNode, NodeResult, ActiveInstanceIdx == ExecutionRequest.ExecuteInstanceIdx);
}
}
else if (TestNode->Children.IsValidIndex(ChildBranchIdx))
{
// was new task found?
NextTask = TestNode->Children[ChildBranchIdx].ChildTask;

// or it wants to move down the tree?
TestNode = TestNode->Children[ChildBranchIdx].ChildComposite;
}
}
FindChildToExecute里调用了GetNextChild来获取下一个子节点,其中调用了组合节点的GetNextChildHandler,这是一个虚函数,Sequence和Selector都在其中实现了自己的逻辑,如果你要自定义组合节点,也可以去实现它。
1
2
3
int32 UBTCompositeNode::FindChildToExecute(FBehaviorTreeSearchData& SearchData, EBTNodeResult::Type& LastResult) const
{
int32 ChildIdx = GetNextChild(SearchData, NodeMemory->CurrentChild, LastResult);
1
2
3
int32 UBTCompositeNode::GetNextChild(FBehaviorTreeSearchData& SearchData, int32 LastChildIdx, EBTNodeResult::Type LastResult) const
{
NextChildIndex = GetNextChildHandler(SearchData, LastChildIdx, LastResult);

搜索过程中的装饰器检查和回调触发

搜索过程中会对每个节点进行装饰器检查,即DoDecoratorsAllowExecution。会调用UBTDecorator::WrappedCanExecute,然后是CalculateRawConditionValue,它是一个虚函数,自定义的装饰器可以重写它。

1
2
3
4
5
6
7
8
9
10
11
12
int32 UBTCompositeNode::FindChildToExecute(FBehaviorTreeSearchData& SearchData, EBTNodeResult::Type& LastResult) const
{
while (Children.IsValidIndex(ChildIdx) && !SearchData.bPostponeSearch)
{
if (DoDecoratorsAllowExecution(SearchData.OwnerComp, SearchData.OwnerComp.ActiveInstanceIdx, ChildIdx))
{
OnChildActivation(SearchData, ChildIdx);
RetIdx = ChildIdx;
break;
}
ChildIdx = GetNextChild(SearchData, ChildIdx, LastResult);
}
蓝图UBTDecorator_BlueprintBase对其进行了重写并调用了事件PerformConditionCheckAIPerformConditionCheck。带AI后缀的是当Controller是一个AIController时执行的。

检查成功后会调用OnChildActivation,它会调用子节点的所有装饰器的OnNodeActivation。如果本身是组合节点(Composite),还会调其 OnNodeActivation

组合节点的OnNodeActivation内部会调用所有Service的NotifyParentActivation,然后其内部又会产生调用UBTService::OnSearchStart。说明它是是一个搜索过程中触发的回调,蓝图类会进一步调用事件ReceiveSearchStart

校验NextTask

主要有两个校验:

  1. 检查NextTask的优先级是否大于SearchEnd,否则就不能继续执行。SearchEnd无限制时它是一个超大值,任何节点的优先级都大于它。
  2. 检查NextTask是否开启了IgnoreRestartSelf,这个配置在蓝图里面就能找到,表示如果当前节点是正在激活的,就不再执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (NextTask)
{
const FBTNodeIndex NextTaskIdx(ActiveInstanceIdx, NextTask->GetExecutionIndex());
bIsSearchValid = NextTaskIdx.TakesPriorityOver(ExecutionRequest.SearchEnd);

if (bIsSearchValid && NextTask->ShouldIgnoreRestartSelf())
{
const bool bIsTaskRunning = InstanceStack[ActiveInstanceIdx].HasActiveNode(NextTaskIdx.ExecutionIndex);
if (bIsTaskRunning)
{
bIsSearchValid = false;
}
}
}

处理搜索结果

失败时,会调用RollbackSearchChanges回滚SearchData的相关修改。 成功时,会终止当前活跃Task,然后把数据存到PendingExecution,然后调用ProcessPendingExecution对其进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (!bIsSearchValid || SearchData.bPostponeSearch)
{
RollbackSearchChanges();
}

if (bIsSearchValid)
{
if (InstanceStack.Last().ActiveNodeType == EBTActiveNode::ActiveTask)
{
AbortCurrentTask();
}
if (!PendingExecution.IsLocked())
{
PendingExecution.NextTask = NextTask;
PendingExecution.bOutOfNodes = (NextTask == NULL);
}
}
ProcessPendingExecution();

调用缓存的回调

但一个新任务节点被激活,自然旧任务节点应该反激活,前面提到,很多反激活函数其实都存到这个阶段去调用了。

1
2
3
void UBehaviorTreeComponent::ProcessPendingExecution()
{
ApplySearchData(SavedInfo.NextTask);
首先是如果新任务节点离开了旧任务节点所在的树,就要触发树的对应代理,
1
2
3
4
5
6
7
8
9
10
void UBehaviorTreeComponent::ApplySearchData(UBTNode* NewActiveNode)
{
for (int32 Idx = 0; Idx < SearchData.PendingNotifies.Num(); Idx++)
{
const FBehaviorTreeSearchUpdateNotify& NotifyInfo = SearchData.PendingNotifies[Idx];
if (InstanceStack.IsValidIndex(NotifyInfo.InstanceIndex))
{
InstanceStack[NotifyInfo.InstanceIndex].DeactivationNotify.ExecuteIfBound(*this, NotifyInfo.NodeResult);
}
}
然后就是执行PendingUpdates里面存下来的回调。第三个参数为bPostUpdate,表明这种回调还分了次序,哪些先调用,哪些后调用。可能给是后调用的会依赖先调用的计算结果。
1
2
3
4
void UBehaviorTreeComponent::ApplySearchData(UBTNode* NewActiveNode)
{
ApplySearchUpdates(SearchData.PendingUpdates, NewNodeExecutionIndex);
ApplySearchUpdates(SearchData.PendingUpdates, NewNodeExecutionIndex, true);
每个Update还分了RemoveAdd两种情况。标记为Remove的时候会调用其辅助节点的WrappedOnCeaseRelevantAdd的时候会调用WrappedOnBecomeRelevant
1
2
3
4
5
6
7
8
9
10
11
12
13
void UBehaviorTreeComponent::ApplySearchUpdates(const TArray<FBehaviorTreeSearchUpdate>& UpdateList, int32 NewNodeExecutionIndex, bool bPostUpdate)
{
uint8* NodeMemory = (uint8*)UpdateNode->GetNodeMemory<uint8>(UpdateInstance);
if (UpdateInfo.Mode == EBTNodeUpdateMode::Remove)
{
UpdateInstance.RemoveFromActiveAuxNodes(UpdateInfo.AuxNode);
UpdateInfo.AuxNode->WrappedOnCeaseRelevant(*this, NodeMemory);
}
else
{
UpdateInstance.AddToActiveAuxNodes(UpdateInfo.AuxNode);
UpdateInfo.AuxNode->WrappedOnBecomeRelevant(*this, NodeMemory);
}
这个两个词看起来很高大上,其实就是停止关联和开始关联的意思,是Unreal提供的两个生命期钩子,怎么使用取决于你的需求。比如对于监听BlackBoard的装饰器,开始关联的时候,我可以注册对BlackBoard的监听回调;而停止关联的时候就取消注册。

执行任务

1
2
3
void UBehaviorTreeComponent::ProcessPendingExecution()
{
ExecuteTask(SavedInfo.NextTask);
执行任务前,会执行该节点上所有Service的WrappedOnBecomeRelevantWrappedTickNode

然后真正执行任务的地方是下面这里,

1
2
3
4
void UBehaviorTreeComponent::ExecuteTask(UBTTaskNode* TaskNode)
{
TaskResult = TaskNode->WrappedExecuteTask(*this, NodeMemory);
OnTaskFinished(TaskNode, TaskResult);
会调用虚函数,
1
EBTNodeResult::Type UBTTaskNode::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
派生类自行实现,自然会调到蓝图类里的UBTTask_BlueprintBase::ReceiveExecute

函数需要返回一个结果。不管什么结果都会调用OnTaskFinished,当结果为InProgress的时候就不会做什么事情。当有了非InProgress的结果之后再用新结果去调用即可。可参考蓝图类的写法UBTTask_BlueprintBase::FinishExecute

流程总结

Unreal的行为树,从它的Tick可知,它的主流程就是,要么就是在请求执行下一个任务节点,要么就是在等待请求。

所谓的请求执行下一个任务节点,就是从一个任务节点行走至另一个任务节点。行走过程中,就会调用适时调用节点的生命周期函数。

激活与反激活

知道整个流程后,我们再来整理一下激活的逻辑。激活与反激活是一个重要概念,它发生在组合节点、Service和Decorator身上。

假设当前搜索到一个组合节点上时,搜索指针会逐个子节点尝试,当一个子节点的装饰器检查通过后,说明我们应该移动到该子节点上,就会调用组合节点的OnChildActivation并传入目标子节点,不管该子节点是什么类型,都会先激活其装饰器NotifyDecoratorsOnActivation,会调用装饰器的WrappedOnNodeActivation,同时也会缓存住装饰器;如果该子节点是一个组合节点,还会调用其OnNodeActivation,该函数会缓存其身上所有Service,注意是先缓存住,调用的地方就是调用缓存的回调章节里的阶段。

对于反激活,有两个执行的地方。

一个是搜索之前,反激活掉当前任务节点,具体见反激活章节。

另一个就是在搜索过程中,当决定要从该节点返回其父节点时,就会调用该节点的反激活逻辑。同理,通过组合节点的OnChildDeactivation来反激活目标子节点。之所以从组合节点这层调用是因为要读取组合节点本身的配置。反激活时,如果该节点是任务节点,首先缓存该节点的素有Service;其次如果该节点是组合节点,会调用其OnNodeDeactivation,也是先缓存住所有Service;然后不管是什么类型,再调用该子节点的所有装饰器的反激活函数,同时缓存住。

Service和Decorator的调用的先后顺序和激活刚好是反过来的。Decorator的反激活既有马上执行的部分,也有缓存的部分。而Service则是先缓存住。

在执行任务节点前,会把缓存的都拿出来执行,激活对应的函数是OnBecomeRelevant,反激活对应的函数是OnCeaseRelevant

Service的回调总结

ReceiveActivationReceiveDeactivation分别对应激活和反激活。

ReceiveSearch在搜索时调用。搜索过程中的装饰器检查和回调触发章节中提到过。

ReceiveTick则是在WrappedTickNode函数中被调用,不只是在Tick的时候调用,还会在部分时机手动调用,具体全局搜索该函数即可。

Decorator的回调总结

PerformConditionCheck是在搜索过程中经过组合节点时被调用的。搜索过程中的装饰器检查和回调触发章节中提到过。

ReceiveExecutionStartReceiveExecutionFinish分别在对应激活和反激活,搜索过程中马上执行时调用。

ReceiveObserverActivatedReceiveObserverDeactivated分别在对应激活和反激活,执行缓存时调用。

ReceiveTick则也可搜索WrappedTickNode查找调用的地方。

Unreal GameplayEffect 源码分析

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×