Appearance
性能优化
如何用 Profiler 定位卡顿?
一句话回答: 用 Profiler 定位卡顿的核心流程是:先复现并录制,找到卡顿尖峰帧,点进去看 Main Thread 的 Timeline / Hierarchy,判断是脚本、GC、UI、物理、渲染还是资源加载造成的,然后加 Marker 精确定位,改完复测。
零基础理解
游戏卡顿本质上是:
某一帧花的时间太长了。如果目标是 60 FPS:
一帧最好小于 16.6ms如果目标是 30 FPS:
一帧最好小于 33.3ms所以 Profiler 要找的不是“平均怎么样”,而是:
最卡那一帧,到底是谁花了最多时间?排查步骤
第一步:用真机复现。
Build Settings 勾 Development Build
勾 Autoconnect Profiler
在真机上操作到卡顿位置
Unity Editor 打开 Window -> Analysis -> Profiler
开始录制不要只在 Editor 里看。 Editor 本身有额外开销,真机数据更可信。
第二步:找到尖峰帧。
在 CPU Usage 里看曲线:
哪一帧突然很高
哪一帧超过 16.6ms 或 33.3ms
哪一帧玩家感觉卡点中那一帧。
第三步:看 Timeline。
Timeline 可以看到这一帧时间花在哪些线程和模块上:
c
Main Thread
Render Thread
Job Worker
Loading ThreadUnity 大多数 GameObject、UI、脚本逻辑都在 Main Thread,所以通常先看主线程。
第四步:看 Hierarchy。
Hierarchy 更像函数耗时排行榜。
重点看:
Total ms:
这个函数加上子函数总共花多少。
Self ms:
这个函数自己花多少,不算子函数。
GC Alloc:
这一帧这里分配了多少托管内存。常见卡顿 1:脚本 Update 太重
Profiler 里可能看到:
c
PlayerLoop
-> Update.ScriptRunBehaviourUpdate
-> YourController.Update常见原因:
c
Update 里遍历大量对象
每帧排序
每帧 FindObjectOfType
每帧 GetComponent
每帧 Instantiate / Destroy
复杂寻路或 AI 全在一帧跑优化方向:
缓存组件
减少每帧遍历
分帧处理
对象池
降低检测频率
把重计算拆到后台或 Job常见卡顿 2:GC Alloc
如果 Profiler 看到:
c
GC.Alloc
GC.Collect或者某个函数 GC Alloc 很高,说明代码在分配临时对象。
常见来源:
c
字符串拼接
LINQ
foreach 某些情况
闭包
装箱
new 临时 List / Dictionary
频繁 ToString
频繁 Instantiate / Destroy错误示例:
c
using UnityEngine;
using UnityEngine.UI;
public class BadScoreUI : MonoBehaviour
{
[SerializeField] private Text scoreText;
private int score;
private void Update()
{
// 每帧字符串拼接,可能产生 GC
scoreText.text = "Score: " + score;
}
}更好一点:
c
using UnityEngine;
using UnityEngine.UI;
public class BetterScoreUI : MonoBehaviour
{
[SerializeField] private Text scoreText;
private int score;
private int lastScore = -1;
private void Update()
{
// 只有分数变化时才刷新 UI
// 避免每帧产生字符串和 UI 重建
if (score != lastScore)
{
lastScore = score;
scoreText.text = $"Score: {score}";
}
}
}常见卡顿 3:UI 重建
Profiler 里可能看到:
c
Canvas.BuildBatch
Canvas.SendWillRenderCanvases
LayoutRebuilder
GraphicRebuild常见原因:
一个大 Canvas 下频繁改 Text
ScrollView 一次生成几百个 Item
LayoutGroup 频繁重算
ContentSizeFitter 滥用
动画频繁改 UI 尺寸优化方向:
c
动静 UI 分 Canvas
ScrollView 做虚拟列表
减少 LayoutGroup 动态刷新
关闭无用 Raycast Target
频繁变化的文本单独拆 Canvas常见卡顿 4:资源加载
Profiler 里可能看到:
c
Resources.Load
AssetBundle.LoadAsset
Addressables 加载完成后 Instantiate
UnloadUnusedAssets常见原因:
战斗中同步加载资源
一帧实例化太多对象
切场景后一次性创建大量 UI 或怪物
退出玩法时集中卸载资源优化方向:
异步加载
提前预加载
分帧实例化
对象池
Loading 阶段集中清理常见卡顿 5:渲染问题
Profiler 里可能看到:
Render Thread 很高
Camera.Render 很高
Draw Calls 很多
Batches 很多
SetPass Calls 很多常见原因:
材质太多
透明物体太多
阴影太重
后处理太重
粒子太多
Overdraw 严重
UI 大量重叠这时除了 Profiler,还要配合:
c
Frame Debugger
Rendering Debugger
Stats 面板
GPU Profiler常见卡顿 6:物理问题
Profiler 里可能看到:
c
Physics.Simulate
Physics.Raycast
FixedUpdate.PhysicsFixedUpdate常见原因:
c
Collider 太多
Rigidbody 太多
FixedUpdate 太频繁
射线检测太多
Layer 没过滤
碰撞矩阵没优化优化方向:
c
减少不必要 Collider
用 LayerMask 过滤 Raycast
合理设置 Fixed Timestep
关闭无意义碰撞矩阵
静态物体不要乱加 Rigidbody自定义 ProfilerMarker
如果你怀疑某段业务逻辑卡,但 Profiler 里不好找,可以自己加标记。
c
using Unity.Profiling;
using UnityEngine;
public class BattleSystem : MonoBehaviour
{
// 创建一个 Profiler 标记
// 它会显示在 Profiler Timeline 里
private static readonly ProfilerMarker UpdateAIMarker =
new ProfilerMarker("BattleSystem.UpdateAI");
private void Update()
{
// using 包住的代码会被 Profiler 统计耗时
using (UpdateAIMarker.Auto())
{
UpdateAI();
}
}
private void UpdateAI()
{
// 这里写 AI 更新逻辑
// 如果这一段很耗时,Profiler 里就能直接看到 BattleSystem.UpdateAI
}
}这样你在 Profiler 里能直接看到:
c
BattleSystem.UpdateAI定位会比盲猜快很多。
Deep Profile 要小心
Deep Profile 可以看到更细的函数调用,但它会带来很大额外开销。
适合:
短时间定位具体脚本函数
小场景复现问题
找不到细节时临时打开不适合:
长期打开
用它判断真实帧率
复杂真机性能评估面试可以说:
Deep Profile 能帮助定位细节,但会放大耗时,最终还是要关闭 Deep Profile 后复测。面试高分回答
TIP
“我会先在真机 Development Build 上用 Profiler 复现卡顿,录制后找到 CPU Usage 里的尖峰帧,而不是只看平均帧率。选中尖峰帧后先看 Timeline 的 Main Thread,判断时间花在脚本、UI、物理、资源加载还是渲染提交上;再切到 Hierarchy 看 Total ms、Self ms 和 GC Alloc,展开 PlayerLoop 找到具体函数。如果看到 GC.Alloc 或 GC.Collect,就继续查字符串、LINQ、闭包、装箱和临时对象分配;如果看到 Canvas.BuildBatch 或 LayoutRebuilder,就查 UI 重建;如果是 Resources.Load、Instantiate 或 UnloadUnusedAssets,就查同步加载和集中创建销毁。对于不好定位的业务逻辑,我会加 ProfilerMarker 标记代码段。最后优化后必须在同设备同流程复测,确认尖峰消失。”
最短记忆版
Profiler 定位卡顿:
1. 真机复现并录制。
2. 找 CPU Usage 尖峰帧。
3. 看 Main Thread Timeline。
4. 看 Hierarchy 的 Total / Self / GC Alloc。
5. 判断类型:
脚本、GC、UI、物理、渲染、加载。
6. 加 ProfilerMarker 精确定位。
7. 优化后同设备复测。
重点:
不要只看平均 FPS;
要看最卡那一帧是谁花了时间。CPU 瓶颈和 GPU 瓶颈怎么区分?
一句话回答: CPU 瓶颈是 CPU 一帧内算不完或提交不完渲染命令;GPU 瓶颈是 CPU 已经提交完了,但 GPU 画不完。判断时主要看 Main Thread、Render Thread、GPU Frame Time,再用降低分辨率、关闭阴影后处理等实验验证。
零基础理解
一帧画面大概像流水线:
CPU:
跑脚本、算物理、处理 UI、准备渲染命令。
GPU:
根据 CPU 提交的命令真正画画面。如果 CPU 这边太慢:
GPU 等 CPU 给活干。这就是 CPU 瓶颈。
如果 CPU 已经把活交出去了,但 GPU 画不过来:
CPU 等 GPU 画完。这就是 GPU 瓶颈。
怎么看 CPU 瓶颈
打开 Profiler,看 CPU Usage,重点看:
c
Main Thread
Render Thread
Timeline
Hierarchy如果看到:
c
Main Thread 很长
BehaviourUpdate 很高
Physics.Simulate 很高
Canvas.BuildBatch 很高
GC.Collect 很高
Resources.Load 很高通常是 CPU 瓶颈。
CPU 瓶颈常见原因:
c
Update 里逻辑太重
每帧遍历大量对象
频繁 Find / GetComponent
频繁 Instantiate / Destroy
大量 GC Alloc
物理模拟太重
UI 重建太频繁
同步加载资源怎么看 GPU 瓶颈
GPU 瓶颈一般看:
c
GPU Profiler
Frame Debugger
Rendering Profiler
设备平台工具,比如 Xcode、Android GPU Inspector、RenderDoc如果看到:
GPU Frame Time 很高
降低分辨率后帧率明显提升
关闭阴影后明显提升
关闭后处理后明显提升
减少透明 UI 或粒子后明显提升通常是 GPU 瓶颈。
GPU 瓶颈常见原因:
分辨率太高
像素填充压力大
透明物体叠太多
Overdraw 严重
复杂 Shader
实时阴影太重
后处理太重
粒子太多
反射、Bloom、SSAO 等效果太贵一个很实用的判断实验
如果你不确定,可以做几个快速实验。
实验 1:降低分辨率。
如果 FPS 明显提升,多半是 GPU 瓶颈。
实验 2:关闭阴影、后处理、抗锯齿。
如果 FPS 明显提升,多半是 GPU 瓶颈。
实验 3:减少同屏怪物数量或关闭 AI。
如果 FPS 明显提升,多半是 CPU 瓶颈。
实验 4:关闭大量 UI 刷新或 ScrollView。
如果 FPS 明显提升,多半是 CPU/UI 重建瓶颈。
实验 5:减少 DrawCall 和材质切换。
如果 Render Thread 明显降低,可能是 CPU 渲染提交瓶颈。注意第三类:Render Thread 高不一定是 GPU 瓶颈。 它可能是 CPU 在忙着提交渲染命令,比如:
DrawCall 太多
SetPass 太多
材质切换太多
合批失败这个属于 CPU 侧渲染提交压力。
用代码做简单 FPS 和帧耗时观察
这不是替代 Profiler,只是方便你在真机上快速看到帧耗时。
c
using UnityEngine;
public class SimpleFrameTimeDisplay : MonoBehaviour
{
private float deltaTime;
private void Update()
{
// 用平滑方式计算帧时间,避免数字跳动太厉害
deltaTime += (Time.unscaledDeltaTime - deltaTime) * 0.1f;
}
private void OnGUI()
{
// 一帧耗时,单位毫秒
float ms = deltaTime * 1000f;
// FPS
float fps = 1f / deltaTime;
GUI.Label(
new Rect(20, 20, 300, 40),
$"Frame: {ms:F1} ms FPS: {fps:F1}"
);
}
}如果目标 60 FPS:
16.6ms 以内比较理想
超过 16.6ms 就可能掉帧如果目标 30 FPS:
33.3ms 以内比较理想
超过 33.3ms 就可能掉帧用 ProfilerMarker 区分脚本 CPU 消耗
如果怀疑某个系统吃 CPU,可以加标记。
c
using Unity.Profiling;
using UnityEngine;
public class EnemyManager : MonoBehaviour
{
// 自定义 Profiler 标记,会显示在 Timeline 里
private static readonly ProfilerMarker UpdateEnemiesMarker =
new ProfilerMarker("EnemyManager.UpdateEnemies");
private void Update()
{
using (UpdateEnemiesMarker.Auto())
{
UpdateEnemies();
}
}
private void UpdateEnemies()
{
// 这里可能是大量敌人的 AI、寻路、状态机更新
// 如果它耗时高,Profiler 里会直接看到 EnemyManager.UpdateEnemies
}
}这能帮助你确认:
到底是我的脚本慢
还是渲染慢
还是 UI 慢常见误判
c
1. VSync 开着。
帧率可能被同步限制,看起来像卡,但不是瓶颈本身。
2. Application.targetFrameRate 限制了帧率。
比如锁 30 FPS,不能直接说性能只能 30。
3. Editor 里测。
Editor 有额外开销,最终要看真机 Development Build。
4. Profiler 开销。
Profiler 本身会影响性能,尤其 Deep Profile。
5. 只看 FPS。
FPS 低只是结果,要看 CPU/GPU frame time 才知道原因。
6. 把 Render Thread 高误判为 GPU 高。
Render Thread 仍是 CPU 侧线程,可能是提交 DrawCall 太多。优化方向完全不同
CPU 瓶颈优化:
c
减少 Update 逻辑
缓存组件引用
减少 GC
对象池
分帧处理
减少物理检测
优化 UI 重建
异步加载和预加载GPU 瓶颈优化:
c
降低分辨率或动态分辨率
减少透明叠加和 Overdraw
优化 Shader
减少实时阴影
降低后处理质量
减少粒子数量
LOD
Occlusion Culling
合并贴图和材质面试高分回答
IMPORTANT
“我会先用 Profiler 在目标设备上看一帧的 CPU 和 GPU 耗时。如果 Main Thread 或 Render Thread 超过帧预算,比如 60 帧下超过 16.6ms,并且 Hierarchy 里能看到脚本、物理、UI 重建、GC 或资源加载耗时,那通常是 CPU 瓶颈。如果 GPU Frame Time 更高,或者降低分辨率、关闭阴影和后处理后帧率明显提升,那通常是 GPU 瓶颈。需要注意 Render Thread 高不等于 GPU 瓶颈,它可能是 CPU 提交 DrawCall、SetPass 或材质切换太多。实际排查时我会结合 CPU Timeline、GPU Profiler、Frame Debugger 和真机实验来判断,确认瓶颈后再决定优化脚本逻辑还是优化渲染效果。”
最短记忆版
c
CPU 瓶颈:
Main Thread / Render Thread 高。
查脚本、GC、物理、UI、加载、DrawCall 提交。
GPU 瓶颈:
GPU Frame Time 高。
降低分辨率、关阴影后处理后明显变快。
判断实验:
降分辨率变快 -> GPU。
关 AI / 脚本变快 -> CPU。
关 UI 重建变快 -> CPU/UI。
减少 DrawCall 后 Render Thread 降 -> CPU 渲染提交。
重点:
Render Thread 高不等于 GPU 高;
最终要在真机上用 Profiler 验证。DrawCall 是什么?
一句话回答:DrawCall 就是 CPU 向 GPU 提交一次绘制命令,告诉 GPU:“用这个 Mesh、这个 Material、这个 Shader 状态,把这一批东西画出来。”DrawCall 太多时,通常会先增加 CPU 渲染提交压力,尤其是 Render Thread。
零基础理解
你可以把 GPU 想成画师,CPU 想成指挥。
CPU 每说一次:
用这个材质,画这个模型。这就是一次 DrawCall。
如果场景里有很多物体,每个物体材质都不一样,CPU 就要不停喊:
换材质,画一次。
再换材质,再画一次。
再换 Shader,再画一次。喊太多次,CPU 自己就累了,画面就可能卡。
DrawCall 为什么多了会慢
每次 DrawCall 不只是“画一下”,CPU 还要做很多准备:
c
判断物体是否可见
准备 Mesh 数据
准备材质和 Shader 参数
切换渲染状态
提交绘制命令给图形 API
驱动层处理命令所以 DrawCall 多,常见瓶颈是:
c
CPU Render Thread 高
SetPass Calls 高
Batches 高注意:DrawCall 多不一定是 GPU 慢。 它很多时候是 CPU 提交命令太累。
DrawCall、Batch、SetPass 的区别
DrawCall:
一次绘制命令。Batch:
Unity 统计里更常看到 Batches。
合批后,多个物体可能被合成一个 Batch 去绘制。SetPass Call:
切换 Shader Pass 或材质渲染状态。
通常比普通绘制提交更贵。面试里可以说:
优化时不仅看 DrawCall / Batches,也要看 SetPass Calls。
如果 SetPass 很高,说明材质或 Shader 状态切换很多。什么会导致 DrawCall 变多
常见原因:
c
材质不同
Shader 不同
Shader Keyword 不同
贴图不同
渲染队列不同
透明物体需要排序
灯光影响不同
阴影 Pass
UI 层级或材质打断批次
粒子系统太多
SkinnedMeshRenderer 太多比如两个物体 Mesh 一样,但材质不一样:
c
Cube_A 使用 RedMaterial
Cube_B 使用 BlueMaterialUnity 很可能要分成不同批次。
如果它们使用同一个材质:
c
Cube_A 使用 SharedMaterial
Cube_B 使用 SharedMaterial就更有机会合批。
如何减少 DrawCall
常见方法:
c
1. 合并材质
多个物体尽量使用同一个 Material。
2. 使用图集
多个小贴图合成一张大图,比如 UI SpriteAtlas。
3. Static Batching
静态物体勾 Static,让 Unity 合并静态网格提交。
4. Dynamic Batching
小型动态物体可能自动合批,但限制较多。
5. GPU Instancing
大量相同 Mesh + 相同 Material 的物体,用 GPU Instancing。
6. SRP Batcher
URP/HDRP 下减少材质状态切换的 CPU 成本。
7. 减少透明物体
透明物体排序和 Overdraw 都更麻烦。
8. UI 合批
同 Canvas、同材质、同图集更容易合批。代码示例:不要频繁创建材质实例
新手很容易这样写:
c
using UnityEngine;
public class BadMaterialExample : MonoBehaviour
{
[SerializeField] private Renderer targetRenderer;
private void Start()
{
// 注意:renderer.material 会生成一份材质实例
// 如果很多物体都这么做,会导致材质变多,合批更困难
targetRenderer.material.color = Color.red;
}
}更推荐用 MaterialPropertyBlock 改每个物体的颜色,同时仍然共享同一个材质:
c
using UnityEngine;
public class BetterMaterialExample : MonoBehaviour
{
[SerializeField] private Renderer targetRenderer;
private MaterialPropertyBlock propertyBlock;
private void Awake()
{
// MaterialPropertyBlock 可以给单个 Renderer 设置参数
// 不需要为每个物体创建新的 Material 实例
propertyBlock = new MaterialPropertyBlock();
}
public void SetColor(Color color)
{
// 读取当前属性
targetRenderer.GetPropertyBlock(propertyBlock);
// 设置颜色参数
// Shader 里要有 _Color 这个属性
propertyBlock.SetColor("_Color", color);
// 应用到 Renderer
targetRenderer.SetPropertyBlock(propertyBlock);
}
}大量相同物体可以用 GPU Instancing
比如草、石头、子弹、简单小怪,如果:
Mesh 一样
Material 一样
只是位置、旋转、缩放不同就适合考虑 GPU Instancing。
c
using UnityEngine;
public class InstancingExample : MonoBehaviour
{
[SerializeField] private Mesh mesh;
[SerializeField] private Material material;
private readonly Matrix4x4[] matrices = new Matrix4x4[100];
private void Start()
{
for (int i = 0; i < matrices.Length; i++)
{
// 给每个实例设置不同的位置
Vector3 position = new Vector3(i % 10 * 2f, 0f, i / 10 * 2f);
// 构造 TRS 矩阵:位置、旋转、缩放
matrices[i] = Matrix4x4.TRS(
position,
Quaternion.identity,
Vector3.one
);
}
}
private void Update()
{
// 一次提交最多 1023 个实例
// 前提是材质开启 Enable GPU Instancing
Graphics.DrawMeshInstanced(
mesh,
0,
material,
matrices,
matrices.Length
);
}
}怎么看 DrawCall
常用工具:
c
Game 视图右上角 Stats:
看 Batches、SetPass Calls、Triangles、Vertices。
Profiler -> Rendering:
看渲染相关指标。
Frame Debugger:
逐条看每一次绘制,最适合分析为什么没合批。
RenderDoc:
更深入分析 GPU 渲染过程。Frame Debugger 特别有用,因为它能告诉你:
这一批为什么被拆开
是不是材质不同
是不是贴图不同
是不是 Shader Pass 不同
是不是 UI 批次被打断面试高分回答
WARNING
“DrawCall 是 CPU 向 GPU 提交的一次绘制命令,里面包含要画的 Mesh、使用的 Material、Shader Pass 和渲染状态。DrawCall 太多时,CPU 需要频繁切换材质和渲染状态并提交命令,通常会增加 Render Thread 压力。Unity 里我会同时看 Batches 和 SetPass Calls,因为 SetPass 代表 Shader Pass 或材质状态切换,成本更高。优化方向是尽量让物体共享材质和贴图,使用图集、Static Batching、GPU Instancing、SRP Batcher,UI 里减少材质和图集切换。也要注意 DrawCall 不是唯一指标,如果瓶颈在 GPU 像素填充、后处理、阴影或 Overdraw,单纯减少 DrawCall 不一定解决问题。”
最短记忆版
c
DrawCall:
CPU 通知 GPU 画一次。
多了为什么慢:
CPU 要频繁准备状态和提交命令。
看什么:
Batches;
SetPass Calls;
Render Thread;
Frame Debugger。
怎么优化:
同材质;
图集;
合批;
GPU Instancing;
SRP Batcher;
减少透明和材质切换。
重点:
DrawCall 多通常是 CPU 渲染提交压力;
GPU 慢还要看像素、阴影、后处理和 Overdraw。Static Batching 和 Dynamic Batching 区别是什么?
一句话回答:Static Batching 是给不动的静态物体用的,提前合批,主要是用更多内存换更少 CPU 渲染提交。 Dynamic Batching 是给很小的动态物体用的,每帧在 CPU 上尝试合批,主要是用 CPU 计算换更少 DrawCall。
零基础理解
合批就是把多个物体尽量合成一批提交给 GPU 画,减少 DrawCall。
比如场景里有 100 个石头:
不合批:
CPU 可能要喊 100 次:画这个石头、画那个石头……
合批后:
CPU 尽量少喊几次,一批一批交给 GPU。但合批不是免费的。 Static Batching 和 Dynamic Batching 的区别就在于:什么时候合、给谁用、代价是什么。
Static Batching 是什么
Static Batching 适合不会动的物体,比如:
建筑
地面
石头
路灯
墙
场景装饰物
固定障碍物这些物体位置基本不变,所以 Unity 可以提前处理它们的合批数据。
特点:
对象要标记 Static
运行时不能随便移动
减少 DrawCall / Batches
降低 CPU 渲染提交压力
但会增加内存为什么会增加内存?
因为 Unity 为了让这些静态物体更容易合批,会保存额外的合批网格数据。 所以 Static Batching 不是“免费优化”。
Dynamic Batching 是什么
Dynamic Batching 适合很小的、可以移动的物体。
比如:
小道具
小装饰
低面数移动物体
简单小 Mesh它的思路是:
每帧在 CPU 上把这些小物体的顶点变换到合适空间
然后尽量合成一批提交特点:
物体可以移动
Mesh 必须很小
材质和 Shader 状态要相同
每帧 CPU 要多做顶点处理
限制很多
现代项目里不一定赚所以 Dynamic Batching 不是“动态物体越多越好”。 如果物体顶点稍微多一点,或者 CPU 已经很紧,它可能反而不划算。
核心区别表
Static Batching:
对象:静态物体
是否能动:不建议动
合批时机:提前处理
主要代价:内存增加
适合:建筑、地面、固定场景物体
Dynamic Batching:
对象:小型动态物体
是否能动:可以动
合批时机:运行时每帧尝试
主要代价:CPU 开销
适合:低面数、小而散、同材质动态物体为什么材质很关键
不管 Static 还是 Dynamic,材质和渲染状态都很重要。
如果两个物体:
同 Mesh
同 Material
同 Shader
同贴图
同渲染状态更容易合批。
如果它们:
材质不同
Shader Keyword 不同
贴图不同
渲染队列不同
Lightmap 不同就容易被拆成不同批次。
所以减少 DrawCall 很常见的做法是:
合并材质
使用图集
统一 Shader
减少材质实例
减少透明排序问题代码示例:标记静态物体
一般你会在 Inspector 里勾 Static。 也可以用代码设置,不过实际项目更多是在编辑器阶段设置。
c
using UnityEngine;
public class StaticBatchingExample : MonoBehaviour
{
[SerializeField] private GameObject buildingRoot;
private void Start()
{
// 注意:实际项目一般在编辑器里勾 Static
// 这里只是演示 Static Batching 适合不移动的场景物体
buildingRoot.isStatic = true;
// 如果运行时还移动这个对象,就不适合 Static Batching
// Static Batching 的前提是物体位置基本固定
}
}不要这样滥用 Static
c
using UnityEngine;
public class BadStaticUsage : MonoBehaviour
{
private void Update()
{
// 如果一个对象每帧都移动,它就不应该当作静态合批对象
transform.position += Vector3.forward * Time.deltaTime;
}
}如果它会移动,就别把它当静态对象处理。 否则你是在给系统制造矛盾:名字叫 Static,但行为是 Dynamic。
大量相同动态物体更适合 GPU Instancing
如果你有很多相同的树、草、石头、子弹:
c
Mesh 一样
Material 一样
只是位置不同相比 Dynamic Batching,更常考虑:
c
GPU Instancing示例:
c
using UnityEngine;
public class InstancingSimpleExample : MonoBehaviour
{
[SerializeField] private Mesh mesh;
[SerializeField] private Material material;
private Matrix4x4[] matrices;
private void Start()
{
matrices = new Matrix4x4[100];
for (int i = 0; i < matrices.Length; i++)
{
// 给每个实例一个不同的位置
Vector3 position = new Vector3(i % 10 * 2f, 0f, i / 10 * 2f);
// 生成位置、旋转、缩放矩阵
matrices[i] = Matrix4x4.TRS(
position,
Quaternion.identity,
Vector3.one
);
}
// 材质上需要开启 Enable GPU Instancing
material.enableInstancing = true;
}
private void Update()
{
// 一次最多绘制 1023 个实例
// 适合大量相同 Mesh + 相同 Material 的物体
Graphics.DrawMeshInstanced(
mesh,
0,
material,
matrices,
matrices.Length
);
}
}怎么验证有没有效果
不要凭感觉说“我开了合批,所以优化了”。 要看工具:
c
Game 视图 Stats:
看 Batches、SetPass Calls。
Profiler:
看 CPU Usage、Render Thread 是否下降。
Frame Debugger:
看每次绘制为什么合了或没合。如果开启 Static Batching 后:
Batches 下降
Render Thread 耗时下降
内存增长可接受说明比较有效。
如果开启 Dynamic Batching 后:
Batches 下降不明显
CPU 反而升高那就不划算。
面试高分回答
TIP
“Static Batching 和 Dynamic Batching 都是为了减少 DrawCall,但适用对象和代价不同。Static Batching 适合不会移动的静态场景物体,比如建筑、地面、石头。Unity 会提前生成合批数据,运行时减少 CPU 的渲染提交成本,但会增加内存占用。Dynamic Batching 适合很小的动态 Mesh,它会在运行时每帧由 CPU 做顶点变换并尝试合批,因此可以减少部分 DrawCall,但会增加 CPU 开销,而且对顶点数、顶点属性、材质和 Shader 状态限制很多。实际项目里我会优先对静态场景物体使用 Static Batching;大量相同动态物体更倾向 GPU Instancing;URP/HDRP 项目还会关注 SRP Batcher,而不是盲目依赖 Dynamic Batching。”
最短记忆版
c
Static Batching:
静态物体;
提前合批;
不能乱动;
内存增加;
CPU 提交降低。
Dynamic Batching:
小动态物体;
每帧合批;
CPU 开销增加;
限制很多;
不一定划算。
共同点:
材质、Shader、贴图、渲染状态越一致,越容易合批。
现代项目:
静态场景看 Static Batching;
大量相同物体看 GPU Instancing;
SRP 项目看 SRP Batcher。GPU Instancing 是什么?
一句话回答:GPU Instancing 是一种减少 DrawCall 的技术:当场景里有大量相同 Mesh + 相同 Material 的物体时,可以用一次或少量 DrawCall 把它们批量交给 GPU 绘制,只给每个实例传不同的位置、旋转、缩放、颜色等数据。
零基础理解
比如你场景里有 1000 棵一样的草。
普通方式可能像这样:
CPU:画第 1 棵草
CPU:画第 2 棵草
CPU:画第 3 棵草
...
CPU:画第 1000 棵草GPU Instancing 的思路是:
CPU:这是同一棵草的 Mesh 和 Material。
CPU:这里有 1000 个位置。
GPU:你一次帮我画很多份。它减少的是:
CPU 向 GPU 提交绘制命令的次数所以它主要优化的是 CPU 渲染提交压力,尤其是大量重复物体时。
适合什么场景
适合:
草
树
石头
金币
子弹
碎片
低模小怪
大量相同道具
重复建筑模块核心条件:
Mesh 相同
Material 相同
Shader 支持 Instancing
材质开启 Enable GPU Instancing如果每个物体都长得不一样、材质不一样、Shader 不一样,就不适合。
和 Static / Dynamic Batching 的区别
Static Batching:
适合不动的静态物体。
提前合批。
用内存换更少 CPU 提交。Dynamic Batching:
适合很小的动态物体。
每帧 CPU 帮忙合批。
用 CPU 计算换 DrawCall。GPU Instancing:
适合大量相同物体。
不是真的把 Mesh 合成一个。
而是一个 Mesh 画很多份。
把“每个实例不同的数据”交给 GPU。一句话记:
Batching 更像“合起来提交”。
Instancing 更像“同一个东西复制画很多份”。最简单的使用方式:材质勾选 Enable GPU Instancing
如果你有很多物体:
同一个 Mesh
同一个 Material可以在材质 Inspector 上勾:
Enable GPU InstancingUnity 会尝试自动实例化绘制。
但注意,不要每个物体都生成自己的材质实例。
错误写法:
using UnityEngine;
public class BadInstancingUsage : MonoBehaviour
{
[SerializeField] private Renderer targetRenderer;
private void Start()
{
// renderer.material 会复制一份材质实例
// 每个物体材质都变成独立的,Instancing 更容易被破坏
targetRenderer.material.color = Color.red;
}
}更推荐:
using UnityEngine;
public class BetterInstancingUsage : MonoBehaviour
{
[SerializeField] private Renderer targetRenderer;
private MaterialPropertyBlock propertyBlock;
private void Awake()
{
// MaterialPropertyBlock 可以给单个 Renderer 设置属性
// 避免直接复制材质实例
propertyBlock = new MaterialPropertyBlock();
}
public void SetColor(Color color)
{
// 读取当前属性
targetRenderer.GetPropertyBlock(propertyBlock);
// 设置颜色参数
// Built-in 常见是 _Color,URP Lit 常见是 _BaseColor
propertyBlock.SetColor("_BaseColor", color);
// 应用属性
targetRenderer.SetPropertyBlock(propertyBlock);
}
}注意:如果你要每个实例有不同颜色,Shader 也要支持对应的实例化属性。否则看起来设置了,但不一定能正确参与 Instancing。
代码方式:Graphics.DrawMeshInstanced
这种方式适合你自己控制一批相同物体的绘制。
using UnityEngine;
public class GrassInstancingExample : MonoBehaviour
{
[SerializeField] private Mesh grassMesh;
[SerializeField] private Material grassMaterial;
// 一批最多常见 1023 个实例
private Matrix4x4[] matrices;
private void Start()
{
int count = 500;
matrices = new Matrix4x4[count];
for (int i = 0; i < count; i++)
{
// 生成随机位置
Vector3 position = new Vector3(
Random.Range(-50f, 50f),
0f,
Random.Range(-50f, 50f)
);
// 生成随机旋转
Quaternion rotation = Quaternion.Euler(
0f,
Random.Range(0f, 360f),
0f
);
// 生成随机缩放
Vector3 scale = Vector3.one * Random.Range(0.8f, 1.2f);
// 每个实例的变换矩阵
matrices[i] = Matrix4x4.TRS(position, rotation, scale);
}
// 开启材质 Instancing
grassMaterial.enableInstancing = true;
}
private void Update()
{
// 用一次 DrawMeshInstanced 绘制很多棵草
// 前提是 Mesh 和 Material 相同
Graphics.DrawMeshInstanced(
grassMesh,
0,
grassMaterial,
matrices,
matrices.Length
);
}
}超过 1023 个怎么办
Graphics.DrawMeshInstanced 一批最多常见是 1023 个实例。 如果超过了,就分批画。
using UnityEngine;
public class ManyInstancesExample : MonoBehaviour
{
private const int BatchSize = 1023;
[SerializeField] private Mesh mesh;
[SerializeField] private Material material;
private Matrix4x4[] allMatrices;
private void DrawAll()
{
int totalCount = allMatrices.Length;
int drawn = 0;
while (drawn < totalCount)
{
// 当前批次数量
int count = Mathf.Min(BatchSize, totalCount - drawn);
// 临时数组保存这一批 Matrix
Matrix4x4[] batch = new Matrix4x4[count];
for (int i = 0; i < count; i++)
{
batch[i] = allMatrices[drawn + i];
}
Graphics.DrawMeshInstanced(
mesh,
0,
material,
batch,
count
);
drawn += count;
}
}
}真实项目里会避免每帧 new Matrix4x4[],可以预先缓存批次数组,减少 GC。
它不解决什么问题
GPU Instancing 不是万能的。
它不减少:
三角面数量
像素填充
Overdraw
Shader 复杂度
阴影计算
后处理成本如果你的瓶颈是 GPU:
像素太多
透明叠加太多
Shader 太复杂
阴影太重那 Instancing 不一定救得回来。
它主要适合:
DrawCall 太多
Render Thread 压力高
大量重复物体
CPU 提交成本高怎么看有没有效果
用这些工具:
Stats:
看 Batches 是否下降。
Profiler:
看 Render Thread 是否下降。
Frame Debugger:
看有没有 Instanced Draw。
RenderDoc:
更细看实际 GPU 绘制。如果开启后:
Batches 降低
SetPass 降低或稳定
Render Thread 降低
画面正常说明有效。
如果:
Batches 没降
材质实例很多
Shader 不支持 Instancing
每个物体材质都不同说明没吃到 Instancing。
常见坑
1. 每个物体用了 renderer.material。
会生成材质实例,破坏共享材质。
2. Mesh 不一样。
Instancing 要求同 Mesh。
3. Material 不一样。
不同材质不能合成同一次 Instanced Draw。
4. Shader 不支持 Instancing。
要用支持 Instancing 的 Shader。
5. 数量太少。
只有几个物体时收益不明显。
6. 以为 Instancing 能减少面数。
不能,它只是减少提交次数。
7. SkinnedMeshRenderer 直接套普通 Instancing。
骨骼动画模型通常不能简单按 MeshRenderer 的方式处理。面试高分回答
可以这样说:
“GPU Instancing 是一种针对大量相同 Mesh 和相同 Material 物体的渲染优化技术。普通绘制时,CPU 可能需要为每个物体提交 DrawCall;Instancing 则把 Mesh 和 Material 提交一次,再把每个实例的 transform、颜色等实例数据传给 GPU,让 GPU 一次绘制多份。它适合草、树、石头、子弹、金币等大量重复物体,主要减少 CPU Render Thread 的提交成本。使用时要保证材质开启 Enable GPU Instancing,Shader 支持 Instancing,并且避免 renderer.material 生成材质实例。它不能减少三角面、Overdraw、Shader 和后处理成本,所以如果瓶颈在 GPU 像素填充,Instancing 不一定有效。”
最短记忆版
GPU Instancing:
同 Mesh + 同 Material 的大量物体,一次或少量 DrawCall 画很多份。
适合:
草、树、石头、子弹、金币、大量重复道具。
条件:
Mesh 相同;
Material 相同;
Shader 支持;
Enable GPU Instancing。
优化的是:
CPU 渲染提交成本。
不解决:
三角面太多;
像素填充太多;
Shader 太复杂;
后处理太重。SRP Batcher 是什么?
一句话回答:SRP Batcher 是 Unity 在 URP / HDRP / 自定义 SRP 里的 CPU 渲染优化机制。它不是把物体合成一个,也不一定减少 DrawCall 数量,而是让材质数据尽量常驻 GPU,减少 CPU 为每个材质准备和绑定数据的成本。
零基础理解
普通渲染时,CPU 每画一批物体,经常要做这些事:
切 Shader
切材质
收集材质参数
把材质数据传给 GPU
提交绘制命令如果场景里材质很多,CPU 会很忙。
SRP Batcher 的思路是:
同一个 Shader Variant 下的材质数据,尽量放在 GPU 里长期保留。
下一次画同类 Shader 的物体时,CPU 不用重复准备那么多材质数据。所以它优化的是:
CPU Render Thread
CPU 渲染提交成本
材质状态切换成本不是优化:
三角面数量
像素填充
Overdraw
后处理
阴影本身它和 DrawCall 的关系
很多人会误会:
开了 SRP Batcher,DrawCall 就会变少。不一定。
更准确是:
DrawCall 数量可能还差不多,
但每个 DrawCall 的 CPU 准备成本变低了。所以你可能看到:
Batches 没明显减少
但 Render Thread 时间下降
CPU 渲染提交更便宜这就是 SRP Batcher 的价值。
它和 Static / Dynamic Batching 的区别
Static Batching:
静态物体提前合批。
用内存换更少提交。Dynamic Batching:
小动态物体每帧 CPU 合批。
用 CPU 顶点处理换 DrawCall。GPU Instancing:
同 Mesh + 同 Material 的大量重复物体。
一次画很多份。SRP Batcher:
不合并 Mesh。
不要求 Mesh 一样。
重点是同 Shader Variant 下更快切换和提交材质数据。一句话记:
Batching 管“怎么把物体合在一起”。
Instancing 管“同一个物体画很多份”。
SRP Batcher 管“材质数据怎么更便宜地提交”。什么时候收益明显
适合:
URP / HDRP 项目
场景里有很多 Renderer
材质很多,但 Shader Variant 相对统一
Render Thread 压力高
DrawCall 不少,但 CPU 提交成本明显比如:
大量建筑使用同一个 Lit Shader,但材质颜色和贴图不同。它们不是同一个 Material,所以 GPU Instancing 不一定适合。 但如果 Shader Variant 一致,SRP Batcher 可能很有帮助。
兼容条件
SRP Batcher 有几个重要条件:
c
必须使用 SRP:
URP / HDRP / 自定义 SRP。
Built-in Render Pipeline 不支持。
Renderer 类型:
通常支持 MeshRenderer 和 SkinnedMeshRenderer。
Shader 要兼容:
材质属性要放在 UnityPerMaterial CBUFFER。
引擎每物体属性要放在 UnityPerDraw CBUFFER。
不要乱用 MaterialPropertyBlock:
MaterialPropertyBlock 会让 Renderer 不兼容 SRP Batcher。官方文档里也强调:SRP Batcher 是 SRP 的特性,目标是减少 CPU 设置材质属性和 DrawCall 的时间;Shader 需要按指定常量缓冲区组织属性才兼容。
Shader 兼容的大概意思
你不需要现在就完全会写 Shader,但面试可以说出这个概念。
兼容 SRP Batcher 的 Shader 需要类似这种结构:
c
CBUFFER_START(UnityPerMaterial)
float4 _BaseColor;
float _Smoothness;
CBUFFER_END意思是:
c
材质相关属性统一放在 UnityPerMaterial。Unity 每个物体的变换矩阵等数据放在:
c
UnityPerDraw这样 SRP Batcher 才能高效管理。
为什么 Shader Variant 很重要
SRP Batcher 喜欢:
c
同一个 Shader Variant 连续画很多个物体如果你的 Shader Keyword 太多:
c
_FOG_ON
_NORMALMAP_ON
_EMISSION_ON
_CLIP_ON每种组合都可能变成不同 Variant。 Variant 一多,Batch 就容易被切碎。
所以优化时不只是减少材质,还要关注:
c
Shader Keyword 数量
Shader Variant 数量
材质是否走同一 Variant怎么打开
URP 项目里通常在:
c
Universal Render Pipeline Asset
-> Advanced
-> SRP Batcher开启。
也可以运行时设置:
c
using UnityEngine;
using UnityEngine.Rendering;
public class SRPBatcherSwitch : MonoBehaviour
{
private void Start()
{
// 开启 SRP Batcher
// 注意:前提是当前渲染管线支持 SRP Batcher
GraphicsSettings.useScriptableRenderPipelineBatching = true;
Debug.Log("SRP Batcher Enabled: " +
GraphicsSettings.useScriptableRenderPipelineBatching);
}
}怎么验证是否生效
看这些工具:
c
Shader Inspector:
看 Shader 是否 SRP Batcher compatible。
Frame Debugger:
看 SRP Batch 是否变长。
看每个 SRP Batch 里有多少 Draw。
Profiler:
看 Render Thread 是否下降。
看 CPU Usage 里的渲染提交是否改善。如果你只看:
c
DrawCall 数没怎么变不要马上说没效果。 要看:
c
CPU Render Thread 时间是否下降
SetPass / 状态切换成本是否降低SRP Batcher 和 GPU Instancing 怎么选
如果是:
1000 个同 Mesh、同 Material 的草或石头优先看:
c
GPU Instancing如果是:
很多物体 Mesh 不同、Material 不同,但用同一个 Shader Variant优先看:
c
SRP Batcher它们有时可以共存,但思路不同。 面试里说清这点很加分。
常见坑
c
1. 以为 SRP Batcher 会减少 DrawCall。
不一定,它主要降低 CPU 提交成本。
2. Built-in 管线里谈 SRP Batcher。
Built-in 不支持,它是 SRP 的机制。
3. Shader 不兼容。
自定义 Shader 没按 UnityPerMaterial / UnityPerDraw 组织。
4. 大量使用 MaterialPropertyBlock。
可能导致 Renderer 不走 SRP Batcher。
5. Shader Keyword 太多。
Variant 太碎,Batch 连续性变差。
6. GPU 已经是瓶颈。
如果慢在像素、后处理、阴影,SRP Batcher 帮助有限。面试高分回答
NOTE
“SRP Batcher 是 URP/HDRP 这类 Scriptable Render Pipeline 下的 CPU 渲染优化机制。它不是传统合批,也不一定减少 DrawCall 数量,而是通过把兼容 Shader 的材质数据组织到固定的 Constant Buffer 中,让材质数据尽量常驻 GPU,减少 CPU 每次 DrawCall 前设置材质属性和绑定数据的成本。它适合很多 Renderer 使用相同 Shader Variant 但不同 Material 的场景,主要优化 Render Thread。要生效需要使用 SRP,Shader 需要兼容 SRP Batcher,比如材质属性放在 UnityPerMaterial,物体属性放在 UnityPerDraw;同时要注意 MaterialPropertyBlock、Shader Keyword 和 Variant 数量可能破坏或削弱效果。”
最短记忆版
c
SRP Batcher:
SRP 下的 CPU 渲染提交优化。
优化什么:
减少材质数据绑定和 DrawCall 准备成本。
不是什么:
不是 Static Batching;
不是 Dynamic Batching;
不是 GPU Instancing;
不一定减少 DrawCall。
适合:
很多不同 Material,
但用相同 Shader Variant 的 Renderer。
条件:
URP/HDRP;
Shader 兼容;
少用 MaterialPropertyBlock;
控制 Shader Variant。
看效果:
Frame Debugger 看 SRP Batch;
Profiler 看 Render Thread。参考:Unity 官方文档 SRP Batcher。
对象池怎么设计?
一句话回答: 对象池就是把“频繁 Instantiate / Destroy 的对象”提前创建好,使用时从池里拿,用完不销毁而是重置状态后放回池里。它的核心是:预热、取出初始化、回收重置、容量限制、防重复回收。
零基础理解
比如子弹、爆炸特效、伤害数字,如果每次都:
Instantiate -> 用一下 -> Destroy会带来:
CPU 创建销毁开销
内存分配
GC 压力
战斗中突然卡一下对象池改成:
提前创建 50 个子弹
发射时拿一个
打中后隐藏并放回池
下次继续用同一个对象一个可用的 GameObject 对象池
c
using System.Collections.Generic;
using UnityEngine;
public interface IPoolable
{
// 从池里取出时调用,用来初始化状态
void OnSpawn();
// 回收到池里时调用,用来清理状态
void OnDespawn();
}
public class GameObjectPool
{
private readonly GameObject prefab;
private readonly Transform poolRoot;
private readonly int maxSize;
private readonly Queue<GameObject> inactiveObjects = new();
private readonly HashSet<GameObject> inPoolSet = new();
private int totalCount;
public GameObjectPool(GameObject prefab, int initialSize, int maxSize, Transform poolRoot)
{
this.prefab = prefab;
this.maxSize = maxSize;
this.poolRoot = poolRoot;
// 预热:提前创建一批对象,避免战斗中突然 Instantiate
for (int i = 0; i < initialSize; i++)
{
GameObject obj = CreateNewObject();
Release(obj);
}
}
public GameObject Get(Vector3 position, Quaternion rotation, Transform parent = null)
{
GameObject obj;
if (inactiveObjects.Count > 0)
{
obj = inactiveObjects.Dequeue();
inPoolSet.Remove(obj);
}
else
{
// 池里不够时扩容,但不能无限增长
if (totalCount >= maxSize)
{
Debug.LogWarning("对象池已达到最大容量:" + prefab.name);
return null;
}
obj = CreateNewObject();
}
obj.transform.SetParent(parent);
obj.transform.SetPositionAndRotation(position, rotation);
obj.SetActive(true);
// 通知对象重置为“刚出生”的状态
obj.GetComponent<IPoolable>()?.OnSpawn();
return obj;
}
public void Release(GameObject obj)
{
if (obj == null)
return;
// 防止同一个对象被重复回收两次
if (inPoolSet.Contains(obj))
{
Debug.LogWarning("对象被重复回收:" + obj.name);
return;
}
// 通知对象清理状态
obj.GetComponent<IPoolable>()?.OnDespawn();
obj.SetActive(false);
obj.transform.SetParent(poolRoot);
inactiveObjects.Enqueue(obj);
inPoolSet.Add(obj);
}
private GameObject CreateNewObject()
{
GameObject obj = Object.Instantiate(prefab, poolRoot);
obj.SetActive(false);
totalCount++;
return obj;
}
}子弹怎么接对象池
c
using UnityEngine;
public class Bullet : MonoBehaviour, IPoolable
{
private float speed;
private float lifeTime;
private float timer;
private GameObjectPool ownerPool;
public void Init(GameObjectPool pool, float speed, float lifeTime)
{
// 记录自己属于哪个池,方便命中或超时后回收
ownerPool = pool;
this.speed = speed;
this.lifeTime = lifeTime;
}
public void OnSpawn()
{
// 每次取出时重置计时器
timer = 0f;
}
public void OnDespawn()
{
// 回收时清理状态,避免下次拿出来还带着旧数据
speed = 0f;
lifeTime = 0f;
timer = 0f;
}
private void Update()
{
transform.position += transform.forward * speed * Time.deltaTime;
timer += Time.deltaTime;
// 超过生命周期后回收到池
if (timer >= lifeTime)
{
ownerPool.Release(gameObject);
}
}
private void OnTriggerEnter(Collider other)
{
// 命中目标后回收
ownerPool.Release(gameObject);
}
}对象池管理器
c
using System.Collections.Generic;
using UnityEngine;
public class PoolManager : MonoBehaviour
{
[SerializeField] private Transform poolRoot;
private readonly Dictionary<string, GameObjectPool> pools = new();
public void CreatePool(string key, GameObject prefab, int initialSize, int maxSize)
{
if (pools.ContainsKey(key))
return;
pools[key] = new GameObjectPool(prefab, initialSize, maxSize, poolRoot);
}
public GameObject Spawn(string key, Vector3 position, Quaternion rotation)
{
if (!pools.TryGetValue(key, out GameObjectPool pool))
{
Debug.LogError("找不到对象池:" + key);
return null;
}
return pool.Get(position, rotation);
}
public void Despawn(string key, GameObject obj)
{
if (pools.TryGetValue(key, out GameObjectPool pool))
{
pool.Release(obj);
}
}
}设计时要注意什么
对象池最重要的不是“能复用”,而是“复用时不能带脏状态”。
回收时要清理:
c
速度
目标
血量
计时器
协程
事件订阅
粒子播放状态
Animator 状态
Collider 状态
Rigidbody velocity比如 Rigidbody 要这样清:
c
public void OnDespawn()
{
Rigidbody rb = GetComponent<Rigidbody>();
if (rb != null)
{
// 清掉上一次飞行留下的速度
rb.linearVelocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
}
}如果你的 Unity 版本还在用旧属性,可以写:
c
rb.velocity = Vector3.zero;什么时候不适合池化
不适合:
很少创建的对象
超大对象长期不用
状态极复杂且重置成本高
生命周期跟场景强绑定的对象因为对象池会让对象常驻内存。 如果你把所有东西都池化,内存可能下不来。
面试高分回答
TIP
“对象池主要用于频繁创建和销毁的对象,比如子弹、特效、伤害数字、掉落物和 ScrollView Item。设计上我会有预热数量、最大容量、空闲队列和防重复回收机制。取出对象时设置父节点、位置、旋转并调用 OnSpawn 初始化状态;回收时调用 OnDespawn 清理速度、计时器、目标、事件、协程、粒子和碰撞状态,然后隐藏并放回队列。如果池不够可以按策略扩容,但要有最大容量,避免无限增长。对象池不是越多越好,低频大对象长期池化会占内存。”
最短记忆版
c
对象池设计:
预热:
提前创建一批。
Get:
从空闲队列取,设置位置和数据,OnSpawn。
Release:
OnDespawn 清状态,隐藏,放回队列。
必须有:
最大容量;
防重复回收;
状态重置;
必要时扩容;
场景切换清理。
适合:
子弹、特效、伤害数字、掉落物、UI Item。如何减少 GC Alloc?
一句话回答: 减少 GC Alloc 的核心不是频繁 GC.Collect(),而是减少托管内存分配源头:不要在 Update、战斗、UI 刷新、碰撞检测这些高频路径里反复 new、拼字符串、用 LINQ、产生闭包、装箱或返回新数组。
零基础理解
GC Alloc 表示这段代码产生了新的托管内存分配。
比如:
new 一个对象
创建一个字符串
创建一个数组
LINQ 生成迭代器
闭包生成临时对象
装箱生成 object分配多了以后,GC 迟早要回收。 GC 回收时可能让游戏卡一下,所以 Unity 里我们希望:
高频逻辑尽量 0 GC Alloc
尤其是 Update、战斗、UI 滚动、技能、子弹、怪物 AI。先用 Profiler 找来源
不要凭感觉优化。
Profiler -> CPU Usage
选中卡顿帧
看 Hierarchy
打开 GC Alloc 列
找到是哪一个函数在分配重点看:
每帧都分配的小 GC
战斗爆发时的大 GC
打开 UI 时的大量分配
ScrollView 滚动时的分配减少字符串分配
坏例子:
c
using UnityEngine;
using UnityEngine.UI;
public class BadHpText : MonoBehaviour
{
[SerializeField] private Text hpText;
private int hp;
private void Update()
{
// 每帧字符串拼接,hp 没变也会生成新字符串
hpText.text = "HP: " + hp;
}
}更好:
c
using UnityEngine;
using UnityEngine.UI;
public class BetterHpText : MonoBehaviour
{
[SerializeField] private Text hpText;
private int hp;
private int lastHp = -1;
private void Update()
{
// 只有 hp 变化时才刷新文本
// 既减少字符串分配,也减少 UI 重建
if (hp != lastHp)
{
lastHp = hp;
hpText.text = "HP: " + hp;
}
}
}如果用 TextMeshPro,可以用:
c
using TMPro;
using UnityEngine;
public class TMPHpText : MonoBehaviour
{
[SerializeField] private TMP_Text hpText;
public void SetHp(int hp)
{
// TMP 的 SetText 有一些重载可以减少格式化字符串带来的分配
hpText.SetText("HP: {0}", hp);
}
}复用 List / Dictionary / 数组
坏例子:
c
using System.Collections.Generic;
using UnityEngine;
public class BadEnemyFinder : MonoBehaviour
{
private void Update()
{
// 每帧 new List,会产生 GC Alloc
List<Enemy> enemies = new List<Enemy>();
// 假装这里收集敌人
}
}更好:
c
using System.Collections.Generic;
using UnityEngine;
public class BetterEnemyFinder : MonoBehaviour
{
// 提前创建,后续复用
private readonly List<Enemy> enemies = new List<Enemy>(128);
private void Update()
{
// 清空内容,但复用内部数组
// Clear 不会把 List 的容量释放掉
enemies.Clear();
// 继续往这个 List 里填数据
CollectEnemies(enemies);
}
private void CollectEnemies(List<Enemy> result)
{
// 把找到的敌人加入 result
}
}关键记忆:
c
new List:分配新对象。
list.Clear:复用旧对象。使用 NonAlloc API
很多 Unity API 有“返回数组”的版本,也有“不分配”的版本。
容易分配:
c
Collider[] hits = Physics.OverlapSphere(transform.position, 5f);更好:
c
using UnityEngine;
public class NonAllocPhysicsExample : MonoBehaviour
{
// 提前准备缓冲区
// 数量要按实际峰值估算,太小会丢结果
private readonly Collider[] hitBuffer = new Collider[32];
private void Update()
{
// NonAlloc 版本不会每次返回新数组
int count = Physics.OverlapSphereNonAlloc(
transform.position,
5f,
hitBuffer
);
for (int i = 0; i < count; i++)
{
Collider hit = hitBuffer[i];
// 处理命中的对象
}
}
}常见 NonAlloc 思路:
c
Physics.RaycastNonAlloc
Physics.OverlapSphereNonAlloc
Physics.OverlapBoxNonAlloc
GetComponents(List<T>)避免 LINQ 出现在热路径
坏例子:
c
using System.Linq;
using UnityEngine;
public class BadLinqExample : MonoBehaviour
{
[SerializeField] private Enemy[] enemies;
private void Update()
{
// LINQ 可读性高,但在热路径里可能产生迭代器和临时分配
Enemy target = enemies
.Where(e => e.IsAlive)
.OrderBy(e => e.DistanceToPlayer)
.FirstOrDefault();
}
}更适合热路径:
c
using UnityEngine;
public class BetterLinqExample : MonoBehaviour
{
[SerializeField] private Enemy[] enemies;
private void Update()
{
Enemy best = null;
float bestDistance = float.MaxValue;
for (int i = 0; i < enemies.Length; i++)
{
Enemy enemy = enemies[i];
if (!enemy.IsAlive)
continue;
float distance = enemy.DistanceToPlayer;
if (distance < bestDistance)
{
bestDistance = distance;
best = enemy;
}
}
// best 就是最近的存活敌人
}
}不是说 LINQ 永远不能用。 是说:
初始化、编辑器工具、低频逻辑可以用;
Update、战斗、每帧 UI 刷新里要谨慎。避免闭包分配
坏例子:
c
using UnityEngine;
using UnityEngine.UI;
public class BadClosureExample : MonoBehaviour
{
[SerializeField] private Button[] buttons;
private void Start()
{
for (int i = 0; i < buttons.Length; i++)
{
int index = i;
// lambda 捕获 index,可能产生闭包对象
buttons[i].onClick.AddListener(() =>
{
OnClickButton(index);
});
}
}
private void OnClickButton(int index)
{
Debug.Log(index);
}
}这种在 Start 里低频执行问题不大。 但如果你在列表刷新里频繁创建按钮监听,就可能产生很多分配。
更好的做法是复用 Item 脚本,让 Item 自己保存 index:
c
using UnityEngine;
using UnityEngine.UI;
public class ButtonItem : MonoBehaviour
{
[SerializeField] private Button button;
private int index;
private System.Action<int> onClick;
private void Awake()
{
// 监听只绑定一次
button.onClick.AddListener(HandleClick);
}
public void Init(int index, System.Action<int> onClick)
{
// 刷新时只更新数据
this.index = index;
this.onClick = onClick;
}
private void HandleClick()
{
onClick?.Invoke(index);
}
private void OnDestroy()
{
button.onClick.RemoveListener(HandleClick);
}
}避免装箱 Boxing
装箱就是值类型被当成 object 使用时,可能产生分配。
坏例子:
c
using UnityEngine;
public class BoxingExample : MonoBehaviour
{
private void LogValue(int value)
{
// int 是值类型,传给 object 会发生装箱
object boxed = value;
Debug.Log(boxed);
}
}常见装箱来源:
c
object 参数
非泛型集合 ArrayList / Hashtable
把 int / enum / struct 当 object
某些接口调用
string.Format 某些用法尽量用:
c
泛型集合 List<int>
Dictionary<int, T>
强类型方法
避免热路径 object 参数协程等待对象可以缓存
坏例子:
c
using System.Collections;
using UnityEngine;
public class BadCoroutineWait : MonoBehaviour
{
private IEnumerator FireLoop()
{
while (true)
{
Fire();
// 每次循环 new 一个 WaitForSeconds
yield return new WaitForSeconds(0.2f);
}
}
private void Fire()
{
}
}更好:
c
using System.Collections;
using UnityEngine;
public class BetterCoroutineWait : MonoBehaviour
{
// 固定等待时间可以缓存
private static readonly WaitForSeconds FireInterval =
new WaitForSeconds(0.2f);
private IEnumerator FireLoop()
{
while (true)
{
Fire();
// 复用等待对象
yield return FireInterval;
}
}
private void Fire()
{
}
}注意:
固定时长可以缓存;
不同时间参数不要乱共用;
依赖 Time.timeScale 的逻辑要确认行为符合预期。对象池减少 Instantiate / Destroy
适合池化:
子弹
特效
伤害数字
掉落物
怪物
UI Item
飘字核心是:
提前创建
用时取出
用完隐藏
清状态后放回对象池能减少:
Instantiate 分配
Destroy 后等待回收
频繁创建组件对象
战斗中 GC 抖动foreach 会不会 GC
这个面试也常追问。
更准确地说:
foreach 不一定产生 GC。一般情况下:
foreach 遍历数组:通常不分配。
foreach 遍历 List<T>:现代 Unity/C# 下通常不分配。
foreach 遍历 IEnumerable<T> 接口:可能装箱或产生迭代器分配。
foreach 遍历某些 Unity API 返回集合:要具体看实现。热路径里如果不确定,用 for 更稳:
c
for (int i = 0; i < list.Count; i++)
{
var item = list[i];
}不要把 GC.Collect 当优化
c
System.GC.Collect();它只是强制 GC 回收。 它不能减少分配源头,甚至可能造成明显卡顿。
正确思路是:
c
先减少 GC Alloc;
再考虑在 Loading 阶段集中清理;
不要在战斗中频繁 GC.Collect。面试高分回答
WARNING
“减少 GC Alloc 首先要用 Profiler 找分配源,重点看 CPU Usage 的 GC Alloc 列,而不是凭感觉优化。高频路径里要避免反复 new 对象、new List、字符串拼接、LINQ、闭包、装箱、返回新数组的 Unity API。容器要预分配并复用,比如 List 用 Clear,物理检测用 RaycastNonAlloc 或 OverlapSphereNonAlloc,频繁创建销毁的子弹、特效、伤害数字和 UI Item 用对象池。UI 文本不要每帧刷新,数值变化时再更新,TMP 可以用 SetText。协程里固定的 WaitForSeconds 可以缓存。GC.Collect 不能解决根本问题,只适合 Loading 阶段谨慎使用。最终要在目标设备上复测 GC Alloc 是否下降,以及卡顿尖峰是否消失。”
最短记忆版
c
减少 GC Alloc:
1. Profiler 找 GC Alloc 来源。
2. Update 里少 new。
3. 字符串变化时再刷新。
4. List / Dictionary / 数组复用。
5. 用 NonAlloc API。
6. 热路径少 LINQ、闭包、装箱。
7. 子弹、特效、UI Item 用对象池。
8. 固定 WaitForSeconds 可缓存。
9. 不要靠 GC.Collect 解决根因。
10. 改完复测。如何优化包体?
一句话回答: 优化包体不要上来就乱删资源,而是先看 Build Report / Editor.log / Addressables Analyze 找出大头,再按纹理、音频、模型、动画、Shader、代码、重复依赖、首包热更拆分逐项处理。
零基础理解
包体就是玩家下载安装包的大小。 包体太大,会导致:
下载慢
安装慢
玩家流失
商店限制
热更新压力大但包体也不是越小越好。 压太狠会出现:
贴图糊
音质差
模型变形
Shader 丢失
运行时报错所以正确思路是:
先分析谁最大,再优化谁。第一步:先看包体来源
常用工具:
c
Build Report:
看构建后资源占比。
Editor.log:
Unity 打包后会输出资源大小信息。
Addressables Analyze:
看重复依赖、Bundle 大小、Group 拆分问题。
Asset Hunter / Build Report Tool:
项目里也常用第三方工具分析未使用资源。面试里一定要说:
包体优化要数据驱动,不靠猜。优化纹理
纹理通常是包体大头。
常见优化:
限制 Max Size
选择平台压缩格式
关闭不需要的 Read/Write
关闭不需要的 Mipmap
UI 小图进 SpriteAtlas
删除未用贴图
高清资源改成热更或可选下载比如:
Android 常用 ETC2 / ASTC。
iOS 常用 ASTC。不是所有图都要 2048 或 4096。 很多 UI 图标可能 256 或 512 就够。
编辑器脚本:检查大贴图
c
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
public class TextureSizeChecker
{
[MenuItem("Tools/Check Large Textures")]
public static void CheckLargeTextures()
{
// 查找项目里所有 Texture2D
string[] guids = AssetDatabase.FindAssets("t:Texture2D");
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter;
if (importer == null)
continue;
// MaxTextureSize 比较大时输出提醒
if (importer.maxTextureSize >= 2048)
{
Debug.LogWarning(
$"发现大纹理:{path}, MaxSize={importer.maxTextureSize}"
);
}
}
}
}
#endif优化音频
音频也很容易大。
常见策略:
短音效:
可以用 Decompress On Load,播放快,但占内存。
中等音效:
Compressed In Memory。
BGM / 长语音:
Streaming,避免一次性进内存。
多语言语音:
不要全放首包,按语言或地区下载。
采样率:
没必要的音频可以降低采样率。
声道:
不需要立体声的音效可以转单声道。比如:
按钮点击音效不需要超高码率。
长 BGM 不适合全量解压进内存。
多语言语音不应该全部塞进首包。优化模型和动画
常见优化:
减少模型面数
删除无用 Mesh
压缩 Mesh
关闭不需要的 Rig
删除无用 Animation Clip
动画压缩
减少骨骼数量
角色模型按需下载对于角色:
高频主角资源可以预置或登录前下载。
低频皮肤、活动 Boss、特殊坐骑可以热更下载。优化 Shader
Shader Variant 可能非常大。
常见问题:
c
Shader Keyword 太多
Variant 没裁剪
Always Included Shaders 乱加
URP/HDRP 功能全开
项目没用的光照、阴影、雾效变体也被打包优化方向:
c
裁剪不用的 Shader Variant
减少 Keyword 组合
清理 Always Included Shaders
按项目实际关闭不用的渲染特性
使用 Shader Variant Collection 管理必要变体注意:Shader 裁剪要小心。 裁太狠可能导致:
真机上材质变粉
某些特效不显示
某些平台 Shader 缺失优化代码和程序集
常见设置:
c
IL2CPP
Managed Stripping Level
Strip Engine Code
删除不用的 Package
删除不用的插件和 SDK
按平台剔除无关库但要注意:
反射
序列化
热更
Lua / HybridCLR
插件回调这些可能依赖看似“没被直接引用”的类型。 如果开启较高 stripping,要配 link.xml 保留必要类型。
示例:
c
<linker>
<assembly fullname="Assembly-CSharp">
<type fullname="MyGame.HotUpdateEntry" preserve="all"/>
</assembly>
</linker>意思是:
这个类型不要被裁掉。减少 Resources 滥用
Resources 文件夹里的资源容易进包。 大型项目不建议把大量资源放 Resources。
尤其这些不适合乱放 Resources:
大 UI
高清图
角色模型
活动资源
语音
地图
不一定使用的 Prefab更合理的是:
首包必需资源放内置。
非必需资源走 Addressables / AssetBundle。处理重复依赖
重复依赖会让包体变大。
比如:
c
背包用 coin.png
商城用 coin.png
任务用 coin.png如果没有抽公共包,可能多个 Bundle 各打一份。
解决:
c
公共图标 -> common_ui
公共字体 -> common_font
公共 Shader -> common_shader
公共材质 -> common_materialAddressables 里可以用 Analyze 查重复 Bundle 依赖。
首包和热更包拆分
首包放:
启动场景
Loading
登录
更新器
基础字体
基础 Shader
错误弹窗
重试界面
首体验必需资源热更包放:
活动资源
语音包
高清资源
后续地图
副本
皮肤
低频 UI
大角色资源
节日资源目标是:
首包小而稳定;
热更包灵活可更新;
公共依赖不重复;
大资源按需下载。面试高分回答
IMPORTANT
“包体优化我会先从 Build Report、Editor.log 或 Addressables Analyze 入手,确认包体主要来自哪些资源,而不是盲目删。通常大头是纹理、音频、模型动画和 Shader Variant。纹理方面会控制 Max Size、平台压缩格式、Read/Write、Mipmap 和图集;音频方面根据短音效、BGM、语音选择不同加载和压缩方式;模型动画会裁剪无用 Clip、压缩 Mesh 和 Animation;Shader 会裁剪无用 Variant,清理 Always Included;代码层面会删除不用 Package、插件和 SDK,谨慎开启 Managed Stripping 并配置 link.xml。资源组织上减少 Resources 滥用,公共依赖抽 common 包,活动、大语音、高清资源和低频玩法拆到热更包,首包只保启动、登录、更新器和首体验。”
最短记忆版
c
包体优化:
先分析:
Build Report / Editor.log / Addressables Analyze。
大头:
纹理、音频、模型、动画、Shader。
纹理:
Max Size、压缩格式、Read/Write、Mipmap、图集。
音频:
BGM Streaming,短音效按需解压,多语言按需下载。
模型动画:
降面数、删 Clip、压缩 Mesh/Animation。
Shader:
裁 Variant,少 Keyword,清 Always Included。
代码:
删无用 Package/SDK,Stripping + link.xml。
资源:
少用 Resources,大资源热更,公共依赖抽 common。如何优化加载时间?
一句话理解: 优化加载时间不是单纯“让 LoadScene 更快”,而是把加载拆成多个阶段:下载、解压、读取、反序列化、实例化、初始化、首次渲染,然后分别优化。
加载慢通常慢在哪里?
Unity 里的“加载时间”一般不是一个动作,而是一串动作:
下载资源
-> 解压资源包
-> 从磁盘读取
-> 反序列化成 Unity 对象
-> Instantiate 实例化
-> Awake / OnEnable / Start 初始化
-> Shader 编译 / 贴图上传 GPU
-> 第一帧显示所以面试时不要只说“异步加载”。更好的回答是: 我会先用 Profiler 和阶段计时定位瓶颈,再针对下载、IO、实例化、初始化、Shader 首帧卡顿分别优化。
第一步:先测量,不要猜
很多新手会直接说“用异步加载”,但面试官更喜欢听到: 先确认到底慢在哪里。
可以用:
c
using System.Diagnostics;
using UnityEngine;
using UnityEngine.Profiling;
public class LoadProfilerExample : MonoBehaviour
{
// ProfilerMarker 可以在 Unity Profiler 里显示自定义耗时区间
private static readonly ProfilerMarker LoadMarker =
new ProfilerMarker("Custom.LoadLevel");
void Start()
{
Stopwatch watch = Stopwatch.StartNew();
using (LoadMarker.Auto())
{
// 这里模拟你的加载逻辑
// 比如加载配置、加载资源、创建对象等
LoadConfig();
LoadResources();
CreateObjects();
}
watch.Stop();
UnityEngine.Debug.Log($"加载总耗时: {watch.ElapsedMilliseconds} ms");
}
void LoadConfig()
{
// 加载表格、Json、本地配置等
}
void LoadResources()
{
// 加载 Addressables、AssetBundle、贴图、音效等
}
void CreateObjects()
{
// Instantiate 角色、怪物、UI、特效等
}
}这段代码的意义不是“优化”,而是先把问题看清楚。 如果你连慢在哪里都不知道,优化很容易变成玄学。
减少加载量,是最有效的优化
加载越少,才越快。 这句话很朴素,但非常重要。
常见做法:
1. 首包只放必须资源
2. 非首日内容放热更包
3. 大场景拆分成多个区域
4. UI、角色、技能按模块加载
5. 清理重复依赖
6. 不要把大量资源塞进 Resources
7. 降低贴图、音频、模型、动画体积比如登录界面只需要:
登录 UI
背景图
按钮音效
基础字体
网络配置不应该一上来加载:
所有角色
所有地图
所有技能
所有怪物
所有剧情语音异步加载场景
Unity 里常用 LoadSceneAsync 异步加载场景:
c
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class SceneLoader : MonoBehaviour
{
public Slider progressSlider;
public IEnumerator LoadSceneAsync(string sceneName)
{
// 开始异步加载场景
AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName);
// 先不让场景自动切换,方便控制进度条和过渡动画
operation.allowSceneActivation = false;
while (!operation.isDone)
{
// Unity 的场景加载 progress 通常最多到 0.9
float progress = Mathf.Clamp01(operation.progress / 0.9f);
if (progressSlider != null)
{
progressSlider.value = progress;
}
// 加载到 90% 后,说明资源基本加载完成
if (operation.progress >= 0.9f)
{
// 这里可以等待淡出动画、点击继续、或预热完成
yield return new WaitForSeconds(0.5f);
// 允许场景正式激活
operation.allowSceneActivation = true;
}
yield return null;
}
}
}面试注意点: LoadSceneAsync 不等于完全不卡。它能把加载过程拆到多帧里,但场景激活、对象初始化、Shader 首帧仍然可能卡。
Addressables 预下载资源
如果用 Addressables,可以提前下载某个 Label 的依赖资源:
c
using System.Collections;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class PreDownloadExample : MonoBehaviour
{
public IEnumerator DownloadBattleResources()
{
// 例如把战斗必须资源都标记成 Battle Label
string label = "Battle";
// 提前下载 Battle 标签下的所有依赖
AsyncOperationHandle downloadHandle =
Addressables.DownloadDependenciesAsync(label);
while (!downloadHandle.IsDone)
{
// 下载进度,0 到 1
float progress = downloadHandle.PercentComplete;
Debug.Log($"战斗资源下载进度: {progress:P0}");
yield return null;
}
if (downloadHandle.Status == AsyncOperationStatus.Succeeded)
{
Debug.Log("战斗资源下载完成");
}
else
{
Debug.LogError("战斗资源下载失败,需要提示玩家或重试");
}
// 下载句柄用完后释放
Addressables.Release(downloadHandle);
}
}这种方式适合:
进入战斗前预下载角色、怪物、技能、音效
进入主城前预下载主城 UI、NPC、场景块
进入副本前预下载副本资源分帧实例化,避免一帧卡死
很多时候不是“加载资源”慢,而是你一帧里创建了太多对象。
比如:
c
for (int i = 0; i < 1000; i++)
{
Instantiate(enemyPrefab);
}这很容易让一帧爆炸。
更好的方式是分帧创建:
c
using System.Collections;
using UnityEngine;
public class SpawnOptimizer : MonoBehaviour
{
public GameObject enemyPrefab;
public IEnumerator SpawnEnemiesInFrames(int count)
{
for (int i = 0; i < count; i++)
{
// 创建一个敌人
Instantiate(enemyPrefab);
// 每创建 10 个,让出一帧
// 这样不会把所有压力堆在同一帧
if (i % 10 == 0)
{
yield return null;
}
}
}
}更进一步,可以配合对象池:
c
using System.Collections.Generic;
using UnityEngine;
public class SimplePool : MonoBehaviour
{
public GameObject prefab;
private readonly Queue<GameObject> pool = new Queue<GameObject>();
public void Prewarm(int count)
{
for (int i = 0; i < count; i++)
{
// 提前创建对象,避免战斗中突然 Instantiate
GameObject obj = Instantiate(prefab);
// 先隐藏,等需要时再取出来
obj.SetActive(false);
pool.Enqueue(obj);
}
}
public GameObject Get()
{
if (pool.Count > 0)
{
GameObject obj = pool.Dequeue();
obj.SetActive(true);
return obj;
}
// 池子不够时再临时创建
return Instantiate(prefab);
}
public void Release(GameObject obj)
{
obj.SetActive(false);
pool.Enqueue(obj);
}
}压缩格式也会影响加载时间
AssetBundle 常见压缩方式:
LZMA:
包体小,但是加载时需要整体解压,加载可能慢。
LZ4:
包体稍大,但支持分块读取,运行时加载更友好。
不压缩:
读取快,但包体大,下载压力大。游戏里通常更偏向使用 LZ4,因为它对运行时加载更友好。 LZMA 更适合下载包,下载后可以再转换成本地 LZ4 缓存。
首次渲染卡顿也要处理
有些资源已经加载完了,但第一次显示还是卡。 原因可能是:
Shader Variant 首次编译
贴图上传 GPU
网格上传 GPU
材质初始化
粒子系统首次播放常见优化:
1. Loading 阶段预热 Shader
2. 使用 ShaderVariantCollection
3. 重要特效提前播放一次再隐藏
4. 关键角色提前 Instantiate 到对象池
5. 第一帧不要同时显示太多新材质资源导入层面的优化
加载时间和资源本身大小强相关。
可以检查:
c
贴图:
降低 Max Size,使用合适压缩格式,关闭不必要 Read/Write。
音频:
背景音乐用 Streaming,短音效用 Decompress On Load 或 Compressed In Memory。
模型:
压缩 Mesh,关闭不必要的 Rig、BlendShape、Read/Write。
动画:
压缩 Animation Clip,删除无用曲线。
字体:
不要一次加载超大字符集,注意动态字体和图集大小。常见坑
1. 用 Resources.Load 同步加载大资源
2. 一进入场景就在 Awake 里做大量初始化
3. 一帧 Instantiate 太多对象
4. 场景里直接拖了大量重资源引用
5. 加载条是假进度,最后 10% 卡很久
6. Addressables Label 设计太粗,导致一次加载过多
7. 没清理重复依赖,多个 Bundle 里重复打包同一张贴图
8. 没有处理下载失败、校验失败、磁盘空间不足
9. Shader 没预热,进战斗第一下技能卡顿面试高分回答
IMPORTANT
我会先把加载时间拆成下载、解压、磁盘 IO、反序列化、实例化、初始化和首次渲染几个阶段。
优化时不会只说异步加载,而是先用 Profiler、自定义 Marker 和日志统计定位瓶颈。
如果是资源量太大,就拆首包和热更包,按模块、场景、Label 管理资源,并清理重复依赖。
如果是加载过程卡主线程,就使用异步加载、预下载、预加载和分帧实例化。
如果是进场后卡顿,就检查 Awake/Start 是否过重,对象池预热,Shader 预热,把非关键 UI、特效、远处场景延迟加载。
对于大场景,我会用 Additive Scene 或 Chunk 分块加载,只加载玩家附近区域。
最终目标不是让加载条看起来快,而是减少真实等待时间,并把不可避免的耗时放到合适的时机。
最短记忆版
c
优化加载时间 = 先测量,再减少加载量,再异步加载,再预下载预热,再分帧初始化,最后延迟非关键内容。如何优化移动端发热?
一句话理解: 移动端发热的本质是“持续高功耗”。CPU、GPU、网络、磁盘 IO、解压、屏幕亮度都会耗电,电能最后大部分都会变成热。所以优化发热不是只做一个点,而是让手机不要长期满负载。
零基础理解
手机像一个小盒子,散热能力很弱。 如果游戏一直让 CPU 算、GPU 画、网络下载、资源解压,手机就会一直耗电,一直耗电就会发热。
所以移动端优化发热的核心不是“让某一帧特别快”,而是:
降低平均负载
避免持续满载
热了以后主动降级
让性能稳定,而不是前几分钟很爽,后面疯狂降频第一步:先定位到底是谁在发热
不要一上来就改代码。先看:
c
CPU 高:逻辑、Update、AI、寻路、物理、GC、Lua/C# 调用太频繁
GPU 高:分辨率、后处理、阴影、Overdraw、粒子、复杂 Shader
内存/GC 高:频繁 new、字符串拼接、LINQ、装箱、Instantiate/Destroy
IO 高:频繁读文件、解压 AssetBundle、加载大资源
网络高:频繁请求、后台下载、热更包太大Unity 里常用:
Unity Profiler:看 CPU、GC、Rendering
Frame Debugger:看 DrawCall、渲染流程
Memory Profiler:看内存和资源占用
真机测试:一定要在真机上看,编辑器不准降低帧率,是移动端最直接的降温手段
如果游戏不是强竞技动作类,没必要一直硬撑 60 FPS。 30 FPS、45 FPS 有时会让手机温度稳定很多。
c
using UnityEngine;
public class MobileFrameRateController : MonoBehaviour
{
void Awake()
{
// 移动端通常关闭垂直同步,改用 targetFrameRate 控制帧率
QualitySettings.vSyncCount = 0;
// 普通界面、主城、挂机场景可以用 30
Application.targetFrameRate = 30;
}
public void EnterBattle()
{
// 战斗中如果需要更顺滑,可以临时提高
Application.targetFrameRate = 60;
}
public void ExitBattle()
{
// 离开高操作场景后降回来,减少持续发热
Application.targetFrameRate = 30;
}
}面试里可以说: 帧率越高,CPU 和 GPU 每秒工作次数越多,功耗越高。移动端要根据玩法场景动态设置帧率。
降低 GPU 压力
移动端发热很多时候是 GPU 长期满载。
常见优化:
1. 降低渲染分辨率
2. 减少后处理,比如 Bloom、景深、SSR
3. 降低阴影质量和阴影距离
4. 减少透明物体叠加,降低 Overdraw
5. 使用 LOD
6. 减少粒子数量
7. 简化 Shader
8. 合批、GPU Instancing、SRP Batcher可以做一个低功耗模式:
c
using UnityEngine;
public class LowPowerMode : MonoBehaviour
{
public void EnableLowPowerMode()
{
// 降低帧率,减少 CPU/GPU 每秒工作次数
Application.targetFrameRate = 30;
// 降低阴影距离,减少阴影渲染开销
QualitySettings.shadowDistance = 20f;
// 降低抗锯齿等级
QualitySettings.antiAliasing = 0;
// 降低纹理质量,数值越大越省资源,但画质越低
QualitySettings.globalTextureMipmapLimit = 1;
// 降低渲染比例,减少 GPU 像素填充压力
// 需要项目渲染管线支持,实际项目也常用 URP Render Scale
ScalableBufferManager.ResizeBuffers(0.8f, 0.8f);
}
public void DisableLowPowerMode()
{
Application.targetFrameRate = 60;
QualitySettings.shadowDistance = 50f;
QualitySettings.antiAliasing = 2;
QualitySettings.globalTextureMipmapLimit = 0;
ScalableBufferManager.ResizeBuffers(1f, 1f);
}
}降低 CPU 压力
CPU 热通常来自逻辑跑太多。
常见问题:
每个怪物都在 Update 里寻路
每个 UI 都在 Update 里刷新
频繁 GetComponent / Find
频繁排序、遍历大列表
频繁创建临时对象
Lua 和 C# 高频互调
物理检测太频繁更好的做法是“不要每帧都做”:
c
using UnityEngine;
public class EnemyAI : MonoBehaviour
{
private float checkTimer;
void Update()
{
checkTimer += Time.deltaTime;
// 不需要每帧检测玩家,0.2 秒检测一次就够了
if (checkTimer >= 0.2f)
{
checkTimer = 0f;
// 执行 AI 判断、距离检测、状态切换
CheckPlayer();
}
}
void CheckPlayer()
{
// 这里放真正的 AI 逻辑
}
}这个思想很重要: 能每 0.2 秒做的事情,不要每帧做。能事件驱动的事情,不要轮询。
减少 GC,避免 CPU 抖动和发热
GC 不只是卡顿,也会增加 CPU 压力。 移动端如果频繁 GC,会导致发热和掉帧。
容易产生 GC 的写法:
Update 里 new 对象
字符串频繁拼接
foreach 遍历某些非泛型集合
LINQ
装箱拆箱
频繁 Instantiate / Destroy
频繁注册匿名事件但不取消简单例子:
c
using System.Text;
using UnityEngine;
using UnityEngine.UI;
public class ScoreView : MonoBehaviour
{
public Text scoreText;
private readonly StringBuilder builder = new StringBuilder(32);
public void RefreshScore(int score)
{
// 复用 StringBuilder,减少字符串临时对象
builder.Clear();
builder.Append("Score: ");
builder.Append(score);
scoreText.text = builder.ToString();
}
}资源和加载也会导致发热
很多人以为只有战斗会热,其实后台下载、解压、加载也会热。
优化方式:
1. 热更包不要太大
2. 不要进游戏后立刻后台狂下载
3. 资源分批下载
4. 解压放在合适时机
5. 大资源异步加载
6. 不要一帧 Instantiate 太多对象
7. Loading 阶段做预热,进入战斗后少做突发工作动态降级是移动端高分点
真实项目里,不能只做固定画质。 手机温度升高后,应该自动降档。
可以按“性能档位”设计:
正常:
60 FPS,高画质,完整特效
温热:
45 FPS,降低部分后处理和粒子
过热:
30 FPS,关闭高耗特效,降低渲染比例
严重过热:
暂停后台下载,减少刷新频率,提示玩家设备温度过高示例逻辑:
c
using UnityEngine;
public enum ThermalLevel
{
Normal,
Warm,
Hot
}
public class ThermalQualityController : MonoBehaviour
{
public void ApplyThermalLevel(ThermalLevel level)
{
switch (level)
{
case ThermalLevel.Normal:
// 正常温度,保持较好体验
Application.targetFrameRate = 60;
QualitySettings.shadowDistance = 50f;
ScalableBufferManager.ResizeBuffers(1f, 1f);
break;
case ThermalLevel.Warm:
// 有点热,先温和降级
Application.targetFrameRate = 45;
QualitySettings.shadowDistance = 30f;
ScalableBufferManager.ResizeBuffers(0.9f, 0.9f);
break;
case ThermalLevel.Hot:
// 明显发热,优先保稳定
Application.targetFrameRate = 30;
QualitySettings.shadowDistance = 15f;
ScalableBufferManager.ResizeBuffers(0.75f, 0.75f);
break;
}
}
}注意:Unity C# 本身没有一个所有手机通用的“温度 API”。项目里通常会接平台原生接口,或者使用性能管理方案,再把温度/降频状态转换成自己的画质档位。
面试高分回答
可以这样答:
移动端发热本质是持续高功耗,所以我会先定位功耗来源,而不是盲目优化。
我会用 Unity Profiler、Frame Debugger、真机测试去判断是 CPU、GPU、GC、IO、网络还是加载导致的发热。
CPU 方面会减少 Update 轮询、分帧处理重逻辑、降低 AI/物理检测频率、减少 GC Alloc。
GPU 方面会降低分辨率、阴影、后处理、粒子和透明叠加,使用 LOD、合批、GPU Instancing、SRP Batcher。
资源方面会避免后台持续下载和频繁解压,大资源异步加载,战斗前预热,减少运行时突发 Instantiate。
移动端还要做动态降级,比如温度升高后降低帧率、渲染比例、特效、阴影,保证长时间稳定运行。
所以优化发热不是追求峰值帧率,而是控制平均负载和持续功耗。最短记忆版
c
移动端发热优化 = 找到热源 + 降 CPU + 降 GPU + 减 GC + 控下载加载 + 动态降级。如何优化大量怪物 AI?
一句话理解: 大量怪物 AI 优化的核心不是“让单个怪物 AI 写得多聪明”,而是不要让所有怪物每一帧都做完整思考。要把 AI 拆成分批更新、距离分层、感知限频、寻路限频、对象池和动画降级。
零基础理解
假设场景里有 500 只怪物。
错误写法是:
500 只怪物
每只怪物
每一帧
都判断玩家距离
都找路
都判断攻击
都播放动画
都做物理检测这就像 500 个人每秒问你 60 次:“玩家在哪?我该去哪?我能不能攻击?” CPU 会非常累,手机会发热,帧率会下降。
正确思路是:
近处怪物:反应快一点
远处怪物:反应慢一点
看不见的怪物:少更新,甚至休眠
所有怪物:不要同一帧一起思考第一点:不要每个怪物都写重 Update
很常见的错误:
c
using UnityEngine;
using UnityEngine.AI;
public class BadEnemyAI : MonoBehaviour
{
public Transform player;
public NavMeshAgent agent;
void Update()
{
// 错误点 1:每帧计算距离
float distance = Vector3.Distance(transform.position, player.position);
// 错误点 2:每帧重新设置寻路目标
agent.SetDestination(player.position);
// 错误点 3:每帧判断攻击
if (distance < 2f)
{
Attack();
}
}
void Attack()
{
// 攻击逻辑
}
}单个怪物这样写问题不大。 但 300 个、500 个怪物一起这样跑,就会非常重。
第二点:用 AI 管理器统一调度
不要让每个怪物自己每帧疯狂 Update。 可以让一个 AIManager 统一管理,每帧只更新一部分怪物。
c
using System.Collections.Generic;
using UnityEngine;
public class AIManager : MonoBehaviour
{
private readonly List<EnemyAI> enemies = new List<EnemyAI>();
// 每帧最多更新多少个 AI
public int maxTickPerFrame = 20;
private int currentIndex;
public void Register(EnemyAI enemy)
{
if (!enemies.Contains(enemy))
{
enemies.Add(enemy);
}
}
public void Unregister(EnemyAI enemy)
{
enemies.Remove(enemy);
}
void Update()
{
if (enemies.Count == 0)
{
return;
}
int tickCount = Mathf.Min(maxTickPerFrame, enemies.Count);
for (int i = 0; i < tickCount; i++)
{
if (currentIndex >= enemies.Count)
{
currentIndex = 0;
}
EnemyAI enemy = enemies[currentIndex];
if (enemy != null)
{
// 只让一部分怪物进行 AI 思考
enemy.TickAI();
}
currentIndex++;
}
}
}这样做的好处是: 原来 500 个怪物同一帧都想问题,现在每帧只让 20 个怪物思考,把压力摊开。
第三点:AI 自己也要限频
不是所有 AI 逻辑都需要每帧执行。 怪物每 0.2 秒判断一次玩家位置,很多时候玩家根本感觉不出来。
c
using UnityEngine;
using UnityEngine.AI;
public class EnemyAI : MonoBehaviour
{
public Transform player;
public NavMeshAgent agent;
private float thinkTimer;
private float repathTimer;
// AI 思考间隔
private float thinkInterval = 0.2f;
// 寻路刷新间隔
private float repathInterval = 0.5f;
private Vector3 lastTargetPosition;
public void TickAI()
{
if (player == null)
{
return;
}
thinkTimer += Time.deltaTime;
repathTimer += Time.deltaTime;
// AI 不需要每帧完整思考
if (thinkTimer >= thinkInterval)
{
thinkTimer = 0f;
Think();
}
// 寻路更不能每帧刷新
if (repathTimer >= repathInterval)
{
repathTimer = 0f;
TryUpdatePath();
}
}
private void Think()
{
float sqrDistance = (player.position - transform.position).sqrMagnitude;
// 用 sqrMagnitude 避免开平方,比 Vector3.Distance 更省
if (sqrDistance < 2f * 2f)
{
Attack();
}
else
{
Chase();
}
}
private void TryUpdatePath()
{
// 玩家位置变化不明显,就没必要重新寻路
float movedSqrDistance = (player.position - lastTargetPosition).sqrMagnitude;
if (movedSqrDistance < 1f)
{
return;
}
lastTargetPosition = player.position;
// SetDestination 内部会触发寻路计算,不要每帧调用
agent.SetDestination(player.position);
}
private void Attack()
{
// 攻击逻辑
}
private void Chase()
{
// 追击逻辑
}
}注意这个细节: Vector3.Distance(a, b) 内部会开平方,开平方比普通乘法更贵。大量怪物判断距离时,优先用 sqrMagnitude。
第四点:AI LOD,远处怪物少思考
LOD 不只是模型可以用,AI 也可以用。
可以这样分:
c
近距离:0.1 秒 Tick 一次,完整 AI
中距离:0.5 秒 Tick 一次,简化 AI
远距离:2 秒 Tick 一次,只做简单移动
不可见:暂停 AI 或进入休眠示例:
c
using UnityEngine;
public enum AILodLevel
{
High, // 近处,高频更新
Medium, // 中距离,低频更新
Low, // 远处,很低频
Sleep // 太远或不可见,休眠
}
public class EnemyAILod : MonoBehaviour
{
public Transform player;
public AILodLevel CurrentLevel { get; private set; }
public float TickInterval { get; private set; }
public void RefreshLod()
{
float sqrDistance = (player.position - transform.position).sqrMagnitude;
if (sqrDistance < 10f * 10f)
{
CurrentLevel = AILodLevel.High;
TickInterval = 0.1f;
}
else if (sqrDistance < 30f * 30f)
{
CurrentLevel = AILodLevel.Medium;
TickInterval = 0.5f;
}
else if (sqrDistance < 60f * 60f)
{
CurrentLevel = AILodLevel.Low;
TickInterval = 2f;
}
else
{
CurrentLevel = AILodLevel.Sleep;
TickInterval = 5f;
}
}
}面试里这点很加分: 玩家看不到、感知不到的怪物,不需要像近处怪物一样聪明。
第五点:感知系统要优化
怪物找玩家、找敌人、找目标,经常会用物理查询。 例如:
c
Physics.OverlapSphere(...)这类查询多了会很贵,而且可能产生 GC。
更好的方式是使用 NonAlloc 版本:
c
using UnityEngine;
public class EnemySensor : MonoBehaviour
{
// 复用数组,避免每次查询都分配新数组
private readonly Collider[] results = new Collider[32];
public LayerMask targetLayer;
public float viewRadius = 10f;
public float viewAngle = 90f;
public Transform FindTarget()
{
// NonAlloc 不会每次产生新数组,适合频繁检测
int count = Physics.OverlapSphereNonAlloc(
transform.position,
viewRadius,
results,
targetLayer
);
Transform bestTarget = null;
float bestDistanceSqr = float.MaxValue;
for (int i = 0; i < count; i++)
{
Transform target = results[i].transform;
Vector3 toTarget = target.position - transform.position;
// 先用距离平方过滤
float distanceSqr = toTarget.sqrMagnitude;
// 再用角度过滤,避免背后的目标也被发现
float dot = Vector3.Dot(transform.forward, toTarget.normalized);
float minDot = Mathf.Cos(viewAngle * 0.5f * Mathf.Deg2Rad);
if (dot < minDot)
{
continue;
}
if (distanceSqr < bestDistanceSqr)
{
bestDistanceSqr = distanceSqr;
bestTarget = target;
}
}
return bestTarget;
}
}优化顺序通常是:
先用 LayerMask 过滤
再用距离过滤
再用角度过滤
最后才做射线遮挡检测不要一上来就对所有目标做 Raycast。
第六点:寻路要特别小心
NavMeshAgent.SetDestination 不是普通赋值。 它可能触发路径计算,大量怪物每帧调用会很重。
优化方式:
1. 不要每帧 SetDestination
2. 目标移动一定距离后再重新寻路
3. 每个怪物错开寻路时间
4. 远处怪物降低寻路频率
5. 群体怪物可以共享大方向
6. 只给精英怪、Boss 更高寻路频率比如小怪不需要每只都精确追玩家。 一群小怪可以先往玩家附近区域移动,靠近后再做精细寻路。
第七点:减少动画和物理开销
大量怪物不只是 AI 逻辑重,动画和物理也重。
可以优化:
c
远处怪物降低 Animator 更新频率
看不见的怪物使用 Animator Culling
远处怪物关闭复杂 IK
远处怪物减少碰撞检测
死亡怪物进入对象池
不用频繁 Instantiate / DestroyUnity Animator 可以设置 Culling Mode:
c
Always Animate:
一直更新动画,最贵。
Cull Update Transforms:
不可见时不更新 Transform。
Cull Completely:
不可见时完全不更新动画,更省。第八点:对象池复用怪物
大量怪物刷出和死亡,如果频繁 Instantiate / Destroy,会产生 CPU 开销和 GC 压力。
应该用对象池:
c
using System.Collections.Generic;
using UnityEngine;
public class EnemyPool : MonoBehaviour
{
public GameObject enemyPrefab;
private readonly Queue<GameObject> pool = new Queue<GameObject>();
public void Prewarm(int count)
{
for (int i = 0; i < count; i++)
{
GameObject enemy = Instantiate(enemyPrefab);
// 预先创建,但先隐藏
enemy.SetActive(false);
pool.Enqueue(enemy);
}
}
public GameObject Spawn(Vector3 position)
{
GameObject enemy;
if (pool.Count > 0)
{
enemy = pool.Dequeue();
}
else
{
enemy = Instantiate(enemyPrefab);
}
enemy.transform.position = position;
enemy.SetActive(true);
return enemy;
}
public void Despawn(GameObject enemy)
{
enemy.SetActive(false);
pool.Enqueue(enemy);
}
}对象池的核心是: 创建对象很贵,销毁对象也会增加 GC 压力,所以提前创建、重复使用。
第九点:空间分区,别让怪物遍历全场
如果每个怪物都遍历所有玩家、所有单位:
500 个怪物 × 500 个目标 = 250000 次判断这会很重。
可以把地图切成格子:
只查自己附近格子的目标
不查全场所有目标这叫空间分区。常见方式:
c
Grid 网格
QuadTree 四叉树
Sector 区域
AOI 感兴趣区域面试里不一定要手写完整四叉树,但你要知道思路: 通过空间结构减少搜索范围,把“全场查找”变成“附近查找”。
常见坑
1. 每个怪物都写 Update
2. 每帧 SetDestination
3. 每帧 Physics.OverlapSphere
4. 每帧 Vector3.Distance 判断大量对象
5. 所有怪物同一帧刷新 AI
6. 死亡后 Destroy,生成时 Instantiate
7. 远处怪物仍然完整播放动画和特效
8. 所有怪物都拥有复杂行为树
9. 小怪和 Boss 使用同样高频 AI
10. AI、动画、物理、寻路没有分层面试高分回答
TIP
大量怪物 AI 的优化重点是降低同一帧的 CPU 峰值。我不会让每个怪物在 Update 里每帧执行完整 AI,而是用 AIManager 统一调度,把怪物分批 Tick。然后根据距离和可见性做 AI LOD:近处怪物高频更新,远处怪物低频更新,看不见的怪物休眠或只做极简逻辑。感知方面,我会减少 Physics 查询频率,使用 LayerMask、距离平方、视野角度过滤,并优先使用 NonAlloc API,避免 GC。寻路方面,不会每帧调用 NavMeshAgent.SetDestination,而是目标移动超过一定距离或者间隔到了才重新计算路径。同时会优化动画、物理和生成销毁:远处怪物降低动画开销,死亡和生成用对象池,避免频繁 Instantiate/Destroy。如果怪物数量特别大,还会做空间分区,比如 Grid 或 AOI,只让怪物查询附近目标,而不是遍历全场。
总结就是:集中调度、分帧执行、AI LOD、感知限频、寻路限频、减少 GC、对象池复用。
最短记忆版
大量怪物 AI 优化 = 不要每帧全量思考;用管理器分批 Tick,按距离做 AI LOD,感知和寻路限频,远处休眠,对象池复用。如何优化粒子特效?
一句话理解: 优化粒子特效,不是只把粒子数量调少,而是要同时看 CPU 模拟、GPU 透明叠加、材质批次、贴图资源、运行时创建销毁、远近 LOD。
零基础理解
粒子特效就是很多“小图片”在屏幕上飞,比如火焰、烟雾、爆炸、技能光圈、飘血闪光。
它会消耗两边性能:
CPU:负责模拟粒子的位置、速度、生命周期、碰撞、拖尾等。
GPU:负责把这些半透明粒子画到屏幕上。移动端最怕两种情况:
1. 粒子太多,CPU 模拟压力大。
2. 半透明面积太大,GPU 反复绘制同一块屏幕,Overdraw 很高。所以面试里不要只说“减少粒子数量”,要说完整: 控数量、控 Overdraw、控模块、控材质、控生命周期、对象池复用、远处降级。
CPU 侧怎么优化
CPU 主要负责粒子模拟。下面这些会明显增加 CPU 开销:
c
Collision 碰撞
Trigger 触发器
Noise 噪声
Sub Emitters 子发射器
Trails 拖尾
Lights 粒子灯光
大量粒子每帧模拟优化方法:
c
减少 Max Particles
降低 Emission Rate
缩短 Lifetime
少用 Collision / Trigger / Noise
远处粒子降低更新或直接关闭
不重要的装饰特效减少播放频率GPU 侧怎么优化
粒子大多是透明材质。透明物体不能像普通不透明物体那样很好地利用深度剔除,所以很容易 Overdraw。
比如一个烟雾特效,屏幕上看起来只有一团烟,但它可能是几十张半透明图片叠在一起。 GPU 需要反复画同一块区域,手机就会热、掉帧。
优化方法:
减少大面积半透明粒子
减少烟雾、光圈、屏幕特效叠加
降低粒子贴图尺寸
使用更简单的 Shader
减少 Soft Particles
减少全屏 Bloom 参与
控制粒子在屏幕上的覆盖面积材质和贴图怎么优化
粒子如果每个特效都用不同材质、不同贴图、不同 Shader,会增加 DrawCall 和资源占用。
建议:
同类特效复用材质
把小粒子贴图打进图集
贴图压缩
降低 Max Size
关闭不必要的 Read/Write
少用超大透明贴图比如火花、烟雾、小爆点可以共用一张特效图集,而不是每个技能单独一张贴图。
运行时不要频繁 Instantiate / Destroy
技能特效、命中特效、爆炸特效经常大量播放。 如果每次都:
c
Instantiate(effectPrefab);
Destroy(effectObject);就容易产生 CPU 开销和 GC 压力。
更好的方式是对象池:
c
using System.Collections.Generic;
using UnityEngine;
public class ParticleEffectPool : MonoBehaviour
{
public GameObject effectPrefab;
// 用队列保存已经创建好的特效对象
private readonly Queue<GameObject> pool = new Queue<GameObject>();
public void Prewarm(int count)
{
for (int i = 0; i < count; i++)
{
// 提前创建,避免战斗中突然 Instantiate
GameObject effect = Instantiate(effectPrefab, transform);
// 先隐藏,等需要时再播放
effect.SetActive(false);
pool.Enqueue(effect);
}
}
public GameObject Spawn(Vector3 position)
{
GameObject effect;
if (pool.Count > 0)
{
// 从池子里取一个旧对象复用
effect = pool.Dequeue();
}
else
{
// 池子不够时才临时创建
effect = Instantiate(effectPrefab, transform);
}
effect.transform.position = position;
effect.SetActive(true);
ParticleSystem ps = effect.GetComponent<ParticleSystem>();
// 清掉上一次播放残留的粒子
ps.Clear(true);
// 重新播放
ps.Play(true);
return effect;
}
public void Release(GameObject effect)
{
ParticleSystem ps = effect.GetComponent<ParticleSystem>();
// 停止发射并清理粒子
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
effect.SetActive(false);
pool.Enqueue(effect);
}
}真实项目里通常会在粒子播放结束后自动回收。 比如通过协程检测 ps.IsAlive(true),或者用 ParticleSystemStopAction.Callback。
做特效 LOD
近处 Boss 技能可以完整播放,远处小怪技能没必要完全一样。
可以分档:
近距离:完整粒子、拖尾、光效、冲击波
中距离:减少粒子数量,关闭部分拖尾
远距离:只保留简单闪光
屏幕外:不播放或延迟播放
低端机:默认使用低配特效简单示例:
c
using UnityEngine;
public class ParticleLodController : MonoBehaviour
{
public ParticleSystem targetParticle;
public Transform cameraTransform;
void Update()
{
float sqrDistance =
(cameraTransform.position - transform.position).sqrMagnitude;
var main = targetParticle.main;
var emission = targetParticle.emission;
if (sqrDistance < 10f * 10f)
{
// 近距离:完整效果
main.maxParticles = 300;
emission.rateOverTimeMultiplier = 1f;
}
else if (sqrDistance < 30f * 30f)
{
// 中距离:减少发射数量
main.maxParticles = 120;
emission.rateOverTimeMultiplier = 0.5f;
}
else
{
// 远距离:大幅降低效果,甚至可以直接停止
main.maxParticles = 40;
emission.rateOverTimeMultiplier = 0.15f;
}
}
}这里用 sqrMagnitude 是因为它不用开平方,比 Vector3.Distance 更适合大量对象判断距离。
常见坑
1. 技能特效粒子数量无限堆高
2. 大面积透明烟雾覆盖屏幕
3. 每个特效使用独立材质,DrawCall 很多
4. 粒子碰撞、拖尾、灯光全开
5. 频繁 Instantiate / Destroy 特效
6. 远处和近处特效一样复杂
7. 特效贴图过大,没有压缩
8. UI 特效和场景特效一起造成 Overdraw
9. 不在真机上测试,只看编辑器效果面试高分回答
NOTE
粒子特效优化我会从 CPU、GPU、资源和运行时管理四个方面看。CPU 方面,减少粒子数量、发射频率和生命周期,少用 Collision、Trigger、Noise、Sub Emitters、Trails、Lights 这些重模块。GPU 方面,重点看透明 Overdraw,减少大面积半透明烟雾、光圈、叠加特效,简化 Shader,降低贴图尺寸。资源方面,复用材质和贴图,使用图集,压缩贴图,减少不同 Shader 和材质带来的批次问题。运行时方面,技能特效用对象池,避免频繁 Instantiate 和 Destroy。并且根据距离、可见性和设备性能做特效 LOD。
最后一定要用 Profiler、Frame Debugger 和真机测试确认瓶颈,因为粒子特效在移动端经常不是粒子数量本身,而是透明叠加和重模块导致的性能问题。
最短记忆版
粒子优化 = 少发射 + 少透明叠加 + 少重模块 + 复用材质贴图 + 对象池 + 特效 LOD + 真机验证。如何优化透明物体过多?
一句话理解: 透明物体过多的核心问题是 Overdraw:同一个屏幕像素被多个半透明物体反复绘制。移动端尤其怕这个,因为它会让 GPU 一直重复干活,导致掉帧和发热。
零基础理解
不透明物体像一堵墙。 前面的墙挡住后面的墙,GPU 可以少画很多东西。
透明物体像玻璃。 你能看到后面的东西,所以 GPU 不能简单地说“前面有东西,后面就不用画了”。它通常要把后面的颜色、透明物体颜色混合起来。
如果有很多层透明物体:
背景画一次
烟雾画一次
光圈画一次
玻璃画一次
粒子画一次
UI 半透明面板再画一次同一个像素可能被画 5 次、10 次,甚至更多。 这就是 Overdraw。
为什么透明物体比不透明物体贵
透明物体通常有几个特点:
1. 需要颜色混合 Blend
2. 通常不写入深度 ZWrite Off
3. 经常需要从后往前排序
4. 多层重叠时无法轻易被深度剔除
5. 大面积透明物体会造成大量像素重复绘制特别是这些东西很容易贵:
烟雾
火焰
技能光圈
玻璃
水面
半透明 UI 面板
UI 遮罩
屏幕特效
大量粒子第一步:先定位是不是 Overdraw
不要看到透明多就乱改。先确认是不是 GPU 瓶颈。
常用方法:
Unity Profiler:看 Rendering / GPU 时间
Frame Debugger:看透明物体绘制顺序和 DrawCall
Scene View Overdraw 模式:看哪些区域被反复绘制
RenderDoc:更深入分析像素绘制和渲染事件
真机测试:移动端一定要看真机面试里可以说: 如果 CPU 时间不高,但 GPU 时间高,并且画面里有大量烟雾、粒子、半透明 UI,那么我会优先怀疑透明 Overdraw。
优化一:减少透明面积
很多粒子贴图看起来只有中间一点烟,但实际上是一张很大的透明方形贴图。 透明的地方虽然看不见,也可能参与绘制,造成浪费。
优化方式:
裁掉贴图四周空白透明区域
使用更小的粒子贴图
用更贴合轮廓的 Mesh,而不是大方片
减少全屏半透明遮罩
减少大面积光圈和烟雾例如不要用一个很大的透明 Quad 只显示中间一小团光。 能缩小就缩小,因为透明物体的开销经常和“覆盖屏幕面积”强相关。
优化二:减少透明层数
透明物体最怕一层叠一层。
比如一个技能:
底部光圈
外圈旋转光
烟雾
火焰
火星
冲击波
屏幕泛光每个都透明,叠在一起就很贵。
优化思路:
合并视觉相近的层
减少同时存在的透明层
远处特效只保留关键层
低端机关闭装饰层
Boss 技能保留完整,小怪技能简化这不是“让画面变丑”,而是把预算花在玩家最能感知的地方。
优化三:能用不透明就不要用透明
有些东西看起来像透明,其实不一定必须用透明。
比如:
墙面贴花
破洞栅栏
树叶
草
铁丝网
某些 UI 装饰可以考虑:
c
Opaque 不透明
Alpha Clip / Cutout 裁剪
Dither 抖动透明Alpha Clip 的意思是: 透明度低于某个阈值的像素直接丢弃,高于阈值的像素当作不透明画。
简单 Shader 思路:
c
// 这是伪代码,表达 Alpha Clip 的核心思想
// 如果透明度太低,就直接丢弃这个像素
if (alpha < 0.5f)
{
discard;
}
// 否则当作不透明像素绘制优点:
可以更好利用深度测试
比真正半透明更容易优化
适合树叶、栅栏、破洞布料缺点:
边缘可能比较硬
不适合玻璃、水、柔和烟雾优化四:粒子特效做 LOD
近处技能可以华丽,远处技能不需要一样华丽。
c
using UnityEngine;
public class TransparentEffectLod : MonoBehaviour
{
public ParticleSystem particle;
public Transform cameraTransform;
void Update()
{
float sqrDistance =
(cameraTransform.position - transform.position).sqrMagnitude;
var main = particle.main;
var emission = particle.emission;
if (sqrDistance < 10f * 10f)
{
// 近距离:保留完整效果
main.maxParticles = 300;
emission.rateOverTimeMultiplier = 1f;
}
else if (sqrDistance < 30f * 30f)
{
// 中距离:减少粒子数量
main.maxParticles = 120;
emission.rateOverTimeMultiplier = 0.5f;
}
else
{
// 远距离:大幅降低透明粒子数量
main.maxParticles = 40;
emission.rateOverTimeMultiplier = 0.15f;
}
}
}这里用 sqrMagnitude,是为了避免大量对象计算距离时频繁开平方。
优化五:UI 半透明也要小心
UI 也会产生 Overdraw。 尤其是这些写法:
多个半透明面板叠在一起
全屏黑色半透明遮罩
ScrollView 里大量半透明 Image
很多不可见但没关闭的 UI
Mask、RectMask2D、阴影、描边叠很多优化方式:
不用的 UI 直接 SetActive(false)
减少全屏半透明遮罩
能用不透明底图就不要半透明叠层
关闭不必要的 Image
减少 Mask 嵌套
复杂 UI 拆 Canvas,避免频繁重建优化六:材质和批次也要管
透明物体多的时候,不只 Overdraw 高,DrawCall 也可能高。
优化方式:
同类透明物体复用材质
使用图集
减少不同 Shader
减少材质实例化
粒子系统尽量共享材质
相同特效用对象池复用但是要注意: 合批只能减少 DrawCall,不能直接解决 Overdraw。 如果一个大烟雾盖满屏幕,就算 DrawCall 只有 1,也可能很贵。
常见坑
1. 只看 DrawCall,不看 Overdraw
2. 大面积透明烟雾覆盖屏幕
3. UI 多层半透明面板叠加
4. 粒子贴图有大量空白透明边
5. 所有设备都播放同样复杂的特效
6. 远处透明特效不降级
7. 能用 Alpha Clip 的物体用了 Transparent
8. 透明材质太多,材质和 Shader 不复用
9. 用全屏半透明图做各种遮罩和转场面试高分回答
NOTE
透明物体过多主要会导致 Overdraw,因为透明物体通常需要混合,而且很多透明材质不写深度,多个透明层重叠时,同一个像素会被反复绘制。
我会先用 Profiler、Frame Debugger 和 Overdraw 视图确认是不是 GPU 填充率瓶颈。
优化时第一步是减少透明物体覆盖面积,比如裁掉贴图空白透明边,避免大面积半透明 Quad。
第二步是减少重叠层数,比如技能特效、烟雾、UI 面板不要多层透明叠加。
第三步是能用不透明或 Alpha Clip 的地方,就不要用真正的 Transparent,比如树叶、栅栏、破洞物体可以用 Cutout。
第四步是做 LOD 和动态降级,远处透明特效减少粒子数量,低端机关闭装饰性透明层。
最后还要复用材质和图集,减少 DrawCall,但我会强调 DrawCall 降低不等于 Overdraw 解决,透明优化重点还是减少像素重复绘制。
最短记忆版
透明物体优化 = 控 Overdraw:少面积、少层数、少大透明、能裁剪就裁剪、远处降级、材质复用。如何做 LOD 和 Occlusion Culling?
一句话理解:LOD 是“远处看不清,就换成低模”;Occlusion Culling 是“被墙挡住,摄像机看不到,就不渲染”。一个减少单个物体的复杂度,一个减少根本不用画的物体数量。
先分清三个概念
Frustum Culling:
物体在摄像机视野外,Unity 自动不画。
LOD:
物体还在视野里,但距离远,所以换成低精度模型。
Occlusion Culling:
物体也在视野方向里,但被墙、建筑、山体挡住,所以不画。比如一个房子:
在镜头背后:Frustum Culling 不画
在远处:LOD 换低模
在墙后面:Occlusion Culling 不画LOD 是什么
LOD 全名是 Level of Detail,意思是细节等级。
近处玩家看得清,就用高模:
LOD0:高面数模型,高质量材质,完整阴影远一点看不清细节,就用中模:
LOD1:减少面数,简化材质更远就用低模或者公告板:
LOD2:低面数模型,甚至一张贴图 Billboard这样做的原因是: 远处物体在屏幕上可能只有几十个像素,却还用几万个三角形去画,很浪费。
Unity 里怎么做 LOD
常见步骤:
1. 准备多个模型版本
2. 比如 Tree_LOD0、Tree_LOD1、Tree_LOD2
3. 在父物体上添加 LODGroup
4. 把不同精度模型拖到不同 LOD 层
5. 设置屏幕占比切换阈值
6. 开启 Fade Mode,避免切换太突兀层级通常像这样:
Tree
├── Tree_LOD0 高模
├── Tree_LOD1 中模
└── Tree_LOD2 低模Unity 里 LODGroup 的切换不是直接按世界距离,而是按屏幕占比。 物体在屏幕里占得越大,越可能使用高 LOD;占得越小,越可能使用低 LOD。
用代码简单创建 LODGroup
真实项目里大多在编辑器里配,但代码可以帮助你理解原理:
c
using UnityEngine;
public class LODSetupExample : MonoBehaviour
{
public Renderer highRenderer;
public Renderer middleRenderer;
public Renderer lowRenderer;
void Start()
{
// 给当前物体添加 LODGroup 组件
LODGroup lodGroup = gameObject.AddComponent<LODGroup>();
// LOD0:屏幕占比大于 60% 时使用高模
LOD lod0 = new LOD(
0.6f,
new Renderer[] { highRenderer }
);
// LOD1:屏幕占比大于 30% 时使用中模
LOD lod1 = new LOD(
0.3f,
new Renderer[] { middleRenderer }
);
// LOD2:屏幕占比大于 10% 时使用低模
LOD lod2 = new LOD(
0.1f,
new Renderer[] { lowRenderer }
);
// 把三个 LOD 层设置进去
lodGroup.SetLODs(new LOD[] { lod0, lod1, lod2 });
// 重新计算包围盒,保证 LOD 判断范围正确
lodGroup.RecalculateBounds();
}
}注意: 0.6f、0.3f、0.1f 表示屏幕相对高度,不是距离米数。
LOD 还能优化哪些东西
LOD 不只是换模型,还可以一起降级:
远处关闭阴影
远处关闭复杂 Animator
远处减少骨骼数量
远处降低粒子数量
远处关闭特效光源
远处使用简单碰撞体
远处使用低精度材质
远处改用 Billboard比如一棵远处的树,玩家根本看不出叶片细节,用一张面片图就够了。
Occlusion Culling 是什么
Occlusion Culling 叫遮挡剔除。
它解决的问题是: 一个物体虽然在摄像机方向上,但被墙挡住了,玩家看不到,那就没必要渲染。
比如室内场景:
玩家站在走廊
墙后面有 20 个房间
房间里有桌子、椅子、灯、怪物如果没有遮挡剔除,摄像机可能还会尝试处理很多墙后的物体。 开启遮挡剔除后,被墙挡住的东西可以不渲染。
Unity 里怎么做 Occlusion Culling
基本步骤:
1. 选中场景里的墙、地板、大建筑
2. 勾选 Static,并设置 Occluder Static
3. 选中可能被挡住的小物体
4. 设置 Occludee Static
5. 打开 Window > Rendering > Occlusion Culling
6. 调整参数
7. 点击 Bake
8. 在 Scene 视图里预览遮挡结果
9. 真机测试性能简单理解:
Occluder:
遮挡别人的物体,比如墙、山、建筑。
Occludee:
会被别人挡住的物体,比如家具、箱子、小道具。不是所有东西都适合当 Occluder。 适合当遮挡物的一般是大、稳定、不怎么移动的物体,比如墙、楼、山体。
Occlusion Culling 适合什么场景
适合:
室内场景
城市街区
迷宫
地牢
房间很多的建筑
有大量墙体遮挡的关卡不太适合:
c
开放大平原
空旷海面
天空盒为主的场景
物体都在视野里,没有明显遮挡
大量动态大物体随便移动的场景所以不是所有项目开了 Occlusion Culling 都赚。 它最怕“没东西挡”,那就没什么可剔除。
LOD 和 Occlusion Culling 的区别
c
LOD:
物体看得见,只是远。
优化方式:换低模、低材质、低动画。
Occlusion Culling:
物体看不见,因为被挡住。
优化方式:直接不渲染。一句话:
c
LOD 是少画细节。
Occlusion Culling 是能不画就不画。和 DrawCall、Batching 的关系
这几个优化点解决的问题不一样:
c
LOD:
减少三角形、顶点、材质复杂度。
Occlusion Culling:
减少需要渲染的物体数量。
Static Batching / Dynamic Batching / SRP Batcher:
减少 CPU 提交渲染命令的成本。
GPU Instancing:
大量相同物体批量绘制。它们可以一起用。 比如远处森林:
近处树:高 LOD
远处树:低 LOD
同种树:GPU Instancing
被山挡住的树:Occlusion Culling 不画常见坑
1. 以为 LOD 只需要换模型,不优化材质和阴影
2. LOD 切换距离太近,玩家看到明显跳变
3. LOD0、LOD1 差异太大,没有 Fade 过渡
4. 低模碰撞体没简化,渲染降了但物理没降
5. 开放大地图强行做 Occlusion Culling,收益很低
6. 把很多动态物体当成静态遮挡物
7. Bake 参数太细,遮挡数据变大,占内存
8. 只在编辑器看效果,不在真机验证面试高分回答
可以这样答:
IMPORTANT
LOD 和 Occlusion Culling 都是减少渲染压力,但解决的问题不同。
LOD 是距离相关优化。物体离摄像机越远,屏幕占比越小,我会用 LODGroup 切换高模、中模、低模,甚至 Billboard。同时远处物体还可以关闭阴影、简化材质、简化动画和碰撞。
Occlusion Culling 是遮挡相关优化。物体虽然在摄像机方向里,但如果被墙、建筑、山体挡住,玩家看不到,就可以不渲染。Unity 里通常把大而静态的墙体、建筑设置为 Occluder,把可被遮挡的物体设置为 Occludee,然后在 Occlusion Culling 窗口 Bake。
它适合室内、城市、迷宫这种遮挡关系明显的场景,不适合很空旷的开放场景。
我也会区分 Frustum Culling、LOD 和 Occlusion Culling:视野外自动剔除,视野内远处用 LOD,被遮挡的用 Occlusion Culling。最后还要结合 Profiler、Frame Debugger 和真机测试确认收益。
最短记忆版
LOD = 看得见但远,所以换低模。
Occlusion Culling = 在视野方向但被挡住,所以不画。
二者常配合合批、实例化、阴影优化一起用。