Unity 6实现轻量级EQS环境查询系统,助力AI智能决策

发布时间:2026/9/1 9:53:25
Unity 6实现轻量级EQS环境查询系统,助力AI智能决策 不少从 Unreal 转到 Unity 的开发者最怀念的工具之一就是 EQSEnvironment Query System环境查询系统。在 Unreal 中EQS 能让 AI 在运行时主动“考核”周围环境批量生成候选点、逐项打分最后选出一个最优位置。这个能力在做掩体躲避、巡逻路线选择、进攻阵地评估等 AI 行为时特别实用而 Unity 自带 AI 方案里没有开箱即用的同类组件。本文会在 Unity 6 中实现一个轻量版 EQS 框架包含生成器、测试、查询资产、运行时执行器这几个核心部分并配套一个 AI 寻找掩体的完整示例。代码会拆开讲清楚涉及多个脚本读者可以照着一步步实践。最终实现出来的系统支持扩展后续加新的打分测试或者生成器都很方便。如果你之前接触过 Unreal 的 EQS可以重点关注本文第二部分的“对象映射表”快速把 Unreal 的概念对应到 Unity 实现上如果你只写过 Unity 的 NavMeshAgent也没关系我会从概念开始讲不需要 Unreal 使用经验。1. 什么是 EQS为什么 Unity 项目需要它1.1 EQS 解决的核心问题先说一个实际场景RPG 或射击游戏里AI 受到攻击后需要找掩体。一个最简单的做法是“在 AI 周围随机找几个点走过去”。但周围有墙、有悬崖、有火堆随机点往往不可用。另一个做法是“策划手工摆放掩体位置”但大型关卡里掩体数量太多维护成本极高而且只要关卡微调AI 点位全部要重新搬。EQS 的思路是不手工指定掩体而是让 AI 在需要掩体时实时生成一批候选位置然后对每个位置做一系列“问答测试”。比如这个位置是不是在导航网格NavMesh上从 AI 当前位置走过去距离是否合适站在这个位置敌人是否看不到我这个位置是否在 AI 前方而不是身后每个测试给一个得分最后按总分排序AI 取最高分的位置作为目标点。这样一来关卡只需要有合理的阻挡物和掩体结构AI 就会自动找出最合适的落脚点。1.2 Unreal EQS 与 Unity 原生 AI 的差异Unreal 的 AI 体系非常完整EQS 是其中一个独立模块。它由生成器Generator、上下文Context、测试Test、查询Query四部分组成开发者可以用蓝图或 C 自由组合。Unity 原生 AI 主要围绕 NavMesh 和 NavMeshAgent 展开。NavMeshAgent 解决“怎么走过去”但“走去哪”这个决策通常要开发者自己写。常见做法是给 AI 身上挂一个脚本用一堆 if-else 判断目标和距离再调用 NavMeshAgent.SetDestination。这种方法在小规模 Demo 里够用一旦 AI 行为丰富起来代码就会快速膨胀而且很多空间条件很难用固定逻辑覆盖。所以本文要做的不是重新发明一个“完全等同于 Unreal”的 EQS而是把 Unreal 的 EQS 设计模式迁移到 Unity 中做一个够用、可扩展、代码清晰的框架。它能融入 Unity 传统的 MonoBehaviour 架构也能为下一步结合行为树或 Utility AI 打好基础。1.3 本文实现目标本文最终会交付一套源码包含以下能力支持在 ScriptableObject 资产中配置一个 EQS 查询。支持 Grid网格、Ring环形、手工点三种候选点生成方式。支持距离、可见性、朝向、NavMesh 可达性等常用测试。支持将测试作为“过滤器”使用淘汰不合格的候选点。支持对候选点加权累加得分并排序返回。示例场景是AI 敌人每隔一段时间做一次掩体查询在玩家周围找掩体点并移动过去。2. 整体架构设计复刻 EQS 的四个核心部件在动手写代码之前先把 EQS 的架构拆开明确每个部件负责什么这样后续贴代码时不会迷失方向。2.1 候选点生成器Generator生成器负责在环境中产生候选点。Unreal 里有网格生成器、圆环生成器、扇形生成器也可以基于关卡中的 Actor 生成点。本文会实现三种最常用的Grid以 AI 为中心生成一个矩形网格适合“地毯式搜索周围区域”。Ring以 AI 为中心生成一圈点适合绕圈搜索掩体。Points手工在 Inspector 中配置点适合针对特定关卡做定制。2.2 上下文Context上下文是查询运行时的输入数据例如 AI 自身的位置、朝向以及目标玩家的位置。Unreal 的 Context 可以通过黑板或 Actor 引用获取本文用 C# 中的数据结构承载这些信息。2.3 测试与得分Test / Filter测试是 EQS 的灵魂。每个测试接收一个候选点输出一个 0 到 1 之间的分数表示该点在这个维度上的合适程度。测试还可以配置为过滤器如果某个候选点的得分低于阈值直接淘汰不参与后续排序。这样可以避免把不可达或者完全不可能的候选点带入最终结果。Score 和 Filter 是分开的两个方法原因是“筛选”必须在“累加”之前完成。如果某个测试要过滤那么会在遍历测试列表时提前 break 掉。2.4 查询资产与执行器Query / Runner查询资产是一个 ScriptableObject相当于 Unreal 的 Query Template。它组合了一个生成器和一组测试。执行器则是一个静态工具类输入 Query 资产和上下文输出一组按得分降序排列的 EQSItem。执行器内部的流程是固定的生成候选点 → 逐项做过滤器检查 → 逐项打分累加 → 排序输出。2.5 与 Unreal EQS 的对象映射Unreal EQS 概念本文 Unity 实现对应文件GeneratorEQSGeneratorConfigEQSGeneratorConfig.csContextEQSQueryDataEQSQueryData.csTest / ScoreEQSTestBase 及其子类EQSTestBase.csQuery TemplateEQSQueryEQSQuery.csItemEQSItemEQSItem.csEQS Manager / RunnerEQSRunnerEQSRunner.cs这张表可以帮有 Unreal 背景的读者快速定位代码。没有 Unreal 背景的读者也不用担心下面会从零解释。3. 环境准备与项目结构3.1 Unity 版本说明本文示例基于 Unity 6Unity 6000.0 及以上版本编写。Unity 6 对 C# 脚本系统的支持与原有用法一致并且默认推荐使用新版 AI Navigation 包来构建 NavMesh。如果你使用的是 Unity 2021 或 Unity 2022大部分代码也可以直接运行但 NavMesh 相关操作可能要走旧版 Navigation 窗口需要根据项目实际情况调整。以下代码不依赖 Unity 6 独有的新 API主要用到了 UnityEngine.AI、UnityEngine.ScriptableObject、UnityEngine.SerializeReference 这些成熟机制所以迁移成本不高。3.2 创建项目与目录打开 Unity Hub新建一个 3D 项目模板选择 Universal 3D 或 Core 3D 均可。项目创建完成后在 Project 窗口中新建以下目录Assets/ ├── Scripts/ │ ├── EQS/ │ │ ├── Core/ │ │ ├── Generators/ │ │ └── Tests/ │ └── AI/ ├── AI/ │ └── EQSQueries/ └── Scenes/其中Scripts/EQS/Core放 EQS 框架的核心脚本Scripts/EQS/Generators放生成器配置Scripts/EQS/Tests放测试实现Scripts/AI放 AI 控制器示例AI/EQSQueries放创建的 EQS 查询资产。3.3 安装 AI Navigation 包如果场景中要用 NavMesh 做路径检测和 AI 移动需要先安装 AI Navigation 包。在 Unity 6 中打开 Window - Package Manager搜索 AI Navigation点击 Install。旧版 Unity 自带的 Navigation 窗口Bake 面板在 Unity 6 中已不再是唯一方式推荐使用 AI Navigation 包里的 NavMeshSurface 组件。安装完成后在场景中创建一个地面给地面添加 NavMeshSurface 组件。在组件面板中点击 Bake 按钮即可生成当前场景的导航网格。后续使用 NavMeshAgent 的 AI 角色会按导航网格寻路。4. 核心代码实现从数据模型到执行器下面进入代码部分。我会按照依赖关系从下往上逐步编写。先写数据模型再写测试再写生成器最后写查询资产与执行器。4.1 运行时上下文 EQSQueryData新建脚本Assets/Scripts/EQS/Core/EQSQueryData.csusing UnityEngine; namespace EQS.Core { public class EQSQueryData { public Transform SelfTransform; public Vector3 SelfPosition; public Quaternion SelfRotation; public Vector3 TargetPosition; public Transform TargetTransform; } }这个类承载一次查询所需的全部外部输入。SelfPosition和SelfRotation通常来自 AI 自身TargetPosition来自玩家或者某条线索位置。为什么要单独定义这样一个数据类而不是把这些值作为参数传进每个测试方法因为 EQS 的查询流程中候选点需要依次经过多个测试如果每个测试都单独传参以后新增测试时要改的方法签名会越来越多。用一个上下文对象统一传递是更简洁的做法。4.2 候选点 EQSItem新建脚本Assets/Scripts/EQS/Core/EQSItem.csusing System.Collections.Generic; using UnityEngine; namespace EQS.Core { [System.Serializable] public class EQSItem { public Vector3 Position; public float Score; public bool IsValid true; public Dictionarystring, float TestScores new Dictionarystring, float(); public void Reset() { Score 0f; IsValid true; TestScores.Clear(); } } }EQSItem 表示一个候选点。除了位置它还保存了最终得分和每个测试的名字与得分。TestScores的用途是调试查询完成后可以通过这个字典看到某个点分别在“距离测试”“可见性测试”上得了多少分便于找出异常点。4.3 测试基类 EQSTestBase新建脚本Assets/Scripts/EQS/Core/EQSTestBase.csusing UnityEngine; namespace EQS.Core { [System.Serializable] public abstract class EQSTestBase { public string TestName EQSTest; public float Weight 1f; public bool EnableFilter false; public float FilterMinScore 0.1f; public abstract float Score(EQSQueryData context, Vector3 candidatePosition); public virtual bool Filter(EQSQueryData context, Vector3 candidatePosition) { if (!EnableFilter) { return true; } float score Score(context, candidatePosition); return score FilterMinScore; } } }几个字段的作用TestName显示在 Inspector 中也写入 EQSItem.TestScores方便排查。Weight权重。实际得分是Score * Weight。如果某个测试比其他测试更重要可以把权重调大。EnableFilter是否开启过滤模式。FilterMinScore开启过滤后低于该分数的候选点会被淘汰。需要明确的是Filter方法内部调用了Score所以在过滤器模式下同一个测试会被计算一次。后续如果为了极致性能可以改成 Filter 和 Score 分开逻辑但在候选点数量不大的情况下这样写已经够清晰。4.4 常用测试实现有了基类接下来实现几种常用测试。这些测试都会放在Assets/Scripts/EQS/Tests/目录下。4.4.1 距离测试 DistanceTest距离测试用于控制 AI 与自身目标点之间的距离偏好。适合“AI 想待在离玩家一段距离的位置”这类需求。using UnityEngine; using EQS.Core; namespace EQS.Tests { [System.Serializable] public class DistanceTest : EQSTestBase { public enum DistancePreference { CloserIsBetter, FartherIsBetter } public DistancePreference Preference DistancePreference.CloserIsBetter; public float OptimalDistance 10f; public override float Score(EQSQueryData context, Vector3 candidatePosition) { float distance Vector3.Distance(context.SelfPosition, candidatePosition); float normalized Mathf.InverseLerp(0f, OptimalDistance, distance); float score Mathf.Clamp01(normalized); if (Preference DistancePreference.FartherIsBetter) { score 1f - score; } return score; } } }这里使用了Mathf.InverseLerp(0f, OptimalDistance, distance)把距离映射到 0 到 1 之间。当距离大于OptimalDistance时结果为 1距离为 0 时结果为 0。具体含义是AI 离这个候选点越接近OptimalDistance得分越高。如果选择FartherIsBetter则反过来越远得分越高。4.4.2 可见性测试 VisibilityToTargetTest这是一个掩体选择场景中非常关键的打分项。它检查“从候选点看向目标是否被阻挡”。如果被阻挡说明这个候选点可能是个掩体点AI 站在这里不容易被目标发现。using UnityEngine; using EQS.Core; namespace EQS.Tests { [System.Serializable] public class VisibilityToTargetTest : EQSTestBase { public LayerMask BlockingMask ~0; public float CheckHeight 1.5f; public override float Score(EQSQueryData context, Vector3 candidatePosition) { Vector3 from candidatePosition Vector3.up * CheckHeight; Vector3 to context.TargetPosition Vector3.up * CheckHeight; Vector3 direction to - from; float distance direction.magnitude; if (distance 0.01f) { return 1f; } if (Physics.Raycast(from, direction.normalized, distance, BlockingMask)) { return 0f; } return 1f; } } }这个测试默认返回 1 或 0属于硬性判断。如果希望有更平滑的得分可以结合距离对分数进行衰减。我设置的BlockingMask默认是~0表示所有碰撞层都会阻挡射线。实际项目中应该只让墙体、箱子、大型障碍物阻挡射线避免角色模型或细小物件干扰所以建议在 Inspector 中显式指定一个“环境阻挡层”。4.4.3 朝向测试 DirectionTestDirectionTest 检查候选点是否在 AI 的前方。适合“AI 优先选择前方区域作为目标点”这类需求避免 AI 经常调头。using UnityEngine; using EQS.Core; namespace EQS.Tests { [System.Serializable] public class DirectionTest : EQSTestBase { public float MaxAngle 60f; public override float Score(EQSQueryData context, Vector3 candidatePosition) { Vector3 forward context.SelfRotation * Vector3.forward; Vector3 toCandidate (candidatePosition - context.SelfPosition).normalized; float dot Vector3.Dot(forward, toCandidate); float angle Mathf.Acos(Mathf.Clamp(dot, -1f, 1f)) * Mathf.Rad2Deg; float normalized Mathf.InverseLerp(MaxAngle, 0f, angle); return Mathf.Clamp01(normalized); } } }这里把候选方向与 AI 前方方向的夹角映射到得分。夹角为 0 时得分 1夹角大于 60 度时得分 0。4.4.4 NavMesh 可达性测试 NavMeshWalkableTest由于 AI 移动依赖 NavMesh候选点必须是导航网格上的可达位置。这个测试可以同时作为过滤器使用。using UnityEngine; using UnityEngine.AI; using EQS.Core; namespace EQS.Tests { [System.Serializable] public class NavMeshWalkableTest : EQSTestBase { public float MaxDistance 1f; public override float Score(EQSQueryData context, Vector3 candidatePosition) { if (NavMesh.SamplePosition(candidatePosition, out NavMeshHit hit, MaxDistance, NavMesh.AllAreas)) { return 1f; } return 0f; } } }在 Inspector 中把EnableFilter打开FilterMinScore设为 0.5 以上就可以让不在 NavMesh 上的候选点全部淘汰。需要注意的是NavMesh.SamplePosition只判断点周围是否存在 NavMesh 面不保证 AI 一定能从当前位置寻路到那里。如果项目需要严格的可达性判断可以在运行时对候选点调用NavMesh.CalculatePath或NavMesh.Raycast做进一步检查但这会带来额外性能开销。4.5 生成器配置 EQSGeneratorConfig生成器部分用一个配置类统一管理。新建脚本Assets/Scripts/EQS/Generators/EQSGeneratorConfig.csusing System.Collections.Generic; using UnityEngine; namespace EQS.Generators { public enum EQSGeneratorType { Grid, Ring, Points } [System.Serializable] public class EQSGeneratorConfig { public EQSGeneratorType Type EQSGeneratorType.Grid; [Header(Grid Settings)] public Vector2 GridSize new Vector2(10f, 10f); public float CellSize 1f; [Header(Ring Settings)] public int RingCount 8; public float RingRadius 5f; public float StartAngle 0f; [Header(Custom Points)] public ListVector3 CustomPoints new ListVector3(); public ListVector3 Generate(Vector3 center, Quaternion rotation) { ListVector3 points new ListVector3(); switch (Type) { case EQSGeneratorType.Grid: GenerateGrid(center, rotation, points); break; case EQSGeneratorType.Ring: GenerateRing(center, rotation, points); break; case EQSGeneratorType.Points: GenerateCustomPoints(center, rotation, points); break; } return points; } private void GenerateGrid(Vector3 center, Quaternion rotation, ListVector3 points) { int halfX Mathf.FloorToInt(GridSize.x / (2f * CellSize)); int halfZ Mathf.FloorToInt(GridSize.y / (2f * CellSize)); for (int x -halfX; x halfX; x) { for (int z -halfZ; z halfZ; z) { Vector3 localOffset new Vector3(x * CellSize, 0f, z * CellSize); Vector3 worldOffset rotation * localOffset; points.Add(center worldOffset); } } } private void GenerateRing(Vector3 center, Quaternion rotation, ListVector3 points) { for (int i 0; i RingCount; i) { float angle StartAngle i * (360f / RingCount); Quaternion offsetRotation Quaternion.Euler(0f, angle, 0f); Vector3 localDirection offsetRotation * Vector3.forward; Vector3 worldDirection rotation * localDirection; points.Add(center worldDirection * RingRadius); } } private void GenerateCustomPoints(Vector3 center, Quaternion rotation, ListVector3 points) { for (int i 0; i CustomPoints.Count; i) { Vector3 localPoint CustomPoints[i]; points.Add(center rotation * localPoint); } } } }这里最重要的设计点在于rotation参数。Grid 的偏移和 Ring 的方向都基于 AI 的朝向旋转这样生成出来的候选点会围绕 AI 而且面向合理的方向。如果你希望生成器始终按世界坐标轴方向生成可以把传入的rotation改为Quaternion.identity。4.6 查询资产 EQSQuery新建脚本Assets/Scripts/EQS/Core/EQSQuery.csusing System.Collections.Generic; using EQS.Generators; using UnityEngine; namespace EQS.Core { [CreateAssetMenu(menuName AI/EQS Query, fileName NewEQSQuery)] public class EQSQuery : ScriptableObject { public EQSGeneratorConfig Generator new EQSGeneratorConfig(); [SerializeReference] public ListEQSTestBase Tests new ListEQSTestBase(); } }注意[SerializeReference]特性。它允许 Unity 的 Inspector 在同一个列表中显示不同类型的子类对象。在编辑器中点击列表元素旁边的类型选择器就能从 DistanceTest、VisibilityToTargetTest、DirectionTest、NavMeshWalkableTest 中挑选。4.7 执行器 EQSRunner新建脚本Assets/Scripts/EQS/Core/EQSRunner.csusing System.Collections.Generic; using UnityEngine; namespace EQS.Core { public static class EQSRunner { public static ListEQSItem Evaluate(EQSQuery query, EQSQueryData context) { ListEQSItem result new ListEQSItem(); if (query null) { Debug.LogError(EQSQuery is null.); return result; } if (context null) { Debug.LogError(EQSQueryData is null.); return result; } ListVector3 candidates query.Generator.Generate(context.SelfPosition, context.SelfRotation); for (int i 0; i candidates.Count; i) { Vector3 candidate candidates[i]; EQSItem item new EQSItem(); item.Reset(); item.Position candidate; bool valid true; for (int j 0; j query.Tests.Count; j) { EQSTestBase test query.Tests[j]; if (test null) { continue; } if (!test.Filter(context, candidate)) { valid false; break; } float score test.Score(context, candidate) * test.Weight; item.Score score; item.TestScores[test.TestName] score; } if (valid) { result.Add(item); } } result.Sort((a, b) b.Score.CompareTo(a.Score)); return result; } } }执行器首先通过生成器拿到候选点列表。然后逐个候选点执行测试列表先做过滤器检查如果过滤失败则标记 invalid 并跳出否则累加得分。最后把有效候选点按得分从高到低排序。这里要注意一个容易被忽视的细节item.TestScores[test.TestName] score存储的是加权前的分数也就是Score()方法的原始输出。这样看起调试数据会更直观。如果想看加权后的贡献值可以把score这个变量名换成weightedScore然后另外存储原始得分。5. 完整实战AI 搜索掩体并移动框架代码已经齐全下面把它组合成一个可在 Unity 中运行的例子。5.1 搭建测试场景场景结构可以参考下面这种方式场景根节点 ├── Ground │ └── NavMeshSurface ├── Obstacles │ ├── Wall_1 │ ├── Wall_2 │ └── Box_1 ├── Player │ └── Capsule (带第三人称或第一人称控制) └── AIEnemy ├── Capsule ├── NavMeshAgent └── CoverAI (脚本)Ground 是一个平面或立方体记得给它添加 NavMeshSurface 组件并点击 Bake。Obstacles 可以放几个 Cube 或者带碰撞体的墙体让掩体查询有实际意义。Player 对象加一个简单的移动控制脚本或者直接手动拖动它来模拟目标位置变化。AIEnemy 需要挂 NavMeshAgent 组件并设置好 Agent Radius、Height 等属性同时把之前写的 CoverAI 脚本挂上去。5.2 创建 EQS Query 资产在 Project 窗口中右键选择 Create - AI - EQS Query得到一个 EQSQuery 资产。选中该资产在 Inspector 中设置Generator Type 选 GridGridSize 设置为 (8, 8)CellSize 设置为 1。Tests 列表中添加 3 个测试NavMeshWalkableTest开启 EnableFilterFilterMinScore 设置 0.5用于淘汰不在 NavMesh 上的点。VisibilityToTargetTestWeight 设置为 2让掩体可见性更重要。DistanceTestPreference 选 CloserIsBetterOptimalDistance 设置 8。这里建议在添加测试时给每个测试的 TestName 起一个可读的名字比如 “NavMeshCheck”、“CoverVisible”、“DistanceScore”这样在调试数据里一眼就能看清来源。5.3 编写 AI 控制器新建脚本Assets/Scripts/AI/CoverAI.csusing System.Collections.Generic; using EQS.Core; using UnityEngine; using UnityEngine.AI; namespace AI { public class CoverAI : MonoBehaviour { public EQSQuery coverQuery; public Transform player; public float queryInterval 2f; public bool showDebugInfo true; private NavMeshAgent agent; private float nextQueryTime; void Start() { agent GetComponentNavMeshAgent(); if (agent null) { agent gameObject.AddComponentNavMeshAgent(); } } void Update() { if (Time.time nextQueryTime) { return; } nextQueryTime Time.time queryInterval; if (player null || coverQuery null) { return; } EvaluateAndMove(); } void EvaluateAndMove() { EQSQueryData data new EQSQueryData { SelfTransform transform, SelfPosition transform.position, SelfRotation transform.rotation, TargetPosition player.position, TargetTransform player }; ListEQSItem results EQSRunner.Evaluate(coverQuery, data); if (results.Count 0) { Debug.LogWarning(EQS 没有找到有效候选点请检查生成器和过滤器配置。); return; } EQSItem best results[0]; if (NavMesh.SamplePosition(best.Position, out NavMeshHit hit, 2f, NavMesh.AllAreas)) { agent.SetDestination(hit.position); } if (showDebugInfo) { for (int i 0; i results.Count; i) { EQSItem item results[i]; Color color Color.Lerp(Color.red, Color.green, item.Score); Vector3 from item.Position; Vector3 to from Vector3.up * 2f; Debug.DrawLine(from, to, color, queryInterval * 0.8f); } Debug.Log($最佳点: {best.Position}, 得分: {best.Score:F2}); } } } }这段代码中AI 每 2 秒执行一次查询。查询时用EQSRunner.Evaluate得到排序后的候选点列表然后让 NavMeshAgent 走向最佳点。showDebugInfo开启后场景视图中会画出所有候选点的得分线红色代表低分绿色代表高分。这样能直观看到 EQS 是否真的选到了想要的掩体位置。5.4 运行与验证点击 Play移动 Player 位置观察 AI 的行为。正常的表现是AI 每隔一小段时间重新评估周围候选点然后朝一个相对安全的位置移动。当 AI 开始朝着某个“看不到玩家”的墙后退缩时说明可见性测试在起作用。如果想进一步验证可以在运行时打开 Inspector查看 CoverAI 组件上的日志输出或者手动调整 EQSQuery 中某个测试的 Weight观察 AI 行为是否随之改变。6. EQS 查询的常见问题与排查思路在实际使用中最容易遇到的问题集中在“没找到候选点”“分数异常”“性能卡顿”这几个方向。下面整理成一张速查表。问题现象常见原因解决思路查询返回空列表AI 一直不动GridSize 过小或 CellSize 过大生成点太少先调大 GridSize再检查 NavMesh 是否覆盖所有点都被过滤掉NavMeshWalkableTest 的 MaxDistance 太小把 MaxDistance 调整到 1 或 2确认地形有 NavMesh得分极高但点位明显不合理测试没有区分“方向”和“可见性”增加 DirectionTest并调大 VisibilityToTargetTest 权重运行时性能卡顿候选点数量太多每个点都做射线检测降低 GridSize增大 CellSize或者改用 Ring 生成器AI 选择的位置在墙后面但不可达候选点虽然在 NavMesh 上但被墙隔开增加 NavMesh 路径计算或者用 NavMesh.Raycast 做可达性判断每次查询结果抖动很大得分相近的候选点太多缺少“方向一致性”约束加入 DirectionTest并给距离测试增加平滑曲线Inspector 中 Tests 列表无法添加元素泛型列表使用了普通 List 而非 SerializeReference确认字段带有 [SerializeReference] 特性从实际使用来说最推荐先开启showDebugInfo观察候选点颜色分布。如果所有点都是绿色说明测试区分度不够如果所有点都是红色则需要检查测试逻辑是否真的在计算如果大量点被过滤没显示过滤器可能过于严格。7. 最佳实践与工程建议7.1 候选点数量控制EQS 的精度和性能是一对矛盾。Grid 生成器如果把 CellSize 设置到 0.510×10 的范围就会生成 400 多个点。每个点要做多次测试如果其中有射线检测开销会比较大。经验值上单次查询控制在 50 到 200 个候选点是比较合理的区间。如果项目需要更多候选点建议把查询从每帧同步执行改成协程分帧或者使用 Unity Job System 做并行计算。本文的框架可以平滑迁移到 Job System因为每个点的 Score 计算是独立的非常适合并行。7.2 合理搭配 Filter 与 Score不要把所有要求都放进 Score把不可接受的条件放进 Filter。例如一个不在 NavMesh 上的点是“绝对不可用”的应该用 NavMeshWalkableTest 的过滤模式直接剔除。而“距离 8 米最合适”是“越靠近越好但差一点也能接受”应该作为 Score 测试参与排名。区分这两个阶段会让查询结果更稳定排查问题也更容易。7.3 测试命名规范测试基类里有TestName字段它在 Inspector 中显示为普通字符串。建议在创建复杂查询时把测试命名统一成“用途 方向”例如NavMeshCheckPlayerVisibleForwardPreferDistance_Optimal8这样最终结果的TestScores字典可读性会非常好。如果多个查询资产共用同一批测试建议用 ScriptableObject 把测试做成可复用资产而不是在每次创建查询时重复配置。7.4 扩展新的生成器与测试当前系统扩展起来很简单。新增一个测试时只需要继承EQSTestBase实现Score方法并在需要时重写Filter。例如想实现“候选点附近是否有掩体高于 AI 头部”的测试可以写一个HeightAdvantageTest用Physics.SphereCast或Physics.OverlapSphere检测。新增生成器时只需要扩展Generate方法或者增加一个EQSGeneratorType枚举值。比如添加一个扇形生成器用于 AI 前方扇形搜索。7.5 配合行为树与 Utility AIEQS 本身只是“环境评估”它不负责决定 AI 做什么。真正完整的 AI 系统应该把 EQS 的查询结果作为输入交给更高层的决策系统。在 Unity 项目中常见做法是把 EQS 查询封装成一个“任务节点”接入 Behavior Designer、NodeCanvas 这类行为树插件或者用自制状态机触发。对于更复杂的 AIEQS 非常适合作为 Utility AI 的“打分器”每种策略射击、找掩体、巡逻都对应一个 EQS 查询最终选择得分最高的策略执行。7.6 数据驱动配置重要的一点是EQS 查询应该全部配置在 ScriptableObject 资产中而不是每个 AI 身上用 Inspector 暴露一堆可调参数。这样同一个掩体查询可以被多个 AI 复用策划也能在不动代码的情况下调整 AI 策略。如果遇到不同 AI 需要不同参数的情况建议使用“查询资产 覆盖参数”的方式而不是在一个 MonoBehaviour 里暴露所有字段。8. 总结本文在 Unity 6 中实现了一个可运行的 EQS 框架覆盖了生成器、测试、过滤、查询执行、结果排序的完整流程。核心代码中EQSQueryData 负责传递上下文EQSTestBase 负责定义打分逻辑EQSGeneratorConfig 负责产生候选点EQSRunner 负责串联整个流程。通过掩体搜索示例可以看到 AI 不再依赖手工点位而是能根据场景实时评估周围空间找到最适合自己的落脚点。这种能力在很多玩法中都能产生明显效果潜行游戏中的视野规避、团队 AI 的战术站位、怪物对玩家的包围都可以用 EQS 来驱动。下一步如果你要继续深挖可以从三个方向入手一是尝试用 Job System 重写 EQSRunner把几百个候选点的评估过程并行化二是结合行为树中间件把本文的查询封装成可复用的任务节点三是增加“查询结果缓存”机制避免多了一个 AI 在同一个区域重复做完全相同的查询。如果本文对你有帮助可以先收藏备用。后续我会继续整理 Unity AI 方向上更深入的实战经验包括 Utility AI、行为树与导航的集成方案。