第 12.4.2.1 节

基本概念

作者:董佳辉

Behavior Tree 的基本概念

对应 BehaviorTree.CPP 3.8 官方文档:Learn the Basic Concepts → Introduction to BTs → Basic Concepts、Types of nodes。

理解行为树,先把三个问题连起来:节点怎样组成树,tick 怎样驱动执行,节点怎样通过状态报告结果。 在此基础上,再区分控制、装饰、条件、动作四类节点。

本文使用通用移动机器人示例。节点逻辑用 C++ 风格伪代码表示;实际节点的继承、注册和 XML 配置将在完整程序中学习。


节点怎样组成一棵树

Behavior Tree,简称 BT,是一棵由 TreeNode 组成的层次化任务树:

Mission                   [ControlNode]
├── CheckBattery          [ConditionNode]
├── Inverter              [DecoratorNode]
│   └── IsPathBlocked     [ConditionNode]
└── NavigateToGoal        [ActionNode]

树中的位置关系是:

名称含义图中的例子
根节点 Root树的执行入口,没有父节点Mission
父节点 Parent直接包含其他节点的节点Inverter 是 IsPathBlocked 的父节点
子节点 Child直接挂在另一个节点下面的节点Inverter 是 Mission 的子节点
叶子节点 Leaf没有子节点的节点CheckBattery、IsPathBlocked、NavigateToGoal

同一个节点可以既是子节点,又是父节点。例如 Inverter 在 Mission 下面,同时又包含 IsPathBlocked。

上层节点组织任务,叶子节点检查条件或执行操作。叶子节点内部仍然可以调用普通函数、读取传感器数据或请求外部服务;“没有子节点”只针对行为树结构。

这里的 Mission 是示例名称,没有规定具体控制规则。图中的包含关系也没有规定每次执行一定会经过哪些节点。


tick 向下传播,状态向上返回

tick 表示让节点执行一次当前逻辑

官方文档中的 “tick a node”,可以理解为调用这个节点的执行回调。

外部从根节点发起一次 tick;有子节点的节点按照自己的规则决定是否继续传播、传播给谁,以及传播多少次:

外部程序
   ↓ tick
根节点
   ↓ 按规则传播 tick
中间节点
   ↓ 按规则传播 tick
叶子节点
   ↓ 检查条件或执行操作

叶子节点返回状态,中间节点处理子节点的结果,再向更上层返回自己的状态:

根节点
   ↑ 自己组织的任务的状态
中间节点
   ↑ 子节点状态
叶子节点

子节点只有被父节点 tick 时,才会执行相应回调。 因此,一次根节点 tick 不等于遍历整棵树;树中的某些分支可能完全没有执行。

父节点拿到子节点结果后,也不一定原样返回。它可以继续执行其他子节点、转换结果,或者报告这部分任务仍在运行,具体取决于节点规则。

和 FSM 的 update() 联系起来

在非阻塞 FSM 中,外部周期性调用 fsm.update(),根据当前状态推进任务。行为树同样需要外部持续驱动,但内部沿树结构组织执行。

在 BehaviorTree.CPP 3.8 中,单次驱动树的常见入口是 tickRoot(),可对照官方 3.8.7 bt_factory.h:

// 从根节点开始一次执行,取得整棵树这次返回的状态
BT::NodeStatus status = tree.tickRoot();

实际节点执行通常经过框架的 executeTick() 入口,再进入节点自己的 tick() 等回调。 概念上的“tick 一个节点”包含了这个调用过程,不需要业务代码绕过框架直接调用每个节点的回调。

外部如何安排下一次 tick,以及使用哪些循环接口,后面在完整程序中说明。


节点通过状态报告执行结果

一个节点被 tick 后,正常通过以下三个状态报告本次执行结果:

状态基本含义条件节点的含义动作节点的含义
BT::NodeStatus::SUCCESS本次执行成功结束条件成立动作成功完成
BT::NodeStatus::FAILURE本次执行未成功条件不成立动作失败结束
BT::NodeStatus::RUNNING尚未得到最终结果不允许返回支持异步执行的动作仍在运行

例如电量不足时,CheckBattery 返回 FAILURE,表示“电量足够”这个条件不成立;它本身不代表程序异常,也不一定让整棵树失败。父节点还要决定如何处理。

对于 NavigateToGoal,返回 SUCCESS 则表示导航成功完成,不能仅因为导航请求已经发出,就报告已经成功到达。

因此,SUCCESS、FAILURE 的具体业务含义由节点职责决定,不能一概替换成布尔值 true、false。

RUNNING 表示任务未结束,当前调用先返回

导航需要时间,典型过程是:

第 1 次 tick 到导航节点:启动导航     → RUNNING
第 2 次 tick 到导航节点:检查进度     → RUNNING
第 3 次 tick 到导航节点:已经成功到达 → SUCCESS

如果最终导航失败,最后一次结果就是 FAILURE。SUCCESS 和 FAILURE 都是这次任务的结束结果,RUNNING 则表示尚未结束。

实现非阻塞动作时,每次回调只启动任务或快速检查当前进度,然后返回。RUNNING 表示任务还没完成,不是函数一直不返回。

例如,在驱动树的线程中这样等待,会卡住这次执行:

// 不适合直接放进非阻塞动作的 tick() 中
while (!GoalReached())
{
    // 一直等到机器人到达,导致这次 tick 无法及时返回
}

持续任务应在后续 tick 中继续检查,具体示例见下面的动作节点。

IDLE 是未执行或已重置的状态

BehaviorTree.CPP 内部还有 BT::NodeStatus::IDLE,表示节点尚未开始执行,或者已被重置回初始状态:

IDLE → 收到 tick → RUNNING → 后续 tick → SUCCESS / FAILURE

可以立即完成的节点也可能从 IDLE 直接得到 SUCCESS 或 FAILURE。

IDLE 用于表示节点生命周期中的状态,普通执行回调不应把它作为执行结果返回。以后再次执行已经结束的任务时,还涉及框架的状态重置;具体机制留到节点实现时学习。


四类节点分别负责什么

节点类型中文含义直接子节点数量主要职责
ControlNode控制节点1 到 N 个组织子节点,决定 tick 的传播和整体结果
DecoratorNode装饰节点恰好 1 个调整子节点的执行方式或返回结果
ConditionNode条件节点0 个检查条件是否成立
ActionNode动作节点0 个执行具体操作

子节点数量只计算直接挂在当前节点下面的节点。例如开头的 Mission 有三个直接子节点;IsPathBlocked 是它的后代,但不是直接子节点。

ControlNode:组织子节点

控制节点根据自身规则、当前状态,以及子节点返回的结果,决定 tick 哪个子节点和自己返回什么状态。

它可以包含条件、动作、装饰节点,也可以继续包含控制节点:

Mission                   [ControlNode]
├── NavigateTask          [ControlNode]
│   ├── CheckPathClear    [ConditionNode]
│   └── NavigateToGoal    [ActionNode]
└── StopRobot             [ActionNode]

这样就能将大任务拆成子任务,再由子任务组织更具体的检查和操作。控制节点既可以处于根部,也可以嵌套在其他节点下面。

控制节点的子节点是有顺序的:横向图通常从左到右表示,本教程的竖向树结构从上到下表示。这个顺序会参与具体控制规则,但不保证每次 tick 都执行全部子节点。

Sequence 和 Fallback 都属于 ControlNode,具体规则下一篇集中学习。

1 到 N 个 是这一大类的数量范围;某些具体控制节点还要求固定数量的子节点,不能把这个范围理解为所有控制节点都接受任意数量。

DecoratorNode:包装一个子节点

装饰节点收到 tick 后,按自己的规则决定是否、何时、多少次 tick 唯一的子节点,并处理它的执行结果。

按照 3.8 官方装饰节点文档,不同装饰节点可以转换结果、重复执行,或中止子节点;每一种只提供自身规定的行为。

例如 Inverter 对成功、失败取反:

Inverter                  [DecoratorNode]
└── IsPathBlocked         [ConditionNode]

IsPathBlocked 检查“路径被堵住了吗”。有障碍物返回 SUCCESS,没有障碍物返回 FAILURE;加上 Inverter 后,这部分树表达的判断就变成“路径没有被堵住吗”。

子节点返回Inverter 返回
SUCCESSFAILURE
FAILURESUCCESS
RUNNINGRUNNING

取反逻辑可以写成:

BT::NodeStatus Inverter::tick()
{
    // 只展示状态转换,省略框架内部的状态维护
    BT::NodeStatus child_status = child.executeTick();

    if (child_status == BT::NodeStatus::SUCCESS)
    {
        return BT::NodeStatus::FAILURE;
    }

    if (child_status == BT::NodeStatus::FAILURE)
    {
        return BT::NodeStatus::SUCCESS;
    }

    // 尚未完成时,保持 RUNNING
    return BT::NodeStatus::RUNNING;
}

例子中的条件节点不会返回 RUNNING;(一般执行起来需要一段时间的才可能返回就RUNNING,而一些能瞬间就知道结果的,不会返回RUNNING。) 表中保留这一行,是因为 Inverter 也能包装支持持续执行的节点。

装饰节点可以位于根部,也可以夹在其他节点之间。它唯一的子节点既可以是叶子,也可以是一整棵子树的入口:

Timeout                   [DecoratorNode]
└── NavigateTask          [ControlNode]
    ├── CheckPathClear    [ConditionNode]
    └── NavigateToGoal    [ActionNode]

这里 Timeout 仍然只有一个直接子节点,下面有多少后代不影响这个数量限制。超时、重试、重复等具体规则将在控制与装饰规则一篇中学习。

ConditionNode:检查条件

条件节点是叶子节点,没有子节点。它通常读取已有数据,快速判断当前条件是否成立。

官方要求 ConditionNode 不应改变系统,也不能返回 RUNNING。

例如检查机器人电量:

BT::NodeStatus CheckBattery::tick()
{
    // battery_level 是已经获得的电量数据,单位为百分比
    const double minimum_battery_level = 20.0;

    if (battery_level >= minimum_battery_level)
    {
        return BT::NodeStatus::SUCCESS;
    }

    return BT::NodeStatus::FAILURE;
}

这个节点只判断电量,不会顺便启动充电。如果需要执行充电操作,应交给 StartCharging 等动作节点。

条件节点只有被 tick 时才会检查。将它放在导航节点旁边,不等于它会一直在后台监控电量;下一轮是否重新检查,仍取决于父节点有没有再次 tick 它。

如果需求是“等待直到电量足够”,其中包含持续等待过程,应使用能表达持续执行的动作或树结构,不能让条件节点用 RUNNING 表示等待。

ActionNode:执行操作

动作节点也是叶子节点。它可以设置指示灯、发送停止命令,或向外部导航系统发起任务并检查结果。

判断和操作的区别,可以通过这两个节点看清:

节点类型职责
IsGoalReachedConditionNode判断机器人现在是否已到达目标
NavigateToGoalActionNode让机器人前往目标,并报告执行结果

动作分为同步与异步两种:

类型执行方式返回状态
同步动作在当前调用内完成,树的执行等待该调用返回SUCCESS / FAILURE
异步动作启动任务后先返回,后续继续检查或推进可以返回 RUNNING,最终返回 SUCCESS / FAILURE

例如能立即完成的本地指示灯设置适合同步执行; 导航需要等待机器人移动,适合异步执行。

在 3.8 中,SyncActionNode 明确禁止返回 RUNNING,否则框架会抛出异常。 ActionNode 是本节使用的分类名称; 实际 C++ 类的共同基类叫 ActionNodeBase,后面常用 SyncActionNode 和 StatefulActionNode 实现动作。

相关接口可对照官方 3.8.7 action_node.h。


用导航任务串起 tick、状态与节点职责

下面用一个导航动作说明持续任务怎样执行。navigation_started 表示保存在节点里的成员状态,初始为 false;导航接口本身应快速返回,实际移动由外部导航系统完成。

BT::NodeStatus NavigateToGoal::tick()
{
    // 第一次执行时发送请求,不在每次 tick 时重复启动
    if (!navigation_started)
    {
        StartNavigation();
        navigation_started = true;
    }

    if (NavigationFailed())
    {
        navigation_started = false;
        return BT::NodeStatus::FAILURE;
    }

    if (GoalReached())
    {
        navigation_started = false;
        return BT::NodeStatus::SUCCESS;
    }

    // 任务仍在进行,当前调用先返回
    return BT::NodeStatus::RUNNING;
}

这是执行语义的伪代码,省略了请求发送失败、取消以及框架状态重置等实现细节。实际使用 StatefulActionNode 时,会通过 onStart()、onRunning()、onHalted() 分别组织启动、运行检查和中止处理。

假设父节点这次选择了导航动作,执行过程是:

外部 tick 根节点
    ↓
父节点按规则 tick NavigateToGoal
    ↓
启动或检查导航
    ↓
RUNNING / SUCCESS / FAILURE
    ↑
父节点处理结果,返回自己的状态
    ↑
外部程序取得根节点的结果

导航返回 RUNNING 后,需要外部再次驱动树,并且父节点再次选择这个动作,执行才会继续到达它。RUNNING 本身不会安排下一次 tick,也不会自动创建后台线程;实际工作如何继续,由节点实现和外部系统决定。

如果上层切换任务并中止导航,动作还需要取消尚未完成的请求、清理内部状态。 这个过程不能只靠返回 FAILURE 代替,后面实现持续任务时再展开。

回看整棵树:控制节点组织执行,装饰节点调整执行规则,条件节点判断,动作节点操作。Root、Parent、Child、Leaf 则描述它们的位置关系,不是额外四种节点类型;一棵只有一个动作节点的树,也可以直接以这个动作为根节点。

下一篇集中学习 Sequence、Fallback 和常用装饰节点,用具体规则把这些节点组合起来。