行为树:失败了,还有 Plan B
Behavior Tree Fundamentals
一个 NPC 要进楼
设想一个 NPC 要进一栋楼。它先走到门口;门能直接开就开,锁着就用钥匙,没钥匙就砸;门实在进不去,就绕去窗户爬进来。
用状态机 (FSM) 写,你会得到「走向门」「开门」「解锁」「砸门」「走向窗」「爬窗」一串状态,以及每种失败之后该跳去哪里的一大把转换。再加一个「找钥匙」,转换又要改一遍。
行为树 (Behavior Tree, BT) 换了一种说法:把方案按优先级排好,前一个失败就自动试下一个。「失败了还有 Plan B」,正是行为树最擅长表达的东西。《游戏引擎架构》(GEA) 也把行为树列为角色 AI 最常见的决策方法。
树的形状:分支管流程,叶子干活
Chris Simpson 对行为树的描述很简洁:树的分支只是控制流程的工具,叶子才是真正的命令——走路、开门、检查条件。树可以很深,子树可以做成库反复使用,而按优先级排列备选分支,是行为树的强项。
每个节点被执行(叫做一次 tick)以后,向父节点返回三种结果之一:
- Success:这件事这一拍已经办成了;
- Failure:这件事办不成;
- Running:还在办,比如还在寻路、还在走路,下一拍接着 tick。
第三种结果是关键。「走到门口」要花好几秒,有了 Running,它就是一个普通的叶子,不必像状态机那样拆成「出发」「途中」「到达」几个状态。
节点按孩子的数量分三类:
| 类型 | 孩子 | 作用 |
|---|---|---|
| 顺序 (Sequence) | 多个 | 从左到右依次执行,任何一个失败就失败,全部成功才成功(相当于「与」) |
| 选择 (Selector) | 多个 | 从左到右依次尝试,任何一个成功就成功,全部失败才失败(相当于「或」,也就是按优先级回退) |
| 装饰 (Decorator) | 一个 | 改写孩子的结果或执行方式:取反 (Inverter)、强制成功 (Succeeder)、重复 (Repeater)、直到失败 (Repeat Until Fail) |
| 叶子 (Leaf) | 没有 | 游戏自己定义:动作、条件测试,或者调用另一棵子树 |
把进楼这件事画成树,就是下面这样:
flowchart TD R["Selector:进建筑"] --> D["Sequence:走门"] R --> W["Sequence:走窗"] D --> D1["走到门"] D --> D2["Selector:开门"] D2 --> O1["直接开"] D2 --> O2["解锁再开"] D2 --> O3["砸开"] W --> W1["走到窗"] W --> W2["爬进去"]
外层的 Selector 先试「走门」,整条门的方案失败了再试「走窗」;内层的 Selector 在三种开门手段之间回退。每个 Sequence 都是「先走到,再进去」。
逐拍走一遍
假设这扇门锁着,NPC 没有钥匙,门也结实得砸不开。下面一拍一拍地看这棵树怎么运转:
| 拍 | 发生了什么 | 根节点返回 |
|---|---|---|
| 1 | 进建筑 → 走门 → 走到门:刚出发 | Running |
| 2 | 走到门:还在路上 | Running |
| 3 | 走到门:到了,Success。轮到「开门」:直接开 Failure(锁着),解锁再开 Failure(没钥匙),砸开 Failure(砸不开),于是开门 Failure,走门 Failure。进建筑转而去试「走窗」:走到窗,Running | Running |
| 4–5 | 走到窗:还在路上 | Running |
| 6 | 走到窗:Success。爬进去:Running | Running |
| 7 | 爬进去:Success,于是走窗 Success,进建筑 Success | Success |
第 3 拍是最热闹的一拍。下图就是这一拍结束时各个节点的状态:
动手:写出 Sequence 和 Selector
两种组合节点的核心只有十几行。下面是一个最小版本(C++ 风格的示意代码):
enum class Status { Success, Failure, Running };
struct Node {
virtual ~Node() = default;
virtual Status Tick(Blackboard& bb) = 0; // 每一拍被调用一次
};
// Sequence:依次执行,任何一个失败就失败(与)
struct Sequence : Node {
std::vector<Node*> children;
size_t current = 0; // 记住上一拍停在哪个孩子
Status Tick(Blackboard& bb) override {
while (current < children.size()) {
Status s = children[current]->Tick(bb);
if (s == Status::Running) return Status::Running; // 下一拍从这里接着来
if (s == Status::Failure) { current = 0; return Status::Failure; }
++current; // 成功了,轮到下一个
}
current = 0;
return Status::Success;
}
};
// Selector:依次尝试,任何一个成功就成功(或,按优先级回退)
struct Selector : Node {
std::vector<Node*> children;
size_t current = 0;
Status Tick(Blackboard& bb) override {
while (current < children.size()) {
Status s = children[current]->Tick(bb);
if (s == Status::Running) return Status::Running;
if (s == Status::Success) { current = 0; return Status::Success; }
++current; // 失败了,换下一个方案
}
current = 0;
return Status::Failure;
}
};
两个类几乎是镜像:Sequence 遇到 Failure 就停,Selector 遇到 Success 就停。对照上面的逐拍表格,在脑子里跑一遍第 3 拍:开门 这个 Selector 的 current 从 0 走到 3,三个孩子全部失败,于是它返回 Failure;走门 这个 Sequence 收到 Failure 立刻停下,也返回 Failure。
current 这个成员值得多看一眼。它让组合节点记住上一拍停在哪里,下一拍直接从那个 Running 的孩子接着 tick。Simpson 特别提到:朴素的实现每一拍都从根节点把整棵树走一遍,生产环境里应该缓存当前 Running 的节点。
节点之间怎么传数据
「走到门」得知道门在哪,「爬进去」得知道是哪扇窗。Simpson 给每个行为树实例配了一块数据上下文 (Data Context):一张「字符串键 → 任意值」的表。前面的节点把目标点写进 destination,后面的 Walk 叶子读出来去走;调用子树的叶子把同一块上下文传下去,子树就能当成模块复用。上下文里还可以放一个栈,让节点依次取出候选的门和窗来逐个尝试。
这块共享的表就是黑板 (Blackboard) 思想在行为树里的样子。虚幻引擎把黑板做成独立的资产,行为树的装饰器、服务和任务都通过黑板的键来通信。
Halo 2 的做法:优先级列表
Halo 2 的 AI 用的是一种行为有向无环图,其中最常用的决策方式叫优先级列表 (prioritized-list):按列表顺序找第一个「相关」的子行为去执行,而且更高优先级的行为可以在之后的某一拍把正在执行的打断。这在语义上很接近「Selector 加上持续抢占」。
为了让这棵树在行为很多时还能管得住,Damian Isla 还加了几样工具:
- 相关性是二元的:一个行为要么相关、要么不相关,不用连续的浮点分数去比;
- 冲动 (Impulse):游离在树外的触发器,指向一个完整的行为,用来在特定条件下翻转优先级、在树里跳转;
- 行为标签:用位向量预先过滤掉一批条件明显不满足的行为。
虚幻引擎的行为树则走了另一条路:它是事件驱动的,不每帧遍历整棵树,而是监听事件(比如黑板键的值变了)再触发重新选择。条件写成装饰器,装饰器可以当观察者,按 Observer Aborts 的设置中止正在执行的低优先级任务——这正好解决了上一节说的「只缓存不重查」的问题。所以在虚幻引擎里看到的行为树,和这一课讲的「经典每拍 tick」的行为树在细节上会有差异。
在游戏里怎么用
取舍与常见的坑
小结与练习
- 分支管流程、叶子干活;每个节点返回 Success、Failure 或 Running,Running 让一个动作可以跨好几拍;
- Sequence 是「全部成功才成功」,Selector 是「一个成功就成功」,后者就是按优先级回退的 Plan B;
- 缓存 Running 节点省计算,但要另外考虑高优先级怎么打断;
- 节点之间靠数据上下文,也就是黑板来传数据。
练习:给「解锁再开」前面加一个「找钥匙」的子树——钥匙可能在桌上,也可能在守卫身上。想一想它该是 Sequence 还是 Selector,放进树里以后,第 3 拍的逐拍表格会怎么变。
行为树 (Behavior Tree, BT) 用层级节点控制决策流:叶子做动作或条件,Sequence / Selector 等组合节点决定子节点如何推进;节点返回 Success / Failure / Running,从而自然跨多帧。相对 FSM,BT 更易用优先级回退表达「失败了还有 Plan B」。
基本模型
Simpson:树的分支是控制流工具,叶子才是真正命令;树可极深,子树可库化;按优先级排备选分支是 BT 的强项。(Behavior trees for AI: How they work(笔记))GEA 亦将行为树列为角色 AI 常见方法论。(GEA4 Vol II §18.2.3 Artificial Intelligence(摘录笔记))
三种返回值
- Success / Failure:本拍已有定论。
- Running:尚未完成(寻路中、走路中);下拍继续 tick。
这使「走去开门」不必拆成一堆状态机状态。(Behavior trees for AI: How they work(笔记))
节点类型
| 类型 | 作用 |
|---|---|
| Sequence | 依次执行;任一失败则失败(AND) |
| Selector | 依次尝试;任一成功则成功(OR / 优先级回退) |
| Decorator | 单子:取反、强制成功、重复、直到失败等 |
| Leaf | 动作、条件、调用子树 |
flowchart TD R["Selector 进建筑"] --> D["Sequence 走门"] R --> W["Sequence 走窗"] D --> D1["走到门"] D --> D2["Selector 开门"] D2 --> O1["直接开"] D2 --> O2["解锁再开"] D2 --> O3["砸开"]
上图(简化自 Simpson 门锁例子):外层 Selector 在「门方案失败」后改试窗;内层 Selector 在开门手段间回退。(Behavior trees for AI: How they work(笔记))
与 Halo 的 prioritized-list
Isla 的 behavior DAG 里最常用决策是 prioritized-list:按表找第一个相关子行为,更高优先级可在后续 tick 打断——语义上贴近 Selector + 持续抢占。(Handling Complexity in the Halo 2 AI(笔记))
数据与模块化
共享 Data Context / 黑板 让叶子交换目标点、门对象、栈等;「调用子树」叶子传递上下文,形成行为库(如 EnsureItemInInventory 递归合成)。(Behavior trees for AI: How they work(笔记);黑板架构)
性能与实现注意
- 朴素实现每帧从根遍历;生产实现应 缓存 Running 节点 直接 tick。(Simpson)
- UE 进一步用事件驱动 + 装饰器观察者,避免无意义全树重选——见实现页 行为树与 StateTree,与「经典每帧 tick BT」有差异。
取舍
- 适合:优先级明确的战术/任务 AI;希望失败可回退;需要可视化编辑。
- 弱点:反应式为主,不做长期符号规划(对比 目标导向行动规划 GOAP);效用连续权衡见 效用 AI;状态极少且转换硬编码时 FSM 可能更简单(状态模式与有限状态机)。
出处
- Behavior trees for AI: How they work(笔记) Chris Simpson, Behavior trees for AI: How they work (2014)
- GEA4 Vol II §18.2.3 Artificial Intelligence(摘录笔记) GEA4 §18.2.3(BT 作为常见方法)
- Handling Complexity in the Halo 2 AI(笔记) Isla Halo 2 behavior DAG / prioritized-list
- www.gamedeveloper.com/programming/behavior-trees-for-ai-how-they-work
- dev.epicgames.com/community/learning/tutorials/qzZ2/unreal-engine-behavior-tree-theory