Appearance
CPU 优化
Profiler 中 Main Thread 主要看什么?
面试高分回答
Profiler 里看 Main Thread,不是只看哪个函数名字最大,而是按顺序判断:这一帧有没有超过帧预算、主线程是在忙还是在等、真正耗时来自哪个模块、Self Time 高还是 Total Time 高。
先看帧预算
如果目标是 60 FPS,一帧预算大约是 16.67ms。 如果目标是 30 FPS,一帧预算大约是 33.33ms。 如果目标是 120 FPS,一帧预算大约是 8.33ms。
Main Thread 如果长期超过预算,就很可能造成掉帧。如果只是偶尔一帧很高,那就是 Spike,需要找尖峰原因。
再看 Timeline
我一般先看 Timeline,因为它能直观看到这一帧主线程到底发生了什么:是 Update 很长,还是 Canvas 重建,还是 GC.Collect,还是在 WaitForTargetFPS 等待。
然后再切到 Hierarchy 或 Raw Hierarchy,看具体哪个函数耗时最高。
重点看 Self Time 和 Total Time
Total Time 是这个函数加上它下面所有子调用的总耗时。 Self Time 是这个函数自己内部真正消耗的时间。
如果 Total Time 高但 Self Time 不高,说明慢的可能是它调用的子函数。 如果 Self Time 很高,说明这个函数本身就重,要重点优化。
Main Thread 常见问题来源
Scripts:大量 Update、LateUpdate、协程、事件回调、循环遍历、寻路、排序、LINQ。
UI:Canvas 重建、Layout 重算、ScrollView 大量 Item、Graphic Raycast、频繁 SetActive 或改 Text。
Physics:Physics.Simulate、碰撞回调、射线检测过多、FixedUpdate 频率过高。
Animation:大量 Animator、骨骼更新、WriteTransforms、动画层和状态机过复杂。
GC:GC.Alloc、GC.Collect、字符串拼接、装箱、闭包、LINQ、频繁 new 对象。
Loading:同步加载资源、Instantiate 大量对象、Resources.Load、AssetBundle 解压、场景切换。
Rendering Prep:剔除、批处理、Camera.Render 准备、渲染提交相关工作。
看到 Wait 不一定是 CPU 慢
比如 WaitForTargetFPS 很高,可能只是主线程在等 VSync 或目标帧率,不代表你的脚本慢。
如果看到 Gfx.WaitForPresentOnGfxThread、等待渲染线程、等待 GPU,也要考虑是不是 GPU 瓶颈,而不是盲目优化 C# 代码。
一句话总结
TIP
Main Thread 主要看:有没有超帧预算、有没有 Spike、耗时是脚本/UI/物理/动画/GC/加载还是等待,最后用 Self Time 找真正该优化的函数。这样回答,比只说“看 CPU Usage”专业很多。
BehaviourUpdate 占用高怎么排查?
面试高分回答
BehaviourUpdate 占用高,通常说明 MonoBehaviour 的更新逻辑很重。它不是一个具体业务函数,而是 Unity 把很多脚本的 Update、部分协程推进、事件回调等脚本执行成本汇总在一起了。排查重点是:把这个“大框”拆到具体脚本、具体函数、具体对象数量。
第一步:先确认是不是尖峰
如果 BehaviourUpdate 只是偶尔一帧很高,优先怀疑:同步加载、Instantiate、GC、一次性大循环、UI 批量刷新。
如果它每一帧都高,优先怀疑:大量 Update 空跑、每帧遍历太多对象、AI 或技能逻辑每帧计算、频繁查找组件、频繁分配内存。
第二步:展开 BehaviourUpdate
在 Profiler 里可以这样看:
先用 Timeline 找到卡顿那一帧。 再切到 Hierarchy 或 Raw Hierarchy。 展开 BehaviourUpdate 或 ScriptRunBehaviourUpdate。 按 Self Time 和 Total Time 排序。 看具体是哪个脚本、哪个函数、哪个调用链占用最高。
Total Time 高,可能是它下面调用的子函数慢。 Self Time 高,说明这个函数自己内部就重。
第三步:常见原因
大量对象都有 Update,哪怕里面只是做一个判断,也会有调度成本。
每帧 Find、FindObjectOfType、频繁 GetComponent,会让脚本更新变重。
每帧遍历大量怪物、背包格子、技能、Buff、任务、红点节点,会把成本堆起来。
每帧使用 LINQ、闭包、字符串拼接、装箱、new List,容易产生 GC Alloc。
协程数量太多,也可能在脚本更新阶段积累成本。
脚本每帧修改 UI 文本、Layout、图片状态,可能进一步引发 Canvas 重建。
第四步:给可疑代码加 ProfilerMarker
c
using UnityEngine; // 引入 Unity 常用类型
using Unity.Profiling; // 引入 Unity Profiler 标记工具
public class MonsterManager : MonoBehaviour // 定义怪物管理器组件
{ // 类开始
private static readonly ProfilerMarker UpdateMonstersMarker = new ProfilerMarker("MonsterManager.UpdateMonsters"); // 创建自定义性能采样点
[SerializeField] private Monster[] monsters; // 保存场景中的怪物数组
private void Update() // 每帧更新怪物逻辑
{ // 方法开始
using (UpdateMonstersMarker.Auto()) // 在 Profiler 中标记这一段代码的耗时
{ // using 开始
for (int i = 0; i < monsters.Length; i++) // 遍历所有怪物
{ // for 开始
if (monsters[i] == null) continue; // 如果怪物为空,就跳过
monsters[i].Tick(); // 执行怪物的逻辑更新
} // for 结束
} // using 结束
} // 方法结束
} // 类结束优化方向
把不需要每帧执行的逻辑改成定时执行,比如 0.1s 检查一次。
把全局轮询改成事件驱动,比如血量变化时才刷新血条,背包变化时才刷新背包 UI。
把大量对象的 Update 集中到一个 Manager 里统一调度,减少脚本生命周期调用数量。
缓存组件引用,不要每帧 GetComponent 或 Find。
把大循环分帧处理,比如一帧只处理一部分怪物 AI。
减少每帧 GC,避免 LINQ、临时 List、字符串拼接、闭包捕获。
一句话总结
IMPORTANT
BehaviourUpdate 高,面试里可以说:我会先用 Timeline 找尖峰,再展开 Hierarchy 看具体脚本,结合 Self Time 和 Total Time 判断是函数本身慢还是子调用慢;如果还不清楚,就加 ProfilerMarker,最后针对大量 Update、轮询、大循环、GC 和同步加载逐个优化。
Camera.Render 占用高可能是什么原因?
面试高分回答
Camera.Render 占用高,不一定是“相机代码慢”,它代表的是某台相机渲染这一帧的总入口。里面可能包含剔除、阴影、DrawCall 提交、后处理、RenderTexture、UI 相机,甚至主线程等待渲染线程或 GPU。
常见原因
第一种是相机数量太多。 比如主相机、UI 相机、小地图相机、角色预览相机、RenderTexture 相机同时存在。每多一台相机,就可能多做一次剔除、多提交一批渲染内容,甚至重新渲染一遍场景。
第二种是渲染内容太复杂。 可见物体太多、DrawCall 太多、材质太碎、Shader 太复杂,都会让 Camera.Render 变高。尤其是场景里有大量小物体、不同材质、动态物体时,CPU 提交渲染命令也会变重。
第三种是后处理很重。 比如 Bloom、景深、SSR、Color Grading、Motion Blur,这些通常会增加额外的全屏 Pass。分辨率越高,后处理越贵。
第四种是阴影和灯光开销高。 实时阴影、多盏实时灯、多级联阴影、阴影分辨率太高,都可能让相机渲染阶段变重。因为阴影本身往往也需要额外渲染 Shadow Map。
第五种是透明物体和粒子过多。 透明物体不能像不透明物体那样简单写深度后提前剔除,容易产生 Overdraw。粒子、半透明 UI、玻璃、水面、特效叠很多层时,GPU 填充率压力会变大。
第六种是RenderTexture 使用过多。 小地图、角色预览、反射相机、监控屏幕、技能预览,如果每帧都渲染到 RenderTexture,也会让 Camera.Render 升高。
怎么排查?
先在 Profiler 的 Timeline 里看是哪台 Camera 的 Camera.Render 高。
再用 Frame Debugger 看这一帧到底画了多少批次、哪些 Pass、哪些材质、哪些后处理。
如果 Main Thread 里 Camera.Render 高,同时有等待类标记,比如等待渲染线程或 GPU,就要结合 GPU Profiler 判断是不是 GPU 瓶颈。
如果是 CPU 侧高,重点看剔除、批处理、DrawCall 提交、相机数量。
如果是 GPU 侧高,重点看分辨率、后处理、阴影、透明 Overdraw、Shader 复杂度。
优化方向
减少不必要的相机,能用一个相机解决就不要多相机叠加。
小地图或角色预览不一定每帧刷新,可以降低刷新频率。
降低阴影距离、阴影分辨率、级联数量,减少实时灯。
减少透明物体叠加,控制粒子数量和屏幕覆盖面积。
减少材质数量,使用合批、GPU Instancing、SRP Batcher。
关闭不必要的后处理,或者为低端机做画质档位。一句话总结
TIP
Camera.Render 高要按三步排查:先看是不是相机太多,再看这台相机画了什么,最后区分是 CPU 提交渲染命令慢,还是 GPU 渲染压力大。
Canvas.BuildBatch 高说明什么?
面试高分回答
Canvas.BuildBatch 高,通常说明 UGUI 在 CPU 侧重新构建 Canvas 的渲染批次很贵。它会整理 UI 顶点、材质、贴图、层级顺序、裁剪信息,然后生成可以提交给渲染系统的批次。
它高说明什么?
最常见就是:Canvas 被频繁弄脏了,Unity 每帧都要重新整理这块 UI。
比如一个很大的 Canvas 里面既有静态背景,又有动态血条、倒计时、聊天文本、背包列表。只要动态 UI 每帧变化,就可能让这一整个 Canvas 的批处理重新计算,成本就会上来。
常见原因
一个 Canvas 太大,静态 UI 和动态 UI 混在一起。
每帧修改 Text,比如倒计时、金币、伤害数字、聊天内容。
频繁 SetActive、改层级、改尺寸、改位置、改透明度。
ScrollView Item 太多,没有做虚拟列表。
LayoutGroup、ContentSizeFitter、GridLayoutGroup 频繁重算布局。
Mask、RectMask2D、裁剪区域多,导致批处理和裁剪更复杂。
图片不在同一个图集,材质、贴图切换多,批次更碎。
怎么排查?
先在 Profiler 里确认是不是 Canvas.BuildBatch 或 Canvas.SendWillRenderCanvases 高。
然后看最近有没有频繁刷新的 UI,比如血条、排行榜、背包、任务列表、红点、聊天。
再用 Frame Debugger 看 UI 的批次是不是很多,材质和贴图是不是频繁切换。
最后看 Canvas 结构:是不是一个超大的 Canvas 包了所有 UI。
优化思路
静态 UI 和动态 UI 分 Canvas。比如背景、固定按钮放一个 Canvas;血条、倒计时、飘字、动态列表放单独 Canvas。
减少每帧改 UI。文本没变化就不要重复赋值。
ScrollView 做虚拟列表,只创建屏幕内可见 Item。
少用复杂 Layout,初始化后能关掉 Layout 组件就关掉,或者手动排版。
把频繁变化的小区域拆成子 Canvas,让它重建时不要拖累整个大 Canvas。
使用图集,减少材质和贴图切换。
一个很常见的优化例子
c
using UnityEngine; // 引入 Unity 常用类型
using UnityEngine.UI; // 引入 UGUI 相关类型
public class HpTextView : MonoBehaviour // 定义血量文本显示组件
{ // 类开始
[SerializeField] private Text hpText; // 引用血量文本
private int lastHp = -1; // 记录上一次显示的血量
public void SetHp(int hp) // 设置血量显示
{ // 方法开始
if (hp == lastHp) return; // 如果血量没有变化,就不重复刷新 UI
lastHp = hp; // 记录新的血量
hpText.text = hp.ToString(); // 只有变化时才修改文本,减少 Canvas 被弄脏的次数
} // 方法结束
} // 类结束一句话总结
NOTE
Canvas.BuildBatch 高不是单纯 DrawCall 问题,而是 UI 重建和批次构建成本高。面试里可以说:我会先找频繁变化的 UI,再看 Canvas 是否拆分合理,重点通过拆 Canvas、减少每帧刷新、虚拟列表、减少 Layout 和 Mask 来优化。
Physics.Simulate 高怎么优化?
面试高分回答
Physics.Simulate 高,说明 Unity 这一帧推进物理世界很贵。它主要在做刚体运动、碰撞检测、接触点生成、触发器检测、关节约束、碰撞回调等工作。优化核心是:减少参与模拟的对象,减少碰撞关系,降低物理步数,简化碰撞体和回调逻辑。
常见原因
活跃 Rigidbody 太多。 比如大量怪物、掉落物、子弹、碎片都在参与物理模拟,即使玩家看不到,也会消耗 CPU。
碰撞体太复杂。 复杂 MeshCollider 比 BoxCollider、SphereCollider、CapsuleCollider 贵很多。动态物体尤其不要随便用复杂网格碰撞。
碰撞关系太宽。 如果子弹、怪物、玩家、场景、特效、掉落物都互相检测,碰撞对会暴涨。应该用 Layer Collision Matrix 过滤无关碰撞。
FixedUpdate 频率过高。 Time.fixedDeltaTime 越小,每秒物理模拟次数越多。比如从 0.02 改成 0.005,物理模拟次数会从约 50 次每秒变成 200 次每秒。
连续碰撞检测太多。 Continuous 比 Discrete 更贵,只应该给高速且重要的物体用,比如高速子弹、玩家关键碰撞体。
碰撞回调太重。 OnCollisionEnter、OnTriggerEnter 里如果做寻路、加载、Instantiate、大量查找,也会拖慢物理阶段。
优化方向
用 Layer Collision Matrix 减少不必要的碰撞关系。 比如子弹只检测敌人和场景,不检测 UI、特效、掉落物、其他子弹。
远处或不活跃物体禁用物理。 不在战斗范围内的怪物可以关闭刚体模拟,或者降低更新频率。
简化碰撞体。 能用基础碰撞体就不用 MeshCollider;能用几个 Box 拼起来,就不要用复杂网格。
控制 FixedUpdate 频率。 不要为了“更平滑”盲目提高物理频率,平滑显示可以用 Rigidbody Interpolation,而不是硬提高物理步数。
减少物理查询。 Raycast、OverlapSphere、SphereCast 不要所有 AI 每帧都做,可以分帧、降频、加 LayerMask,并优先用 NonAlloc 版本。
LayerMask 优化射线检测示例
c
using UnityEngine; // 引入 Unity 常用类型
public class EnemySightCheck : MonoBehaviour // 定义敌人视野检测组件
{ // 类开始
[SerializeField] private LayerMask targetMask; // 只检测目标所在的层
[SerializeField] private LayerMask obstacleMask; // 只检测障碍物所在的层
[SerializeField] private float checkInterval = 0.2f; // 设置检测间隔,避免每帧检测
[SerializeField] private float sightDistance = 10f; // 设置视野检测距离
private float timer; // 记录检测计时器
private void Update() // 每帧更新计时器
{ // 方法开始
timer += Time.deltaTime; // 累加经过的时间
if (timer < checkInterval) return; // 如果还没到检测时间,就直接返回
timer = 0f; // 重置检测计时器
CheckSight(); // 执行一次视野检测
} // 方法结束
private void CheckSight() // 执行视野检测逻辑
{ // 方法开始
Vector3 origin = transform.position + Vector3.up; // 设置射线起点
Vector3 direction = transform.forward; // 设置射线方向
bool hitTarget = Physics.Raycast(origin, direction, sightDistance, targetMask); // 只检测目标层,减少无关碰撞查询
bool hitObstacle = Physics.Raycast(origin, direction, sightDistance, obstacleMask); // 只检测障碍物层,减少无关碰撞查询
if (hitTarget && !hitObstacle) // 如果看到目标并且中间没有障碍
{ // if 开始
Debug.Log("发现目标"); // 执行发现目标逻辑
} // if 结束
} // 方法结束
} // 类结束面试一句话
TIP
Physics.Simulate 高时,我会先看活跃刚体和碰撞体数量,再看碰撞矩阵和接触对是否过多,然后检查 FixedUpdate 频率、MeshCollider、CCD、Joint、碰撞回调和物理查询。优化方向是减少模拟对象、减少碰撞关系、简化碰撞体、降低检测频率,而不是一上来盲目调物理参数。
大量 Update 怎么优化?
面试高分回答
大量 Update 的优化核心是:减少每帧轮询,把不必要每帧执行的逻辑改成按需、降频、分帧或集中调度。不是所有 Update 都有问题,真正有问题的是大量对象每帧空跑、查找、遍历、分配内存。
常见优化方向
删除空的 Update。 很多脚本里留着空 Update,哪怕什么都不做,也会进入 Unity 的脚本调度流程。
缓存引用。 不要在 Update 里反复 Find、FindObjectOfType、GetComponent,这些应该放到 Awake 或初始化阶段。
降频更新。 AI 感知、距离检测、红点刷新、任务检查,不需要每帧跑,可以 0.1s 或 0.2s 跑一次。
事件驱动。 血量变化时才刷新血条,背包变化时才刷新背包,任务状态变化时才刷新任务 UI。不要每帧问“有没有变”。
分帧处理。 比如 1000 个怪物做感知,不要一帧全算完,可以每帧处理 100 个,10 帧轮一遍。
集中调度。 大量对象各自挂 Update,可以改成一个 UpdateManager 统一管理,方便暂停、分组、降频、统计和移除。
简单降频写法
c
using UnityEngine; // 引入 Unity 常用类型
public class EnemySense : MonoBehaviour // 定义敌人感知组件
{ // 类开始
[SerializeField] private float checkInterval = 0.2f; // 定义检测间隔
private float timer; // 记录计时器
private void Update() // 每帧执行一次
{ // 方法开始
timer += Time.deltaTime; // 累加这一帧经过的时间
if (timer < checkInterval) return; // 如果还没到检测时间,就直接返回
timer = 0f; // 重置计时器
CheckPlayer(); // 执行玩家检测逻辑
} // 方法结束
private void CheckPlayer() // 检测玩家
{ // 方法开始
Debug.Log("检测玩家位置"); // 这里模拟比较重的检测逻辑
} // 方法结束
} // 类结束集中 UpdateManager 示例
c
using System.Collections.Generic; // 引入 List 容器
using UnityEngine; // 引入 Unity 常用类型
public interface IUpdatable // 定义可更新对象接口
{ // 接口开始
void Tick(float deltaTime); // 定义由管理器调用的更新方法
} // 接口结束
public class UpdateManager : MonoBehaviour // 定义统一更新管理器
{ // 类开始
private readonly List<IUpdatable> items = new List<IUpdatable>(); // 保存需要更新的对象列表
private void Update() // 管理器每帧执行一次
{ // 方法开始
float dt = Time.deltaTime; // 缓存本帧 deltaTime
for (int i = 0; i < items.Count; i++) // 遍历所有注册对象
{ // for 开始
items[i].Tick(dt); // 调用对象自己的更新逻辑
} // for 结束
} // 方法结束
public void Register(IUpdatable item) // 注册一个需要更新的对象
{ // 方法开始
if (items.Contains(item)) return; // 如果已经注册过,就直接返回
items.Add(item); // 把对象加入更新列表
} // 方法结束
public void Unregister(IUpdatable item) // 取消注册一个对象
{ // 方法开始
items.Remove(item); // 从更新列表中移除对象
} // 方法结束
} // 类结束事件驱动例子
c
using UnityEngine; // 引入 Unity 常用类型
using UnityEngine.UI; // 引入 UGUI 类型
public class HpView : MonoBehaviour // 定义血量显示组件
{ // 类开始
[SerializeField] private Text hpText; // 引用血量文本
private int lastHp = -1; // 记录上一次显示的血量
public void RefreshHp(int hp) // 当血量变化时刷新 UI
{ // 方法开始
if (hp == lastHp) return; // 如果血量没变,就不刷新
lastHp = hp; // 记录新的血量
hpText.text = hp.ToString(); // 更新文本内容
} // 方法结束
} // 类结束一句话总结
IMPORTANT
大量 Update 优化,不是简单把逻辑全塞到一个地方,而是先用 Profiler 找热点,再把“每帧轮询”改成“按需触发、低频 Tick、分帧处理、集中调度”。面试里能说出这些,就比只说“少写 Update”成熟很多。
如何做 Update Manager?
面试高分回答
Update Manager 的核心是:把大量对象分散的 Update,改成统一注册、统一调度。这样可以集中做暂停、降频、分帧、统计和性能优化。
核心设计
对象不再自己写大量 Update,而是实现一个接口。 启用时注册到 UpdateManager,禁用时取消注册。 UpdateManager 每帧统一调用这些对象的 ManagedUpdate。
c
using System.Collections.Generic; // 引入 List 和 HashSet 容器
using UnityEngine; // 引入 Unity 常用类型
public interface IManagedUpdate // 定义一个可被 UpdateManager 调度的接口
{ // 接口开始
void ManagedUpdate(float deltaTime); // 定义统一更新方法
} // 接口结束
public class UpdateManager : MonoBehaviour // 定义统一 Update 管理器
{ // 类开始
public static UpdateManager Instance { get; private set; } // 提供全局访问入口
private readonly List<IManagedUpdate> updateItems = new List<IManagedUpdate>(); // 保存正在更新的对象列表
private readonly HashSet<IManagedUpdate> registeredItems = new HashSet<IManagedUpdate>(); // 保存已注册对象,用来防止重复注册
private bool isPaused; // 记录当前是否暂停
private void Awake() // 初始化时调用
{ // 方法开始
Instance = this; // 保存当前管理器实例
} // 方法结束
private void Update() // 每帧统一调度
{ // 方法开始
if (isPaused) return; // 如果暂停,就不更新普通逻辑
float dt = Time.deltaTime; // 缓存本帧 deltaTime
for (int i = 0; i < updateItems.Count; i++) // 遍历所有注册对象
{ // for 开始
updateItems[i].ManagedUpdate(dt); // 调用对象自己的更新逻辑
} // for 结束
} // 方法结束
public void Register(IManagedUpdate item) // 注册一个需要更新的对象
{ // 方法开始
if (item == null) return; // 如果对象为空,就直接返回
if (registeredItems.Contains(item)) return; // 如果已经注册过,就不要重复注册
registeredItems.Add(item); // 把对象加入注册集合
updateItems.Add(item); // 把对象加入更新列表
} // 方法结束
public void Unregister(IManagedUpdate item) // 取消注册一个对象
{ // 方法开始
if (item == null) return; // 如果对象为空,就直接返回
if (!registeredItems.Contains(item)) return; // 如果对象没有注册过,就直接返回
registeredItems.Remove(item); // 从注册集合中移除对象
updateItems.Remove(item); // 从更新列表中移除对象
} // 方法结束
public void SetPaused(bool paused) // 设置暂停状态
{ // 方法开始
isPaused = paused; // 保存暂停状态
} // 方法结束
} // 类结束使用示例
c
using UnityEngine; // 引入 Unity 常用类型
public class EnemySense : MonoBehaviour, IManagedUpdate // 敌人感知组件实现统一更新接口
{ // 类开始
[SerializeField] private float checkInterval = 0.2f; // 设置感知检测间隔
private float timer; // 保存计时器
private void OnEnable() // 组件启用时调用
{ // 方法开始
UpdateManager.Instance.Register(this); // 注册到 UpdateManager
} // 方法结束
private void OnDisable() // 组件禁用时调用
{ // 方法开始
UpdateManager.Instance.Unregister(this); // 从 UpdateManager 取消注册
} // 方法结束
public void ManagedUpdate(float deltaTime) // 由 UpdateManager 统一调用
{ // 方法开始
timer += deltaTime; // 累加时间
if (timer < checkInterval) return; // 如果还没到检测间隔,就不执行检测
timer = 0f; // 重置计时器
CheckTarget(); // 执行目标检测
} // 方法结束
private void CheckTarget() // 检测目标
{ // 方法开始
Debug.Log("敌人执行一次感知检测"); // 模拟敌人感知逻辑
} // 方法结束
} // 类结束什么时候适合用
适合:大量怪物 AI、Buff Tick、技能 CD、红点系统、任务检查、低频轮询、可暂停系统。
不适合:只有几个简单对象的小项目,或者必须依赖 Unity 特定生命周期顺序的逻辑。少量 Update 没必要硬上架构。
可以继续升级的点
可以分组:普通更新、低频更新、暂停时仍更新。 可以分帧:一帧只处理一部分对象,避免尖峰。 可以加优先级:先更新输入,再更新角色,再更新表现。 可以加统计:记录每组耗时,方便 Profiler 定位。
一句话总结
IMPORTANT
Update Manager 就是把大量分散的 Update 变成统一调度:对象注册进来,管理器统一 Tick,再按需要做暂停、降频、分帧和统计。面试里这样讲,比只说“用一个 Manager 管 Update”更完整。
AI 逻辑如何分帧执行?
面试高分回答
AI 逻辑分帧执行,就是不要让所有怪物在同一帧里一起做感知、寻路、选目标、放技能,而是每帧只处理一部分 AI,把 CPU 压力摊平。比如 100 个怪物,每帧只更新 20 个,5 帧轮完一遍。
哪些 AI 逻辑适合分帧?
适合分帧:视野检测、距离检测、仇恨排序、目标筛选、技能选择、路径请求、巡逻点选择、低频状态判断。
不适合分帧:攻击命中、受击反馈、闪避窗口、玩家附近关键判定。因为这些要求即时性,延迟太久会影响手感。
固定数量分帧写法
c
using System.Collections.Generic; // 引入 List 和 HashSet 容器
using UnityEngine; // 引入 Unity 常用类型
public interface ISlicedAI // 定义可分帧更新的 AI 接口
{ // 接口开始
void TickAI(float deltaTime); // 定义 AI 的分帧更新方法
} // 接口结束
public class AIFrameSliceManager : MonoBehaviour // 定义 AI 分帧管理器
{ // 类开始
public static AIFrameSliceManager Instance { get; private set; } // 提供全局访问入口
[SerializeField] private int aiPerFrame = 20; // 每帧最多更新多少个 AI
private readonly List<ISlicedAI> aiList = new List<ISlicedAI>(); // 保存所有正在参与分帧更新的 AI
private readonly HashSet<ISlicedAI> aiSet = new HashSet<ISlicedAI>(); // 用来防止同一个 AI 重复注册
private int cursor; // 记录下一次从哪个 AI 开始更新
private void Awake() // 初始化时调用
{ // 方法开始
Instance = this; // 保存当前管理器实例
} // 方法结束
private void Update() // 每帧执行一次分帧调度
{ // 方法开始
if (aiList.Count == 0) return; // 如果没有 AI,就直接返回
int count = Mathf.Min(aiPerFrame, aiList.Count); // 计算本帧实际需要更新的 AI 数量
float dt = Time.deltaTime; // 缓存本帧 deltaTime
for (int i = 0; i < count; i++) // 循环更新本帧分配到的 AI
{ // for 开始
if (cursor >= aiList.Count) cursor = 0; // 如果游标越界,就从头开始轮询
aiList[cursor].TickAI(dt); // 更新当前游标指向的 AI
cursor++; // 游标后移,下一次更新下一个 AI
} // for 结束
} // 方法结束
public void Register(ISlicedAI ai) // 注册一个 AI
{ // 方法开始
if (ai == null) return; // 如果 AI 为空,就直接返回
if (aiSet.Contains(ai)) return; // 如果已经注册过,就直接返回
aiSet.Add(ai); // 加入去重集合
aiList.Add(ai); // 加入更新列表
} // 方法结束
public void Unregister(ISlicedAI ai) // 取消注册一个 AI
{ // 方法开始
if (ai == null) return; // 如果 AI 为空,就直接返回
if (!aiSet.Remove(ai)) return; // 如果没有注册过,就直接返回
int index = aiList.IndexOf(ai); // 找到 AI 在列表中的位置
if (index < 0) return; // 如果列表里找不到,就直接返回
aiList.RemoveAt(index); // 从更新列表中移除 AI
if (cursor > index) cursor--; // 如果移除的是游标前面的元素,就修正游标
if (cursor < 0) cursor = 0; // 防止游标小于 0
if (cursor >= aiList.Count) cursor = 0; // 防止游标超过列表长度
} // 方法结束
} // 类结束AI 使用示例
c
using UnityEngine; // 引入 Unity 常用类型
public class EnemyAI : MonoBehaviour, ISlicedAI // 敌人 AI 实现分帧更新接口
{ // 类开始
[SerializeField] private Transform player; // 引用玩家位置
[SerializeField] private float detectDistance = 10f; // 设置敌人检测距离
private void OnEnable() // 对象启用时调用
{ // 方法开始
if (AIFrameSliceManager.Instance == null) return; // 如果管理器不存在,就直接返回
AIFrameSliceManager.Instance.Register(this); // 把当前 AI 注册到分帧管理器
} // 方法结束
private void OnDisable() // 对象禁用时调用
{ // 方法开始
if (AIFrameSliceManager.Instance == null) return; // 如果管理器不存在,就直接返回
AIFrameSliceManager.Instance.Unregister(this); // 从分帧管理器取消注册
} // 方法结束
public void TickAI(float deltaTime) // 分帧管理器调用的 AI 更新方法
{ // 方法开始
SensePlayer(); // 执行玩家感知逻辑
DecideState(); // 执行 AI 状态决策逻辑
} // 方法结束
private void SensePlayer() // 检测玩家是否在范围内
{ // 方法开始
if (player == null) return; // 如果玩家不存在,就直接返回
float sqrDistance = (player.position - transform.position).sqrMagnitude; // 计算敌人到玩家的平方距离
float sqrDetectDistance = detectDistance * detectDistance; // 计算检测距离的平方
if (sqrDistance <= sqrDetectDistance) // 如果玩家在检测范围内
{ // if 开始
Debug.Log("发现玩家"); // 执行发现玩家后的逻辑
} // if 结束
} // 方法结束
private void DecideState() // 决定 AI 当前状态
{ // 方法开始
Debug.Log("根据感知结果决定巡逻、追击或攻击"); // 这里模拟 AI 状态决策
} // 方法结束
} // 类结束实际项目怎么做得更好?
近处怪物、正在战斗的怪物,可以每帧或高频更新。 远处怪物、屏幕外怪物,可以低频更新,甚至休眠。 寻路请求可以排队,避免一帧里大量怪物同时请求路径。 AI 感知可以分帧,攻击命中不要分帧太久。 如果 AI 每次耗时差异很大,可以用“时间预算”方式,比如每帧最多花 1ms 做 AI。
一句话总结
NOTE
AI 分帧的本质是把一帧里的大计算拆散:用游标轮询、固定数量、时间预算或优先级,把大量 AI 的感知和决策摊到多帧执行,避免 BehaviourUpdate 或 AI 模块出现尖峰卡顿。
寻路如何异步或分帧?
面试高分回答
寻路异步或分帧的核心是:不要让所有 A* 计算在同一帧、同一个主线程瞬间算完。应该把寻路请求放进队列,然后按“每帧处理一部分节点”或“后台线程计算纯数据路径”的方式摊平压力。
常见方案
协程分帧:仍然在主线程,但每帧只展开一部分节点,用 yield return null 把计算摊到多帧,适合中小规模项目。
后台线程:把地图数据转成纯 C# 数据,在子线程里算路径,算完后回到主线程使用结果。注意子线程不能访问 Transform、GameObject、Physics 等 Unity API。
Job System:大量寻路请求时更适合,用 NativeArray、Burst、Job 做数据并行,但代码结构要求更高。
协程分帧示例
c
using System; // 引入 Action 回调类型
using System.Collections; // 引入 IEnumerator 协程类型
using System.Collections.Generic; // 引入 Queue 和 List 容器
using UnityEngine; // 引入 Unity 常用类型
public class PathRequestManager : MonoBehaviour // 定义寻路请求管理器
{ // 类开始
private class PathRequest // 定义一个寻路请求数据类
{ // 类开始
public Vector3 Start; // 保存寻路起点
public Vector3 End; // 保存寻路终点
public Action<List<Vector3>> OnComplete; // 保存寻路完成后的回调
} // 类结束
[SerializeField] private int nodesPerFrame = 100; // 每帧最多展开多少个寻路节点
private readonly Queue<PathRequest> requestQueue = new Queue<PathRequest>(); // 保存等待处理的寻路请求队列
private bool isProcessing; // 标记当前是否正在处理寻路请求
public void RequestPath(Vector3 start, Vector3 end, Action<List<Vector3>> onComplete) // 对外提供寻路请求接口
{ // 方法开始
PathRequest request = new PathRequest(); // 创建一个新的寻路请求
request.Start = start; // 记录寻路起点
request.End = end; // 记录寻路终点
request.OnComplete = onComplete; // 记录完成回调
requestQueue.Enqueue(request); // 把请求加入队列
if (!isProcessing) StartCoroutine(ProcessQueue()); // 如果当前没有处理请求,就启动处理协程
} // 方法结束
private IEnumerator ProcessQueue() // 处理寻路队列的协程
{ // 协程开始
isProcessing = true; // 标记正在处理请求
while (requestQueue.Count > 0) // 只要队列里还有请求就继续处理
{ // while 开始
PathRequest request = requestQueue.Dequeue(); // 取出一个寻路请求
yield return StartCoroutine(FindPathFrameSliced(request)); // 分帧计算这条路径
} // while 结束
isProcessing = false; // 队列处理完后标记为空闲
} // 协程结束
private IEnumerator FindPathFrameSliced(PathRequest request) // 分帧执行寻路逻辑
{ // 协程开始
List<Vector3> resultPath = new List<Vector3>(); // 创建最终路径结果
int openedNodeCount = 0; // 记录已经展开的节点数量
bool pathFound = false; // 记录是否已经找到路径
while (!pathFound) // 只要还没找到路径就继续搜索
{ // while 开始
for (int i = 0; i < nodesPerFrame; i++) // 本帧最多展开指定数量的节点
{ // for 开始
openedNodeCount++; // 模拟展开一个 A* 节点
if (openedNodeCount >= 500) // 这里模拟已经找到路径的条件
{ // if 开始
pathFound = true; // 标记路径已经找到
resultPath.Add(request.Start); // 把起点加入结果路径
resultPath.Add(request.End); // 把终点加入结果路径
break; // 跳出本帧节点展开循环
} // if 结束
} // for 结束
if (!pathFound) yield return null; // 如果还没找到路径,就等到下一帧继续算
} // while 结束
request.OnComplete?.Invoke(resultPath); // 把路径结果回调给请求者
} // 协程结束
} // 类结束后台线程思路
后台线程适合算“纯数据路径”。比如把地图转成 int[,]、Vector2Int、节点数组,然后在线程里跑 A*。算完后只把路径点结果传回主线程。
不要在子线程里做这些事:访问 transform.position、创建或销毁 GameObject、调用 Physics.Raycast、修改 Unity 组件。
实际项目注意点
寻路请求要排队,不要 100 个怪物同一帧全部开始算。
近处、战斗中的怪物优先寻路,远处怪物低频寻路。
路径结果可能过期,比如怪物还没走到,玩家已经换位置了,所以要检查目标是否变化太大。
寻路失败要有兜底,比如等待、靠近、重新请求、切换状态。
多个怪物可以共享部分路径或使用流场寻路,避免每个怪物都单独 A*。一句话总结
IMPORTANT
寻路异步或分帧,就是把“一帧算完整路径”改成“请求排队、分批展开节点、结果回调”。协程分帧能摊平主线程尖峰,后台线程或 Job 能把纯数据计算移出主线程,但最终操作 Unity 对象仍然要回到主线程。
日志输出为什么会影响性能?
面试高分回答
日志输出会影响性能,因为 Debug.Log 不只是“打印一行字”。它可能包含字符串拼接、对象 ToString、装箱、堆栈信息生成、主线程日志分发、Editor Console 刷新,以及真机上的系统日志或文件 I/O。
为什么会慢?
第一,字符串本身有成本。
比如 `Debug.Log("hp = " + hp)` 会产生新的字符串;如果拼接复杂对象,还可能调用 `ToString`,甚至产生 GC。
第二,日志可能会带堆栈信息。
错误、警告、异常日志通常需要记录调用栈,堆栈采集比普通代码贵很多。
第三,Console 刷新很贵。
在 Unity Editor 里,如果每帧刷很多日志,Console 面板要接收、显示、滚动、合并、刷新,很容易让编辑器卡。
第四,真机日志也不是免费的。
移动端大量日志可能写入系统日志缓冲或文件,I/O 太多会影响帧时间。
第五,日志在热路径里会被放大。
如果在 `Update`、AI 循环、碰撞回调、寻路循环里打印,一秒可能输出几百到几千条,性能会明显下降。常见错误写法
c
using UnityEngine; // 引入 Unity 常用类型
public class BadLogExample : MonoBehaviour // 定义错误日志示例组件
{ // 类开始
private int frameCount; // 保存帧计数
private void Update() // 每帧调用一次
{ // 方法开始
frameCount++; // 帧计数加一
Debug.Log("当前帧数:" + frameCount); // 每帧拼接字符串并输出日志,会产生性能开销
} // 方法结束
} // 类结束推荐做日志封装
c
using UnityEngine; // 引入 Unity 常用类型
using System.Diagnostics; // 引入 Conditional 特性所在命名空间
public static class GameLog // 定义游戏日志工具类
{ // 类开始
[Conditional("UNITY_EDITOR")] // 只有在 Unity Editor 条件下才编译调用
public static void Info(string message) // 定义普通日志方法
{ // 方法开始
Debug.Log(message); // 输出普通日志
} // 方法结束
[Conditional("UNITY_EDITOR")] // 只有在 Unity Editor 条件下才编译调用
public static void Warning(string message) // 定义警告日志方法
{ // 方法开始
Debug.LogWarning(message); // 输出警告日志
} // 方法结束
[Conditional("UNITY_EDITOR")] // 只有在 Unity Editor 条件下才编译调用
public static void Error(string message) // 定义错误日志方法
{ // 方法开始
Debug.LogError(message); // 输出错误日志
} // 方法结束
} // 类结束使用方式
c
using UnityEngine; // 引入 Unity 常用类型
public class LogUseExample : MonoBehaviour // 定义日志使用示例组件
{ // 类开始
private void Start() // 游戏对象启动时调用
{ // 方法开始
GameLog.Info("角色系统初始化完成"); // 只在 Editor 下输出日志,正式包不会编译这次调用
} // 方法结束
} // 类结束注意一个坑
即使你封装了日志开关,也不要这样写复杂参数:
c
using UnityEngine; // 引入 Unity 常用类型
public class LogStringCostExample : MonoBehaviour // 定义日志字符串成本示例
{ // 类开始
private int hp = 100; // 定义血量
private void Update() // 每帧调用一次
{ // 方法开始
GameLog.Info("当前血量:" + hp); // 如果调用被条件编译去掉,这种简单写法通常没问题
} // 方法结束
} // 类结束如果是运行时开关而不是条件编译,就要注意:参数字符串可能在进入日志函数前就已经拼好了。所以高性能项目会避免在热路径里拼复杂日志,或者使用等级过滤、延迟格式化、采样输出。
一句话总结
CAUTION
日志影响性能的原因是:字符串和 GC、堆栈采集、Console 刷新、I/O、主线程调度都会有成本。面试里可以说:开发期保留必要日志,热路径限制日志,正式包关闭 Debug 级日志,并用日志等级和条件编译做控制。