Skip to content

Wiki

基本概念

About 12 min read

Existing English translations were reused. 90 current source blocks remain in Simplified Chinese, without triggering paid translation.

1.Behavior Tree 的基本概念

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

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

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


2.节点怎样组成一棵树

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 是示例名称,没有规定具体控制规则。图中的包含关系也没有规定每次执行一定会经过哪些节点。


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

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

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

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

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

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

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

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

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

3.2.和 FSM 的 update() 联系起来

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

在 BehaviorTree.CPP 4.x 中,单次驱动树的常见入口是 tickExactlyOnce(),可对照官方 4.10.0 bt_factory.h:

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


5.SKIPPED 表示本次没有执行

v4 还包含 BT::NodeStatus::SKIPPED,表示节点本次被跳过。 例如 _skipIf 条件为真时,框架不调用节点的业务回调,而是把跳过结果交给父节点。

状态 表示什么
SUCCESS 执行成功,或框架按条件直接判定成功
FAILURE 执行失败,或框架按条件直接判定失败
RUNNING 本次任务尚未完成,需要后续执行
SKIPPED 本次跳过,需要由父节点决定怎样组合
IDLE 尚未执行或已重置,不能作为普通执行回调的结果

SKIPPED 不等于 SUCCESS 或 FAILURE。例如普通 Sequence 会继续后面的子节点,但所有子节点都被跳过时,整个序列返回 SKIPPED。自己的普通动作节点先按成功、失败、运行三种业务结果实现;脚本与前置条件篇再实践由框架产生的跳过。


6.四类节点分别负责什么

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

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

6.1.ControlNode:组织子节点

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

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

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

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

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

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

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

6.2.DecoratorNode:包装一个子节点

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

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

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

Inverter                  [DecoratorNode]
└── IsPathBlocked         [ConditionNode]

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

子节点返回 Inverter 返回
SUCCESS FAILURE
FAILURE SUCCESS
RUNNING RUNNING
SKIPPED SKIPPED

取反逻辑可以写成:

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 保持运行,SKIPPED 保持跳过;此伪代码省略 IDLE 非法状态检查。
    return child_status;
}

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

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

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

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

6.3.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 表示等待。

6.4.ActionNode:执行操作

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

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

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

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

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

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

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

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


7.用导航任务串起 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 和常用装饰节点,用具体规则把这些节点组合起来。


v4 学习路线 · 下一篇:控制与装饰规则