控制与装饰规则
控制与装饰规则
对应 BehaviorTree.CPP 3.8 官方文档:Learn the Basic Concepts → Introduction to BTs → Examples,并参考 Nodes Library 中的 Sequences、Fallbacks 与 Decorators。
上一篇已经知道:tick 从父节点向下传播,状态由子节点向上返回。现在进一步学习,父节点拿到 SUCCESS、FAILURE、RUNNING 后,具体怎样决定下一步。
| 需要表达的任务关系 | 常用节点 |
|---|---|
| 前一步成功后,继续下一步 | Sequence |
| 当前方案失败后,尝试备用方案 | Fallback |
| 修改一个节点的结果、次数或时限 | 装饰节点 |
本篇先讲普通 Sequence、普通 Fallback 和常用装饰节点,使用移动机器人示例。
C++ 是解释逻辑的伪代码;
XML 只展示树的片段,完整工程、节点注册和 XML 文件结构后面统一学习。
Sequence:依次完成一组步骤
Sequence 按子节点的排列顺序执行,只有前面的步骤成功,才继续后面的步骤:
Sequence
├── CheckBattery 检查电量是否足够
├── NavigateToGoal 导航到目标
└── ReportArrival 报告到达
| 当前子节点返回 | Sequence 接下来做什么 | Sequence 的结果 |
|---|---|---|
SUCCESS | 继续 tick 下一个子节点 | 所有子节点成功后,返回 SUCCESS |
FAILURE | 停止本次序列,不再 tick 后面的节点 | FAILURE |
RUNNING | 停在当前子节点,等待后续继续执行 | RUNNING |
例如 CheckBattery 返回 FAILURE,本轮不会发送导航请求,也不会报告到达;NavigateToGoal 返回 FAILURE,则跳过 ReportArrival。
一次 tick 可以继续执行多个步骤
假设电量足够,导航要经过几次检查才完成:
| 外部第几次 tick | 本次实际执行的子节点 | Sequence 返回 |
|---|---|---|
| 第 1 次 | CheckBattery → SUCCESS,接着 NavigateToGoal → RUNNING | RUNNING |
| 第 2 次 | NavigateToGoal → RUNNING | RUNNING |
| 第 3 次 | NavigateToGoal → SUCCESS,接着 ReportArrival → SUCCESS | SUCCESS |
前一步在当前调用中成功后,Sequence 可以立刻继续 tick 下一步,不必等到下一轮。
普通 Sequence 在子节点 RUNNING 时,会记住当前位置。 因此第 2 次 tick 会继续导航,不会重新检查前面已经成功的 CheckBattery。
如果导航期间电量变低,这棵普通 Sequence 不会因此自动重新检查电量并中止导航。需要持续检查条件时,要使用合适的响应式结构,后面结合任务中止来学习。
用子节点下标理解执行位置
下面的 current_index 是节点保存的成员状态,初始为 0:
BT::NodeStatus Sequence::tick()
{
while (current_index < children.size())
{
BT::NodeStatus status = children[current_index].executeTick();
if (status == BT::NodeStatus::RUNNING)
{
// 保留位置,下次继续 tick 同一个子节点
return BT::NodeStatus::RUNNING;
}
if (status == BT::NodeStatus::FAILURE)
{
ResetChildren();
current_index = 0;
return BT::NodeStatus::FAILURE;
}
// 此处假设子节点只返回三个合法执行结果,剩下的是 SUCCESS
++current_index;
}
ResetChildren();
current_index = 0;
return BT::NodeStatus::SUCCESS;
}
这段伪代码省略框架状态维护和非法状态检查。
ResetChildren() 表示结束本次执行并重置子节点,不能理解为撤销已经产生的业务效果。
这一行为可对照官方 3.8.7 sequence_node.cpp。
普通 Sequence 成功或失败结束后,位置都会回到开头;如果上层以后重新执行它,会从第一个子节点开始。
返回失败不会在当前 tick 中自动从头重试。
后面的节点不保证一定执行
下面这棵树不能保证导航失败后执行停止节点:
Sequence
├── NavigateToGoal
└── StopRobot
导航返回 FAILURE 时,Sequence 立即返回失败,StopRobot 会被跳过。
动作失败或被中止时需要的取消、停止和清理,应在动作及相关模块中正确实现。
仅把清理动作放在 Sequence 最后,不能保证它在所有退出路径上执行。
Fallback:按顺序尝试方案
Fallback 在其他框架中也常叫 Selector。
它按顺序尝试子节点,只要找到成功的方案,就结束本次选择:
Fallback
├── NavigateToPrimaryGoal 尝试主要目标
└── NavigateToBackupGoal 主要目标失败后,尝试备用目标
| 当前子节点返回 | Fallback 接下来做什么 | Fallback 的结果 |
|---|---|---|
FAILURE | 继续 tick 下一个子节点 | 全部失败后,返回 FAILURE |
SUCCESS | 不再尝试后面的子节点 | SUCCESS |
RUNNING | 继续等待当前方案,不启动后面的方案 | RUNNING |
RUNNING 不算失败。 主要目标还在导航时,不会因为“尚未到达”就立即启动备用目标。
例如:
| 外部第几次 tick | 本次实际执行的子节点 | Fallback 返回 |
|---|---|---|
| 第 1 次 | NavigateToPrimaryGoal → RUNNING | RUNNING |
| 第 2 次 | NavigateToPrimaryGoal → FAILURE,接着 NavigateToBackupGoal → RUNNING | RUNNING |
| 第 3 次 | NavigateToBackupGoal → SUCCESS | SUCCESS |
普通 Fallback 在 RUNNING 时同样保留当前位置。
第 3 次继续备用导航,不会重新尝试前面已经失败的主要目标。
本次选择成功或全部失败结束后,它会重置位置;上层以后重新执行时,再从第一个子节点开始。具体可对照官方 3.8.7 fallback_node.cpp。
条件节点也能作为优先方案
例如“如果已经到达,就不用重新导航”:
Fallback
├── IsGoalReached
└── NavigateToGoal
IsGoalReached 返回 SUCCESS,这部分树就成功结束;返回 FAILURE,才启动导航。
这里的成功表示“到达目标这项需求已经满足”,所以一个条件判断也可以让整段任务成功,不一定非要执行动作。
对照两种控制规则
| 对比项 | 普通 Sequence | 普通 Fallback |
|---|---|---|
| 表达的关系 | 一组都需要成功的步骤 | 一组可以依次尝试的方案 |
| 哪种结果让它继续下一个子节点 | SUCCESS | FAILURE |
| 哪种结果让它提前结束 | FAILURE | SUCCESS |
当前子节点 RUNNING 时 | 返回 RUNNING,保留当前位置 | 返回 RUNNING,保留当前位置 |
| 所有子节点都执行完 | 全部成功,返回 SUCCESS | 全部失败,返回 FAILURE |
树结构中的前后顺序会影响结果。把子节点重新排序,可能改变先检查什么、先执行哪个方案,以及哪些节点会被跳过。
3.8 官方文档还介绍了 ReactiveSequence、ReactiveFallback 和带记忆的序列变体。本篇不展开它们;这里的“保留 RUNNING 位置”只是在说明普通节点的行为,不能套用到所有变体。
装饰节点:处理一个子节点的结果或执行方式
装饰节点始终只有一个直接子节点,子节点也可以是整棵子树的入口。
Inverter、ForceSuccess 与 ForceFailure
这三种节点主要处理子节点的返回结果:
| 子节点返回 | Inverter | ForceSuccess | ForceFailure |
|---|---|---|---|
SUCCESS | FAILURE | SUCCESS | FAILURE |
FAILURE | SUCCESS | SUCCESS | FAILURE |
RUNNING | RUNNING | RUNNING | RUNNING |
Inverter 已在上一篇用路径检查解释过。这里补充两种强制结果的用途:
Sequence
├── NavigateToGoal
└── ForceSuccess
└── UploadArrivalLog
如果“导航到达”是主要任务,而“上传到达日志”允许失败,可以只给日志动作加 ForceSuccess。日志最终失败时,这个装饰节点仍返回成功;日志还在上传时,仍返回 RUNNING,不会立刻跳过它。
ForceFailure 则让已结束的子节点统一报告失败。例如,在 Fallback 中,执行完一段诊断记录后仍希望继续下一个方案,可以用它包装这段记录。
强制改变的是向父节点报告的状态,不会让失败的操作实际成功,也不会撤销已经完成的动作。使用前要确定,这个结果是否符合父节点所代表的任务含义。
RetryUntilSuccessful:失败后重新尝试
3.8 文档中的 RetryNode,在 XML 中使用的注册名称是 RetryUntilSuccessful:
<RetryUntilSuccessful num_attempts="3">
<NavigateToGoal/>
</RetryUntilSuccessful>
这里表示总共最多尝试 3 次,包含第一次:
| 子节点返回 | RetryUntilSuccessful 的处理 |
|---|---|
SUCCESS | 立即返回 SUCCESS,不再重试 |
FAILURE | 记一次失败;还有次数就重置子节点再试,用完则返回 FAILURE |
RUNNING | 返回 RUNNING,当前尝试继续,不消耗一次失败次数 |
例如导航结果依次为:
第 1 次尝试:RUNNING → FAILURE
第 2 次尝试:RUNNING → RUNNING → SUCCESS
整体结果:SUCCESS,第 3 次尝试不会发生
每次完整尝试可以跨越多个 tick。
num_attempts="3" 限制的是尝试次数,不是整个装饰节点只能被 tick 三次。
根据官方 3.8.7 retry_node.cpp, 如果子节点立即失败,下一次尝试可能在同一次父节点 tick 中继续执行。 这个节点没有自动增加重试间隔;对子节点反复检查,不等于等待环境或传感器数据变化。
Repeat:成功后继续重复
Repeat 用于重复完成一个操作:
<Repeat num_cycles="3">
<CaptureObservation/>
</Repeat>
这里假设 CaptureObservation 每次执行会采集并保存一份观测数据。任务是成功完成 3 次采集:
| 子节点返回 | Repeat 的处理 |
|---|---|
SUCCESS | 记一次成功;未到次数则重置子节点继续,达到次数后返回 SUCCESS |
FAILURE | 立即停止重复,返回 FAILURE |
RUNNING | 返回 RUNNING,继续当前这次操作,不增加已完成次数 |
它与 Retry 的区别是:Retry 失败后继续,Repeat 成功后继续。 num_cycles="3" 同样包含第一次执行,不是先做一次再额外做三次。
官方 3.8.7 repeat_node.cpp 也使用循环处理立即结束的子节点。
因此,3 次快速采集可能在一次 tick 内完成;
Repeat 不会自动让它们分布在三个外部更新周期,也不会自动插入时间间隔。
Timeout:给本次执行设置时限
Timeout 使用 msec 指定毫秒数,例如:
<Timeout msec="5000">
<NavigateToGoal/>
</Timeout>
这给本次导航设置 5 秒时限:
| 子节点情况 | Timeout 的处理 |
|---|---|
未超时就返回 SUCCESS | 返回 SUCCESS |
未超时就返回 FAILURE | 返回 FAILURE |
| 还在运行且未超时 | 返回 RUNNING |
| 时限到达且子节点仍在运行 | 中止子节点,随后返回 FAILURE |
在 3.8.7 timeout_node.h 中,计时从装饰节点本次执行开始,后续 tick 不会反复把这 5 秒重新计时。 到期处理会调用子节点的中止逻辑;超时失败结果在后续执行入口中返回给父节点。
导航动作需要在中止处理中取消实际导航请求。
Timeout 不能替代动作的取消实现,也不能把一个长时间堵在 tick() 里的同步函数自动变成可及时中止的异步任务。
组合示例:检查电量,再有限次尝试导航
把规则组合成一棵任务树:
Sequence
├── CheckBattery
├── RetryUntilSuccessful [num_attempts=3]
│ └── Timeout [msec=5000]
│ └── NavigateToGoal
└── ReportArrival
对应的 XML 片段:
<Sequence>
<CheckBattery/>
<RetryUntilSuccessful num_attempts="3">
<Timeout msec="5000">
<NavigateToGoal/>
</Timeout>
</RetryUntilSuccessful>
<ReportArrival/>
</Sequence>
阅读时,从外向内看任务约束:
| 所在层 | 职责 |
|---|---|
外层 Sequence | 电量检查通过后才执行导航;导航最终成功后才报告到达 |
RetryUntilSuccessful | 导航尝试失败时重新尝试,最多 3 次 |
Timeout | 限制每次尝试的运行时间 |
NavigateToGoal | 启动、检查并在必要时取消实际导航任务 |
一种可能的执行过程是:
CheckBattery → SUCCESS
↓
第 1 次导航尝试 → RUNNING → 超时并取消 → FAILURE
↓ Retry 重置并重新尝试
第 2 次导航尝试 → RUNNING → SUCCESS
↓ Retry 返回 SUCCESS
ReportArrival → SUCCESS
↓
Sequence → SUCCESS
如果 3 次都失败,Retry 返回 FAILURE,外层序列也失败,ReportArrival 不会执行。
若报告动作本身失败,外层序列同样会报告失败,因此报告动作应按需求定义好“成功”的含义。
这里的普通 Sequence 只在开始时检查电量,后续不会在导航或重试期间持续重查。
这个例子用于掌握顺序、重试和超时的组合;持续监控条件及任务切换,留到响应式行为章节。
装饰节点的内外顺序会改变含义
RetryUntilSuccessful [3 次]
└── Timeout [5 秒]
└── NavigateToGoal
这是上面的结构:每次尝试分别有 5 秒时限,一次超时算一次尝试失败,Retry 可以再试。
如果交换位置:
Timeout [5 秒]
└── RetryUntilSuccessful [3 次]
└── NavigateToGoal
则是所有尝试共享一个 5 秒时限。 只有导航自身先返回失败,Retry 才能在剩余时间内重试; 外层一旦超时,就中止整个重试子树,不再保证用完 3 次。
两种结构的节点相同,但任务约束不同。 读树时既要看子节点顺序,也要看装饰节点包住了哪一层。
当前学习进度
本篇完成了 Introduction to BTs 中 Sequence、Fallback 和装饰节点的核心规则,并补充了机器人任务常用的重试、重复与超时组合。