游戏 AI 系统设计:行为树、状态机与 Flecs 的融合模式 AI Behavior Trees, FSMs & Flecs Patterns
别让一千个 NPC 一起思考:三种 AI 范式在 ECS 里的真实成本,以及怎么给它们装一个「按需结账」的脑子
为什么 AI 是 ECS 的试金石,也是照妖镜
先说一个我自己的翻车现场。三年前我接手一个 roguelike 沙盒项目,200 来个 NPC,每一只都是独立的小怪物:有的守着宝箱,有的在地图上乱逛,有的看到你就跑。原来的代码写得挺规矩——一个怪物基类,下面挂三层继承,各自重写「更新」方法。第一个月跑得挺好,第三个月开始,我加一个「被吓到逃跑」的状态,得改 11 个文件;我加一个「夜里更凶」的规则,又得改 9 个。程序员最怕的不是写不出来,是「本来应该很简单的事,突然变成一件要交代遗嘱的事」。
这就是 AI 系统在 ECS 里最迷人,也最伤人的地方。你以为 AI 只是「一个怪物往哪走」的问题,其实它是一整套关于状态该怎么存储、决策该多久算一次、感知该对谁可见的问题。它和渲染、物理不一样:渲染系统写完就能跑,物理系统写完就能跑,AI 系统写完,你永远不知道它「对不对」,只知道它「动起来了」。
说句实话,Flecs 这个框架最大的价值不是它快,是它把「数据」和「决策」还能拆开。你的敌人不是一堆继承树,而是一群带着 Tag、组件、关系的实体;你的行为不是被调用的函数,而是被查询筛出来的「符合条件的实体」。这个思维一变,你会突然发现:状态机、行为树、甚至 GOAP,本质上都是同一种东西——一个关于「实体现在是谁、接下来该是谁」的数据迁移问题。
这篇文章我打算讲四层:先拆三种 AI 范式和 ECS 的契合度(谁会痛、谁不痛);再讲一个每天都在拷问我的问题——状态到底该做成 Tag 还是 Component;然后是大量 NPC 的「摸鱼」艺术,也就是 LOD 降频,它决定了你能做 50 个 NPC 还是 1000 个;最后是感知系统,这是「关系」和「空间索引」在 ECS 里最漂亮的一次联姻。配上参数表和真实案例,咱们一次讲透。
第一层:三种 AI 范式的 ECS 契合度——谁是亲儿子,谁是上门女婿
新手有个特别常见的误解:以为行为树是最好的一种 AI,因为大厂在用。其实行为树、状态机、效用 AI 这三者,在 ECS 里是三种完全不同的生命形态。你选范式之前,先得知道自己要付什么账。
状态机(FSM)是 ECS 的亲儿子。为什么?因为状态机的本质是「离散状态的迁移」,而 ECS 的本质是「实体的增删改」。你看这两句话是不是同一件事的两面。在 Flecs 里,一个敌人的「巡逻/警戒/追击/攻击」状态,最直接的表达就是一个 Tag——有「追击」这个 Tag 的敌人,就是正在追击的敌人。状态切换 = 移除旧 Tag、加上新 Tag。查询「所有正在追击的敌人」,一行就筛出来了。干脆利落。
行为树是那个很能干的上门女婿。它能表达复杂逻辑,能复用节点,但它的树结构天然是指针状的:父节点管着子节点,叶子节点返回成功或失败,状态要从根一路运行到叶。你在一个「数据导向」的世界里搞一棵指针树,就像在超市的自动结账通道里推一辆购物车,能用,但通道不是为你设计的。稍不留神,行为树状态会从一堆散落在各实例上的「当前节点指针」变成你的主循环噩梦。
效用 AI(Utility AI)反而是另一种亲儿子。它不靠规则树,靠给每个可选行动打分,然后选最高分的。这个「打分」天然是数据:行动是一个实体,目标是一个实体,分数是一个组件,选择 = 查询 + 排序。在 Flecs 里你可以把「行动」做成预制体,把「评估参数」做成挂在实体上的小数,然后一个系统算分数,一个系统挑赢家。相比于行为树,它写起来更散,但每一块更干净。
| AI 范式 | 在 ECS 中的表达 | 契合度 | 主要痛点 | 适合的规模 |
|---|---|---|---|---|
| 有限状态机(FSM) | 状态=Tag,切换=增删 | ★★★★★ | 状态爆炸时迁移表难维护 | 50-300 个 NPC |
| 行为树(BT) | 树=父关系,节点=实体 | ★★★☆☆ | 实例碎片化、调试链路深 | 10-100 个精细 NPC |
| 效用 AI / GOAP | 行动=实体,分数=组件 | ★★★★☆ | 调参地狱、行为漂移 | 100-2000 个 NPC |
这里我得插一个真实的案例,因为它太有说服力了。《模拟人生》这套 1999 年的经典,它的 AI 设计放到今天看,简直像提前 20 年给 ECS 写了一封情书。它的核心是「广告牌系统」(Billboard):小人本身不写复杂逻辑,世界往小人头上「贴」广告牌——「我想去冰箱」「我想上厕所」「我想跟邻居聊天」。小人每一刻只是从头顶那几块广告牌里选一块来执行。你看这个结构,广告牌就是挂在实体上的组件,选不选得中取决于当前分值,执行完了摘下来。没有继承树,没有状态迁移表,就是数据的增删和打分。
为什么这套东西在 1999 年就那么有效?因为《模拟人生》要的不是一只能打 BOSS 的怪物,它要的是几百个 NPC 各活各的。你今天用 Flecs 做城市模拟、做殖民地管理、做沙盒,走的就是这条老路——用组件和关系去描述「我脑子里现在想干嘛」,而不是用代码去硬编码「第一,第二,第三」。说直白点:行为树是给少数角色写剧本,广告牌式设计是给一群角色办公司。你的游戏只有 20 个角色,写剧本没问题;你有 500 个 NPC,你就得开公司。
当然,反过来也得说清楚:《光环 2》为什么不走这条路?Bungie 在 2004 年前后从有限状态机切换到行为树,理由很清晰——当时他们的敌人 AI 状态迁移表已经庞大到难以维护,一堆状态组合出几百条边,动一根线就断三片。行为树把「条件的层次」用树形结构表达出来,让设计者能看懂哪一根分支先判断,哪一根写后备行为。Squad 级别的战术(找掩体、组织包抄、拉扯战线)需要这样的层次结构。但请注意,光环里每场战斗同时在场的精英数量通常是个位数到十几,单场战斗的精细度远高于模拟人生里的几百个市民。这是两个不同规模的 AI 问题,不是两个不同优劣的技术。
在 Flecs 里我要做行为树,最不坏的一种做法是把树「扁平化」:把整棵树预先编译成一张静态数据表,每个节点是一条记录,有类型、有参数、有子节点索引;每个实体的 AI 组件里只存一个「当前运行到哪几个节点」的小数组加上黑板数据。树的定义不占实体,实体的开销只有那一小撮数据。这样行为树在 ECS 里能活,但你要接受一个事实:你是在手脚都被绑住的情况下表达层次结构。要不要走这条路,取决于你对树层次的需求有多强。
第二层:状态作为 Tag 还是 Component——这个选择决定了你的帧时间
这是我在真实项目里被追问最多的问题,没有之一。「我的敌人有巡逻、警戒、追击、攻击四种状态,到底是每个状态一个 Tag,还是一个状态组件里填枚举值?」这个问题的答案不只是一个编码风格问题,它会实打实地改写你后面每个月的代码手感。
方案 A,状态作为 Tag。巡逻就是一个空的 Tag 组件,挂上就是巡逻。切换状态就是「移除巡逻 Tag,添加追击 Tag」。好处非常明显:查询「所有正在追击的敌人」时,这套查询只扫追击的那几张 archetype 表,天然就排除了非追击的对象,不用在每一行里做一次判断。这就是 Flecs 最爽的「免费筛选」。
坏处也很实在:每一次状态切换,实体都在换 archetype。它要从巡逻对应的表里把自己那一行挪走,然后挪到追击对应的表里。这不是零成本的。如果一个敌人频繁在「警戒」和「巡逻」之间来回横跳——你想想那种当敌人刚看到你一瞬间又缩回去的场景——你就在给实体反复搬家。而且灾难不止于此:一旦你有很多个 Tag 状态变量(战斗状态、移动状态、感知状态、天气影响各来几个),这些组合会催生出一堆 archetype。最初你还觉得「我看得过来」,等 archetype 数涨到两三百,内存里一堆表各自只有几行,缓存局部性直接崩掉。
方案 B,状态作为 Component。一个「AI 状态」组件,里面存当前状态的枚举值,加上一些状态需要的小数据(比如追击要存目标实体、搜索计时器)。状态切换就是原地改一个字段,实体不动窝,不换 archetype。开销小得可以忽略。
坏处是查询就没那么香了。你要查「所有正在追击的敌人」,得在一张大表里逐行判断「这一行状态是不是追击」。Flecs 对枚举组件有优化支持,查询可以按枚举值过滤(也就是把枚举值直接当成筛选条件),这部分性能其实挺好——但前提是你的状态字段真的是「枚举」,而不是「用字符串或整型硬编码告诉它」。这一点我踩过坑,一定要用 Flecs 的枚举注册方式,否则查询退化为逐行判断,那点优势就没了。
那到底怎么选?我的经验是用混合方案,而且必须区分两类「状态」:
高层的、互斥的、参与查询过滤的状态,用 Tag。比如「死亡」「眩晕」「警觉」这类决定实体整个 AI 生死的状态,是互斥的、周期长的、以及经常被别的系统查询的(渲染要知道死没死,物理要知道眩晕要不要动)。这类状态用 Tag,换 archetype 的频率低,换来的查询优势是真金白银。
底层的、高频跳变的、带数据的进程状态,用 Component。比如「当前正在执行哪个行为节点」「当前追踪的目标是谁」「上一次看到玩家是多少秒前」。这些数据每一帧都可能微调,用组件原地改,别提多省。
| 维度 | 状态=Tag | 状态=Component(枚举) | 建议 |
|---|---|---|---|
| 切换开销 | 换 archetype,有搬运成本 | 原地改字段,几乎免费 | 高频切换的用组件 |
| 查询效率 | 天然过滤,只扫相关表 | 整表扫,靠枚举优化 | 参与多系统过滤的用 Tag |
| 调试可读性 | 一眼看出谁在干嘛 | 要点开组件看数值 | 周期长的用 Tag |
| archetype 压力 | 每个组合都催生一张新表 | 一张表通吃 | 周围变量多时用组件 |
我要再泼一盆冷水:别把「状态」和「行为」混淆。状态是「我处于什么状况」,行为是「我在执行什么动作」。一个敌人可以「正在攻击」,同时「处于中毒状态」。如果你只有一个状态字段,你会发现这两件事装不进去——你被迫把它们压成互斥的枚举,然后代码里冒出「中毒时攻击要打八折」这种判断,于是你又会想要「多重状态」。所以真实工程里,你通常不是一个状态字段,而是几个:一个高层的「AI 主状态」(Tag)、一个「动作进程」(组件)、再加一组短期的「临时效果」(Tag)。这就是为什么状态设计是 ECS AI 的地基——你在给「到底谁算谁」立法。
再说一个参数维度:你状态切换的「节流」。别让 AI 每一帧都做状态判断。经验的区间是:一般状态机每 100 到 200 毫秒做一次状态评估完全够用,只有战斗接触期的敌人(或者主角附近的)才需要每帧评估。你自己试着把评估频率从每帧降到每 6 帧,然后再看画面——绝大多数情况下你根本看不出差别,但帧时间能省下 30% 到 60%(取决于你 NPC 数量和 AI 复杂度)。这是投资回报率最高的一次调优,没有之一。
第三层:LOD 降频——让远处的 NPC 合法摸鱼
这一层是我最喜欢的,因为它充满了朴素的生活智慧。你的所有 NPC 都每一帧思考,就像你家里所有电器都二十四小时全功率运转一样——浪费,而且没必要。玩家的眼睛只盯着屏幕中央那一小块地方,剩下 90% 的世界在玩家的感知之外背景运行。一个在 200 米外沿街巡逻的守卫,它每一帧都在算「往左还是往右」,这对玩家来说是 0 价值。
LOD 降频(Level of Detail)这个名字偷自渲染,但概念是一样的:离得越远的东西,大脑(算力)越简化。体验上你必须做对一件事——远处的简化不能让玩家看出破绽。这是关键。玩家走进一个区域,远处那群本来在「发呆」的 NPC 必须自然地「活」起来,不能像睡着了突然惊醒一样。这是「LOD 切换」最容易翻车的点,也是很多偷懒方案被玩家一眼看穿的原因。
先给出一个我反复用过的分档表,你拿去当起点调:
| 层级 | 距离(以主角为圆心) | AI 决策频率 | 寻路频率 | 动画/表现 | 感知检测 |
|---|---|---|---|---|---|
| L0 近身 | 0-15 米 | 每帧 | 每帧 | 全特效+物理 | 每帧 |
| L1 中距 | 15-40 米 | 每 3 帧 | 每 6 帧 | 降级动画 | 每 6 帧 |
| L2 远距 | 40-100 米 | 每 10 帧 | 每 30 帧 | 骨骼动画开着,蒙皮简化 | 每 20 帧 |
| L3 远景 | 100 米+ | 事件驱动,无周期评估 | 路径预计算/直接位移 | 静态姿态或极小动画 | 关闭 |
| 离屏 | 不在视锥内 | 完全冻结,纯结算 | 不做 | 无 | 关闭 |
这个表最值得玩味的是 L3 和「离屏」这两档。L3 意味着一个家伙在玩家 100 米外,它根本不需要每一帧做决策,它可以「好好地在街上走」,但它的决策只在你「靠近」或某个事件发生时被唤醒。而离屏的这一档,你可以把它理解成「结算式模拟」——它不真的在一帧一帧跑,它只是按时间结算一下「过去了多久、它应该走到哪了、有没有碰到什么事件」。这是把「实时模拟」偷偷变成「数学计算」——一样的游戏体验,少得多的开销。
这里有一个我从实战里提炼出的经验:分档边界要加迟滞(hysteresis),别用一刀切。比如 L1 到 L2 的边界设在 40 米,但如果玩家刚好站在 40 米处前后晃,NPC 就会在 L1 和 L2 之间反复横跳,你会看到远处的敌人走路一抽一抽的。搞定这个很粗暴但有效:**上升档用 40 米,下降档用 34 米**,给它留 6 米缓冲区。这就像空调别把目标温度设成每 0.1 度就启停一样。真实游戏里这个参数我一般设成「距离的 10% 到 15%」。
第二个经验:别让 LOD 切换瞬间出现行为中断。一个正在追击玩家的敌人,哪怕玩家跑远了把它降到 L2,它也得继续追,只是决策频率变低——它是在追,只是脑子变得迟钝一点。千万别在降级那一刻把它的状态重置回「巡逻」。后果是玩家回头一看,敌人在原地发呆——这太出戏了。所以 LOD 影响的是「频率」,不是「状态」。记住这两个词的边界。
真实案例看《环世界》(RimWorld)。这款游戏最神奇的一点是它屏幕上随时可能有 20 来个殖民者、几十个动物、一堆机械体,每个都有自己的工作队列和需求,但它跑得动。秘密就在于它不是每一帧让所有东西思考——它有一个粗糙但极其有效的任务系统(Job System):每个单位只负责执行一个「Job」,Job 完成或被打断时才去重新找下一个。你看到的是每个小人都在各忙各的,其实是——**大多数时候,它们在执行一个已经确定的任务,根本没在做决策。**这不就是 LOD 降频的极致版本吗?把 AI 从「每一帧都要想」变成「有活才想」。我在 Flecs 里做这种任务队列,靠的是一组组件(当前任务、任务目标、任务进度)+ 一个低频的「任务分配系统」,每 20 帧左右扫一遍空闲实体,分配新任务。这个粒度在殖民模拟和沙盒游戏里几乎不会让玩家察觉。
还有一点是我事后才想明白的:LOD 不只是省 CPU,它降低了「副作用的容量」。越是低频的 AI,改起来越不担心某处连锁反应。比如你有一百个 NPC 都跑着每帧决策,你改一个行为参数——你心跳都变快,因为你不知道这一改会在哪触发什么连锁。放到 L2 那些 NPC 上,你可以大胆调。所以 LOD 其实是在工程上给你「分层确定性」:近处的那几个你精细做、慢慢调;远处的一千个你粗略做、快速调。这个心理上的解脱,比那点性能更值钱。
第四层:感知系统——关系和空间索引的一次联姻
感知系统是 AI 里最容易被低估的部分。很多教程会告诉你:「AI 看得见玩家吗?用距离判断就行。」这句话在 10 个敌人的游戏里没错,在 500 个敌人的游戏里是原罪。你想想,如果每帧让每个敌人去遍历「所有玩家、所有其他 NPC、所有声响」算距离,那就是 N×M 次判断——10 个敌人和 500 个敌人是 50 倍的差距,100 个可能就是 2500 倍。感知系统的第一课,永远是「不要做全表扫」。
在 Flecs 里做感知,你需要三块东西,我把它叫「三件套」:
第一件:视野信息,用关系绑。Flecs 的 relations(关系)在这里简直是量身定制。你可以定义「可见目标」关系、「最后已知位置」关系、「敌对」关系。一个敌人当前看到了谁,就挂上和谁的「可见目标」关系;目标丢了,就把关系转成「最后已知位置」(带上坐标和看到的时间戳)。这样一来,你的 AI 系统查询「所有正在被追的目标」,就是一个关系查询,干净利落。而且关系还带来一个挺有用的副产品——关系是双向可查的:你既可以问「这个敌人看到谁」,也可以问「这个玩家被谁看到了」。后者在「被围攻」触发、战争迷雾、仇恨链统计里都好用。
第二件:周围候选,用空间索引缩范围。视野检测确实是距离加角度,但光是距离就不能对全表算。做法是加一层网格哈希:场景按格子切(格子尺寸一般设成「最大视野半径」或它的 2 倍),每个实体注册到当前所在的格子里。一个敌人视野判定时,只去查自己所在格子和邻接的 8 个格子里的候选目标。这样你从全表扫变成了「扫周围一圈」,候选数通常从几千降到几十。这个优化是非线性收益——1000 个 NPC 的场景,光这一招就能救回一半帧时间。
为什么格子尺寸要约等于视野半径?因为如果格子远大于视野半径,你会算一堆确实在不邻格的候选;如果格子远小于视野半径,你又得查一大圈格子。约等视野半径(或稍大一点)的时候,通常只需要扫 9 格,而且每格里的候选数小。这套参数我在沙盒项目里反复验证过,通用性不错。当然,如果视野半径是可变的(远处看很远,近处反而瞎),那要取最大半径来定格子。
第三件:感知的「记忆衰减」。这一点特别容易被忽略,但它是「敌人是否显得有人味」的关键。一个 AI 看到玩家躲到柱子后面了,不应该立刻「忘掉」。它应该往「最后看到的位置」走,走一段、东张西望一下,找不到,然后才放弃。这中间的数据就是记忆:最后看到的位置、最后看到的时间点、当前搜索倒计时。用关系来表达非常自然——「最后已知位置」关系带一个时间戳组件,一个计时系统定期检查:如果现在时间减去时间戳超过 8 秒,就删掉这个关系,进入放弃状态。
这个「记忆 + 搜索」流程是我认为最值得投时间的部分,因为它是玩家体验里「敌人好像有点聪明」的主要来源。玩家能不能从柱子后面绕出来偷袭,取决于 AI 有没有「往最后位置去搜索」这个行为。这个行为不需要复杂,只要 8 到 12 秒的搜索期,玩家就会感觉「卧槽,它知道我在哪」。而实际上它就是记了一个坐标——这就是数据结构的魔法:你只是存了一个点,玩家以为它很聪明。
案例这块,我想聊《中土世界:战争之影》(Shadow of War)的宿敌系统作为辅助对比。它对标「感知系统」的启发不在实时 AI,而在记忆的持久化:一个兽人队长被打败后,它记得你、记得战斗的地点、记得是谁赢了。这些记忆不只是一次战斗内的记忆,它跨战斗、跨存档、跨游戏流程。它告诉我们一个道理:感知系统的精妙不在于它「看」得多好,而在于它「记」得多对。你在 Flecs 里做感知,请先把数据结构(谁感知到了谁、何时、何地)设计清楚,再去琢磨规则。记忆的边界清晰了,AI 的人味自然就出来了。
顺便讲一个参数区间:感知检测的频率。我在前面 LOD 表里给了「近身每帧、中距每 6 帧、远距每 20 帧」。这里具体到视野判定,我的建议是——L0 近身圈可以每帧,因为目标少;L1 可以放宽到每 4 到 6 帧,因为即使延迟 100 毫秒,玩家也很难察觉「它 100 毫秒后才看见我」这件事。只有一种情况你需要小心:潜行游戏。潜行玩法里玩家就是在抠「什么时候被看见」这个瞬间,这时候你要把感知频率提到足够高,否则玩家会觉得「我明明藏好了它还是发现我」或者反过来「它明明看着我但没反应」。潜行类游戏的感知通常要提到每帧,但那就得用更聪明的空间索引来补上开销。
把 AI 变成一张可调的配置表
聊完四层,你可能有一堆「道理我都懂了,但下周开工我该填什么数」的困惑。我把常见参数整理成三张表。不要照抄,把它们当作起点,你项目里的数值一定不一样,但有起点你才敢改。
第一张:状态频率表。这是你第一天就该定的表。它决定了你的调度粒度。
| 评估项 | 建议区间 | 作用 | 我踩过的坑 |
|---|---|---|---|
| 高层状态机评估 | 100-200 毫秒一次 | 决定巡逻/警戒/追击 | 设成每帧,无事发生但 CPU 爆炸 |
| 战斗内行为树运行 | 每 2-4 帧一次 | 决定当前攻击/找掩体 | 设成每帧,一两场战斗就卡 |
| 寻路重算 | 目标变化时触发 / 1 秒兜底 | 防止每帧重算 | 没加兜底,有时候 AI 卡住不动 |
| 感知视觉判定 | 近身每帧到每 6 帧,远距每 20 帧 | 侦测玩家 | 潜行类必须提高 |
第二张:LOD 分档表。前述那张表的简版;关键参数是边界、频率、迟滞。
| 参数 | 建议值 | 说明 |
|---|---|---|
| L0 半径 | 10-20 米 | 通常约等于玩家「能看清一个守卫表情」的距离 |
| L1 半径 | 35-50 米 | 战斗中距离 |
| L2 半径 | 80-120 米 | 超过这个距离就进入远景档 |
| 边界迟滞 | 边界距离的 10%-15% | 防抖,避免边界反复切换 |
| 远景冻结延迟 | 出画 2-3 秒后才切「离屏」档 | 防止玩家快速转动镜头时把 NPC 切碎 |
第三张:感知参数表。它决定了敌人「精不精」。
| 参数 | 建议值 | 说明 |
|---|---|---|
| 视野角 | 90-120 度 | 超过 120 度玩家会觉得不真实 |
| 视野距离 | 15-30 米(近战),40-60(远程) | 看你的战斗节奏 |
| 记忆保留时长 | 8-12 秒 | 短了敌人瞬间失忆,长了像开了挂 |
| 搜索时长 | 8-15 秒 | 追、找、放弃的完整周期 |
| 空间格子尺寸 | 约等于最大视野半径 | 太小查得多、太大判断多 |
| 感知频率分档 | 近身每帧、中距 6 帧、远距 20 帧 | 按 LOD 联动 |
两条路:你先抄哪个,看你手上有多少人
初级速览(一个人做原型,200 个 NPC 以内):别管行为树,先用 Tag 做状态机。巡逻、警戒、追击、攻击四个 Tag,状态切换就是增删。感知系统先用距离 + 视野角,先不对全表做,用「玩家 + 怪物所在的格子」做粗筛。LOD 先做一档——玩家 40 米外所有 NPC 的 AI 按每 6 帧跑一次,先别做 L2、L3 那两层。这套方案你一个下午就能跑起来,先让东西动,然后才开始谈调优。不要因为看到行为树、GOAP 这些「时髦货」就跳过状态机直接上手。我见过太多团队,第一个版本把最复杂的范式全用上,项目在 3 个月后死于维护。状态机在原型期就是无敌的,别跟它较劲。
进阶深入(团队 3-5 人,500-2000 个 NPC,或做模拟/策略类):这时候要做三件在初级方案里没做的「结构工程」。第一件,把「行为定义」和「行为执行」分离——顶层的决策(任务/状态/目标)用数据描述,执行部分交给系统;用 Tag 加组件混合的方式,而不是一根铁丝捅到底。第二件,做两级哈希(层级空间索引):大格子套小格子,保证一个 NPC 的感知候选永远不超过几十个,这决定了你能不能到几千。第三件,把感知系统做成事件驱动——不再是每个 NPC 每帧主动扫描,而是「谁进入了谁的视野」变成一次事件(Flecs 的 Observer 在这里很好用),然后由 NPC 的响应式逻辑去处理。这一步能让你从几百稳定跨到一两千。
还有一个更关键的分水岭:你的 AI 是「实时战斗为主」还是「世界模拟为主」?如果是前者,你的核心矛盾是「反应速度」——低延迟、高频率、精确判定,LOD 的激进程度要保守一点。如果是后者,核心矛盾是「世界可信度」——频率可以低、细节可以糊,但 NPC 的行为要在长时间里还可信、还有连续性。这两个方向里,状态机、行为树、效用 AI 的权重完全不同。先想清楚你是哪种,再选你的方案,否则你会在两种方案之间反复横跳,衣服都脱了没洗成澡。
常见问题与反面教材
FAQ
Q1:我的游戏只有 30 个 NPC,还有必要做 LOD 吗?
看情况。30 个 NPC、每个跑简单状态机,你确实不需要 LOD。但如果你每个 NPC 都开了感知、寻路、行为树,那 30 个也不少了。我的判断标准是:AI 占了你多少帧时间?低于 5% 就先别碰;到 15%、20% 了,优先做「感知分档」和「决策分档」两刀,不用上全套 LOD。
Q2:行为树和状态机,我该选哪个?
别被「行为树更先进」误导。**状态机适合「有明确态、切换关系简单」的场景,行为树适合「有条件层次、需要复用」的场景。**我自己的经验准则是:如果你的 AI 能在纸上画出不超过 8 个状态、而且状态之间的迁移能填进一张表,就用状态机;如果你发现自己需要「先判断 A,再判断 B,否则 C」这种多层嵌套,那才是行为树出场的时机。还有一个很现实的判断标准:你的设计者能不能看懂状态迁移表?能看懂就用状态机。行为树本质上是给「状态多到没人能画完」的情况准备的。
Q3:状态用 Tag 和用枚举组件,切换成本到底差多少?
粗说:枚举组件切换是「改字段」,几乎无感;Tag 切换是「换表」,有搬运成本。在单次切换上,两者的差距可能只有微秒级——你不会在 100 个 NPC 时感觉到差别。差的是概率的累积:如果你每秒有几万次状态切换(不是 n 次,是几万次),那 Tag 方案会被放大成瓶颈。所以关键不是「哪个快」,而是「你的切换频率是多少」。频率低于每帧每实体一次的,可以放心用 Tag;高于这个的,果断用组件。
Q4:感知系统要不要用 Flecs 的 Observer 来做?
用得对,非常好。Observer 可以在「某个实体获得某个关系」的那一刻触发响应,而不是每帧轮询,这正是感知系统的理想形态。但要注意一个陷阱:Observer 是同步触发的,而且可能在高频操作下连环触发。如果你的视野变化每帧都在发生(比如一个玩家反复在敌人视野边缘晃),Observer 会被高频唤醒,这时反而不如低频轮询。我的做法是:视野「进入」用 Observer(事件少、要立即反应),视野「保持」用轮询(每 4 到 6 帧扫一次即可)。不要把两者混成一个。
Q5:为什么我的 AI 在远处看起来像在发呆?
99% 是 LOD 切换时状态没接好。三个常见原因:第一,切换时把状态重置了(见坑四);第二,你在远处把它的动画频率砍太狠,走路像抽搐,人眼一看就觉得不自然;第三,远距离的 NPC 没有「闲逛」状态,就是站着不动,于是看起来像雕像。给你的建议:给远处 NPC 至少保留一层「闲逛/待机」的轻量行为,让它慢慢在一个小范围里游走,玩家远远看过去,会觉得「这个世界是活的」。这个「假活」的成本极低,但对世界可信度贡献极大——这是所有开放世界游戏的公共秘密,你迟早得学会。
最后留一个问题给你:你有没有想过,玩家嘴里「这个游戏的 AI 好聪明」,指的到底是什么?大概率不是它会用复杂的战术,也不是它能记住你三天前干了啥——而是它在你预料之外又意料之内地「像一个有动机的存在」做了一件小事。那么,你的 AI 系统里,哪一种数据迁移,是那个「像」的来源?想清楚这个,你就不是在做 AI,你是在做「像」。