“如果 AI 只会走固定路径,那遇到动态掩体和多目标威胁时基本等于裸奔。”这句话是我在 Unity 社区里看到次数最多的一句吐槽,也是 Unreal EQS 被反复提起的原因。这次我们来看一套在 Unity 6 中复刻 Unreal Engine EQS(Environment Query System,环境查询系统)的完整思路与代码框架。
先说明白:这不是让你下载一个现成插件,而是从零搭建一个可以在运行时动态评估场景、对候选位置打分排序、最终让 AI 做出最优决策的模块。如果你熟悉 Unreal,一定知道 EQS 的工作方式:AI 发起一次查询,系统根据上下文生成一批候选点,再用距离、可见性、角度、路径可达性等多个维度给候选点打分,最后 AI 走向分数最高的位置。Unity 官方没有直接提供这套系统,传统做法是 NavMeshAgent 加上手动维护的路径点,但这套方案很难应对动态场景。
本文会从 Unreal EQS 的核心概念讲起,在 Unity 6 中用 C# 实现上下文、生成器、测试项和查询运行器,然后接入一个带 NavMeshAgent 的 AI 角色,演示动态选点和掩体选择。最后补充调试可视化、性能观察、常见问题排查和工程化扩展建议。适合对 Unity AI 有一定基础、想突破固定巡逻点限制的开发者,也适合从 Unreal 转向 Unity、想找回 EQS 开发体验的读者。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 目标引擎 | Unity 6(也可用于 2022 LTS,需注意 AI Navigation 包版本差异) |
| 实现语言 | C#,纯脚本实现 |
| 系统类型 | 轻量环境查询系统,逻辑参考 Unreal EQS |
| 核心能力 | 查询上下文、候选点生成、多测试项评分、最优位置排序 |
| 可扩展性 | 生成器与测试项均为抽象类,可自行扩展 |
| 依赖组件 | Unity AI Navigation 包、NavMeshAgent、Collider |
| 运行方式 | MonoBehaviour + ScriptableObject,编辑器内可视化配置 |
| 外部插件 | 不需要,复用 Unity 内置 API |
| 调试能力 | Gizmos 绘制候选点与分数,支持运行时查看 |
| 性能策略 | 查询冷却、候选点上限、后续可接 Job System 并行评分 |
| 适合场景 | AI 掩体选择、动态巡逻点、多目标威胁评估、技能释放位置选择等 |
这里再补充一句关于项目定位的判断:Unreal 的 EQS 是一套完整且复杂的系统,本文不会也没有必要在 Unity 里复刻全部细节。核心目标是复刻它的“查询-生成-测试-排序”工作流,把这套决策逻辑变成 Unity 工程里可复用、可调试、可扩展的模块。从实际项目角度看,这套精简版会比盲目照搬 Unreal 的完整架构更实用。
2. Unreal EQS 是什么,Unity 为什么需要它
Unreal 的 EQS 全称是 Environmental Query System,直译是环境查询系统。它不是某个具体功能,而是一套通用的“让 AI 在环境中找最优位置/最优目标”的决策框架。一个标准 EQS 查询流程包含五个部分:
- 查询请求方(Querier):通常是 AI Controller,发起一次查询请求。
- 上下文(Context):描述查询的参考对象,比如查询者自身、目标玩家、某个技能释放点。
- 生成器(Generator):根据上下文在空间中生成一批候选点,常见的有网格、圆环、圆锥、点阵。
- 测试项(Test):对每个候选点执行一个或多个测试,测试可以过滤候选点,也可以给候选点打分。
- 结果排序:把多个测试项的分数加权合并,选出最高分位置返回给 AI。
Unreal 的 EQS 之所以强,是因为它把“找位置”这件事从硬编码中解放出来。AI 不关心具体在哪几个点之间巡逻,而是通过查询动态获取候选位置,再用测试项模拟真实决策:太远不要、没掩体不要、敌人能看见不要、路径走不到不要。这些规则全部可配置,换一个场景或者换一种 AI,只需要调整查询资产,不需要改代码。
Unity 的问题在于没有原生 EQS。常用 AI 方案是 NavMeshAgent + 固定路径点,或者运行时随机选一个 NavMesh 上的位置。固定路径点显然不够灵活,随机位置又缺少合理性判断。更关键的是,当场景里存在多个 AI 角色,每个 AI 需要根据视野、距离、覆盖物、路径长度做不同决策时,没有一套统一的“评估环境”框架,代码很快就会变成一团乱麻。
所以,在 Unity 中复刻 EQS 的核心意义不是炫技,而是补上 Unity AI 开发中缺失的这一层抽象:把环境感知变成可配置、可评分、可查询的模块。这里的“可配置”非常关键,它意味着策划或者搞 AI 的同事可以在 Inspector 里调整测试项权重,而不是每次改逻辑都打开 IDE 改代码。
3. 环境准备与 Unity 6 项目配置
开始写代码之前,先准备一个干净的 Unity 6 工程。后面所有实现都基于这个工程,建议按下面的清单逐项确认:
3.1 Unity 6 版本与模块
- 通过 Unity Hub 安装 Unity 6 正式版,选择包含 Windows/Mac Build Support 的模块。
- 创建 3D(Built-In Render Pipeline)项目即可,不需要 URP/HDRP。
- 代码编辑器推荐 VS Code 或 Rider,也可以直接用 Visual Studio。
3.2 AI Navigation 包
Unity 6 中 NavMesh 相关功能被整理进了 AI Navigation 包。打开 Package Manager,搜索AI Navigation,点击 Install。这个包会提供 NavMeshSurface 组件,用于在场景中烘焙 NavMesh。
# 如果命令行安装,可以在项目根目录执行: # 但更推荐直接在 Package Manager 中搜索安装3.3 场景基础结构
搭建一个简易测试场景,至少包含以下几类物件:
- 一个地面(Plane 或者任意 Cube 拉伸,注意加 Collider)。
- 一个烘焙好的 NavMesh(挂 NavMeshSurface,点击 Bake)。
- 一个 AI 角色:一个 Capsule + NavMeshAgent 组件。
- 一个玩家目标:一个 Cube 或 Capsule,作为 Target 上下文。
场景结构参考如下:
场景 ├── Ground │ └── NavMeshSurface ├── AI_Agent │ ├── Capsule │ └── NavMeshAgent └── Player_Target └── Capsule3.4 目录与脚本规划
建议在 Assets 下建立如下目录结构,保持代码和配置分离:
Assets/ ├── Scripts/ │ ├── EQS/ │ │ ├── Core/ │ │ ├── Generators/ │ │ └── Tests/ │ └── AI/ ├── EQSAssets/ └── Scenes/Core 放上下文、候选点、查询运行器等基础类;Generators 放生成器;Tests 放测试项;AI 放最终调用查询的角色脚本。EQSAssets 用来存放运行时创建的 ScriptableObject 查询配置资产。
4. 自研 EQS 框架的架构设计
在写代码前,先把架构过一遍。这套 Unity EQS 框架包含 5 个核心类型:
4.1 核心概念映射
| Unreal EQS 概念 | Unity 自研实现 |
|---|---|
| 上下文(Context) | EQContext类,保存 Querier、Target、QueryCenter |
| 候选点(Item) | EQItem类,包含位置、总分、各测试得分 |
| 生成器(Generator) | EQGenerator抽象类,提供候选点列表 |
| 测试项(Test) | EQTest抽象类,对候选点返回分数 |
| 查询配置(Query) | EQSQueryConfigScriptableObject 资产 |
| 查询运行器 | EQSRunner静态类,执行完整查询流程 |
4.2 执行流程
一次完整查询的执行顺序如下:
- 外部调用方(AI 角色脚本)构造
EQContext并传入查询配置EQSQueryConfig。 - 运行器调用生成器的
Generate(context)获取候选点列表。 - 对每个候选点创建
EQItem。 - 遍历
config.tests中的每个测试项,调用test.Score(item, context),按权重累加分数。 - 对全部候选点按总分排序。
- 截取前 N 个候选点,返回给调用方。
这个流程和 Unreal EQS 的“生成优先、测试评分、排序选择”一致,只是处理方式更轻量。设计上,生成器和测试项都用抽象类实现,这样后续扩展新的生成策略或测试维度,不需要改动运行器逻辑,符合开放封闭原则。
5. 从零实现 EQS 核心:生成器与测试
下面开始写核心代码。建议按小节顺序逐个创建脚本。
5.1 上下文与候选点
上下文是查询的输入,候选点是查询的输出基础。新建Assets/Scripts/EQS/Core/EQContext.cs:
using UnityEngine; namespace EQSFramework { /// <summary> /// 查询上下文:所有生成器和测试项都从这里获取参考信息。 /// </summary> public class EQContext { public Transform Querier; // 查询者,通常是 AI 角色 public Transform Target; // 目标,通常是玩家 public Vector3 QueryCenter; // 查询中心点 } }再新建EQItem.cs:
using UnityEngine; namespace EQSFramework { /// <summary> /// 候选点:一个待评估的位置。 /// Score 是多个测试项加权后的最终分数。 /// </summary> public class EQItem { public Vector3 Position; public float Score; public float DistanceToTarget; public bool IsVisible; } }这里把距离和可见性作为常用字段直接放进候选点,是为了方便在调试 Gizmos 中直接读取,也方便后续测试项复用。实际项目中可以按需扩展,但不要塞太多一次性字段,否则候选点对象会变得臃肿。
5.2 生成器:网格与圆环
生成器负责产生候选位置。先定义抽象基类EQGenerator.cs:
using System.Collections.Generic; using UnityEngine; namespace EQSFramework { public abstract class EQGenerator { public abstract List<Vector3> Generate(EQContext context); } }网格生成器是最常用的生成器,适合在某个区域内均匀撒点。新建EQGridGenerator.cs:
using System.Collections.Generic; using UnityEngine; namespace EQSFramework { [System.Serializable] public class EQGridGenerator : EQGenerator { public Vector2 size = new Vector2(10f, 10f); public float cellSize = 1f; public float heightOffset = 0f; public override List<Vector3> Generate(EQContext context) { List<Vector3> points = new List<Vector3>(); Vector3 center = context.QueryCenter; int xCount = Mathf.Max(1, Mathf.RoundToInt(size.x / cellSize)); int zCount = Mathf.Max(1, Mathf.RoundToInt(size.y / cellSize)); Vector3 start = center - new Vector3(size.x * 0.5f, 0f, size.y * 0.5f); for (int x = 0; x < xCount; x++) { for (int z = 0; z < zCount; z++) { Vector3 p = start + new Vector3( x * cellSize + cellSize * 0.5f, heightOffset, z * cellSize + cellSize * 0.5f ); points.Add(p); } } return points; } } }圆环生成器适合在角色周围呈圆形分布候选点,常用于掩体选择或技能位置选择。新建EQRingGenerator.cs:
using System.Collections.Generic; using UnityEngine; namespace EQSFramework { [System.Serializable] public class EQRingGenerator : EQGenerator { public int count = 8; public float radius = 5f; public float heightOffset = 0f; public override List<Vector3> Generate(EQContext context) { List<Vector3> points = new List<Vector3>(); Vector3 center = context.QueryCenter; for (int i = 0; i < count; i++) { float angle = (360f / count) * i * Mathf.Deg2Rad; Vector3 offset = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radius; Vector3 p = center + offset; p.y += heightOffset; points.Add(p); } return points; } } }生成器本身不关心点是否可到达,这是测试项的事。这样职责分离,生成策略可以随意组合测试策略。
5.3 测试项:距离、可见性、角度、导航
测试项是 EQS 的核心。先定义抽象基类EQTest.cs:
using UnityEngine; namespace EQSFramework { public abstract class EQTest { public bool negate; // 取反,用于做“反向过滤” [Range(0f, 1f)] public float weight = 1f; // 权重 public abstract float Score(EQItem item, EQContext context); } }距离测试:候选点离目标越近分越高。
using UnityEngine; namespace EQSFramework { [System.Serializable] public class EQDistanceTest : EQTest { public float maxDistance = 10f; public override float Score(EQItem item, EQContext context) { Vector3 targetPos = context.Target ? context.Target.position : context.QueryCenter; float dist = Vector3.Distance(item.Position, targetPos); item.DistanceToTarget = dist; float score = 1f - Mathf.Clamp01(dist / maxDistance); float result = negate ? 1f - score : score; return result * weight; } } }可见性测试:AI 所在候选位置能否看到目标。用 Physics.Linecast 实现,注意需要排除 AI 自身碰撞体,最好通过 LayerMask 处理。
using UnityEngine; namespace EQSFramework { [System.Serializable] public class EQVisibilityTest : EQTest { public float eyeHeight = 1.5f; public LayerMask obstacleMask = ~0; public override float Score(EQItem item, EQContext context) { Vector3 from = item.Position + Vector3.up * eyeHeight; Vector3 to = context.Target ? context.Target.position + Vector3.up * eyeHeight : context.QueryCenter + Vector3.up * eyeHeight; bool visible = !Physics.Linecast(from, to, obstacleMask); item.IsVisible = visible; float score = visible ? 1f : 0f; float result = negate ? 1f - score : score; return result * weight; } } }角度测试:AI 朝向和候选点方向的夹角越小,分越高。这个测试适合“优先选择正前方位置”的场景。
using UnityEngine; namespace EQSFramework { [System.Serializable] public class EQDotTest : EQTest { public float maxAngle = 60f; public override float Score(EQItem item, EQContext context) { Vector3 from = context.Querier ? context.Querier.position : context.QueryCenter; Vector3 forward = context.Querier ? context.Querier.forward : Vector3.forward; Vector3 dir = item.Position - from; dir.y = 0f; dir.Normalize(); float angle = Vector3.Angle(forward, dir); float score = 1f - Mathf.Clamp01(angle / maxAngle); float result = negate ? 1f - score : score; return result * weight; } } }导航可达性测试:候选点是否能通过 NavMesh 到达,路径越短分越高。这个测试可以在一定程度上避免 AI 选中不可达位置。
using UnityEngine; using UnityEngine.AI; namespace EQSFramework { [System.Serializable] public class EQPathfindingTest : EQTest { public float maxPathDistance = 20f; public override float Score(EQItem item, EQContext context) { Vector3 from = context.Querier ? context.Querier.position : context.QueryCenter; NavMeshPath path = new NavMeshPath(); bool hasPath = NavMesh.CalculatePath(from, item.Position, NavMesh.AllAreas, path); if (!hasPath || path.status != NavMeshPathStatus.PathComplete) { float failScore = negate ? 1f : 0f; return failScore * weight; } float pathLen = 0f; if (path.corners.Length > 1) { for (int i = 1; i < path.corners.Length; i++) { pathLen += Vector3.Distance(path.corners[i - 1], path.corners[i]); } } float successScore = 1f - Mathf.Clamp01(pathLen / maxPathDistance); float result = negate ? 1f - successScore : successScore; return result * weight; } } }这里有个工程细节要说明:NavMesh.CalculatePath不保证终点一定在 NavMesh 上,如果候选点偏到 NavMesh 外,路径状态会变成PathPartial或PathInvalid。因此更稳妥的做法是在EQItem生成后首先用NavMesh.SamplePosition把候选点修正到最近 NavMesh 表面上,然后再执行其他测试。后面第 5.4 节会给出这个修正步骤。
5.4 查询配置与运行器
查询配置使用 ScriptableObject,这样可以把一组生成器和测试项保存成资产,多个 AI 角色复用同一份配置。新建EQSQueryConfig.cs:
using System.Collections.Generic; using UnityEngine; namespace EQSFramework { [CreateAssetMenu(fileName = "EQSQuery", menuName = "AI/EQS Query")] public class EQSQueryConfig : ScriptableObject { [SerializeReference] public EQGenerator generator = new EQGridGenerator(); [SerializeReference] public List<EQTest> tests = new List<EQTest>(); public int maxItemCount = 32; public bool sampleNavMeshPosition = true; public float sampleNavMeshDistance = 2f; } }这里使用了[SerializeReference],它的作用是让 ScriptableObject 在 Inspector 中能够序列化保存抽象类派生实例。生成器和测试列表都会在 Inspector 中显示为可折叠对象,可以手动添加不同的生成器类型和测试类型。这是 Unity 对多态序列化的标准做法,需要 Unity 2019.3 以上版本,Unity 6 完全支持。
然后编写查询运行器EQSRunner.cs:
using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; namespace EQSFramework { /// <summary> /// 查询运行器:负责执行一次完整的 EQS 查询。 /// </summary> public static class EQSRunner { public static List<EQItem> Run(EQSQueryConfig config, EQContext context) { if (config == null || context == null) { Debug.LogWarning("EQS 查询失败:配置或上下文为空。"); return new List<EQItem>(); } List<Vector3> points = config.generator.Generate(context); List<EQItem> items = new List<EQItem>(points.Count); foreach (Vector3 p in points) { Vector3 candidatePos = p; if (config.sampleNavMeshPosition && NavMesh.SamplePosition(p, out NavMeshHit hit, config.sampleNavMeshDistance, NavMesh.AllAreas)) { candidatePos = hit.position; } EQItem item = new EQItem { Position = candidatePos, Score = 0f }; foreach (EQTest test in config.tests) { if (test == null) continue; item.Score += test.Score(item, context); } items.Add(item); } items.Sort((a, b) => b.Score.CompareTo(a.Score)); if (items.Count > config.maxItemCount) { items.RemoveRange(config.maxItemCount, items.Count - config.maxItemCount); } return items; } } }到这里,一套基础的 EQS 查询框架已经跑通了。它的工作流程是:传入配置和上下文,运行器生成候选点,修正 NavMesh 位置,逐项测试打分,最后返回排序后的列表。接下来要验证的是,这套框架能不能真正驱动 Unity 场景里的 AI 角色。
6. 在 Unity 6 场景中接入 AI 角色
写完框架只是第一步,接入 AI 角色才能验证功能。下面给出一个可以实际运行的测试脚本,并演示三种典型用法。
6.1 创建 AI 角色脚本
新建Assets/Scripts/AI/AISoldier.cs:
using UnityEngine; using UnityEngine.AI; using EQSFramework; namespace AITest { public class AISoldier : MonoBehaviour { public EQSQueryConfig moveQuery; // 移动到最优位置 public Transform playerTarget; // 目标玩家 private NavMeshAgent agent; private void Start() { agent = GetComponent<NavMeshAgent>(); } [ContextMenu("执行一次查询")] public void ExecuteQuery() { if (moveQuery == null) { Debug.LogWarning("未配置 moveQuery。"); return; } EQContext context = new EQContext { Querier = transform, Target = playerTarget, QueryCenter = transform.position }; List<EQItem> results = EQSRunner.Run(moveQuery, context); if (results.Count > 0) { agent.SetDestination(results[0].Position); Debug.Log($"最优候选点:{results[0].Position},分数:{results[0].Score:F2},共 {results.Count} 个候选点"); } else { Debug.LogWarning("查询结果为空。"); } } } }在 Inspector 中把AISoldier挂到 AI 角色上,创建一个 EQSQuery 资产,配置好生成器和测试项,然后通过 ContextMenu 右键调用ExecuteQuery,就能看到 AI 角色走出来。这个验证方式对排查问题非常高效。
6.2 场景一:掩体选择
掩体选择是 EQS 的经典用法:AI 在受到威胁时,需要快速找到一个“离目标足够近、目标看不见我、我能通过路径到达”的位置。
测试配置建议:
| 配置项 | 建议值 |
|---|---|
| 生成器类型 | EQRingGenerator,半径 6 米,数量 16 |
| 测试项 1 | EQDistanceTest,maxDistance 10,weight 0.4 |
| 测试项 2 | EQVisibilityTest,negate 勾选,weight 1.0 |
| 测试项 3 | EQPathfindingTest,maxPathDistance 20,weight 0.6 |
这里的核心是EQVisibilityTest的negate勾选。正常情况下,候选点可见目标会得到 1 分;勾选 negate 后,可见得到 0 分,不可见得到 1 分。这样 AI 会优先选择“目标看不到我”的位置。
6.3 场景二:动态巡逻点
固定巡逻点的问题在于,玩家蹲在一个固定点就能猜到 AI 路线。动态巡逻点可以让 AI 每次巡逻时通过 EQS 重新选一个当前位置周边最优的点,距离和朝向的权重可以自行调整。
测试配置建议:
| 配置项 | 建议值 |
|---|---|
| 生成器类型 | EQGridGenerator,size 12x12,cellSize 2 |
| 测试项 1 | EQDistanceTest,maxDistance 15,weight 0.5 |
| 测试项 2 | EQDotTest,maxAngle 90,weight 0.5 |
| 测试项 3 | EQPathfindingTest,maxPathDistance 30,weight 0.3 |
这样 AI 会优先选择“离目标距离适中、在自身朝向前方、路径可达”的位置进行巡逻,而不是每次都走向同一个固定点。
6.4 场景三:多目标威胁评估
EQS 不仅可以评估位置,也可以评估目标。思路很简单:把每个目标的位置当作一个候选点,用距离、可见性、目标当前状态等测试项给目标打分,然后选择分数最高的目标作为攻击对象。
具体做法是写一个EQTargetTest,让它从context.Target之外的多目标列表中逐个取出目标,构造EQItem。这里不再展开完整代码,只说明思路:运行时把目标列表作为候选点传入框架,测试项按目标属性评分。这是 EQS 从“位置查询”向“目标查询”扩展的自然方式。
7. 调试可视化与性能观察
EQS 这类系统调试起来比普通 AI 逻辑更困难,因为候选点可能有一百多个,如果不做可视化,你根本不知道分数为什么高、为什么低。所以调试可视化要优先做。
7.1 用 Gizmos 绘制候选点
在 AI 角色脚本中加上 Gizmos 绘制代码,把最近一次查询结果显示在 Scene 视图里。新建Assets/Scripts/AI/AISoldierDebug.cs,或者直接把下面的代码合并到 AISoldier 中:
#if UNITY_EDITOR using UnityEngine; using EQSFramework; namespace AITest { public partial class AISoldier { private void OnDrawGizmosSelected() { if (lastResults == null || lastResults.Count == 0) return; foreach (EQItem item in lastResults) { float t = Mathf.Clamp01(item.Score / 2f); Gizmos.color = Color.Lerp(Color.red, Color.green, t); Gizmos.DrawWireSphere(item.Position, 0.3f); } } } } #endif注意lastResults是需要在AISoldier类中新增的字段,把ExecuteQuery中得到的results赋值给它。这样在 Scene 视图中选中 AI 角色,就能看到所有候选点的分数分布。
7.2 性能数据怎么看
EQS 的性能瓶颈主要在于候选点数量和测试项数量相乘的评分次数。10x10 网格 100 个候选点、4 个测试项,就是 400 次评分;20x20 网格 400 个候选点、8 个测试项,就是 3200 次评分。如果 AI 角色数量超过 10 个,同步执行就可能出现瞬时卡顿。
建议在查询运行器中加入耗时统计,最简单的方式是使用System.Diagnostics.Stopwatch:
System.Diagnostics.Stopwatch sw = System.Diagnostics.Stopwatch.StartNew(); List<EQItem> items = EQSRunner.Run(moveQuery, context); sw.Stop(); Debug.Log($"EQS 查询耗时:{sw.ElapsedMilliseconds} ms");判断标准很简单:单次查询耗时稳定低于 2ms,可以接受;超过 5ms,就要降候选点数量或者测试项数量。如果 AI 数量多,优先做查询冷却,比如每个 AI 至少间隔 0.3 秒到 0.5 秒再发起下一次查询;更进一步可以用 Unity Job System 把多个候选点的评分并行化。
7.3 降低性能压力的策略
- 降低网格密度:cellSize 从 1 改成 1.5 或 2,候选点数量会显著下降。
- 缩短查询冷却时间:每次查询后强制等待最短间隔。
- 分帧执行:把候选点分批,一帧只测试其中一部分。
- 结果缓存:如果场景环境变化不剧烈,可以缓存最近一次查询结果,短时间复用。
- 按需查询:比如角色只有在进入某种状态时才触发 EQS,而不是每帧都执行。
8. 常见问题与排查方法
下面的表格列出了使用这套 Unity EQS 框架时最可能遇到的问题和排查方向:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 查询结果为空 | 生成器没配置或上下文为空 | 检查EQSQueryConfig资产和EQContext赋值 | 确认 Querier、Target、QueryCenter 都已赋值 |
| 所有候选点分数都是 0 | 测试项权重为 0,或所有测试项返回 0 | 在 Inspector 中展开测试项,检查 weight 和 negate | 调整 weight;打开可视化逐项看分数 |
| 候选点不在 NavMesh 上 | 生成器产生的位置高度不对 | 检查sampleNavMeshPosition是否开启 | 勾选sampleNavMeshPosition,调大sampleNavMeshDistance |
| AI 走向不可达位置 | 缺少路径可达性测试 | 查看最终选择位置是否在 NavMesh 内 | 添加EQPathfindingTest并提高权重 |
| 可见性检测结果不符合预期 | Linecast 检测到了 AI 自身或地面的 Collider | 检查obstacleMask设置 | 把 AI 本体放到指定 Layer,并在obstacleMask中排除 |
| 每帧执行查询导致卡顿 | 查询过于频繁,候选点数量过大 | 用 Stopwatch 统计耗时,查看候选点数量 | 加查询冷却,降低网格密度,改用 Job System |
| Inspector 中无法添加生成器或测试项 | [SerializeReference]未正确显示 | 检查 ScriptableObject 资产是否保存 | 点击资产面板中的 Add 按钮,选择具体类型 |
| NavMesh.CalculatePath 总是返回 PathInvalid | 起点或终点不在 NavMesh 上 | 分别检查起点和终点位置 | 对起点和终点都执行NavMesh.SamplePosition修正 |
排查时有个技巧:先只保留一个测试项,把其他的 weight 临时设为 0,逐项验证每个测试的单独表现。如果单个测试都能跑通,再叠加所有测试权重,问题通常出在权重配比上。
9. 最佳实践与扩展方向
框架跑通只是基础,工程化才是关键。下面这些实践建议来自常见的 Unity 项目经验,按重要性排序:
查询配置资产化:不要把生成器和测试项直接写死在场景里,尽量用 ScriptableObject 资产。这样同一种 AI 可以复用配置,也方便在版本控制中统一管理调整记录。
测试项分数统一归一化:框架中的每个测试项最好都返回 0 到 1 之间的分数,权重再乘以这个分数。如果某个测试项返回超大值或负值,整体排序会被带偏。尽量不要在测试项内部直接做复杂的分数转换。
AI 数量多时引入查询冷却:每个 AI 设置一个
lastQueryTime字段,只有间隔超过阈值才允许发起新的 EQS 查询。否则多个 AI 同时查询,场景会有明显的帧率抖动。与行为树或状态机配合:EQS 的价值在于“决策位置”,而什么时候触发查询、查询结果如何使用,应该交给行为树或状态机。不要让 AI 角色每帧都调用 EQS,也不要在任何状态里无脑执行查询。
运行时调试信息分层:编辑器下可以用 Gizmos 画出上千个候选点,但打包后这些调试代码要全部去掉。建议在
#if UNITY_EDITOR中包裹所有调试绘制逻辑。注意数据合规与资源使用:如果项目包含玩家位置、角色生命值、朝向等数据,在接入网络同步、云端统计或对局回放功能时,要注意隐私边界和数据权限。AI 查询过程中产生的候选点坐标、路径计算耗时等数据,如果不做脱敏处理,不建议直接上报到第三方分析平台。
扩展方向:走向 ECS 与 Job System。当前框架的评分循环是纯同步的,候选点数量增加后,可以把测试循环改成
IJobEntity或IJobParallelFor,用 Burst 编译加速。Unity 6 中对 ECS 的支持更成熟,这个方向可以作为后续优化重点。扩展方向:增加过滤阶段。Unreal EQS 支持先过滤再评分,本框架目前只实现了评分阶段。如果需要高性能,可以在
EQTest中增加一个bool PassFilter(EQItem item, EQContext context)方法,先过滤掉完全不合要求的候选点,再执行得分累加,能有效降低无效计算。
10. 总结
这套 Unity 6 自研 EQS 框架最值得尝试的点,是它用不到 300 行核心代码复刻了 Unreal EQS 的完整工作流。第一次上手建议先做两件事:第一,用 6.1 小节的AISoldier场景跑通一次查询,确认候选点能生成、分数能排序、AI 能移动;第二,打开 Gizmos 可视化,观察不同测试项权重对最终选点的影响。最容易踩的坑有两个:一是[SerializeReference]字段在 Inspector 中不显示,二是 NavMesh 候选点没有修正导致路径计算始终失败。这两个问题在代码中都已经给出了解决方案,直接参照即可。
从项目后续扩展看,这套框架的价值不在代码量,而在它把环境评估从“硬编码路径点”提升到了“动态查询排序”的层面。用它可以继续做掩体、巡逻、目标威胁评估,也可以和行为树、状态机、ECS 结合,演变成一套更完整的 Unity AI 支撑模块。如果你正在做射击类、潜行类或者其他需要 AI 独立做空间决策的项目,这套 EQS 思路值得直接拿过去用。先跑通一次查询,剩下的按项目需求慢慢扩展。