UE 中实现 GOAP
UE 中实现 GOAP
这篇记录在 Unreal 里从零搭一套 GOAP 框架的做法。GOAP 的概念、规划为什么是反向图搜索,见 GOAP:目标导向行为规划;这里只谈落到 UE 上的实现——数据结构怎么设计、规划器怎么写、以及项目跑起来之后撞上的两个坑。
整体结构
一套可用的 UE GOAP 框架大致是这些类:
UGOAPAgentComponent 是整个框架的载体,管理所有 Action 和 Goal 的生成与替换。它有个蓝图子类 BP_GOAPAgentComponent,里面放着「什么环境/状态变化会选中哪个 Goal」的条件池,这部分交给策划配置。
WorldState 的位集设计
WorldState 是被 Tick 时反复创建、比较、拷贝的东西,所以它的数据结构直接决定规划器的性能。一个实用的做法是用一个 64 位整数当布尔状态集:
Values:一个uint64,存 64 个布尔状态。NotUsedFlag:标记Values里哪些位当前没被使用(这个状态对本次匹配无关紧要)。SharedFlag:标记哪些位是世界共享的、哪些是 AI 私有的。
64 位对绝大多数 AI 够用,比较两个状态就是几条位运算,比字典结构快得多。规划器里频繁调用的启发函数就建在它上面——一个 CalcCorrelation(other) 计算「当前状态和目标状态差多少位」,直接拿来当 A* 代价函数 F = G + H 里的启发值 H。
用位集换来了速度,但也把状态数量顶死在 64 个。如果项目的世界状态维度会超过 64,就得改成多字或字典结构,这时候要重新掂量比较/拷贝的开销。
规划器:反向 A* 与两处不对称
规划器本体就是一个 A*,产出一串 Action 组成的 Plan。节点是 GOAPNode(每个节点含一个 Action 和一份 WorldState)。判断节点是否已经在 OpenList / CloseList 时,比的是节点内容(WorldState)是否一致,而不是对象地址。
真正容易写错的是 Precondition 和 Effect 的处理,它们有两处不对称:
匹配逻辑不同。 Precondition 用完全匹配——传入的 WorldState 必须和 Precondition 完全一致才算通过;Effect 用交集匹配——只要 WorldState 和 Effect 的交集吻合就算命中。因为是反向 A*,一个 Action 只要它的 Effect 是当前子目标的子集,就有资格接进路径里。
Apply 逻辑不同。 Precondition 的 Apply 直接把内容引用到目标 WorldState 上——它服务的是反向搜索时「生成下一个节点的状态」,不关心数据来源。而 Effect 的 Apply 是要真正写回存储世界的 WorldState,所以要根据 SharedFlag 判断这一位是写世界共享部分还是 AI 私有部分。
把这两点分清楚,反向规划才不会串味。
多模板:GoapSubSystem
不同 AI 认知的世界并不一样。比如 A 类 AI 配置了「对昼夜的认知」这个状态关键字,B 类没有。如果每个 AI 各自维护一套配置(UGoapWorldStateRuntimeSettings 模板),就需要一个 Subsystem 统一托管:对不同模板都能正确返回对应的状态数据。用 UWorldSubsystem 承载这层查询很合适,AI 只管问「我这个关键字现在是什么值」,模板差异藏在 Subsystem 内部。
中断
规划出来的 Plan 不是执行到底不管的。框架里给 Goal 和 Action 都配了中断选项:监听 GOAP 状态变化,当特定关键字发生变动时,直接中断当前 Goal 或 Action、触发重新规划。潜行 AI 里最常见的就是「巡逻中突然看到玩家」——SeePlayer 位一翻,中断巡逻 Goal,重新规划成战斗。
StateTree 作为目标选择编辑器
UE5 的 StateTree 是一个通用分层状态机,把行为树的选择器和状态机的状态/转换揉在了一起。把它拿来当 GOAP 的目标选择层很顺手:StateTree 的每个节点对应一个 GOAP Goal。
这样做的好处是把「选哪个 Goal」这件事交回给策划:
- 每个节点的进入条件、Goal 优先级、增删改,都在 StateTree 编辑器里可视化配置。
- 进入条件由 StateTree 的求值器(Evaluator)直接从
BP_GOAPAgentComponent取值。 - 进入节点后自动执行该节点的 Task——所有节点的 Task 其实是同一个:通知
BP_GOAPAgentComponent创建对应 Goal、产生 Plan。
实践中会拆成两棵 StateTree,一棵管战斗 AI 的目标,一棵管非战斗 AI 的目标,各自维护、互不干扰。调试也方便,StateTree 的运行时高亮能直接看到当前停在哪个 Goal 上。
上生产后撞的两个坑
UObject 数量超上限导致崩溃
游戏跑一段时间后必崩,原因是 UObject 总数触顶。根子在规划器:A* 每次 Tick 都在创建 GOAPNode 里的 UGOAPState 来模拟路径,而 GC 回收的速度跟不上创建速度,UObject 越堆越多,最后撞上引擎上限崩溃。
解法是给 UGOAPState 做对象池:每次 Tick 规划时从池子里借对象,Tick 结束归还,不再让 GC 去追。把「反复 new UObject」换成「池子借还」之后,UObject 数量就稳住了。这个坑本质上是——别在每帧的热路径里创建 UObject,GOAP 的规划循环正好是最典型的犯案现场。
A* 算力吃不消
规划本身太贵,因为默认会把所有 Action 都塞进 A* 一起搜,每次 Tick 计算量爆炸。
解法是按当前 Goal 限制进入 A* 的 Action 数量:当前是 AttackGoal,就只放 MeleeAction、RangeAttackAction 这类相关动作进去,IdleAction、PatrolAction 根本不参与这次搜索。搜索空间一砍,单次规划开销立刻降下来。这不是我拍脑袋的做法,育碧在《刺客信条:奥德赛》和《渡神纪》的 AI 规划 postmortem 里也提了同一招——限制参与每个 Goal 规划的动作节点数量。
AI 跑起来之后还有一整套 Tick 管理、碰撞查询、群体避让的性能问题,那些不专属于 GOAP,单独放在 UE AI 性能优化实践。