简介:一份聚焦Unity引擎AI集成的演示项目,面向游戏开发者与Unity学习者。项目通过具体示例展示NavMesh导航网格、行为树决策、ML-Agents机器学习、物理碰撞感知、C#脚本控制等关键知识点,同时覆盖动画状态机、粒子系统触发及性能优化思路,适合希望从零搭建角色智能行为的开发者参考。压缩包共178个文件,以meta场景配置、asset资源、cs脚本、csproj工程文件、dll库及sln解决方案等类型为主,整体体积仅1.35MB,目录结构精简,便于快速查看核心代码与场景设置。已有265人学习,可用作Unity AI入门与进阶的轻量参考资料。对照Demo中的代码与配置,读者可理解导航网格烘焙、行为树节点设计、ML-Agents训练流程、多智能体协作以及动画与AI状态联动等实现细节,获得可直接借鉴到实际项目的AI模块搭建思路,同时资源还展示了如何通过物理模拟提升交互真实性,并提供了若干脚本层面的性能优化示意,帮助开发者在学习AI的同时兼顾运行效率。
1. 接了个 AI demo 需求,先别急着写代码
拿到“unity AIdemo”这个需求时,很多人第一反应是“要不上个大模型 API”?先冷静三秒。在 Unity 里做 AI demo,绝大多数场景不是让 NPC 学会聊天,而是让 NPC“看起来有脑子”——会巡逻、会追玩家、会躲障碍、会根据血量切换行为。这才是团队验收时真正会盯着看的东西。本文要讲的,就是一条从零把 AI demo 跑通的完整路径:行为树怎么搭、状态机怎么写、ML-Agents 什么时候才值得引,以及我踩过的那些编辑器里不报错、一打包就翻车的坑。适合刚被安排做 AI 原型验证的 Unity 开发者,也适合想把手游 NPC 行为从“脚本堆 if-else”升级到可维护架构的人。
2. 先想清楚 AI demo 要哪种“智能”:贴图还是真逻辑
2.1 三种常见方案的选型边界
Unity 生态里做 AI,绕不开三件事:自写状态机、行为树可视化方案、ML-Agents 训练。它们各占一个生态位,选错了后面全是血泪。
状态机适合流程固定、状态少、每个状态行为简单的场景。比如一个只会站桩、受击、攻击三态转换的怪物。好处是零依赖、好调试、性能开销可忽略。缺点是状态一多,状态转移图变成蜘蛛网,加一个状态要检查所有旧状态的影响。
行为树适合 NPC 需要“决策”的场景——巡逻、警戒、追踪、攻击、逃跑这些行为要能组合、能打断、能复用。它把“决策”和“执行”拆开了:Selector 决定下一步走哪条分支,Sequence 要求一串动作全部完成才继续。调试时能清楚看到当前走到哪个节点,策划也看得懂。
ML-Agents 是最容易被高估的方案。它适合的是“行为无法手工编写”的场景,比如人形角色的平衡控制、群体对抗策略。代价是训练时间长、需要调 reward 函数、结果不可解释。做一个 demo 级的 AI,除非你确实在验证强化学习本身,否则别碰——这属于给自己挖坑。
2.2 选型判据:看决策复杂度,不看项目大小
我用过一条经验来快速定方案:把 NPC 需要响应的外部事件列出来,超过 8 个且存在优先级冲突(比如“血量低时即使看到玩家也要先跑”),直接上行为树;少于 6 个且行为是线性串行,状态机足够;如果行为连规则都描述不清,才考虑 ML-Agents。
另一个判据是团队结构。有策划愿意一起调行为,行为树的优势会被放大;纯程序项目,状态机反而更快落地。记住一点:demo 的目的是验证“这玩法行不行”,不是炫技。最快跑通的那条路才是好路。
常见做法是先用状态机把 demo 跑通,然后在下一轮迭代里换成行为树。这样做的额外好处是,你能在对比中直观感受到两种架构的差异,而不是背概念。
3. 用状态机在 Unity 里跑通最小 AI:三态怪物的完整实现
3.1 搭建最小场景:从空场景到可追踪的 NPC
目标很明确:一个怪物,能在空闲、巡逻、追击三个状态间切换。把场景搭出来只要三步:
- 建一个 Plane 当地面,给怪物放一个 Capsule(带 Animator 更好,没有也不阻塞逻辑)。
- 给怪物挂 NavMeshAgent 组件,这是 Unity 内置的寻路方案,能自动绕障。
- 在场景里放一个空物体当巡逻点数组的父节点,用来标记巡逻路径。
注意一个容易漏的配置:地面需要先勾选 Navigation 窗口里的 Navigation Static,再点击 Bake 按钮,否则 NavMeshAgent 根本没有可行走区域,agent 会原地打转。
代码结构上,我一般把状态机拆成三块:状态枚举、状态基类、状态实例。先定义枚举和基类:
public enum AIState { Idle, Patrol, Chase } public abstract class AIStateBase { protected MonsterController controller; public AIStateBase(MonsterController ctrl) { controller = ctrl; } public abstract void OnEnter(); public abstract void OnUpdate(); public abstract void OnExit(); }基类做的事情是统一状态生命周期。每个状态只在进入时拿一次需要的组件引用,Update 里只做本状态的事,退出时清理状态。这个设计能避免后面最常见的“状态切换后上一状态的协程还在跑”的坑。
3.2 三个状态的实现与切换条件
接下来写三个状态。Idle 最简单,进入时记录时间,超过指定秒数后切到 Patrol;Patrol 让 NavMeshAgent 走向下一个巡逻点,到达后换点并切回 Idle;Chase 负责设置 agent 的目标为玩家,超出距离后回到 Patrol。
public class MonsterController : MonoBehaviour { public Transform player; public Transform[] patrolPoints; public float chaseRange = 8f; public float idleDuration = 3f; private NavMeshAgent agent; private AIState currentState; private AIStateBase[] states; void Start() { agent = GetComponent<NavMeshAgent>(); states = new AIStateBase[] { new IdleState(this), new PatrolState(this), new ChaseState(this) }; TransitionTo(AIState.Idle); } void Update() { currentState?.OnUpdate(); CheckStateChange(); } void CheckStateChange() { float distToPlayer = Vector3.Distance(transform.position, player.position); if (currentState != AIState.Chase && distToPlayer < chaseRange) { TransitionTo(AIState.Chase); } else if (currentState == AIState.Chase && distToPlayer > chaseRange * 1.2f) { TransitionTo(AIState.Patrol); } } public void TransitionTo(AIState newState) { currentState?.OnExit(); currentState = newState; states[(int)newState].OnEnter(); } public void MoveTo(Vector3 destination) { agent.isStopped = false; agent.SetDestination(destination); } }两个关键参数要说明:chaseRange是进入追击的触发距离,chaseRange * 1.2f是退出追击的滞回距离。为什么要区别对待?因为如果进出都用一个值,NPC 会在边界来回抖动,看起来像抽风。这是状态机调参里最容易忽略的“滞回区间”概念。
PatrolState 的到达判断不要用agent.isStopped,推荐用剩余距离:
if (!agent.pathPending && agent.remainingDistance < 0.5f) { // 到达当前巡逻点,切换到下一个 }pathPending这个属性非常关键。SetDestination 之后路径计算是异步的,立刻读remainingDistance得到的是 0,直接判断到达会导致 NPC 根本没动就去下一个点了。这个坑至少坑了我两小时。
4. 从状态机到行为树:用 Behavior Tree 重构同一个 NPC
4.1 行为树的核心节点:Sequence、Selector、Decorator
状态机版本跑通后,你会立刻碰到一个问题:需求方说“能不能让 NPC 血量低于 30% 时先跑,不追了”。在状态机里这意味着一组新状态和状态转移条件,代码开始膨胀。这时就该考虑行为树了。
行为树的核心认知是:它不是在“描述状态”,而是在“描述决策流程”。每个 tick 从根节点往下走,遇到条件不满足就返回失败,让父节点换一条分支。三个基础节点要理解透:
- Sequence(序列):从左到右执行子节点,全部成功才返回成功,任何一个失败就中断并返回失败。适合“先看有没有目标,再走过去,再攻击”这类必须全中的流程。
- Selector(选择):从左到右尝试子节点,任何一个成功就返回成功,全部失败才返回失败。适合“能打就打,不能打就巡逻”这类优先级选择。
- Decorator(装饰):包在子节点外层,用来反转结果、限流、加冷却时间。
市面上有 Behavior Designer、NodeCanvas 这类插件,但 demo 阶段我更建议先手写一个极简树。因为插件有学习成本,而且 debug 时你不知道插件内部怎么跑的。手写版 200 行内能完成,逻辑全在掌控中。
4.2 手写极简行为树的核心框架
先定义节点接口和组合节点:
public enum BTResult { Success, Failure, Running } public abstract class BTNode { public abstract BTResult Execute(); } public class BTSequence : BTNode { private BTNode[] children; private int currentIndex = 0; public BTSequence(params BTNode[] nodes) { children = nodes; } public override BTResult Execute() { while (currentIndex < children.Length) { var result = children[currentIndex].Execute(); if (result == BTResult.Running) return BTResult.Running; if (result == BTResult.Failure) { currentIndex = 0; return BTResult.Failure; } currentIndex++; } currentIndex = 0; return BTResult.Success; } }这里最核心的设计是Running状态。一个巡逻行为是持续性的,不能一帧就返回 Success。Running告诉父节点“这事还没干完,下帧继续”。不用协程来跑行为树是关键——协程和 Update 的生命周期管理混在一起,很容易出现“NPC 死了树还在跑”的玄学问题。行为树的 tick 应该放在 Update 里,每帧一次,节点内部用时间累计来判断“是否持续了足够久”,而不是用 yield 等。
4.3 把三态怪物改成行为树版本:代码对比与决策逻辑迁移
用行为树重写三态怪物,逻辑变成这样:根节点是 Selector,两个子分支——第一条是“追击分支”(Sequence:检测玩家是否在范围内 → 追向玩家),第二条是“巡逻分支”(Sequence:走到下一个巡逻点 → 等待 → 换下一个点)。运行逻辑变成:每帧先问“玩家在范围内吗?”——在,就追;不在,就继续巡逻。不需要状态机里那种刻意的 TransitionTo,行为树天然在每一帧做“决策”。
public class BTMonsterController : MonoBehaviour { private BTNode rootNode; void Start() { var chaseBranch = new BTSequence( new BTCondition_HasTargetInRange(transform, player, chaseRange), new BTAction_MoveToTarget(agent, player) ); var patrolBranch = new BTSequence( new BTAction_MoveToNextPatrolPoint(agent, patrolPoints), new BTAction_Wait(waitDuration) ); rootNode = new BTSelector(chaseBranch, patrolBranch); } void Update() { rootNode.Execute(); } }这个重构的价值在于:后续要加“血量低就跑”的需求,不需要改任何旧节点,只需要在 Selector 最前面加一个“逃跑分支”:
var fleeBranch = new BTSequence( new BTCondition_HealthBelow(health, 30f), new BTAction_FleeFromPlayer(agent, player) );这就是行为树的真正优势——新行为对旧行为零侵入。状态机版本做同样的事,得改状态枚举、加转移条件、加新状态类,至少动五个地方。demo 阶段如果需求变更频繁,这个优势能帮你省下大量改 bug 的时间。
5. 踩坑记录:Demo 能跑只是错觉,这些坑真会咬人
5.1 NavMeshAgent 不动,但 isStopped 已经是 false
现象:agent 的 destination 已经设置,isStopped = false,但 NPC 站在原地不动。检查 Animator 没有异常,没有报错。
原因:最常见的是地面没烘焙 NavMesh,或者烘焙时选错了 agent 类型。另一个高频原因是 agent 的 Radius 或 Height 配置比通道宽/矮,导致没有可行路径。还有个隐蔽的:NavMeshAgent 的updatePosition被改成 false,但代码里没有自己同步 transform 位置。
解决:先看 Scene 视图里 agent 周围有没有浅蓝色网格。没有就重新 Bake。有的话,把 Agent 的 Radius 调小到 0.3 以下再试。最后检查updatePosition是否被某段代码动过——我遇到过一次是团队里有人为了做移动平滑把它关了,结果寻路和位移完全脱节。
5.2 编辑器里表现正常,打包后 AI 完全不动
现象:在 Editor 里运行,NPC 巡逻、追击全正常;打成 Windows 包或 Android 包后,NPC 像被定身一样。
原因:这是 NavMesh 烘焙数据没进包。场景里的 NavMesh 是运行时生成的,如果代码里没有在运行时动态烘焙,或者 Build Settings 里没把包含 NavMesh 数据的场景勾选,打包后自然没寻路数据。
解决:确认 Navigation 窗口里 Bake 过后,还必须在 Build Settings 里检查场景是否被打进包。建议再做一层兜底——在目标平台上跑一次运行时烘焙:
var navMeshData = NavMeshBuilder.BuildNavMeshData( NavMesh.GetSettingsByIndex(0), UnityEngine.AI.NavMesh.GetSettingsByIndex(0).agentTypeID, transform.position + Vector3.zero, new Vector3(50, 50, 50), Vector3.one, new Bounds(Vector3.zero, new Vector3(10, 10, 10)) );运行时烘焙能解决“发布后没数据”的问题,但只推荐在 demo 阶段用,性能开销不值得长期保留。
5.3 状态切换后,上一状态的协程还在跑
现象:NPC 从 Chase 切到 Idle 后,原本的追踪协程依然在修改 agent 的 destination,NPC 反复抖动。Debug 后发现协程还在跑。
原因:协程一旦 Start,只有 StopCoroutine 或所在组件销毁才会停。状态机里 OnExit 只写了状态逻辑清理,忘了 Stop 协程。
解决:在状态基类 OnExit 统一做一次协程清理。或者在切换状态前,先遍历当前状态持有的所有协程引用,逐个 Stop:
public override void OnExit() { StopCoroutine(chaseRoutine); }同时养一个好习惯:所有协程的引用都存字段,不直接StartCoroutine("ChaseLoop")用字符串启动。字符串启动的方式在协程重名时是静默失败的,排查起来非常玄学。
5.4 Time.timeScale 不归一时,AI 表现完全不可用
现象:UI 面板弹出来,暂停游戏时 AI 也在动;或者把 timeScale 调慢做慢动作,AI 的巡逻和追击节奏跟着变快/变慢,完全不像设定好的表现。
原因:协程里的WaitForSeconds受 timeScale 影响。AI 逻辑设计上应该“AI 自己决定什么时候决策”,而不是“全部被 timeScale 绑架”。
解决:行为树节点里不要用WaitForSeconds做延时,改用手动计时:
public class BTAction_Wait : BTNode { private float duration; private float elapsed = 0f; public BTAction_Wait(float duration) { this.duration = duration; } public override BTResult Execute() { elapsed += Time.deltaTime; if (elapsed >= duration) { elapsed = 0f; return BTResult.Success; } return BTResult.Running; } }这样 timeScale 变化时 AI 计时稳定,不被系统缩放影响,UI 暂停时 AI 也能真正停下来。
5.5 AI 用 Transform 移动和 NavMeshAgent 打架
现象:NPC 用 NavMeshAgent 寻路,但又用transform.Translate做朝向或小位移修正,结果 NPC 在移动时呈现卡顿感,甚至不停抖动。
原因:NavMeshAgent 每帧会强制执行自己的位置和旋转,外部用 Transform 做的修改会被下一帧覆盖,产生视觉上的抖动和位移回退。
解决:所有移动、朝向都交给 NavMeshAgent。需要做朝向修正时,设置agent.updateRotation = false然后自己用Quaternion.Slerp处理,但此时位置的更新依然由 agent 负责。另一个做法是把需要“自己控制移动”的行为(比如逃跑)也用 NavMeshAgent 的velocity属性来施加方向,而不是直接改 transform。
6. 收尾技巧:一把 Profiler 教你判断 AI 值不值得优化
Demo 能跑之后,验收时最常被问的是“性能行不行”。先把 Profiler 打开,Window → Analysis → Profiler,选择 CPU Usage,跑五分钟玩法流程,然后看两件事。
第一看NavMeshAgent在 Update 里的耗时占比。如果占了主线程 15% 以上,且 NPC 数量超过 20 个,你就有充分理由做分层更新:把 NPC 按距离玩家远近分两批,近距离每帧更新寻路,远距离每 0.5 秒更新一次。实现方式很简单,每个 NPC 记录一个下一帧更新时间,只有到达时间才调用SetDestination或执行行为树 tick。
第二看Animator的耗时。AI demo 里动画状态机常常比 AI 逻辑本身更费性能。一个典型情况是:30 个 NPC 每个都有三层动画层,每层都在计算 blend。处理方式是把真正需要流畅动画的 NPC 放在近距离,远距离 NPC 直接播放预设动画或者降低 Animator 更新频率。用animator.updateMode = AnimatorUpdateMode.AnimatePhysics降低动画频率,效果立竿见影。
最后一招,用OnDrawGizmos把 AI 的决策过程画出来。在 Scene 视图里画出玩家检测范围、当前位置的目标点、行为树当前执行的节点名。这招帮我省掉的 debug 时间比什么插件都多:
void OnDrawGizmosSelected() { Gizmos.color = Color.red; Gizmos.DrawWireSphere(transform.position, chaseRange); if (agent != null && agent.hasPath) { Gizmos.color = Color.green; Gizmos.DrawLine(transform.position, agent.destination); } }我一直保持的习惯是:AI demo 做出来之后,先在地面画几条路径线让策划看,再拿 Profiler 截图对比,最后才谈优化。很多团队在 AI 上翻车,不是逻辑写不对,而是表现不对、性能说不清。先把这两件事落地,AI demo 就算真正站稳了。希望帮到你。
本文还有配套的精品资源,点击获取