☰
Unity 6实现轻量级EQS环境查询系统,助力AI智能决策
2026/10/7 9:00:25 网站建设 项目流程

不少从 Unreal 转到 Unity 的开发者,最怀念的工具之一就是 EQS(Environment 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.cs
ContextEQSQueryDataEQSQueryData.cs
Test / ScoreEQSTestBase 及其子类EQSTestBase.cs
Query TemplateEQSQueryEQSQuery.cs
ItemEQSItemEQSItem.cs
EQS Manager / RunnerEQSRunnerEQSRunner.cs

这张表可以帮有 Unreal 背景的读者快速定位代码。没有 Unreal 背景的读者也不用担心,下面会从零解释。

3. 环境准备与项目结构

3.1 Unity 版本说明

本文示例基于 Unity 6(Unity 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.cs:

using 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.cs:

using System.Collections.Generic; using UnityEngine; namespace EQS.Core { [System.Serializable] public class EQSItem { public Vector3 Position; public float Score; public bool IsValid = true; public Dictionary<string, float> TestScores = new Dictionary<string, float>(); public void Reset() { Score = 0f; IsValid = true; TestScores.Clear(); } } }

EQSItem 表示一个候选点。除了位置,它还保存了最终得分和每个测试的名字与得分。TestScores的用途是调试:查询完成后,可以通过这个字典看到某个点分别在“距离测试”“可见性测试”上得了多少分,便于找出异常点。

4.3 测试基类 EQSTestBase

新建脚本Assets/Scripts/EQS/Core/EQSTestBase.cs:

using 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 朝向测试 DirectionTest

DirectionTest 检查候选点是否在 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.cs:

using 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 List<Vector3> CustomPoints = new List<Vector3>(); public List<Vector3> Generate(Vector3 center, Quaternion rotation) { List<Vector3> points = new List<Vector3>(); 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, List<Vector3> 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, List<Vector3> 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, List<Vector3> 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.cs:

using 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 List<EQSTestBase> Tests = new List<EQSTestBase>(); } }

注意[SerializeReference]特性。它允许 Unity 的 Inspector 在同一个列表中显示不同类型的子类对象。在编辑器中,点击列表元素旁边的类型选择器,就能从 DistanceTest、VisibilityToTargetTest、DirectionTest、NavMeshWalkableTest 中挑选。

4.7 执行器 EQSRunner

新建脚本Assets/Scripts/EQS/Core/EQSRunner.cs:

using System.Collections.Generic; using UnityEngine; namespace EQS.Core { public static class EQSRunner { public static List<EQSItem> Evaluate(EQSQuery query, EQSQueryData context) { List<EQSItem> result = new List<EQSItem>(); if (query == null) { Debug.LogError("EQSQuery is null."); return result; } if (context == null) { Debug.LogError("EQSQueryData is null."); return result; } List<Vector3> 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 选 Grid,GridSize 设置为 (8, 8),CellSize 设置为 1。
  • Tests 列表中添加 3 个测试:
    • NavMeshWalkableTest:开启 EnableFilter,FilterMinScore 设置 0.5,用于淘汰不在 NavMesh 上的点。
    • VisibilityToTargetTest:Weight 设置为 2,让掩体可见性更重要。
    • DistanceTest:Preference 选 CloserIsBetter,OptimalDistance 设置 8。

这里建议在添加测试时,给每个测试的 TestName 起一个可读的名字,比如 “NavMeshCheck”、“CoverVisible”、“DistanceScore”,这样在调试数据里一眼就能看清来源。

5.3 编写 AI 控制器

新建脚本Assets/Scripts/AI/CoverAI.cs:

using 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 = GetComponent<NavMeshAgent>(); if (agent == null) { agent = gameObject.AddComponent<NavMeshAgent>(); } } 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 }; List<EQSItem> 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.5,10×10 的范围就会生成 400 多个点。每个点要做多次测试,如果其中有射线检测,开销会比较大。

经验值上,单次查询控制在 50 到 200 个候选点是比较合理的区间。如果项目需要更多候选点,建议把查询从每帧同步执行改成协程分帧,或者使用 Unity Job System 做并行计算。本文的框架可以平滑迁移到 Job System,因为每个点的 Score 计算是独立的,非常适合并行。

7.2 合理搭配 Filter 与 Score

不要把所有要求都放进 Score,把不可接受的条件放进 Filter。

例如,一个不在 NavMesh 上的点是“绝对不可用”的,应该用 NavMeshWalkableTest 的过滤模式直接剔除。而“距离 8 米最合适”是“越靠近越好但差一点也能接受”,应该作为 Score 测试参与排名。区分这两个阶段,会让查询结果更稳定,排查问题也更容易。

7.3 测试命名规范

测试基类里有TestName字段,它在 Inspector 中显示为普通字符串。建议在创建复杂查询时,把测试命名统一成“用途 + 方向”,例如:

  • NavMeshCheck
  • PlayerVisible
  • ForwardPrefer
  • Distance_Optimal8

这样最终结果的TestScores字典可读性会非常好。如果多个查询资产共用同一批测试,建议用 ScriptableObject 把测试做成可复用资产,而不是在每次创建查询时重复配置。

7.4 扩展新的生成器与测试

当前系统扩展起来很简单。

新增一个测试时,只需要继承EQSTestBase,实现Score方法,并在需要时重写Filter。例如,想实现“候选点附近是否有掩体高于 AI 头部”的测试,可以写一个HeightAdvantageTest,用Physics.SphereCast或Physics.OverlapSphere检测。

新增生成器时,只需要扩展Generate方法或者增加一个EQSGeneratorType枚举值。比如添加一个扇形生成器,用于 AI 前方扇形搜索。

7.5 配合行为树与 Utility AI

EQS 本身只是“环境评估”,它不负责决定 AI 做什么。真正完整的 AI 系统,应该把 EQS 的查询结果作为输入,交给更高层的决策系统。

在 Unity 项目中,常见做法是把 EQS 查询封装成一个“任务节点”,接入 Behavior Designer、NodeCanvas 这类行为树插件,或者用自制状态机触发。对于更复杂的 AI,EQS 非常适合作为 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、行为树与导航的集成方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询