ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity 6 从零实现轻量级 EQS:AI 环境查询决策系统

Unity 6 从零实现轻量级 EQS:AI 环境查询决策系统 在实际动作游戏或策略游戏的 AI 逻辑里最难的往往不是“看到玩家并走过去”而是让 AI 根据当前战况决定“下一步应该站在哪里”。Unreal 的 EQSEnvironment Query System就是为解决这类问题而设计的它把环境中的候选位置抽象成可查询、可过滤、可评分的对象AI 在行动前先执行一次环境查询再根据排序结果决定移动、攻击或撤退。Unity 6 没有内置 EQS但复刻一个足够轻量的版本并不复杂核心只需要四块候选点生成、过滤、评分、排序。这篇内容会从零实现一套可在 Unity 6 中运行的 EQS 示例并把查询结果接入 NavMeshAgent 的移动流程。1. 为什么 AI 需要 EQS从 Unreal 到 Unity 的问题映射1.1 没有查询系统的 AI 是如何“瞎走”的很多 Unity 项目里的 AI 移动决策会写成这样提前在场景里摆好若干空物体作为巡逻点或者当 AI 需要找掩体时从当前位置向外随机偏移一段距离再把结果丢给 NavMeshAgent。这种写法在固定场景里能跑通但只要场景发生变化或者玩家位置导致某个预设点失效AI 就会出现三种典型行为走到墙里、站在原地发呆、反复在两个点之间抖动。EQS 的逻辑和这种“拍脑袋选点”完全不同。它把“在哪里挑选候选点”抽象成 Generator把“这个点是否可用”抽象成 Filter 测试把“这个点相对于目标有多好”抽象成 Score 测试。AI 每次做决策时只需要提供查询上下文例如“查询者是谁、目标是谁、当前位置在哪”系统就会生成一批候选点过滤掉不可用点再按分数排序输出结果。1.2 EQS 的核心概念与 Unity 复刻建议Unreal 的 EQS 有完整的查询资产、生成器和测试节点体系。Unity 侧没有同等封装但可以用 C# 接口和 MonoBehaviour 组合出类似结构。理解概念对应关系比直接抄代码更重要。Unreal EQS 概念作用Unity 复刻建议UEnvQuery 查询资产配置生成器、测试和权重一个 EQSRunner 组件持有 Generator 和 Test 列表Generator 生成器产生候选点实现Generate(output, context)方法的 MonoBehaviourTest 测试过滤候选点或给候选点打分实现Filter和Score方法的 MonoBehaviourContext 上下文提供查询者和目标的位置信息使用EQSQueryContext或自定义数据结构QueryResult 查询结果排序后的候选点集合使用ListEQSItem按分数降序排列这套映射不会和 Unreal 完全一致但已经足够覆盖多数 AI 场景掩体选择、狙击点评估、巡逻点生成、逃跑方向选择。1.3 一个典型场景AI 寻找掩体假设玩家在掩体后方射击AI 需要选择一个既能躲避玩家视线、又不会离玩家太远的掩体。传统做法很难同时满足两个条件因为“距离合适”和“对方看不见我”是两个互相冲突的评估维度。EQS 的做法是先生成一批候选点再用距离测试计算“这个点距离目标 8 到 12 米”的得分用视线测试过滤掉“站在这里会被玩家直接看到”的点最后对剩余点做遮挡加分。这样AI 的移动目标就不是随机点而是经过环境验证的“最优解”。2. 在 Unity 6 中搭建 EQS 的基础框架2.1 环境准备Unity 6 项目与 AI 导航依赖建议使用 Unity 6 的 3D 模板创建项目内置渲染管线或 URP 都可以。EQS 本身不依赖渲染管线但下面的示例会让 AI 在地形上移动所以需要 NavMeshAgent 和一个已经烘焙好的导航网格。在 Unity 6 中导航相关模块已经从内置模块调整为com.unity.ai.navigation包。新建项目后如果找不到Window AI Navigation菜单说明包还没有安装。在 Package Manager 搜索 “AI Navigation” 并安装即可。安装后场景中需要烘焙 NavMesh把地面设置成Navigation Static打开 Navigation 窗口点击 Bake。没有 NavMeshNavMeshAgent 就无法寻路EQS 选出的候选点也无法到达。环境检查清单Unity 6 项目已创建模板为 3D。com.unity.ai.navigation包已安装。场景地面已标记 Navigation Static 并成功烘焙。场景中有 NavMeshAgent 对象和玩家目标对象。测试脚本所在目录有脚本引用命名空间一致。2.2 EQS 查询的执行链路一次 EQS 查询按以下顺序执行构建查询上下文包含查询者 Transform、目标 Transform、查询者位置和目标位置。遍历所有 Generator向候选集合中添加候选点。遍历所有 Filter 测试删除不满足条件的候选点。遍历所有 Score 测试对剩余候选点计算加权总分数。按分数从高到低排序返回结果。整个过程是同步的。在编辑器里使用没问题但如果查询频率很高或者候选点很多就要考虑把过滤和评分放入 Job System。这一点在第 5 章专门讨论。2.3 最小接口设计为了不依赖具体项目先定义三个基础类型候选点结构体、查询上下文、生成器与测试接口。using System; using System.Collections.Generic; using UnityEngine; namespace EQSLite.Core { [Serializable] public struct EQSItem { public Vector3 position; public Vector3 direction; public float score; public EQSItem(Vector3 position, Vector3 direction) { this.position position; this.direction direction; this.score 0f; } } public class EQSQueryContext { public Transform querier; public Transform target; public Vector3 querierPosition; public Vector3 targetPosition; public float time; } public interface IEQSGenerator { void Generate(ListEQSItem output, EQSQueryContext context); } public interface IEQSTest { bool IsFilter { get; } float Weight { get; } bool Filter(EQSItem item, EQSQueryContext context); float Score(EQSItem item, EQSQueryContext context); } }这里把EQSItem设计成结构体而不是类主要原因是候选点通常会批量生成用结构体可以减少小对象在堆上分配降低 GC 压力。后续如果需要附加更多数据可以在结构体里增加字段或者用字典保存附加信息。EQSQueryContext是查询的输入上下文包含位置和目标引用。实际项目中可以换成自己的黑板对象只要保证查询时能拿到所需数据即可。3. 从零实现一个可运行的 EQS 查询流程3.1 生成器在掩体周边生成候选点第一个功能是生成候选点。示例实现一个围绕查询者在指定半径内随机采样的生成器。因为 AI 最终要移动所以每个候选点都用NavMesh.SamplePosition做一次地形校验尽可能保证点落在可走区域。using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; using EQSLite.Core; public class CoverPointGenerator : MonoBehaviour, IEQSGenerator { public int sampleCount 32; public float radius 8f; public float minDistance 2f; public float maxDistance 12f; public Vector3 heightOffset new Vector3(0f, 0.2f, 0f); public void Generate(ListEQSItem output, EQSQueryContext context) { if (context.querier null) return; int generated 0; int attempts 0; while (generated sampleCount attempts sampleCount * 4) { attempts; Vector2 circle Random.insideUnitCircle * radius; Vector3 candidate context.querierPosition new Vector3(circle.x, 0f, circle.y) heightOffset; if (NavMesh.SamplePosition(candidate, out NavMeshHit hit, 1f, NavMesh.AllAreas)) { float distance Vector3.Distance(hit.position, context.querierPosition); if (distance minDistance || distance maxDistance) continue; output.Add(new EQSItem(hit.position, hit.normal)); generated; } } } }这段代码有两个关键点。第一attempts sampleCount * 4是保护条件避免采样区域里大量位置不可走时无限循环。第二NavMesh.SamplePosition的maxDistance参数不能太大否则候选点可能被修正到离原始采样点很远的位置导致采样区域失真。如果希望每次查询结果稳定在生成前可以调用Random.InitState(seed)固定种子否则同一场景每次运行时随机点会变化。这个技巧第 5 章会继续讲。3.2 过滤测试去掉被遮挡和不可用点过滤测试只做一件事候选点不满足条件时直接标记为不可用。示例实现一个视线过滤测试检查候选点与目标之间是否有障碍物遮挡。如果 AI 要选择射击位置这个测试能排除掉“虽然很近但躲在墙后”的位置。using UnityEngine; using EQSLite.Core; public class LineOfSightFilter : MonoBehaviour, IEQSTest { public LayerMask blockMask; public Vector3 rayHeightOffset Vector3.up * 0.5f; public bool IsFilter true; public float Weight 1f; public bool Filter(EQSItem item, EQSQueryContext context) { if (context.target null) return true; Vector3 start item.position rayHeightOffset; Vector3 end context.targetPosition rayHeightOffset; return !Physics.Linecast(start, end, blockMask); } }这里把射线起点抬高 0.5 米是为了模拟角色上半身的视线不是从脚底发射。blockMask需要配置成场景中的墙体和障碍物层。如果目标为 null就不过滤逻辑上可以理解成“没有目标时不限制位置”但实际项目中更推荐让过滤返回 false避免查询出无效点。类似地可以再实现一个距离过滤测试把离目标过近或过远的点直接去掉避免 AI 跑到玩家脸上或者走到完全够不着的位置。3.3 评分测试距离和遮挡的多目标打分过滤测试决定“能不能用”评分测试决定“哪个更好”。一个常见需求是AI 希望找到距离目标 10 米左右的点。距离太近不够安全太远又不利于反击。这个需求用距离评分测试实现。using UnityEngine; using EQSLite.Core; public class DistanceScorer : MonoBehaviour, IEQSTest { public AnimationCurve curve AnimationCurve.Linear(0f, 0f, 1f, 1f); public float optimalDistance 10f; public float tolerance 3f; public float weight 1f; public bool IsFilter false; public float Weight weight; public float Score(EQSItem item, EQSQueryContext context) { float distance Vector3.Distance(item.position, context.targetPosition); float t Mathf.InverseLerp( optimalDistance - tolerance, optimalDistance tolerance, distance); return curve.Evaluate(Mathf.Clamp01(t)); } }optimalDistance是理想距离tolerance是允许波动的范围。当候选点正好在 10 米时t 0.5曲线值由AnimationCurve决定。这里用曲线而不是直接用线性距离是为了让项目策划或开发者可以在不修改代码的情况下调整“距离与得分的映射关系”。再实现一个遮挡评分器如果候选点与目标之间有障碍物说明这个位置具有一定遮挡价值得 1 分否则得 0 分。using UnityEngine; using EQSLite.Core; public class CoverScoreTest : MonoBehaviour, IEQSTest { public LayerMask coverMask; public Vector3 offsetUp Vector3.up * 0.5f; public float weight 1f; public bool IsFilter false; public float Weight weight; public float Score(EQSItem item, EQSQueryContext context) { if (context.target null) return 0f; Vector3 start item.position offsetUp; Vector3 end context.targetPosition offsetUp; return Physics.Linecast(start, end, coverMask) ? 1f : 0f; } }实际项目中遮挡评分还可以考虑“遮挡物到候选点的距离”。障碍物离候选点太近AI 贴墙移动容易卡住反而应该降分。这里保持最简单版本后面可以根据项目继续加分层级。3.4 查询运行器组合生成器与测试生成的候选点、过滤测试和评分测试需要由统一入口调度。EQSRunner负责执行整条查询链路并返回排序后的结果。using System.Collections.Generic; using UnityEngine; using EQSLite.Core; public class EQSRunner : MonoBehaviour { public EQSQueryContext sharedContext new EQSQueryContext(); public ListMonoBehaviour generators new ListMonoBehaviour(); public ListMonoBehaviour tests new ListMonoBehaviour(); private ListEQSItem candidates new ListEQSItem(64); private ListEQSItem result new ListEQSItem(64); public ListEQSItem lastResult; public ListEQSItem Run(EQSQueryContext context) { candidates.Clear(); result.Clear(); if (context.querier ! null) context.querierPosition context.querier.position; if (context.target ! null) context.targetPosition context.target.position; for (int i 0; i generators.Count; i) { var generator generators[i] as IEQSGenerator; if (generator ! null) generator.Generate(candidates, context); } for (int i 0; i candidates.Count; i) { var item candidates[i]; bool passed true; for (int j 0; j tests.Count; j) { var test tests[j] as IEQSTest; if (test ! null test.IsFilter !test.Filter(item, context)) { passed false; break; } } if (passed) { item.score 0f; result.Add(item); } } for (int i 0; i result.Count; i) { var item result[i]; float totalScore 0f; for (int j 0; j tests.Count; j) { var test tests[j] as IEQSTest; if (test ! null !test.IsFilter) totalScore test.Score(item, context) * test.Weight; } item.score totalScore; result[i] item; } result.Sort((a, b) b.score.CompareTo(a.score)); lastResult result; return result; } }这里使用两个私有 List 复用内存避免每帧查询都产生新 List。需要特别注意的是result作为返回值在下一次Run时会被清理并复用。如果外部要长期保存结果应该拷贝到自己的 List而不是直接持有引用。sharedContext字段只是方便在 Inspector 里查看实际调用时应该传入包含当前查询者的上下文或者在Run内动态更新。4. 把 EQS 查询结果接入 AI 移动决策4.1 设计一个最小 AI 状态机EQS 查询本身不决定 AI 行为它只是输出一个候选位置列表。真正让 AI 动起来的是上层状态机。设计一个简单但完整的状态流程等待查询冷却时间。执行 EQS 查询。把最优候选点设为 NavMeshAgent 目的地。到达后进入掩体状态停留一段时间。冷却结束后重新查询。using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; using EQSLite.Core; [RequireComponent(typeof(NavMeshAgent))] public class EQSAIController : MonoBehaviour { public EQSRunner eqs; public NavMeshAgent agent; public Transform playerTarget; public EQSQueryContext queryContext new EQSQueryContext(); public float queryInterval 2f; public float inCoverTime 3f; public float minMoveDistance 1.5f; private enum AIState { WaitForQuery, MoveToResult, InCover } private AIState state AIState.WaitForQuery; private float timer; private Vector3 currentCoverPosition; private ListEQSItem queryResult; void Start() { agent GetComponentNavMeshAgent(); timer queryInterval; } void Update() { switch (state) { case AIState.WaitForQuery: timer - Time.deltaTime; if (timer 0f) { RunQuery(); timer queryInterval; } break; case AIState.MoveToResult: if (!agent.pathPending agent.remainingDistance agent.stoppingDistance) { state AIState.InCover; timer inCoverTime; } break; case AIState.InCover: timer - Time.deltaTime; if (timer 0f) state AIState.WaitForQuery; break; } } void RunQuery() { if (eqs null || playerTarget null) return; queryContext.querier transform; queryContext.target playerTarget; queryContext.querierPosition transform.position; queryContext.targetPosition playerTarget.position; queryResult eqs.Run(queryContext); if (queryResult null || queryResult.Count 0) { state AIState.WaitForQuery; return; } Vector3 destination queryResult[0].position; if (Vector3.Distance(destination, currentCoverPosition) minMoveDistance state AIState.MoveToResult) { state AIState.InCover; return; } currentCoverPosition destination; agent.SetDestination(destination); state AIState.MoveToResult; } }minMoveDistance的作用是防止 AI 在两个相近候选点之间来回切换。Agent进入 Move 状态后如果新目标离当前目标太近直接进入 InCover而不是再次发起寻路。这个机制对减少抖动非常重要。4.2 查询结果为空时的降级策略EQS 查询结果可能为空例如所有候选点都被过滤测试排除。最常见的错误处理是直接让 AI 原地等待但生产环境中应该有一个降级路径。常见的降级策略包括使用上一次有效结果直到新结果可用。关闭部分过滤条件后重新查询。直接移动到玩家目标的反向最近 NavMesh 点。进入“找不到位置”状态然后播放搜索动画。降级逻辑放到RunQuery返回后判断最合适。不要在EQSRunner内部处理因为不同 AI 的降级策略差异很大。4.3 可视化调试在 Scene 视图查看候选点EQS 这类系统最怕看不见内部过程。在EQSRunner中增加OnDrawGizmosSelected把上次查询结果画出来。private void OnDrawGizmosSelected() { if (lastResult null || lastResult.Count 0) return; for (int i 0; i lastResult.Count; i) { var item lastResult[i]; float t Mathf.Clamp01(item.score); Gizmos.color Color.Lerp(Color.red, Color.green, t); Gizmos.DrawSphere(item.position, 0.25f); } if (lastResult.Count 0) { Gizmos.color Color.cyan; Gizmos.DrawLine(transform.position, lastResult[0].position); } }这样选中场景里的 EQSRunner 对象后就能看到候选点的分数分布。红色表示低分绿色表示高分青色连线指向最优选择。这一步对第 5 章排查问题非常有用。5. 常见问题与排查路径5.1 候选点全部被过滤查询结果为空现象AI 不移动Scene 视图里没有任何点或生成的点全部消失。排查顺序检查EQSQueryContext是否填充了querier和target。如果target为 null视线过滤会进入预置逻辑。检查过滤测试的LayerMask是否包含了地面或墙体的错误层。Physics.Linecast如果打到了不该打的地面层过滤结果会异常。检查NavMesh.SamplePosition是否成功。候选点如果都在未烘焙区域生成数为 0。把过滤测试的IsFilter返回临时改成 false观察生成点是否恢复。常见原因表问题现象常见原因检查方式处理建议生成器没有输出未烘焙 NavMesh 或 LayerMask 错误检查 Navigation 窗口、Gizmos重新 Bake 导航网格点全部被过滤Linecast 误击地面临时关闭过滤测试调整 blockMask 层级查询偶尔为空随机采样区域不可走在 OnDrawGizmos 中绘制所有原始点增加采样尝试次数5.2 查询结果抖动AI 在两个点之间反复移动现象AI 明明已经到达一个掩体下一秒又跑回原来的点。原因通常是查询频率太高、随机采样导致每次候选点不同、两个候选点得分非常接近。解决方案有三层第一层增加查询冷却时间。把queryInterval从 0.5 秒提高到 2 秒以上避免每帧查询。第二层增加最小切换距离。在EQSAIController.RunQuery中如果新结果和当前目标距离过近不执行移动。第三层固定随机种子。同一个 AI 在查询时使用稳定种子避免每次生成的候选点集合差异过大。Random.InitState(Mathf.RoundToInt(transform.position.x * 100 transform.position.z));注意随机种子不能全局统一否则多个 AI 的候选点会完全相同看起来像排队。5.3 性能消耗过高出现 GC 和卡顿现象候选点数量提升到 64 或 128 后帧率明显下降Profiler 里出现频繁的 GC Alloc。原因可能来自三处EQSRunner内部使用 List但外部每帧调用时又拷贝了列表。Physics.Linecast高频调用每个候选点都发射射线。评分或过滤逻辑里使用了 LINQ例如Where、OrderByDescending。优化方向// 不推荐每次查询都产生新分配 public ListEQSItem RunWithLinq(EQSQueryContext context) { return generated .Where(item allTests.All(t t.Filter(item, context))) .OrderByDescending(item allTests.Sum(t t.Score(item, context))) .ToList(); }推荐做法是复用 List避免 LINQ并把射线检测放进RaycastCommand配合 Job System。起步阶段可以先限制候选点数量例如 32 个点、每 2 秒查询一次绝大多数手机项目可以接受。性能预算参考场景规模候选点数量查询频率建议小型 Demo16每秒 2 次直接同步查询无需优化中型关卡64每秒 1 次复用 List控制采样范围大型开放场景256每 2 秒 1 次使用 Job System 并行执行射线检测5.4 Unity 6 中导航 API 和包依赖问题Unity 6 项目中如果脚本报错The type or namespace name NavMesh does not exist in the namespace UnityEngine.AI原因通常是没有安装 AI Navigation 包。打开 Package Manager搜索AI Navigation安装后重新编译。安装完成后NavMeshAgent、NavMeshHit、NavMesh.AllAreas这些 API 都可以继续使用。需要区分的是UnityEngine.AI.NavMesh仍然保留但导航网格的烘焙流程已经迁移到 AI Navigation 包。旧项目升级到 Unity 6 后最好重新 Bake 一次 NavMesh否则部分区域的可行走数据可能不一致。5.5 Gizmos 看不到查询点如果OnDrawGizmosSelected没有生效先确认是否选中了EQSRunner所在物体。OnDrawGizmosSelected只在物体被选中时绘制调试阶段比较适合。如果想一直显示用OnDrawGizmos代替。另外lastResult在非运行时为空必须在查询完成后才能看到点。6. 最佳实践与扩展方向6.1 学习环境与生产环境的差异在编辑器里调试 EQS可以依赖 Gizmos、随机种子和同步查询因为方便观察数据流。但进入生产环境后这套代码还需要考虑性能、可配置性和异常保护。维度学习环境生产环境查询结果同步返回 List可异步、可缓存结果调试信息Gizmos 常开用宏或开关控制随机种子固定种子便于复现随机种子避免 AI 行为雷同候选点数量可大范围采样按性能预算限制上下文来源手动填充从行为树黑板或 ECS 组件读取过滤测试单个 LayerMask支持多 Layer 组合和 Editor 配置不要把编辑器调试用的lastResult暴露给运行时 AI 使用。运行时应该把查询结果拷贝到 AI 控制器自己的字段中。6.2 接入 EQS 时的检查清单新功能接入 EQS 前按下面清单逐项确认查询上下文中的querierPosition和targetPosition已正确赋值。生成器产生的点已经通过NavMesh.SamplePosition或类似方式校验。过滤测试返回 false 时确认候选点会被移除而不是只降低分数。评分测试返回值的范围已经归一化到 0 到 1。多个评分测试的权重总和经过设计而不是随意填。查询结果按分数降序排列索引 0 是最优项。查询冷却时间和最小切换距离已配置。查询结果为空时存在降级策略。编辑器调试开关和生产环境编译宏已分离。候选点和测试数量在性能预算范围内。6.3 扩展方向Job System、行为树和编辑器工具当前示例是同步查询适合小型项目和初学理解。随着 AI 数量和场景复杂度提升可以按以下路径扩展第一把过滤和评分计算改成RaycastCommand和NativeArray配合 Job System 并行执行。Physics.Raycast在候选点很多时是主要瓶颈改用批量命令可以明显降低 CPU 消耗。第二把查询配置改成 ScriptableObject。同一个 EQSRunner 可以在 Inspector 里引用不同的 Query 资产例如“进攻查询”“逃跑查询”“巡逻查询”。这样不同 AI 可以复用同一套执行代码只需要换配置。第三把查询结果接入行为树。Unity 中常用的行为树插件都能自定义 Action 或 Task可以直接在 Task 中调用EQSRunner.Run把结果写入黑板变量再由移动 Task 读取。第四结合 Unity ECS。EQS 查询结果是典型的临时数据在 ECS 架构中更适合放在NativeListEQSItem中由 Job 处理避免主线程同步等待。建议新手先不要直接跳到 Job System 优化而是先完成一个完整的同步链路生成候选点、过滤、评分、移动到最优点。把这条链路跑通再根据 Profiler 数据决定是否优化。EQS 的价值不在于代码多么炫酷而在于让 AI 的环境决策变得可组合、可调试、可复用。
返回列表