如果 AI 角色只会“朝玩家直线冲过去”,你很快会发现游戏关卡里的 AI 行为漏洞百出:它不会找掩体、不会拉开距离、不会选一个视野盲区再靠近目标。Unreal 的 EQS(Environment Query System,环境查询系统)正是为了解决这一类问题而设计的——它把“选点”“找位置”“评估环境”变成一套可配置、可调试、可复用的查询系统。Unity 官方一直没有内置等价物,因此很多项目干脆把决策逻辑写死在状态机里,或者用一层又一层 if-else 模拟,结果就是改一个数值要翻半天代码。
本文要做的,不是把 Unreal 的源码翻译成一份 C# 脚本,而是复刻 EQS 的整套设计思路:生成候选点、逐个测试、加权评分、排序返回最优解。我们先搭出核心接口,再实现网格生成器、环形生成器、距离测试、可视性测试,最后接上 NavMeshAgent 和 Gizmos 调试,让角色真正在场景里自己挑位置。读完你会发现,Unity 6 虽然没有原生 EQS,但只要把“生成器 + 测试器 + 评分器”这个抽象做好,一套轻量级 AI 决策层很快就能立起来。
这篇文章适合两类读者:一类是战斗 AI、潜行 AI、RTS 寻位系统的开发者,正在为“选点怎么设计才不僵死”发愁;另一类是想理解 Unreal EQS 到底在解决什么,又不想立刻切换引擎的 Unity 开发者。我会把 Unreal 术语映射到 Unity 的日常开发里,尽量不在概念上绕弯子,所有代码都从零写起,不依赖任何付费插件。
1. 为什么要在 Unity 6 里复刻 EQS
先想一个具体场景:AI 角色正在被玩家追射,它需要向后撤退,但撤退不是掉头就跑——稍微有点战术的 AI 会先找一个掩体,然后躲到掩体后方。用硬编码写这个行为,你会遇到这些问题:AI 周围 5 米内没有掩体怎么办?掩体后面虽然安全,但玩家已经绕到侧面了怎么办?两条候选路径一条要穿过火线,另一条远但是安全,选哪条?
这些问题的本质都是在问:给定当前 AI 的位置、目标的位置、环境阻挡关系,如何从大量候选位置里挑出最符合战术意图的那个。EQS 的做法是把“选择”本身变成数据驱动。你不再写“如果离玩家小于 5 米就向右转”这种难以维护的逻辑,而是声明一句“我要生成周围 8 米内的 64 个候选点,靠近掩体的加分、玩家看得见的地方减分,最后选分数最高的”。
这带来的好处非常明显:
- 行为调整不再改代码,改权重和曲线即可。
- 同一套查询可以复用在寻找撤退点、寻找攻击点、寻找巡逻巡逻边界。
- 候选点生成逻辑和评估逻辑分离,新增一种“测试器”就能影响所有查询。
- 调试时能看到每个候选点的得分,AI 为什么选这个位置一眼可知。
当然,EQS 也不是银弹。如果你的 AI 只需要站在固定点攻击,或者敌人数量极少、行为模式完全固定,那么引入一套查询系统的成本确实高于收益。它更适用于角色对“位置”的选择敏感、环境复杂、需要反复调整战术行为的项目。做动作游戏、射击游戏、潜行游戏、生存类游戏的开发者,最值得把 EQS 思路沉淀到自己的工具链里。
2. Unreal EQS 的核心概念:生成器、测试器、评分
Unreal 的 EQS 之所以强大,是因为它把环境查询拆成了几个非常清晰的组成部分。我们不用照搬它的 C++ 实现,但必须理解这套抽象,否则很容易写成一锅“既能生成又负责打分的巨型类”,后续扩展会非常痛苦。
核心概念可以分成四块:
| Unreal EQS 术语 | 作用 | 对应到 Unity 开发 |
|---|---|---|
| Context(上下文) | 描述查询发起者、目标对象、辅助参考点 | EQSQueryContext 结构体,保存 Querier 和 Target |
| Generator(生成器) | 产生一组候选位置或候选对象 | IGenerator 抽象类,返回 List |
| Test(测试器) | 对每个候选点执行一次评估,输出 0~1 分数 | ITest 抽象类,接收候选点返回 float |
| Score(评分因子) | 每个测试乘以一个权重,汇总后决定候选点优先级 | EQSTest.Weight 字段,加权求和 |
用一句通俗的话讲:生成器负责“提出可能性”,测试器负责“给每个可能性打标签”,最终分数代表“这个点到底有多适合当前目标行为”。
Generator 在 Unreal 里有很多现成类型:网格生成器(Grid)、环形生成器(Ring)、目标生成器(ActorsOfClass)、路径点生成器(Pathing)。Unity 里我们最常用的是网格和环形,这与你找掩体和撤退点的需求最匹配。Test 则更像一个判断函数,常见的有 DistanceTest(距离)、VisibilityTest(可视)、DotProductTest(朝向夹角)。测试器返回的是一个浮点数,但真正重要的是曲线——同一个距离值,在某些场景下希望“越近越好”,在另一些场景下希望“越远越好”,这完全由曲线和权重控制。
我把这套结构复刻成 C# 之后,你的 Unity 项目中会出现一个名为AI.EQS的命名空间,里面包含查询上下文、抽象基类、具体生成器、具体测试器、查询执行器。你可以在 Inspector 中直接创建查询资产,把不同的生成器和测试器组合在一起,然后被 AI 状态机调用。整个过程更像是在搭积木,而不是在写流程控制。
3. 整体架构设计:分层与文件规划
动手写代码之前,先把模块边界画清楚。我不建议写一个大的 EnvQueryManager,然后让所有逻辑都堆在里面。模块化的目的是:以后加一种生成器、加一种测试器,不需要改动执行器代码。
我规划的目录结构如下:
Assets/ └─ AI/ └─ EQS/ ├─ Core/ │ ├─ EQSQueryContext.cs │ ├─ EQSGenerator.cs │ ├─ EQSTest.cs │ └─ EnvQueryDef.cs ├─ Generators/ │ ├─ GridGenerator.cs │ └─ RingGenerator.cs ├─ Tests/ │ ├─ DistanceTest.cs │ └─ VisibilityTest.cs ├─ Runtime/ │ └─ EQSQueryRunner.cs └─ Examples/ ├─ EQSAIController.cs └─ EQSGizmosVisualizer.cs各层的职责非常明确:
Core定义抽象和查询资产。Generators负责产出候选点,不关心评分。Tests只负责打分,不关心候选点怎么来的。Runtime是整个查询流程的调度入口。Examples提供接入示例和场景调试工具。
在 Unity 中,我选择用 ScriptableObject 作为查询资产载体,因为这样可以把一组查询配置保存成.asset文件,AI 策划可以直接在项目窗口里创建、复制、调整参数。为了让 Inspector 支持多态列表,基类字段必须加上[SerializeReference]。这个特性在 Unity 6 的编辑器中已经比较成熟,可以在检查器中直接选择具体生成器和测试器的实现类型。
4. 环境准备:Unity 6 场景与 NavMesh 设置
代码层面几乎没有额外依赖,但为了让查询结果真正被 AI 使用,我们需要一个能跑 NavMeshAgent 的场景。下面列出基础准备步骤。
4.1 创建测试场景
创建一个新场景,放一块足够大的地面(我这篇文章统一用 Plane 演示),在地面上放置几块 Cube 作为掩体,再放两个角色:一个是 AI 角色,命名为AITester;一个是玩家目标,命名为Player。AI 角色需要配置 NavMeshAgent 组件。
4.2 确认 AI Navigation 包
Unity 6 中,NavMesh 相关功能已经作为 AI Navigation 包提供。如果你的编辑器菜单里找不到Window -> AI -> Navigation,需要先在 Package Manager 中安装AI Navigation包。如果项目里根本没有这个包,后面NavMesh.SamplePosition这段代码会编译报错。为了避免这种情况,我先在示例里保留一个UseNavMesh开关,对于不需要 NavMesh 的测试项目,可以直接关掉。
4.3 设置 Layer 与碰撞掩码
后面要写的可视性测试会做射线检测。这里最容易出的问题:射线打到了 AI 自己身上。所以项目中最好划分出独立的 Layer。例如把玩家放在Player层,AI 角色放在AI层,掩体放在Environment层。可视性测试的 BlockingMask 建议只勾选Environment,这样 AI 自己在路径上也不会干扰遮挡判断。
4.4 烘焙 NavMesh
在 Navigation 窗口中,选择地面和掩体,烘焙出一张 NavMesh。如果 GridGenerator 开了 UseNavMesh,查询把候选点吸附到 NavMesh 表面时,会依赖这张烘焙数据。如果你只是先用平地测试逻辑,也可以暂时关闭 UseNavMesh。
环境准备好之后,我们就可以从核心代码开始搭系统了。
5. 核心实现:查询上下文与查询定义
先定义 EQSQueryContext。它负责在生成器和测试器之间传递关键位置信息。
// Assets/AI/EQS/Core/EQSQueryContext.cs using UnityEngine; namespace AI.EQS { public struct EQSQueryContext { public Vector3 QuerierPosition; public Vector3 TargetPosition; public Transform Querier; public Transform Target; } }这个结构体很薄,但它是整个查询的“信息中枢”。后续如果希望查询支持多个关注点,比如“队友当前位置”“上次发现玩家的位置”,直接往这个结构体里加字段就行。
然后是抽象基类。为了简化教程,我不使用接口,而是使用抽象类。这样可以在基类里保留公共字段,比如Weight。后续子类只需实现自己的业务逻辑。
// Assets/AI/EQS/Core/EQSGenerator.cs using System.Collections.Generic; using UnityEngine; namespace AI.EQS { public abstract class EQSGenerator { public abstract List<Vector3> Generate(EQSQueryContext context); } }// Assets/AI/EQS/Core/EQSTest.cs using UnityEngine; namespace AI.EQS { public abstract class EQSTest { [Tooltip("测试结果的权重,正数表示该测试项的得分越高越优先")] public float Weight = 1f; public abstract float Evaluate(EQSQueryContext context, Vector3 candidate); } }现在定义查询资产。这里使用 ScriptableObject,使每个查询成为一个可复用的项目资产。
// Assets/AI/EQS/Core/EnvQueryDef.cs using System.Collections.Generic; using UnityEngine; namespace AI.EQS { [CreateAssetMenu(fileName = "NewEnvQuery", menuName = "AI/EQS/EnvQuery")] public class EnvQueryDef : ScriptableObject { [Header("候选点生成器")] [SerializeReference] public List<EQSGenerator> Generators = new List<EQSGenerator>(); [Header("候选点评测器")] [SerializeReference] public List<EQSTest> Tests = new List<EQSTest>(); [Header("返回数量")] public int MaxResults = 1; } }创建查询资产的操作流程是:在 Project 窗口右键,选择Create -> AI/EQS/EnvQuery,然后在 Inspector 中给 Generators 列表添加具体生成器,给 Tests 列表添加具体测试器。因为[SerializeReference]的存在,Inspector 会显示“New Generator”这样的多态类型选择入口,选择GridGenerator或RingGenerator即可。
一个设计要点:我把MaxResults放在查询资产里,而不是执行器里。因为不同查询在业务上返回的候选数量不同——寻找攻击点可能只需要返回 1 个,寻找巡逻兴趣点则可能需要返回 3 个让 AI 轮流使用。
6. 生成器实现:网格采样与环形采样
生成器的职责非常简单,就是“给我一批候选点”。我们实现两种最常用的:网格生成器负责在 AI 周围均匀撒点,环形生成器负责在 AI 周围按圆环撒点。两者的目标都是解决“AI 不知道去哪找位置”的问题,区别只是分布形态。
6.1 GridGenerator 网格生成器
网格生成器以查询者为中心,沿 XZ 平面等间距生成候选点。在实际项目中,如果开启 NavMesh 吸附,还能把候选点贴合到地面高度。
// Assets/AI/EQS/Generators/GridGenerator.cs using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; namespace AI.EQS { public class GridGenerator : EQSGenerator { [Header("网格范围")] public Vector2 AreaSize = new Vector2(10f, 10f); [Tooltip("采样间距,越小候选点越多,性能越差")] public float StepSize = 1f; [Header("NavMesh 吸附")] public bool UseNavMesh = true; public override List<Vector3> Generate(EQSQueryContext context) { var results = new List<Vector3>(); int xCount = Mathf.FloorToInt(AreaSize.x / StepSize); int zCount = Mathf.FloorToInt(AreaSize.y / StepSize); for (int x = -xCount; x <= xCount; x++) { for (int z = -zCount; z <= zCount; z++) { Vector3 sample = context.QuerierPosition + new Vector3(x * StepSize, 0f, z * StepSize); if (UseNavMesh && NavMesh.SamplePosition( sample, out NavMeshHit hit, StepSize, NavMesh.AllAreas)) { sample = hit.position; } results.Add(sample); } } return results; } } }这里需要注意,NavMesh.SamplePosition并不是把任意点水平投影到 NavMesh 上,它是在源点的一定范围内寻找最近的 NavMesh 位置。StepSize同时充当搜索半径,如果候选点离 NavMesh 太远,会采样失败。对于较大的网格范围,建议把 StepSize 调大到 1 或 2,避免生成过多无效点。如果 UseNavMesh 为 false,生成的点保持原高度,适用于地图完全平坦、高度无关的测试场景。
6.2 RingGenerator 环形生成器
环形生成器适合生成以 AI 为圆心的扇形或闭环候选点,常用于“找一个方向退开”这类需求。实现时不需要追逐精确的三角函数,直接用 Quaternion 旋转向量即可。
// Assets/AI/EQS/Generators/RingGenerator.cs using System.Collections.Generic; using UnityEngine; namespace AI.EQS { public class RingGenerator : EQSGenerator { public float Radius = 5f; public int SampleCount = 12; public override List<Vector3> Generate(EQSQueryContext context) { var results = new List<Vector3>(); for (int i = 0; i < SampleCount; i++) { float angle = i * 360f / SampleCount; Vector3 direction = Quaternion.Euler(0f, angle, 0f) * Vector3.forward; Vector3 point = context.QuerierPosition + direction * Radius; results.Add(point); } return results; } } }环形生成器比网格生成器更轻量,因为候选点很少。网格生成器在 10x10 米、步长 0.5 时会产生 400 多个候选点,而环形生成器通常 12~32 个点就够了。如果你的 AI 决策频率很高,环形生成器是更安全的选择。
7. 测试器与评分机制
测试器决定“哪些候选点值得选”。我们实现两种关键测试:距离测试和可视性测试。它们分别覆盖了“空间位置合理性”和“视线暴露程度”这两个最常见的 AI 决策维度。
7.1 DistanceTest 距离测试
距离测试可以测量候选点到查询者或目标的距离,并通过一条线性递减逻辑将距离映射到 0~1 分。它的核心思想是:距离越近,得分越高。但通过设置BestDistance和MaxDistance,也可以表达“过近会危险、过远会无效”的区间。
// Assets/AI/EQS/Tests/DistanceTest.cs using UnityEngine; namespace AI.EQS { public abstract class DistanceTestBase : EQSTest { public enum DistanceReference { Querier, Target } public DistanceReference Reference = DistanceReference.Target; [Tooltip("得分最高的距离")] public float BestDistance = 0f; [Tooltip("距离超过该值后得分为 0")] public float MaxDistance = 10f; protected Vector3 GetOrigin(EQSQueryContext context) { return Reference == DistanceReference.Querier ? context.QuerierPosition : context.TargetPosition; } public override float Evaluate(EQSQueryContext context, Vector3 candidate) { float distance = Vector3.Distance(GetOrigin(context), candidate); if (distance <= BestDistance) return 1f; if (distance >= MaxDistance) return 0f; return 1f - Mathf.InverseLerp(BestDistance, MaxDistance, distance); } } public class DistanceFromQuerierTest : DistanceTestBase { } public class DistanceFromTargetTest : DistanceTestBase { } }我在基类里也保留了Weight,所以子类不需要重复定义。这种拆法看起来有些冗余,但它解决了 Inspector 中的多态选择问题:你可以明确选择“相对查询者的距离”或“相对目标的距离”,而不是在一个测试器里用枚举来回切换。当你用[SerializeReference]在 Inspector 中选择测试器时,能直接看到这两个具体类名,更容易识别。
7.2 VisibilityTest 可视性测试
可视性测试的判定逻辑如下:从目标位置向候选点发一条射线,如果射线碰到了阻挡物,说明该候选点处于目标视线之外,得分高;如果射线没有碰到阻挡物,说明目标能看到这个点,得分低。它本质上是奖励“躲进视野盲区”。
// Assets/AI/EQS/Tests/VisibilityTest.cs using UnityEngine; namespace AI.EQS { public class VisibilityTest : EQSTest { [Tooltip("所有会阻挡视线的 Layer")] public LayerMask BlockingMask = ~0; [Tooltip("射线起始高度偏移,避免贴地射线被地面挡死")] public float HeightOffset = 1f; [Tooltip("候选点完全不可见时的得分")] public float InvisibleScore = 1f; [Tooltip("候选点可见时的得分")] public float VisibleScore = 0f; public override float Evaluate(EQSQueryContext context, Vector3 candidate) { Vector3 from = context.TargetPosition + Vector3.up * HeightOffset; Vector3 to = candidate + Vector3.up * HeightOffset; if (Physics.Linecast(from, to, BlockingMask)) return InvisibleScore; return VisibleScore; } } }这段逻辑很直观,但坑也很多。最大的坑就是 LayerMask 配置:如果BlockingMask设置为~0,表示所有碰撞体都会阻挡,AI 自己的胶囊体、玩家角色都会成为“掩体”,导致测试器给出毫无意义的结果。我在团队项目里通常会专门建一个BlockVision层,只把真正的墙壁、箱子、栅栏放进这个层,AI 和玩家都不在这个层。这样可视线检测结果更稳定。
7.3 评分组合方式
单个测试器返回 0~1 分,多个测试器通过加权求和得到最终得分。执行器遍历所有候选点,对每个候选点都执行一遍全部测试器,累加得分 * 权重。
举个例子:一个查询同时包含 DistanceFromTargetTest(权重 0.8)和 VisibilityTest(权重 0.4)。离目标越近的点,Distance 分数越高;被目标看不见的点,Visibility 分数越高。最后得分最高的候选点,就是“既不会离目标太远,又处于视线盲区”的位置。如果某个点离目标太远,Distance 部分会把分数拉低;如果某个点完全暴露,Visibility 部分会把它扣成 0 分。
权重可以为负数。把 DistanceFromTargetTest 的权重设为 -0.5,它的行为就会从“奖励近点”变成“优先远点”,效果等同于一个“远离目标”测试器。这给了项目很大的灵活性:不需要为“远离某物”专门写新测试器,只需要调整参考点和权重正负。
8. 查询执行器与 AI 调用示例
生成器和测试器都准备好了,现在需要把它们串起来。查询执行器是 EQS 的引擎:拿到查询资产 → 用生成器生成候选点 → 用测试器打分 → 排序 → 返回前 N 个结果。
8.1 EQSQueryRunner 执行器
// Assets/AI/EQS/Runtime/EQSQueryRunner.cs using System.Collections.Generic; using UnityEngine; namespace AI.EQS { public class EQSCandidate { public Vector3 Position; public float Score; } public static class EQSQueryRunner { public static List<EQSCandidate> Run(EnvQueryDef query, EQSQueryContext context) { var candidates = new List<EQSCandidate>(); // Step 1: 生成候选点 if (query.Generators != null) { foreach (var generator in query.Generators) { if (generator == null) continue; List<Vector3> points = generator.Generate(context); foreach (Vector3 point in points) { candidates.Add(new EQSCandidate { Position = point }); } } } // Step 2: 执行测试并评分 if (query.Tests != null) { foreach (EQSCandidate candidate in candidates) { float score = 0f; foreach (EQSTest test in query.Tests) { if (test == null) continue; float testScore = test.Evaluate(context, candidate.Position); score += testScore * test.Weight; } candidate.Score = score; } } // Step 3: 按得分从高到低排序 candidates.Sort((a, b) => b.Score.CompareTo(a.Score)); // Step 4: 按查询定义截取前 N 个结果 if (query.MaxResults > 0 && candidates.Count > query.MaxResults) { candidates.RemoveRange(query.MaxResults, candidates.Count - query.MaxResults); } return candidates; } } }执行器的逻辑顺序非常关键:先生成,再测试,最后排序。如果在生成阶段就做排序,或者在测试阶段又改生成范围,整个流程会失控。保持单向流水线,后续要加“过滤测试”也很容易——在测试阶段如果某个测试返回 0,可以提前跳过该候选点。
8.2 EQSAIController 接入 NavMeshAgent
现在把查询系统接到一个 AI 角色上。AI 每 1 秒评估一次当前环境,选择得分最高的位置作为 NavMeshAgent 的移动目标。这个调用方式适用于任意状态机:巡逻、撤退、寻找掩体、寻找攻击位置都可以复用。
// Assets/AI/EQS/Examples/EQSAIController.cs using System.Collections; using UnityEngine; using UnityEngine.AI; namespace AI.EQS.Examples { public class EQSAIController : MonoBehaviour { public EnvQueryDef FleeQuery; public Transform Player; public float EvaluateInterval = 1f; private NavMeshAgent agent; private void Start() { agent = GetComponent<NavMeshAgent>(); if (FleeQuery == null) { Debug.LogWarning("FleeQuery 为空,请在 Inspector 中指定查询资产"); return; } StartCoroutine(EvaluateLoop()); } private IEnumerator EvaluateLoop() { while (true) { yield return new WaitForSeconds(EvaluateInterval); var context = new EQSQueryContext { QuerierPosition = transform.position, Querier = transform, TargetPosition = Player != null ? Player.position : Vector3.zero, Target = Player }; List<EQSCandidate> results = EQSQueryRunner.Run(FleeQuery, context); if (results.Count > 0) { agent.SetDestination(results[0].Position); } } } } }这段代码里有两个值得注意的工程点。第一,不要每帧执行一次查询,EQS 在 Unreal 里通常也不会每帧跑,因为候选点生成和射线检测都可能产生开销。第二,如果查询结果为空,应该有一个降级策略,而不是让 AI 停在原地。你可以在results.Count == 0时切换到“向目标反方向移动”或者“切换到巡逻状态”,这比卡死在原地自然得多。
8.3 用 Gizmos 可视化候选点得分
写 AI 系统最怕的就是“结果不可见”。我强烈建议把查询结果用 Gizmos 画出来,这比看日志高效十倍。下面这个脚本挂在 AI 角色上,场景视图中会实时显示所有候选点,并按分数映射成从红到绿的颜色。
// Assets/AI/EQS/Examples/EQSGizmosVisualizer.cs using System.Collections.Generic; using UnityEngine; namespace AI.EQS.Examples { public class EQSGizmosVisualizer : MonoBehaviour { public EnvQueryDef Query; public Transform Target; public bool ShowAllCandidates = true; private void OnDrawGizmos() { if (Query == null || Target == null) return; var context = new EQSQueryContext { QuerierPosition = transform.position, Querier = transform, TargetPosition = Target.position, Target = Target }; List<EQSCandidate> results = EQSQueryRunner.Run(Query, context); foreach (EQSCandidate candidate in results) { Gizmos.color = Color.Lerp(Color.red, Color.green, Mathf.Clamp01(candidate.Score)); Gizmos.DrawSphere(candidate.Position, 0.2f); } } } }注意这个脚本是编辑器辅助性质,不应该挂在正式 AI 角色上。更好的做法是单独创建空 GameObject,把可视化器放在上面,这样 AI 的每帧逻辑与编辑器调试逻辑解耦。不要把 OnDrawGizmos 日志写到游戏逻辑里,否则发布版本会带着无意义的编辑器代码。
9. 运行验证与效果调试
场景全部搭建好后,把FleeQuery资产赋给EQSAIController,然后点击 Play。预期的表现是:AI 角色不会直直冲向玩家,而是在环境中找到一个分数最高的候选点并移动过去。如果你同时挂上EQSGizmosVisualizer,场景中会看到大量红色到绿色的点,绿色越明显说明这个位置在当前测试组合下越适合。
验证步骤按以下顺序走:
- 先确认查询资产可以正常创建并赋值。
- 在 Inspector 中查看 Generators 列表,确认里面至少有一个
GridGenerator。 - 在 Tests 列表中添加一个
DistanceFromTargetTest,权重设 1,MaxDistance 设为 10。 - 确认
EQSQueryRunner.Run能返回候选点。 - 如果 AI 不移动,优先检查 NavMeshAgent 是否有目标可达路径。
- 如果 Gizmos 没有任何点,优先检查 GridGenerator 的 UseNavMesh 是否因为 NavMesh 未烘焙导致所有候选点都被跳过。
你可能会遇到一个奇怪的现象:场景里到处都是绿色点,但 AI 还是不动。这时候第一时间看 AI 角色当前站的位置是不是离 NavMesh 边界过近,导致 NavMeshAgent 无法真正寻路。或者把EvaluateInterval调小到 0.3,让 AI 更快更新目标点,观察它是不是已经在选点,只是路径太远。
10. 常见问题与排查思路
根据我实际接触过的项目,下面这些问题出现频率最高,这里直接给出排查方案。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| Gizmos 没有任何候选点 | GridGenerator 开启 UseNavMesh,但 NavMesh 未烘焙 | 打开 Navigation 窗口查看 NavMesh 是否存在 | 关闭 UseNavMesh,或者重新烘焙场景 |
| 所有候选点都是 0 分 | 测试器未被正确添加,或者查询资产里 tests 列表为空 | 检查 Inspector 中 EnvQueryDef 的 Tests 列表 | 至少添加一个 EQSTest 子类,并设置权重 |
| 可视性测试结果异常 | BlockingMask 包含了 AI 自身所在的 Layer | 打印射线命中的碰撞体名称 | 将 BlockingMask 只勾选真正遮挡视野的层 |
| AI 一直选同一个点 | 候选生成器范围太小,或者测试权重相互抵消 | 在 Gizmos 中查看候选点分布 | 增大生成器范围,或检查权重正负是否合理 |
| 查询非常卡顿 | 候选点数量过多,且每帧都在执行查询 | 查看 GridGenerator 的 StepSize 和调用频率 | 调低 EvaluateInterval,或增加采样步长 |
| ScriptableObject 无法创建 | 菜单路径或脚本编译错误 | 检查 Console 窗口报错 | 确认所有类都能编译通过,命名空间 |