在面试中回答这个问题,建议从宏观的系统层面和微观的渲染层面两个维度来展开,并重点围绕“CPU瓶颈”与“GPU瓶颈”这两个核心概念来阐述。
系统层面
CPU与逻辑优化(解决主线程阻塞)
减少DrawCall(绘制调用)
- 这是最常见的优化点。通过静态合批(合并静态场景)、动态合批(合并同材质小模型)以及GPU Instancing(大量重复物体如草丛、子弹的实例化渲染)来大幅降低CPU向GPU发送指令的次数。
预加载
- 预加载常用的资源,比如场景中的静态资源、UI 资源、音效资源等等。这些UI可以在游戏开始前加载UI资源但不实例化,只是将其加载到内存中。
- 有些UI的实例化和初始化也可以提前到游戏开始前。实例化和初始化完成后,将其“隐藏”起来,待到需要的时候再将其展示出来。
- “隐藏”既可以将UI放在某一特定不被渲染或者未激活的层级,也可以将其放到摄像机视角之外藏起来。或者可以单独设置一个层级为不可见的layout,需要显示时再将UI元素放在可见层级中去。
- 比如在场景加载阶段(Loading 界面)预先实例化核心 UI 并隐藏(SetActive(false)),这属于将提前初始化,而非延迟初始化,适合高频使用的核心界面。
异步处理与分帧加载 (Time-Slicing)
- 避免在每一帧(Update)中执行大量计算。将路径寻路、AI决策等耗时逻辑拆分到多帧执行,或者放入异步线程/WebWorker中,防止阻塞主线程导致卡顿。
- 对于包含大量子元素的列表(如背包系统),不要在同一帧初始化所有项。可以使用协程(Coroutine)每帧只初始化 2-3 个元素,避免单帧计算过载导致的掉帧。
物理与AI精简
- 关闭远处或视野外物体的物理碰撞检测和AI逻辑,只计算玩家周边的关键交互。
内存优化(杜绝泄漏与峰值溢出)
对象池
- 对象池可以提高对象创建和销毁的效率,因为对象池中的对象可以重复使用,从而避免了创建和销毁对象的开销。
- 延迟初始化后,不使用的 UI 不要直接 Destroy,而是放入对象池中。下次需要时,直接从池中取出并重新初始化,这比反复 Instantiate 性能更高。
延迟初始化
- 采用按需加载/Lazy Initialization的思路,在运行时加载需要的资源,而不是在编译时加载所有的资源。
- 仅在 UI 界面真正需要显示时,才调用 Instantiate 创建对象或从对象池中取出。
资源管理与按需加载
- 将资源采用高压缩率格式压缩,例如LZ4HC (用打包时间换空间)
- 大世界采用流式加载,只加载玩家周围一定范围内的资源;切换场景或退出副本时,强制卸载无用资源并释放内存。
- 在背包或一些数据项比较多的应用中,scroll view常常会有大量的元素存在,他们在窗口中的不断滚动变化导致前面说的网格重构,消耗大量的CPU资源。
- 可以只实例化加载稍微比可视化区域大点的数据项UI,当滚动达到一定范围后再销毁一部分不可见的元素,同时加载一部分后续可能显示的元素(结合对象池)
字体拆分
- 字体一定程度上站了很大的空间(汉字😣),而且游戏打多需要的文字固定,没必要仅为少数的文字使用而加入整个字体库。
- 数字字母等固定的字符可以单独打包,汉字常用字符3000足够游戏使用,真遇到特殊字符可以单独添加进去,在通过unity下的字体制作工具打包处理。
UI贴图
- 倒入贴图资源时,贴图的某些属性或数据其实并不是必要的,但要占用很大的空间,不合理的贴图大小也会增大资源占用。
- 导入贴图资源时注意事项:
- alpha通道是否需要/
- 2次方贴图大小 (宽、高分别是 2 的整数次幂,也叫 Power of Two 纹理,例如:256 × 256,512 × 1024)
以前很多 GPU 对非 POT 纹理有限制,现在通常支持。但部分压缩格式仍对尺寸有要求,例如 PVRTC 常有 2 次幂限制,具体取决于平台和格式。不必把所有图片都强制改成 POT,应结合目标平台、压缩格式和图集设置判断。 - 读写权限是否需要
- MipMap是否需要(多级渐远纹理)。它会为原图生成一组逐级缩小的版本,例如:1024 × 1024 → 512 × 512 → 256 × 256 → … → 1 × 1,物体远离镜头、贴图只占少量屏幕像素时,GPU 可以使用较小的版本
开启 Mipmap 后,在普通纹理采样中,GPU 通常会自动选择合适的层级,前提是采样设置允许使用 Mipmap。它主要根据纹理坐标在相邻屏幕像素之间的变化率判断,相当于估算:一个屏幕像素覆盖了原贴图多少像素。 - 合适的压缩方式
- 上述过程可以通过unity editor扩展asset importer pipeline的方式自动化管理,可以通过Texture Importer类控制指定导入路径的图片具体设置。
网络与IO优化
协议压缩
- 使用 Protobuf 等二进制协议替代 JSON/XML,大幅减小数据包体积,解析速度更快。
- Protobuf 是基于预定义协议(契约),在“对象”和“二进制字节数组”之间进行高效、双向转换的通信标准
- Protobuf 按照提前写好的 .proto 协议文件(也就是双方约定好的“契约”),通过字段编号、数据类型等规则,进行极其精简的二进制编码。
数据差分同步
- 状态同步时只发送变化的数据(如位置偏移量、血量变化),而不是发送全量数据。
- 只发送变化的字段:血量从 100 变成 90,只发送 { hp: 90 },省略未变化的位置、装备等字段。
- 只发送变化量:发送 { hpDelta: -10 },客户端在原有血量上减去 10。
- 前者更容易纠正状态,后者依赖双方基准一致;丢包、重复处理或乱序可能导致偏差,因此通常需要版本号、顺序控制,以及定期全量同步来校准。
渲染层面
渲染优化的核心是减轻GPU的压力,核心指标是降低 Overdraw(过度绘制)和 FillRate(填充率)的消耗。
UI画布
动静分离
- unity中UGUI系统都是使用网格模型来构建,UI元素只要“动”,就会发生网格的重构合并。正所谓牵一发而动全身,少的UI元素的动导致整个UI的重构,由此带来重绘导致了cpu的开销。
- UGUI系统以Canvas作为自己的重绘节点,所以需要以canvas作为节点拆分动和静的UI元素。
- 将频繁更新的动态 UI 与不常变动的静态 UI 拆分到不同的 Canvas 中,因为每次UI更新都会触发 Canvas 的重建。
- 这样当动态 UI 延迟加载并触发重建(Rebuild)时,不会影响到静态 Canvas 的性能。
降低 Overdraw(过度绘制)
- 控制透明层叠加:透明物体(如特效、粒子、UI流光)是 Overdraw 的重灾区。严格限制透明层的叠加数量(通常不超过2层),远距离的特效直接降低分辨率或关闭。
- 遮挡剔除(Occlusion Culling):开启引擎的遮挡剔除功能,被墙体等物体完全挡住的物体直接不参与渲染。
- 分层渲染:将UI、场景、角色、特效划分不同的渲染层级,透明与不透明物体严格分离。
避免 Layout Group 重建
- 瞬间加载大量元素到含有 VerticalLayoutGroup 的父物体时,会导致剧烈的 Layout 重建。
- 可以先禁用 Layout 组件,等所有元素加载完毕后,使用 LayoutRebuilder.ForceRebuildLayoutImmediate 一次性刷新。
网格重构
- UGUI系统网格合并机制是,只有将相同材质球的网格合并在一起,才能达到最佳效果。一个材质球对应一个图集,只有相同图集内的图片才需要合并在一起。
- 当元素需要改变颜色时,是通过改变顶点颜色实现,然后将他们重新合批到网格中去。在UI动画里修改UI颜色时,UGUI都会对网格进行重构。
- 此时我们需要自定义UI元素的材质球,通过自定义材质球改变相应的颜色变化。
- 此时UGUI不再重构网格,因为把渲染工作交给了新的材质球,而非通过UGUI设置顶点颜色达到效果。
模型与纹理优化
LOD(多细节层次)
- 根据物体距离摄像机的远近,自动切换不同精度的模型。远处的物体使用低模(面数少),近处使用高模。
纹理压缩与格式选择
- 使用 GPU 友好的压缩格式(如 ASTC、ETC2、BC7),在保证画质的前提下大幅降低显存占用和带宽压力。
Shader(着色器)轻量化
简化光照计算
- 移动端尽量避免复杂的光照模型、实时阴影和高光反射。
减少分支指令
- Fragment Shader(片元着色器)中的 if/else 分支会严重影响GPU并行计算效率,尽量用数学运算替代逻辑分支。
性能分析工具
- 优化不能靠猜,必须依赖工具。
- 常用的有 Unity 的 Profiler / Frame Debugger,或者第三方的 RenderDoc、Snapdragon Profiler。
- 通过这些工具可以精准定位到当前帧的瓶颈是在 CPU(如 DrawCall 过高)还是 GPU(如 Overdraw 严重或 Shader 过于复杂),从而进行针对性的优化。