Skip to content

性能 / 资源必背

Profiler 如何定位卡顿

Profiler 定位卡顿的核心:先抓到卡顿那一帧,再从线程和调用栈一路钻到具体模块。

unity-profiler-stutter-diagnosis

1. 基本流程

先在目标设备复现,不要只在 Editor 里测。 打 Development Build,勾选 Autoconnect Profiler。 打开 Profiler 录制,执行会卡顿的操作。 在时间线上找到突然升高的 spike 帧。 选中这一帧,看 CPU UsageTimelineHierarchy。 从 Main ThreadRender ThreadGCUIPhysics 方向继续展开。 找到具体函数、资源、UI 节点或脚本逻辑。 优化后用同样场景再测一遍,对比数据。

2. 常见卡顿标记怎么看

BehaviourUpdate 高:

大概率是脚本逻辑重。 看是不是大量 Update、AI、寻路、排序、循环、事件广播。

GC.Collect 高:

说明触发了 GC。 往前看 GC Alloc,找每帧分配来源,比如字符串、LINQ、闭包、装箱、临时 List。

Canvas.BuildBatch 高:

UI 重建重。 看是不是频繁改 Text、LayoutGroup、ContentSizeFitter、大 Canvas 动态刷新。

Physics.Simulate 高:

物理模拟重。 看碰撞体数量、刚体数量、Layer 碰撞矩阵、FixedUpdate 频率。

Camera.Render 高:

渲染压力大。 继续用 Frame Debugger 看 DrawCall、SetPass、Overdraw、阴影、后处理、透明物体。

WaitForTargetFPS 高:

可能是在等帧率限制,不一定是性能问题。

Gfx.WaitForPresent 高:

可能是 GPU 瓶颈、VSync 或等待显示提交,要结合 GPU Profiler、Frame Debugger、RenderDoc 判断。

3. 给业务代码加 ProfilerMarker

c
using Unity.Profiling; // 引入 Unity Profiler 标记命名空间
using UnityEngine; // 引入 Unity 引擎命名空间
public class EnemyAiProfilerDemo : MonoBehaviour // 定义敌人 AI 性能标记脚本
{ // 类开始
    private static readonly ProfilerMarker AiTickMarker = new ProfilerMarker("Game.EnemyAI.Tick"); // 创建自定义 Profiler 标记
    private void Update() // 每帧调用 AI 更新逻辑
    { // Update 方法开始
        using (AiTickMarker.Auto()) // 自动统计 using 范围内代码的耗时
        { // using 作用域开始
            TickEnemyAi(); // 执行敌人 AI 逻辑
        } // using 作用域结束
    } // Update 方法结束
    private void TickEnemyAi() // 定义敌人 AI 更新方法
    { // TickEnemyAi 方法开始
        for (int i = 0; i < 100; i++) // 模拟遍历多个敌人
        { // for 循环开始
            Vector3 direction = transform.forward; // 模拟计算敌人方向
            float value = Vector3.Dot(direction, Vector3.forward); // 模拟 AI 视野或方向判断
            if (value > 0.5f) // 判断模拟结果是否满足条件
            { // if 语句开始
                continue; // 示例中不做额外逻辑
            } // if 语句结束
        } // for 循环结束
    } // TickEnemyAi 方法结束
} // 类结束

4. 面试高分回答

NOTE

我定位卡顿不会只说“打开 Profiler 看一下”,而是先在目标设备上复现问题,录制 Profiler 数据,找到 spike 帧,再从 CPU Usage 的 Timeline 或 Hierarchy 里看是哪条线程、哪个模块突然变高。如果是 BehaviourUpdate,继续展开脚本调用;如果是 GC.Collect,回头找 GC Alloc 来源;如果是 Canvas.BuildBatch,检查 UI 频繁重建;如果是 Physics.Simulate,检查碰撞体、刚体和 FixedUpdate;如果是 Camera.RenderGfx.WaitForPresent,再结合 Frame Debugger、GPU Profiler 或 RenderDoc 判断渲染瓶颈。最后一定要做优化前后对比,用耗时、GC Alloc、帧率曲线证明优化有效。

CPU 瓶颈和 GPU 瓶颈判断

判断 CPU 瓶颈还是 GPU 瓶颈,本质是看一帧里谁最慢、谁在等谁。

unity-cpu-gpu-bottleneck-judgment

1. CPU 瓶颈是什么?

CPU 负责:

脚本逻辑。 AI。 物理。 动画。 UI 重建。 资源加载。 DrawCall / SetPass 提交。

如果 CPU 太慢,GPU 可能已经画完了,但还在等 CPU 提交下一帧命令。

Profiler 里常见表现:

Main Thread 很高。 BehaviourUpdate 很高。 Physics.Simulate 很高。 Canvas.BuildBatch 很高。 Animator.Update 很高。 GC.CollectGC Alloc 明显。

这种一般偏 CPU 瓶颈。

2. GPU 瓶颈是什么?

GPU 负责:

顶点处理。 光栅化。 片元着色。 纹理采样。 阴影。 透明混合。 后处理。 最终显示输出。

如果 GPU 太慢,CPU 可能已经提交完命令,但要等 GPU 画完。

常见表现:

GPU Frame Time 高。 Gfx.WaitForPresent 或类似等待显示的时间高。 降低分辨率后帧率明显提升。 关闭阴影、后处理、透明特效后明显变快。 Frame Debugger 里看到大量 DrawCall、Overdraw、阴影 Pass、后处理 Pass。

这种一般偏 GPU 瓶颈。

3. 快速判断方法

最实用的办法:

先用 Profiler 看 CPU Frame TimeGPU Frame Time。 如果 CPU 时间明显高,先查脚本、物理、动画、UI、GC。 如果 GPU 时间明显高,先查分辨率、阴影、后处理、透明、Shader、Overdraw。 降低渲染分辨率测试。 关闭后处理测试。 关闭实时阴影测试。 减少大量脚本逻辑测试。

如果降分辨率明显变快,通常偏 GPU。 如果降分辨率几乎没变化,通常偏 CPU 或同步等待。

4. Unity 中可以用代码看 FrameTiming

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class FrameTimingDemo : MonoBehaviour // 定义帧耗时检测脚本
{ // 类开始
    private readonly FrameTiming[] _timings = new FrameTiming[1]; // 创建数组保存最近一帧的 CPU 和 GPU 时间
    private void Update() // 每帧调用一次
    { // Update 方法开始
        FrameTimingManager.CaptureFrameTimings(); // 请求 Unity 捕获当前帧的时间数据
        uint count = FrameTimingManager.GetLatestTimings(1, _timings); // 获取最近一帧的时间数据
        if (count == 0) // 判断是否没有拿到有效数据
        { // if 语句开始
            return; // 没拿到数据就直接返回
        } // if 语句结束
        double cpuMs = _timings[0].cpuFrameTime; // 读取 CPU 帧耗时,单位是毫秒
        double gpuMs = _timings[0].gpuFrameTime; // 读取 GPU 帧耗时,单位是毫秒
        if (cpuMs > gpuMs) // 判断 CPU 耗时是否更高
        { // if 语句开始
            Debug.Log("当前更像 CPU 瓶颈,CPU ms = " + cpuMs); // 输出 CPU 瓶颈提示
        } // if 语句结束
        else // 如果 GPU 耗时大于或等于 CPU 耗时
        { // else 语句开始
            Debug.Log("当前更像 GPU 瓶颈,GPU ms = " + gpuMs); // 输出 GPU 瓶颈提示
        } // else 语句结束
    } // Update 方法结束
} // 类结束

5. 面试高分回答

TIP

我判断 CPU/GPU 瓶颈不会只看 FPS,而是看一帧里 CPU 和 GPU 谁耗时更高、谁在等待。CPU 瓶颈通常表现为 Main ThreadBehaviourUpdatePhysics.SimulateCanvas.BuildBatchGC.Collect 等耗时高,说明脚本、物理、动画、UI 或 GC 在拖慢。GPU 瓶颈通常表现为 GPU Frame Time 高,或者降低分辨率、关闭阴影后处理后帧率明显提升,说明像素填充、Shader、阴影、透明 Overdraw 或后处理压力大。实际定位时我会用 Profiler 看线程和帧耗时,用 Frame Debugger 看渲染 Pass 和 DrawCall,必要时用 RenderDoc 或平台 GPU 工具确认。核心是:CPU 瓶颈是命令来不及准备,GPU 瓶颈是命令来了但画不完。

DrawCall 是什么

DrawCall 是 CPU 向 GPU 提交的一次绘制命令。

unity-drawcall-explanation

1. DrawCall 是什么?

可以理解成 CPU 对 GPU 说:

“用这个 Mesh、这个 Material、这个 Shader、这些渲染状态,把这一批东西画出来。”

一次这样的提交,就可以粗略理解成一次 DrawCall

它通常包含:

要画的网格 Mesh。 使用的材质 Material。 使用的 Shader 和 Pass。 贴图、颜色、矩阵、光照数据。 渲染状态,比如深度、混合、剔除等。

2. DrawCall 多为什么会慢?

因为每次 DrawCall 都需要 CPU 做准备和提交。

如果场景里有很多物体,而且它们材质不同、Shader 不同、贴图不同,CPU 就要频繁切换状态并提交命令。

所以 DrawCall 多,很多时候首先表现为 CPU 压力:

Main Thread 高。 Render Thread 高。 提交渲染命令耗时高。 移动端容易卡。

但注意:DrawCall 多不一定等于 GPU 瓶颈。 GPU 瓶颈更多可能来自复杂 Shader、Overdraw、阴影、后处理、高分辨率。

3. SetPass Call 和 DrawCall 的关系

SetPass Call 可以理解成切换 Shader Pass 或材质状态。

很多时候,SetPass 比普通 DrawCall 更贵,因为它代表 GPU 渲染状态发生切换。

简单说:

DrawCall:让 GPU 画一次。 SetPass:切换一套渲染状态再画。

所以优化时不只看 DrawCall,也要看 SetPass。

4. 怎么减少 DrawCall?

常见方法:

使用 Static Batching 合并静态物体。 使用 Dynamic Batching 合并小物体。 使用 GPU Instancing 画大量相同 Mesh 和 Material 的对象。 使用 SRP Batcher 降低 SRP 下材质提交成本。 减少材质数量。 多个小贴图合成图集。 复用同一个 Material,不要运行时频繁创建材质实例。 UI 使用图集,减少材质和纹理切换。 用 Frame Debugger 查看每一次 DrawCall 为什么没有合批。

5. 代码示例:错误地创建材质实例

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class BadMaterialDemo : MonoBehaviour // 定义错误材质用法示例脚本
{ // 类开始
    public Renderer TargetRenderer; // 保存 Renderer 引用
    private void Start() // Unity 启动时调用
    { // Start 方法开始
        TargetRenderer.material.color = Color.red; // material 会创建材质实例,可能破坏合批
    } // Start 方法结束
} // 类结束

6. 更好的做法:用 MaterialPropertyBlock

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class GoodMaterialDemo : MonoBehaviour // 定义较好的材质属性设置示例脚本
{ // 类开始
    public Renderer TargetRenderer; // 保存 Renderer 引用
    private MaterialPropertyBlock _block; // 保存材质属性块,避免创建材质实例
    private void Awake() // Unity 初始化时调用
    { // Awake 方法开始
        _block = new MaterialPropertyBlock(); // 创建材质属性块对象
    } // Awake 方法结束
    private void Start() // Unity 启动时调用
    { // Start 方法开始
        TargetRenderer.GetPropertyBlock(_block); // 读取当前 Renderer 的属性块
        _block.SetColor("_BaseColor", Color.red); // 设置颜色属性,URP 常用 _BaseColor
        TargetRenderer.SetPropertyBlock(_block); // 把属性块应用回 Renderer
    } // Start 方法结束
} // 类结束

7. 面试高分回答

可以这样说:

IMPORTANT

DrawCall 是 CPU 向 GPU 提交的一次绘制命令,里面会指定 Mesh、Material、Shader、贴图、矩阵和渲染状态。DrawCall 多通常会增加 CPU 提交成本,尤其在移动端比较敏感;但 DrawCall 多不一定就是 GPU 瓶颈,还要结合 SetPass、Overdraw、Shader 复杂度、阴影和后处理一起看。优化上可以通过 Static Batching、Dynamic Batching、GPU Instancing、SRP Batcher、合图集、减少材质数量和复用材质来降低提交开销。实际定位时我会用 Profiler 看 CPU/Render Thread,用 Stats 看 Batches,用 Frame Debugger 看每一次 DrawCall 为什么被拆开。

合批方式有哪些

unity-batching-methods

合批是什么?

合批就是把多个渲染对象尽量合成更少的提交次数,减少 CPU 向 GPU 发命令的成本。

面试里要注意一句话:合批主要优化的是 CPU 端的 DrawCall / SetPass 开销,不代表 GPU 一定更轻松。 如果物体太多、像素覆盖太大、Shader 太复杂,GPU 仍然可能很慢。

常见合批方式

  1. Static Batching 静态合批 适合场景里不移动的物体,比如墙、地面、石头、建筑。 优点是运行时提交更少;缺点是会增加内存,而且物体不能随便移动。
  2. Dynamic Batching 动态合批 Unity 自动把一些小 Mesh 合起来提交。 限制很多:顶点数、顶点属性、材质都要满足条件。现代项目里它不一定总是划算,因为合批本身也要 CPU 处理。
  3. GPU Instancing 适合大量相同 Mesh、相同 Material 的对象,比如草、树、子弹、同类怪物。 它的核心是:一次 DrawCall 画很多实例,每个实例可以有不同位置、旋转、颜色等数据。
  4. SRP Batcher URP/HDRP 常用。它不一定减少 DrawCall 数量,而是减少切换 Shader/Material 常量数据的 CPU 成本。 面试里可以说:SRP Batcher 优化的是渲染提交管线的状态设置开销。
  5. 手动合并 Mesh 把多个 Mesh 合成一个大 Mesh。 适合静态区块、地图块、装饰物。缺点是会降低剔除精度,比如一个角落可见,整个大 Mesh 可能都要画。
  6. UI 合批 UGUI 里同 Canvas、同材质、同图集、层级连续的 UI 更容易合批。 不同贴图、Mask、不同材质、频繁 Layout 改动,都可能打断 UI 合批。
  7. 图集和材质合并 把多张小图放进一张图集,让多个对象使用同一个 Material。 这是 2D、UI、特效里非常常见的合批基础。

GPU Instancing 示例

c
using UnityEngine; // 引入 Unity 引擎命名空间

public class InstancingColorDemo : MonoBehaviour // 定义一个 GPU Instancing 颜色示例脚本
{ // 类开始
    public Renderer TargetRenderer; // 保存当前物体的 Renderer 引用

    private MaterialPropertyBlock _block; // 保存材质属性块,避免直接复制材质实例

    private void Awake() // Unity 初始化时调用
    { // Awake 方法开始
        _block = new MaterialPropertyBlock(); // 创建一个材质属性块对象
    } // Awake 方法结束

    public void SetColor(Color color) // 设置当前实例的颜色
    { // SetColor 方法开始
        TargetRenderer.GetPropertyBlock(_block); // 读取当前 Renderer 已有的属性块数据
        _block.SetColor("_BaseColor", color); // 设置 URP 常用的基础颜色属性
        TargetRenderer.SetPropertyBlock(_block); // 把属性块应用回 Renderer,不创建新材质
    } // SetColor 方法结束
} // 类结束

面试高分回答

CAUTION

Unity 里的合批方式主要有 Static Batching、Dynamic Batching、GPU Instancing、SRP Batcher、手动 Mesh 合并和 UI 合批。 我会先用 Frame Debugger 看为什么没有合批,比如材质不同、贴图不同、Shader Keyword 不同、Render Queue 不同、Mask 或排序打断。然后根据对象类型选择方案:静态场景用 Static Batching 或手动合并,大量相同物体用 GPU Instancing,URP/HDRP 项目开启 SRP Batcher,UI 则用图集、减少材质和 Canvas 重建。核心目标不是盲目减少 DrawCall,而是降低 CPU 提交成本,同时不能让内存、剔除精度和 GPU Overdraw 变得更差。

GPU Instancing 是什么

unity-gpu-instancing

GPU Instancing 是什么?

GPU Instancing 叫 GPU 实例化渲染。它解决的问题是:场景里有很多“长得一样”的物体时,不要让 CPU 一个一个提交 DrawCall,而是一次提交一批实例,让 GPU 批量画出来。

比如 1000 棵草:

普通方式: CPU 提交 1000 次,每次都告诉 GPU:画这棵草。

GPU Instancing: CPU 提交一批数据:同一个 Mesh、同一个 Material、1000 个不同位置。 GPU 根据每个实例的矩阵,把这 1000 棵草画出来。

它的核心条件

必须尽量满足:

  1. 同一个 Mesh 比如都是同一个草模型、石头模型、子弹模型。
  2. 同一个 Material 材质不同,Shader Keyword 不同,渲染状态不同,都可能被拆批。
  3. Shader 支持 Instancing Unity 的材质上通常要开启 Enable GPU Instancing
  4. 每个实例只变化少量数据 比如位置、旋转、缩放、颜色、ID。 这些可以作为 instance data 传给 GPU。

它省的是什么?

主要省的是 CPU 提交渲染命令的开销

不是说 GPU 完全不干活了。 GPU 还是要处理所有顶点和像素,只是 CPU 不用反复提交那么多 DrawCall。

面试里可以这样说:

GPU Instancing 本质是把多个相同 Mesh、相同 Material 的对象合成一次或少量几次实例化绘制。CPU 只提交共享的 Mesh 和材质,再提交每个实例自己的 Transform 或颜色等数据,GPU 在顶点阶段根据实例 ID 读取对应数据并绘制多个对象。

适合什么场景?

适合大量重复物体:

草、树、石头、路灯、子弹、弹幕、特效碎片、场景装饰物、同类怪物外观。

不太适合:

每个物体 Mesh 都不同、材质都不同、Shader Keyword 不同、数量很少、透明物体需要严格排序、SkinnedMeshRenderer 角色动画这种复杂情况。

和 Static Batching 区别

Static Batching 更适合不动的静态场景物。 GPU Instancing 更适合数量很多、可以移动、但模型和材质相同的对象。

一句话记:

Static Batching 像是提前把静态物体整理好。 GPU Instancing 像是告诉 GPU:这个模型给我按这批位置画很多份。

C# 示例:手动绘制一批实例

c
using UnityEngine; // 引入 Unity 引擎命名空间

public class DrawMeshInstancedDemo : MonoBehaviour // 定义一个 GPU Instancing 示例脚本
{ // 类开始
    public Mesh Mesh; // 要重复绘制的同一个网格

    public Material Material; // 要重复使用的同一个材质

    public int Count = 100; // 要绘制的实例数量

    private Matrix4x4[] _matrices; // 保存每个实例的位置旋转缩放矩阵

    private void Awake() // Unity 初始化时调用
    { // Awake 方法开始
        _matrices = new Matrix4x4[Count]; // 根据实例数量创建矩阵数组

        for (int i = 0; i < Count; i++) // 循环生成每一个实例的数据
        { // for 循环开始
            Vector3 position = new Vector3(i % 10 * 2f, 0f, i / 10 * 2f); // 计算当前实例的位置

            Quaternion rotation = Quaternion.identity; // 使用默认旋转

            Vector3 scale = Vector3.one; // 使用默认缩放

            _matrices[i] = Matrix4x4.TRS(position, rotation, scale); // 把位置旋转缩放合成矩阵
        } // for 循环结束

        Material.enableInstancing = true; // 开启材质的 GPU Instancing
    } // Awake 方法结束

    private void Update() // 每帧调用绘制逻辑
    { // Update 方法开始
        Graphics.DrawMeshInstanced(Mesh, 0, Material, _matrices, Count); // 一次提交一批实例给 GPU 绘制
    } // Update 方法结束
} // 类结束

面试高分回答

NOTE

GPU Instancing 是一种批量绘制相同 Mesh 和相同 Material 物体的技术。它不会把物体真的合成一个 Mesh,而是复用同一份 Mesh 和材质,再给 GPU 传一组实例数据,比如每个物体的矩阵、颜色等。这样 CPU 可以用一次或少量 DrawCall 提交大量对象,减少渲染提交开销。它适合草、树、石头、弹幕这类大量重复物体,但要求材质、Shader、渲染状态一致;如果材质实例化、Shader Keyword 不同,或者透明排序复杂,就容易被拆批。

SRP Batcher 是什么

unity-srp-batcher

SRP Batcher 是什么?

SRP Batcher 是 Unity 在 URP / HDRP / 自定义 SRP 里提供的一种渲染优化机制。

它的核心作用不是把 Mesh 合成一个,也不是让 DrawCall 一定减少,而是:

减少 CPU 在渲染时反复设置 Shader、材质参数、常量缓冲区的成本。

普通渲染里,CPU 每画一个物体,经常要做很多准备工作:

设置 Shader。 设置材质参数。 设置矩阵。 设置光照数据。 提交 DrawCall。

如果场景里有很多物体,即使 DrawCall 数量不算特别夸张,CPU 也可能因为反复设置这些状态而变慢。

SRP Batcher 的思路是:

把符合规则的 Shader 和材质数据整理成固定格式。 材质参数放到 UnityPerMaterial 常量缓冲区里。 对象参数放到 UnityPerDraw 常量缓冲区里。 渲染时 CPU 不用频繁重新绑定一堆材质数据,而是走一条更快的提交路径。

它优化的是什么?

主要优化:

CPU Render Thread。 SetPass 成本。 材质常量绑定成本。 大量 Renderer 提交时的 CPU 压力。

注意,它不是主要优化 GPU 计算量。 你的像素还是要画,顶点还是要算,Overdraw 还是存在。

它和 GPU Instancing 区别

GPU Instancing 解决的是:

很多物体 Mesh 相同、Material 相同,一次画很多份。

SRP Batcher 解决的是:

很多物体可能 Mesh 不同、Material 不同,但 Shader 结构兼容,CPU 设置材质数据更快。

所以可以这样记:

GPU Instancing:减少大量重复物体的 DrawCall。 SRP Batcher:减少 SRP 渲染提交时的 CPU 状态切换成本。

它和 Static Batching 区别

Static Batching 是把静态物体提前合批,减少绘制提交,但会增加内存,而且物体不能随便动。

SRP Batcher 不要求 Mesh 合并,也不要求物体静止。 它更关注 Shader 和材质数据布局是否符合 SRP 规范。

SRP Batcher 需要满足什么条件?

常见条件有:

项目使用 URP、HDRP 或自定义 SRP。 Pipeline Asset 里开启 SRP Batcher。 Shader 要兼容 SRP Batcher。 材质属性要放在 UnityPerMaterial CBUFFER 里。 同一批对象尽量使用相同 Shader Variant。 不要乱用 MaterialPropertyBlock,它可能让对象走不了 SRP Batcher 快速路径。

Shader 示例:SRP Batcher 兼容写法

c
Shader "Custom/SRPBatcherExample" // 定义一个自定义 Shader 名称
{ // Shader 开始
    Properties // 定义材质面板上可以调的属性
    { // Properties 开始
        _BaseColor ("Base Color", Color) = (1, 1, 1, 1) // 定义一个基础颜色属性
    } // Properties 结束
    SubShader // 定义一个子着色器
    { // SubShader 开始
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } // 标记这是 URP 不透明 Shader
        Pass // 定义一个渲染 Pass
        { // Pass 开始
            HLSLPROGRAM // 开始编写 HLSL 代码
            #pragma vertex vert // 指定顶点着色器函数
            #pragma fragment frag // 指定片元着色器函数
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" // 引入 URP 核心函数库
            CBUFFER_START(UnityPerMaterial) // 开始定义材质常量缓冲区,这是 SRP Batcher 兼容的关键
                float4 _BaseColor; // 把材质颜色放进 UnityPerMaterial
            CBUFFER_END // 结束材质常量缓冲区
            struct Attributes // 定义顶点输入结构
            { // Attributes 开始
                float4 positionOS : POSITION; // 接收模型空间顶点坐标
            }; // Attributes 结束
            struct Varyings // 定义顶点传给片元的数据结构
            { // Varyings 开始
                float4 positionHCS : SV_POSITION; // 保存裁剪空间坐标
            }; // Varyings 结束
            Varyings vert(Attributes input) // 定义顶点着色器函数
            { // 顶点函数开始
                Varyings output; // 创建顶点输出变量
                output.positionHCS = TransformObjectToHClip(input.positionOS.xyz); // 把模型空间坐标转换到裁剪空间
                return output; // 返回顶点输出
            } // 顶点函数结束
            half4 frag(Varyings input) : SV_Target // 定义片元着色器函数
            { // 片元函数开始
                return _BaseColor; // 输出材质基础颜色
            } // 片元函数结束
            ENDHLSL // 结束 HLSL 代码
        } // Pass 结束
    } // SubShader 结束
} // Shader 结束

怎么判断 SRP Batcher 有没有生效?

可以看:

Frame Debugger。 Shader Inspector 里的 SRP Batcher Compatible。 Profiler 里的 Render Thread。 Stats 面板里的 Batches 和 SetPass Calls 变化。

但面试里要特别说清楚:

SRP Batcher 生效后,DrawCall 数量可能没有明显减少,但 CPU 渲染提交耗时会下降。

面试高分回答

TIP

SRP Batcher 是 Unity 在 URP/HDRP 里优化 CPU 渲染提交的一套机制。它要求 Shader 按 SRP 规范组织常量缓冲区,比如材质属性放在 UnityPerMaterial 中,这样 Unity 可以把材质数据缓存起来,渲染时减少重复绑定和 SetPass 成本。它和 GPU Instancing 不一样,Instancing 更偏向大量相同 Mesh/Material 的对象合批,而 SRP Batcher 更偏向减少兼容 Shader 下的 CPU 状态切换。判断它是否有效不能只看 DrawCall 数量,还要看 Render Thread、SetPass 成本和 Frame Debugger 里的兼容情况。

Addressables 原理

unity-addressables-principle

Addressables 是什么?

Addressables 是 Unity 的 地址化资源管理系统

它不是一种全新的资源格式,而是站在 AssetBundle 之上,帮你管理:

资源地址。 资源目录 Catalog。 资源依赖。 本地或远程加载。 异步加载。 缓存。 引用计数。 资源释放。

你可以把它理解成:

AssetBundle 更像底层包格式。 Addressables 更像资源管理框架。

核心原理

Addressables 的加载流程大概是:

你传入一个 Key。 Addressables 去 Catalog 里查这个 Key。 Catalog 找到资源真实位置。 系统分析这个资源依赖哪些 Bundle。 Provider 负责加载本地或远程资源。 加载完成后返回 AsyncOperationHandle。 不用时必须 Addressables.Release 释放引用。

所以它的核心链路是:

Key -> Catalog -> ResourceLocation -> Provider -> AssetBundle -> Asset -> Handle

Catalog 是什么?

Catalog 可以理解成资源目录表。

它记录:

某个 address 对应哪个资源。 某个 label 对应哪些资源。 资源在哪个 Bundle 里。 Bundle 的 Hash 是多少。 资源依赖哪些其他 Bundle。 应该用哪个 Provider 加载。

热更新时,Addressables 可以先检查远程 Catalog 是否变化。 如果 Catalog 变化,再根据 Hash 下载新的 Bundle。

Provider 是什么?

Provider 是具体加载器。

比如:

从本地文件加载。 从远程服务器下载。 从 AssetBundle 里加载资源。 加载场景。 加载二进制数据。

你调用的可能只是:

c
Addressables.LoadAssetAsync

但底层会根据 Catalog 找到对应 Provider,再执行真正的加载逻辑。

Handle 是什么?

AsyncOperationHandle 是 Addressables 返回的加载句柄。

它很重要,因为 Addressables 的释放通常不是靠你直接卸载 Bundle,而是靠 Handle 做引用计数。

加载一次,引用增加。 Release 一次,引用减少。 引用为 0 后,资源和依赖才有机会被释放。

面试要特别说:

Addressables 不是自动替你无限管理内存,你加载了资源,最后还是要 Release。

代码示例:加载和释放 Prefab

c
using UnityEngine; // 引入 Unity 引擎命名空间
using UnityEngine.AddressableAssets; // 引入 Addressables 地址化资源命名空间
using UnityEngine.ResourceManagement.AsyncOperations; // 引入异步操作句柄命名空间

public class AddressablesLoadDemo : MonoBehaviour // 定义一个 Addressables 加载示例脚本
{ // 类开始
    private AsyncOperationHandle<GameObject> _handle; // 保存加载资源时返回的异步句柄

    private GameObject _instance; // 保存实例化出来的场景对象

    public void LoadPlayer() // 定义加载玩家 Prefab 的方法
    { // LoadPlayer 方法开始
        _handle = Addressables.LoadAssetAsync<GameObject>("PlayerPrefab"); // 通过地址异步加载 PlayerPrefab 资源

        _handle.Completed += OnPlayerLoaded; // 注册加载完成回调
    } // LoadPlayer 方法结束

    private void OnPlayerLoaded(AsyncOperationHandle<GameObject> handle) // 定义资源加载完成后的回调
    { // 回调方法开始
        if (handle.Status != AsyncOperationStatus.Succeeded) // 判断加载是否失败
        { // if 开始
            Debug.LogError("PlayerPrefab 加载失败"); // 输出加载失败日志

            return; // 直接返回,避免继续使用空资源
        } // if 结束

        GameObject prefab = handle.Result; // 从句柄中取出加载完成的 Prefab

        _instance = Instantiate(prefab); // 把 Prefab 实例化到场景中
    } // 回调方法结束

    public void ReleasePlayer() // 定义释放玩家资源的方法
    { // ReleasePlayer 方法开始
        if (_instance != null) // 判断场景实例是否存在
        { // if 开始
            Destroy(_instance); // 销毁场景中的实例对象

            _instance = null; // 清空实例引用
        } // if 结束

        if (_handle.IsValid()) // 判断异步句柄是否仍然有效
        { // if 开始
            Addressables.Release(_handle); // 释放 Addressables 资源引用

            _handle = default; // 清空句柄,避免重复释放
        } // if 结束
    } // ReleasePlayer 方法结束
} // 类结束

常见坑

  1. 只加载不释放LoadAssetAsync 后忘记 Release,资源引用计数一直不归零,内存就下不来。
  2. 实例化和资源释放混淆Destroy(instance) 只是销毁场景对象。 Addressables.Release(handle) 才是释放资源引用。
  3. Handle 丢失 如果你没保存 AsyncOperationHandle,后面就很难正确释放。
  4. 重复加载同一个资源 多次 Load 会增加引用计数。 要么统一资源管理器缓存 Handle,要么保证谁加载谁释放。
  5. 依赖资源没加载完就使用 Addressables 会处理依赖加载,但你必须等异步完成后再使用结果。

和 AssetBundle 的区别

AssetBundle 偏底层,你要自己管理:

包名。
依赖。
版本。
下载。
缓存。
加载。
卸载。

Addressables 偏上层,它帮你封装:

Address。
Label。
Catalog。
Group。
Provider。
依赖加载。
远程更新。
引用计数释放。

但底层很多时候还是 AssetBundle。

面试高分回答

IMPORTANT

Addressables 是 Unity 对 AssetBundle 的上层资源管理框架。它通过 Address 或 Label 找资源,运行时先查 Catalog,把 Key 映射成 ResourceLocation,再由 Provider 加载本地或远程 Bundle,并自动处理依赖。加载结果会返回 AsyncOperationHandle,Addressables 通过这个 Handle 做引用计数管理,所以资源不用时必须调用 Addressables.Release。它的优势是统一了本地资源、远程资源、依赖、缓存和热更新流程,但项目里仍然要设计好分组策略、引用释放和重复加载缓存,否则一样会出现资源泄漏和重复打包。

资源卸载流程

unity-resource-unload-flow

资源卸载流程是什么?

Unity 资源卸载不能只说一句 Resources.UnloadUnusedAssets(),要分清 4 层东西:

场景实例GameObjectComponent,比如实例化出来的怪物。 资源对象TextureMeshMaterialAudioClipPrefab资源包壳AssetBundle 文件被加载后的容器。 托管内存:C# 对象、List、Dictionary、字符串、闭包等。

完整流程一般是:

  1. 业务层停止使用资源 比如关闭 UI、切出场景、战斗结束、角色死亡。
  2. 销毁场景实例 调用 Destroy(gameObject)。 这只表示场景里的实例不用了,不代表贴图、Mesh、Prefab 资源立刻释放。
  3. 断开引用 清掉缓存字典、静态变量、事件订阅、对象池引用。 如果还有引用,Unity 会认为资源仍然在用。
  4. 释放资源系统引用 Addressables 调 Addressables.Release(handle)Addressables.ReleaseInstance(instance)。 AssetBundle 自己管理时,要让引用计数减一。
  5. 引用计数归零后卸载资源或包 AssetBundle 可以 Unload(false)Unload(true)。 Addressables 会根据引用计数处理依赖释放。
  6. 必要时调用 Resources.UnloadUnusedAssets() 它会扫描当前没有引用的 UnityEngine.Object 资源并释放。 这个操作比较重,通常放在切场景、Loading 界面、战斗结算后。
  7. 托管对象交给 GC C# 层对象由 GC 回收。 GC.Collect() 不建议频繁手动调用,只适合明确的大阶段切换。

Destroy 不等于资源卸载

Destroy(instance) 只是销毁场景对象。

比如你实例化了一个怪物 Prefab:

Destroy(monster) 会销毁怪物实例。 但是怪物用到的 Prefab、Texture、Mesh、Material 不一定释放。 如果资源管理器、静态变量、对象池、事件系统还持有引用,它们仍然留在内存里。

Addressables 卸载重点

Addressables 的核心是 Handle 引用计数。

加载:

c
Addressables.LoadAssetAsync<T>()

释放:

c
Addressables.Release(handle)

实例化:

c
Addressables.InstantiateAsync()

释放实例:

c
Addressables.ReleaseInstance(instance)

面试里要说清:

Destroy 只销毁实例。 Release 才是释放 Addressables 引用。 Handle 丢了,就很容易资源泄漏。

AssetBundle 卸载重点

AssetBundle.Unload(false): 只卸载 Bundle 包壳。 已经 LoadAsset 出来的资源还在。

AssetBundle.Unload(true): 卸载 Bundle 包壳,也卸载从这个 Bundle 加载出来的资源。 如果场景对象还在用这些资源,可能出现贴图丢失、材质异常、引用失效。

所以真实项目里通常不会随便 Unload(true),而是通过引用计数控制。

C# 示例:Addressables 引用计数释放

c
using System.Collections.Generic; // 引入 Dictionary 集合命名空间
using UnityEngine; // 引入 Unity 引擎命名空间
using UnityEngine.AddressableAssets; // 引入 Addressables 地址化资源命名空间
using UnityEngine.ResourceManagement.AsyncOperations; // 引入异步操作句柄命名空间
public class AddressableRefManager : MonoBehaviour // 定义一个简单的 Addressables 引用计数管理器
{ // 类开始
    private readonly Dictionary<string, AsyncOperationHandle<GameObject>> _handles = new Dictionary<string, AsyncOperationHandle<GameObject>>(); // 保存每个资源地址对应的加载句柄
    private readonly Dictionary<string, int> _refCounts = new Dictionary<string, int>(); // 保存每个资源地址当前被引用的次数
    public async System.Threading.Tasks.Task<GameObject> LoadAsync(string key) // 定义异步加载资源的方法
    { // LoadAsync 方法开始
        if (_handles.TryGetValue(key, out AsyncOperationHandle<GameObject> cachedHandle)) // 判断这个资源是否已经加载过
        { // if 开始
            _refCounts[key] = _refCounts[key] + 1; // 已加载资源的引用计数加一
            await cachedHandle.Task; // 等待之前的加载任务完成
            return cachedHandle.Result; // 返回已经加载好的资源对象
        } // if 结束
        AsyncOperationHandle<GameObject> handle = Addressables.LoadAssetAsync<GameObject>(key); // 通过 Addressables 地址异步加载资源
        _handles[key] = handle; // 把加载句柄保存起来,后续释放时需要用
        _refCounts[key] = 1; // 第一次加载时引用计数设置为一
        await handle.Task; // 等待资源加载完成
        return handle.Result; // 返回加载完成的资源对象
    } // LoadAsync 方法结束
    public void Release(string key) // 定义释放资源引用的方法
    { // Release 方法开始
        if (!_handles.ContainsKey(key)) // 判断资源是否没有被管理器记录
        { // if 开始
            return; // 没有记录就直接返回,避免错误释放
        } // if 结束
        _refCounts[key] = _refCounts[key] - 1; // 当前资源引用计数减一
        if (_refCounts[key] > 0) // 判断是否还有其他地方在使用这个资源
        { // if 开始
            return; // 还有引用就不能真正释放
        } // if 结束
        Addressables.Release(_handles[key]); // 引用计数归零后释放 Addressables 句柄
        _handles.Remove(key); // 从句柄缓存中移除这个资源
        _refCounts.Remove(key); // 从引用计数字典中移除这个资源
    } // Release 方法结束
} // 类结束

面试高分回答

TIP

资源卸载我会分成实例、资源、Bundle 和托管内存四层。流程上先停止业务使用并销毁场景实例,然后断开静态变量、事件、缓存、对象池等引用,再通过资源管理器减少引用计数。Addressables 要释放 Handle 或实例,AssetBundle 要区分 Unload(false)Unload(true)。当资源确认没有引用后,再在切场景或 Loading 阶段调用 Resources.UnloadUnusedAssets() 清理 Native 资源,C# 托管对象则由 GC 回收。最后用 Memory Profiler 做卸载前后快照对比,确认 Texture、Mesh、AudioClip、Material 等资源真的下降。

内存泄漏排查

unity-memory-leak-debugging

内存泄漏是什么?

在 Unity 里,内存泄漏不一定是传统 C++ 那种 malloc 后忘记 free。 更常见的是:

对象已经不该用了,但仍然被引用着。 资源已经离开玩法了,但 Texture、Mesh、AudioClip 没释放。 反复进入退出某个界面或场景后,内存基线越来越高。

面试里判断一句话:

不是看内存一瞬间涨了,而是看退出玩法后能不能回落。

排查步骤

  1. 固定复现路径 比如:进入战斗 -> 退出战斗 -> 再进入 -> 再退出。 或者:打开背包 -> 关闭背包 -> 反复 10 次。 如果每一轮结束后内存基线都升高,就有泄漏嫌疑。
  2. Profiler 看趋势 先看 Memory、GC Alloc、Managed Heap、Texture、Mesh、Audio、RenderTexture。 目的是判断泄漏大概属于哪类。
  3. Memory Profiler 拍快照 拍两张快照:

进入玩法前一张。 退出玩法并清理后一张。

然后做 Compare,看退出后还多了哪些对象。

  1. 查引用链 Memory Profiler 里重点看对象为什么还活着。 比如被静态变量、事件、单例、缓存字典、对象池、协程、闭包引用着。
  2. 修复后重新验证 修完不要只靠感觉。 要重复同样路径,再拍快照,确认内存基线稳定。

常见泄漏来源

事件没取消订阅

发布者还活着,订阅者就会被委托引用住。 比如 UI 关闭了,但还订阅全局事件,UI 对象就可能无法释放。

静态变量持有对象

静态 List、Dictionary、单例缓存,很容易把场景对象、资源对象一直挂住。

对象池只回收不清理

对象池可以减少 GC,但如果池子无限增长,或者切场景不清池,也会变成内存常驻。

Addressables 忘记 Release

LoadAssetAsync 后没有 Addressables.Release(handle)InstantiateAsync 后没有 Addressables.ReleaseInstance(instance)

AssetBundle 没卸载

只销毁实例,不卸载 Bundle 或资源引用。 Unload(false)Unload(true) 也要分清。

RenderTexture、Texture2D、Mesh 动态创建后没释放

运行时 new Texture2Dnew Meshnew RenderTexture,不用时要释放或销毁。

泄漏防御代码:事件订阅和退订

c
using System; // 引入 Action 委托所在的命名空间
using UnityEngine; // 引入 Unity 引擎命名空间

public class EventLeakSafeView : MonoBehaviour // 定义一个避免事件泄漏的 UI 脚本
{ // 类开始
    public static event Action OnGoldChanged; // 定义一个静态事件,用来模拟全局金币变化通知

    private void OnEnable() // 当前对象启用时调用
    { // OnEnable 方法开始
        OnGoldChanged += RefreshView; // 订阅事件,让金币变化时刷新界面
    } // OnEnable 方法结束

    private void OnDisable() // 当前对象禁用时调用
    { // OnDisable 方法开始
        OnGoldChanged -= RefreshView; // 取消订阅,避免事件继续持有当前对象
    } // OnDisable 方法结束

    private void RefreshView() // 定义刷新界面的方法
    { // RefreshView 方法开始
        Debug.Log("刷新金币显示"); // 输出日志,模拟刷新 UI
    } // RefreshView 方法结束

    public static void RaiseGoldChanged() // 定义触发金币变化事件的方法
    { // RaiseGoldChanged 方法开始
        OnGoldChanged?.Invoke(); // 如果事件有订阅者,就依次通知它们
    } // RaiseGoldChanged 方法结束
} // 类结束

泄漏排查辅助代码:简单引用计数检查

c
using System.Collections.Generic; // 引入 Dictionary 集合命名空间
using UnityEngine; // 引入 Unity 引擎命名空间

public class SimpleResourceTracker // 定义一个简单资源引用追踪器
{ // 类开始
    private readonly Dictionary<string, int> _refCounts = new Dictionary<string, int>(); // 保存每个资源 key 的引用计数

    public void Retain(string key) // 定义增加资源引用的方法
    { // Retain 方法开始
        if (!_refCounts.ContainsKey(key)) // 判断字典里是否还没有这个资源
        { // if 开始
            _refCounts[key] = 0; // 第一次记录时把引用计数初始化为 0
        } // if 结束

        _refCounts[key] = _refCounts[key] + 1; // 把这个资源的引用计数加一
    } // Retain 方法结束

    public void Release(string key) // 定义减少资源引用的方法
    { // Release 方法开始
        if (!_refCounts.ContainsKey(key)) // 判断资源是否没有被记录过
        { // if 开始
            Debug.LogWarning("释放了没有加载记录的资源:" + key); // 输出警告,提示释放流程有问题

            return; // 直接返回,避免继续操作不存在的数据
        } // if 结束

        _refCounts[key] = _refCounts[key] - 1; // 把资源引用计数减一

        if (_refCounts[key] <= 0) // 判断资源是否已经没有任何引用
        { // if 开始
            _refCounts.Remove(key); // 从追踪字典中移除这个资源

            Debug.Log("资源引用归零,可以卸载:" + key); // 输出日志,提示资源可以卸载
        } // if 结束
    } // Release 方法结束

    public void DumpLeaks() // 定义打印疑似泄漏资源的方法
    { // DumpLeaks 方法开始
        foreach (KeyValuePair<string, int> pair in _refCounts) // 遍历当前所有仍有引用的资源
        { // foreach 开始
            Debug.LogWarning("疑似未释放资源:" + pair.Key + ",引用数:" + pair.Value); // 打印资源 key 和引用数量
        } // foreach 结束
    } // DumpLeaks 方法结束
} // 类结束

面试高分回答

TIP

我排查内存泄漏会先固定复现路径,比如反复进入退出战斗或 UI,然后用 Profiler 看内存基线是否持续上涨,并区分是 Managed Heap 还是 Texture、Mesh、AudioClip 这类 Native 资源上涨。接着用 Memory Profiler 在进入前和退出后各拍一张快照做 Diff,找退出后仍然残留的对象,再看引用链,重点排查静态变量、事件未退订、缓存字典、对象池、Addressables Handle 未 Release、AssetBundle 未卸载这些点。修复后我会用同样路径重新跑多轮,再对比快照,确认内存基线能回落,而不是只凭代码看起来释放了。

Texture 内存和压缩格式

unity-texture-memory-compression

Texture 内存怎么算?

Texture 运行时内存主要看:

分辨率。 像素格式。 是否有 Alpha。 是否开启 MipMap。 是否开启 Read/Write。 平台压缩格式。

最基础公式:

内存 = 宽 × 高 × 每像素字节数

比如 1024 × 1024RGBA32

1024 × 1024 × 4 = 4194304 字节 ≈ 4 MB

如果开启 MipMap,一般额外增加约 1/3

4 MB × 1.33 ≈ 5.33 MB

重点:PNG / JPG 文件大小不等于运行时内存

一张 JPG 在硬盘上可能只有 200 KB。 但导入 Unity 后,如果运行时格式是 RGBA32,它进显存可能就是几 MB。

因为:

JPG / PNG 是磁盘压缩格式。 TextureFormat 是 GPU 运行时采样格式。 Unity 导入后会转成平台可用的纹理格式。

常见格式怎么选?

RGBA32:质量最好,4 字节每像素,但显存最大。 RGB24:没有 Alpha,3 字节每像素。 Alpha8:只有透明度,适合遮罩。 ETC2:Android 常见格式,支持 RGB 和 Alpha。 ASTC:移动端常用,高质量高压缩率,Android/iOS 都常见。 PVRTC:老 iOS 设备常见,但对尺寸和画质有要求。 DXT/BC:PC 常见压缩格式,移动端不一定适合。

ASTC 怎么理解?

ASTC 按块压缩,比如:

ASTC 4x4:质量高,占用较大。 ASTC 6x6:质量和内存比较平衡。 ASTC 8x8:更省内存,但画质更差。 ASTC 12x12:很省,但容易糊。

块越大,压缩率越高,画质越低。 角色、UI、法线贴图一般别压太狠;远景、地表、低频颜色图可以压得狠一点。

MipMap 有什么影响?

MipMap 是为远处纹理准备的一系列小图。

优点:

减少远处闪烁。 减少纹理采样噪声。 提升远处渲染稳定性。 可能降低远处采样带宽。

缺点:

增加约 33% 内存。 UI 一般不需要 MipMap。 2D 精灵多数也不需要。

Read/Write Enabled 为什么危险?

如果 Texture 开了 Read/Write Enabled,Unity 可能会保留一份 CPU 可读写副本,同时 GPU 还有一份纹理。

结果就是:

显存有一份。 内存里可能还有一份。 总占用可能接近翻倍。

所以除非你要运行时读写像素,比如 GetPixelsSetPixels,否则应该关闭。

C# 估算代码

c
using UnityEngine; // 引入 Unity 引擎命名空间
public static class TextureMemoryEstimator // 定义一个纹理内存估算工具类
{ // 类开始
    public static float EstimateMB(Texture2D texture) // 定义一个估算纹理内存 MB 的方法
    { // 方法开始
        if (texture == null) // 判断纹理是否为空
        { // if 开始
            return 0f; // 空纹理没有内存可估算
        } // if 结束
        int bitsPerPixel = GetBitsPerPixel(texture.format); // 根据纹理格式获取每像素 bit 数
        float bytes = texture.width * texture.height * bitsPerPixel / 8f; // 根据宽高和 bit 数估算字节数
        if (texture.mipmapCount > 1) // 判断纹理是否开启了 MipMap
        { // if 开始
            bytes *= 1.33f; // MipMap 通常额外增加约三分之一内存
        } // if 结束
        return bytes / 1024f / 1024f; // 把字节数转换成 MB 并返回
    } // 方法结束
    private static int GetBitsPerPixel(TextureFormat format) // 定义根据 TextureFormat 返回 bpp 的方法
    { // 方法开始
        switch (format) // 根据纹理格式进行分支判断
        { // switch 开始
            case TextureFormat.RGBA32: // 判断是否是 RGBA32 格式
                return 32; // RGBA32 是每像素 32 bit
            case TextureFormat.ARGB32: // 判断是否是 ARGB32 格式
                return 32; // ARGB32 是每像素 32 bit
            case TextureFormat.RGB24: // 判断是否是 RGB24 格式
                return 24; // RGB24 是每像素 24 bit
            case TextureFormat.Alpha8: // 判断是否是 Alpha8 格式
                return 8; // Alpha8 是每像素 8 bit
            case TextureFormat.DXT1: // 判断是否是 DXT1 格式
                return 4; // DXT1 约等于每像素 4 bit
            case TextureFormat.DXT5: // 判断是否是 DXT5 格式
                return 8; // DXT5 约等于每像素 8 bit
            case TextureFormat.ETC_RGB4: // 判断是否是 ETC RGB 格式
                return 4; // ETC RGB 通常约等于每像素 4 bit
            case TextureFormat.ETC2_RGBA8: // 判断是否是 ETC2 RGBA 格式
                return 8; // ETC2 RGBA 通常约等于每像素 8 bit
            case TextureFormat.ASTC_4x4: // 判断是否是 ASTC 4x4 格式
                return 8; // ASTC 4x4 约等于每像素 8 bit
            case TextureFormat.ASTC_6x6: // 判断是否是 ASTC 6x6 格式
                return 4; // ASTC 6x6 约等于每像素 4 bit
            case TextureFormat.ASTC_8x8: // 判断是否是 ASTC 8x8 格式
                return 2; // ASTC 8x8 约等于每像素 2 bit
            default: // 处理没有列出的其他格式
                return 32; // 默认按 RGBA32 粗略估算,避免低估内存
        } // switch 结束
    } // 方法结束
} // 类结束

面试高分回答

NOTE

Texture 的运行时内存不是看 PNG/JPG 原文件大小,而是看 Unity 导入后的 GPU 格式。未压缩 RGBA32 是宽乘高乘 4 字节,1024 方图约 4 MB,开 MipMap 后大约增加 33%。移动端通常优先考虑 ASTC,Android 也会用 ETC2,iOS 旧设备可能遇到 PVRTC,PC 常见 BC/DXT。优化时我会先控制 Max Size,再根据用途选择格式:UI 和角色贴图保证质量,远景和低频贴图可以用更高压缩率;不需要读写像素就关闭 Read/Write;UI 和 2D 图通常关闭 MipMap;最后用 Memory Profiler 或 Build Report 验证 Texture 内存是否真的下降。

Shader Variant 爆炸

unity-shader-variant-explosion

Shader Variant 爆炸是什么?

Shader Variant 爆炸,就是同一个 Shader 因为太多 Keyword 组合,被编译出大量不同版本。

比如一个 Shader 有 10 个二选一开关:

2^10 = 1024 个变体

如果再乘上 Pass、平台、光照、阴影、雾、质量等级,数量就会继续膨胀。

所以面试里要记住一句话:

变体爆炸不是 Shader 文件多,而是 Keyword 组合数太多。

为什么会爆炸?

Shader 里经常会写:

_USE_NORMAL_MAP_USE_EMISSION_USE_RIM_LIGHT_ENABLE_FOG_SHADOW_ON_ADDITIONAL_LIGHTS

每个开关都有开和关两种状态。 多个开关组合起来,不是相加,而是相乘。

3 个开关是 8 种。 10 个开关是 1024 种。 20 个开关就可能是百万级理论组合。

multi_compile 和 shader_feature 区别

multi_compile: 通常会把声明的组合都编译进来。 适合运行时一定可能切换的功能,比如管线关键功能、平台宏、必须动态切换的开关。

shader_feature: Unity 构建时可以剔除没有被材质使用到的组合。 适合材质静态选择的功能,比如某些材质用法线贴图,某些不用。

shader_feature_local: 只影响当前 Shader,不污染全局 Keyword。 项目里更推荐优先使用 local keyword。

变体爆炸有什么后果?

构建时间变长。 包体变大。 Shader 编译缓存变大。 运行时首次使用某些变体可能卡顿。 内存和加载时间变差。 移动端首帧或切场景时更容易抖。

怎么优化?

  1. 减少 Keyword 数量 能用普通参数控制的,就不要用 Keyword。 比如颜色、强度、阈值,通常用 uniform 参数就行。
  2. shader_feature 替代不必要的 multi_compile 如果某个功能不是运行时必须动态切换,优先考虑 shader_feature
  3. 使用 local keywordshader_feature_localmulti_compile_local,减少全局 Keyword 污染。
  4. 拆 Shader 不要一个超级 Shader 兼容所有材质。 角色、地形、UI、特效可以拆开,减少互不相关的开关组合。
  5. 构建时剔除无用变体 根据平台、质量等级、是否使用阴影、是否使用雾,剔除不会用到的组合。
  6. Shader Variant Collection 预热 常用变体可以收集并预热,减少运行时首次使用卡顿。
  7. 关闭不用的管线功能 URP/HDRP 里如果项目不用额外光源、某些阴影、某些后处理,就不要让它们参与变体。

示例:不好的写法

c
#pragma multi_compile _ USE_NORMAL_MAP // 声明法线贴图开关,并强制保留相关变体
#pragma multi_compile _ USE_EMISSION // 声明自发光开关,并强制保留相关变体
#pragma multi_compile _ USE_RIM_LIGHT // 声明边缘光开关,并强制保留相关变体
#pragma multi_compile _ USE_DISSOLVE // 声明溶解开关,并强制保留相关变体

这 4 个开关理论上就是:

2 × 2 × 2 × 2 = 16 个变体

如果每个 Shader 都这么写,而且项目 Shader 很多,就会很快膨胀。

示例:更合理的写法

c
#pragma shader_feature_local _ USE_NORMAL_MAP // 只在当前 Shader 内声明法线贴图功能,并允许构建时剔除未使用变体
#pragma shader_feature_local _ USE_EMISSION // 只在当前 Shader 内声明自发光功能,并允许剔除没有材质使用的组合
#pragma shader_feature_local _ USE_RIM_LIGHT // 只在当前 Shader 内声明边缘光功能,避免污染全局 Keyword

如果 USE_DISSOLVE 只给溶解材质用,可以拆成单独的溶解 Shader,而不是塞进所有材质通用 Shader。

面试高分回答

TIP

Shader Variant 爆炸的根因是 Keyword 组合数乘法增长。同一个 Shader 里每多一个二选一 Keyword,理论变体数量就会翻倍;再叠加 Pass、平台、阴影、雾、光照和质量等级,构建数量会迅速膨胀。它会导致构建时间变长、包体变大、运行时首次加载变体卡顿。优化上我会先减少 Keyword 维度,能用参数就不用 Keyword;材质静态功能用 shader_feature_local,运行时必须切换才用 multi_compile;再按平台和项目配置做变体剔除,必要时拆分超级 Shader,并用 Shader Variant Collection 预热常用变体。

包体优化

unity-package-size-optimization

包体优化是什么?

包体优化就是减少 APK、IPA、安装包、首包、热更包的体积。 它不是一句“压缩图片”就完事,而是要先分析包体由什么组成,再按资源类型逐项处理。

Unity 项目里包体大头通常是:

Texture。 Audio。 Shader Variant。 模型和动画。 重复资源。 未使用资源。 第三方 SDK。 Resources 目录资源。 首包和热更包边界不合理。

核心流程

  1. 先分析包体构成 看 Build Report、Addressables Analyze、AssetBundle Browser。 先找大头,比如 Texture 占 60%,那优先处理纹理。
  2. 优化 Texture 控制 Max Size。 移动端用 ASTC、ETC2。 UI 不需要 MipMap 就关掉。 不需要读写像素就关 Read/Write Enabled。 透明图注意 Alpha 通道成本。
  3. 优化 Audio BGM 用压缩格式和 Streaming。 短音效可以 Decompress On Load。 语音按语言拆包。 不用的采样率不要太高。
  4. 裁剪 Shader Variant 减少 multi_compile。 用 shader_feature_local。 关闭不用的 URP/HDRP 功能。 构建时剔除无用变体。
  5. 清理重复资源 公共贴图、公共材质、公共 Shader 单独成包。 避免多个 Bundle 各自带一份依赖。
  6. 清理未使用资源 尤其检查 Resources 目录。 Resources 下的资源容易被整体打进包。 废弃 Prefab、测试贴图、临时音频要清掉。
  7. 拆首包和热更包 首包只放登录、基础 UI、公共 Shader、必要配置。 大场景、活动、语音、高清资源放远程包。 这样安装包小,后续按需下载。

编辑器扫描大资源示例

c
#if UNITY_EDITOR // 只在 Unity 编辑器环境下编译这段代码
using System.Collections.Generic; // 引入 List 集合类型
using System.IO; // 引入文件信息读取相关类型
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Unity 引擎基础 API
public static class LargeAssetScanner // 定义一个大资源扫描工具类
{ // 类开始
    private class AssetSizeInfo // 定义一个保存资源大小信息的内部类
    { // 内部类开始
        public string Path; // 保存资源路径
        public long Bytes; // 保存资源字节大小
    } // 内部类结束
    [MenuItem("Tools/Build/Scan Large Assets")] // 在 Unity 菜单栏添加扫描入口
    private static void ScanLargeAssets() // 定义扫描大资源的方法
    { // 方法开始
        string[] guids = AssetDatabase.FindAssets(""); // 查找 Assets 目录下所有资源 GUID
        List<AssetSizeInfo> assets = new List<AssetSizeInfo>(); // 创建列表保存资源大小信息
        foreach (string guid in guids) // 遍历每一个资源 GUID
        { // foreach 开始
            string path = AssetDatabase.GUIDToAssetPath(guid); // 把 GUID 转换成 Unity 资源路径
            if (!File.Exists(path)) // 判断路径是否不是普通文件
            { // if 开始
                continue; // 跳过文件夹或不存在的路径
            } // if 结束
            FileInfo info = new FileInfo(path); // 创建文件信息对象
            if (info.Length < 1024 * 1024) // 判断资源是否小于 1 MB
            { // if 开始
                continue; // 小资源暂时忽略,优先看大资源
            } // if 结束
            AssetSizeInfo item = new AssetSizeInfo(); // 创建一个资源大小记录
            item.Path = path; // 记录资源路径
            item.Bytes = info.Length; // 记录资源字节数
            assets.Add(item); // 把资源记录加入列表
        } // foreach 结束
        assets.Sort((a, b) => b.Bytes.CompareTo(a.Bytes)); // 按资源大小从大到小排序
        int count = Mathf.Min(30, assets.Count); // 最多输出前 30 个大资源
        for (int i = 0; i < count; i++) // 循环输出排序后的资源
        { // for 开始
            float mb = assets[i].Bytes / 1024f / 1024f; // 把字节数转换成 MB
            Debug.Log($"{mb:F2} MB  {assets[i].Path}"); // 在 Console 输出资源大小和路径
        } // for 结束
    } // 方法结束
} // 类结束
#endif // 结束编辑器条件编译

面试高分回答

NOTE

包体优化我不会直接上来压图,而是先用 Build Report 或 Addressables Analyze 看包体构成,确认是纹理、音频、Shader Variant、重复依赖还是未使用资源占比高。纹理上控制 Max Size、平台压缩格式、MipMap 和 Read/Write;音频上区分 BGM、音效、语音,选择压缩和加载方式;Shader 上裁剪无用 Variant,减少 multi_compile;资源组织上清理 Resources,处理重复依赖,把公共资源单独成包。最后会规划首包和热更包边界,让登录和基础 UI 留在首包,大场景、活动、语音和高清资源走按需下载。每次优化后都重新打包对比数字,比如首包从多少 MB 降到多少 MB,同时确认画质、加载时间和运行时内存没有变差。

加载优化

unity-loading-optimization

加载优化是什么?

加载优化不是单纯让进度条动得快,而是减少玩家等待时间,并避免进入场景后第一帧卡顿。

Unity 加载慢通常来自:

下载慢。 磁盘 IO 慢。 AssetBundle 解压慢。 资源反序列化慢。 Prefab 实例化太重。 Shader 首次使用卡顿。 GC 或资源释放卡顿。 大量脚本 Awake / OnEnable / Start 集中执行。

核心思路

  1. 先定位瓶颈 用 Profiler Timeline 看 Loading 阶段耗时。 不要靠猜,要知道是 IO、解压、反序列化、Instantiate、Shader 还是 GC。
  2. 减少加载资源量 首屏只加载必要资源。 大场景、语音、活动资源、高清资源按需加载。 Texture、Audio、Mesh、Shader Variant 都要控制体积。
  3. 提前加载 能预测下一步要用的资源,就提前在后台加载。 比如玩家靠近副本入口时,提前加载副本公共资源。
  4. 异步加载 场景用 LoadSceneAsync。 资源用 Addressables 或 AssetBundle 异步接口。 不要在主线程一次同步加载大量资源。
  5. 分帧实例化 加载资源不一定最卡,真正卡的经常是 Instantiate。 大量怪物、UI Item、特效对象不要一帧创建完,要分帧或使用对象池。
  6. Shader 预热 常用 Shader Variant 提前预热。 否则第一次显示角色、特效、场景材质时,可能出现明显卡顿。
  7. Loading 阶段做清理 切场景时可以在 Loading 阶段卸载不用资源。 不要等战斗开始后才触发大 GC 或 UnloadUnusedAssets

场景异步加载示例

c
using System.Collections; // 引入协程相关命名空间
using UnityEngine; // 引入 Unity 引擎命名空间
using UnityEngine.SceneManagement; // 引入场景管理命名空间
using UnityEngine.UI; // 引入 UI 组件命名空间

public class SceneLoadingController : MonoBehaviour // 定义一个场景加载控制器
{ // 类开始
    public Slider ProgressSlider; // 保存进度条组件引用

    public Text ProgressText; // 保存进度文本组件引用

    public void LoadScene(string sceneName) // 定义开始加载场景的方法
    { // 方法开始
        StartCoroutine(LoadSceneAsync(sceneName)); // 启动异步加载场景协程
    } // 方法结束

    private IEnumerator LoadSceneAsync(string sceneName) // 定义异步加载场景的协程
    { // 协程开始
        AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName); // 开始异步加载目标场景

        operation.allowSceneActivation = false; // 先不允许自动激活场景,方便控制进度条和预热阶段

        while (operation.progress < 0.9f) // Unity 场景加载到 90% 后会等待激活
        { // while 开始
            float progress = operation.progress / 0.9f; // 把 0 到 0.9 的真实进度映射到 0 到 1

            ProgressSlider.value = progress; // 更新进度条数值

            ProgressText.text = Mathf.RoundToInt(progress * 100f) + "%"; // 更新进度文本

            yield return null; // 等待下一帧继续刷新
        } // while 结束

        yield return StartCoroutine(PrewarmAfterLoad()); // 在激活场景前做一些预热工作

        ProgressSlider.value = 1f; // 把进度条显示到满

        ProgressText.text = "100%"; // 把进度文本显示为 100%

        operation.allowSceneActivation = true; // 允许 Unity 激活新场景
    } // 协程结束

    private IEnumerator PrewarmAfterLoad() // 定义场景激活前的预热协程
    { // 预热协程开始
        yield return null; // 空一帧,让 UI 有机会刷新

        Shader.WarmupAllShaders(); // 预热已经加载到内存中的 Shader,减少首次使用卡顿

        yield return null; // 再空一帧,避免预热和激活挤在同一帧
    } // 预热协程结束
} // 类结束

分帧实例化示例

c
using System.Collections; // 引入协程相关命名空间
using UnityEngine; // 引入 Unity 引擎命名空间

public class BatchInstantiateDemo : MonoBehaviour // 定义一个分帧实例化示例脚本
{ // 类开始
    public GameObject Prefab; // 保存需要实例化的预制体

    public int TotalCount = 100; // 保存总共需要创建的数量

    public int CountPerFrame = 10; // 保存每一帧最多创建的数量

    public IEnumerator CreateObjects() // 定义分帧创建对象的协程
    { // 协程开始
        int created = 0; // 记录当前已经创建的对象数量

        while (created < TotalCount) // 当还没有创建完所有对象时继续循环
        { // while 开始
            int frameCount = 0; // 记录当前帧已经创建的对象数量

            while (frameCount < CountPerFrame && created < TotalCount) // 当前帧没超量并且总数没完成时继续创建
            { // 内层 while 开始
                Instantiate(Prefab); // 实例化一个预制体对象

                created++; // 已创建总数加一

                frameCount++; // 当前帧创建数量加一
            } // 内层 while 结束

            yield return null; // 等待下一帧再继续创建,避免单帧卡顿
        } // while 结束
    } // 协程结束
} // 类结束

面试高分回答

CAUTION

加载优化我会先用 Profiler Timeline 把 Loading 阶段拆开看,确认是下载、IO、解压、反序列化、实例化、Shader 首次使用还是 GC 造成的。资源层面会压缩 Texture、Audio、Mesh,裁剪 Shader Variant,并把首包和远程包拆清楚;流程层面会提前预测和预加载下一阶段资源,场景和资源都走异步加载;进入场景前做 Shader 预热和对象池预创建;大量 Prefab、UI Item、怪物、特效不要一帧 Instantiate 完,而是分帧创建。最后用数据验证,比如 Loading 总耗时、峰值帧耗时、首帧耗时和内存峰值优化前后分别下降多少。

大量怪物优化

unity-large-monster-optimization

大量怪物优化的核心

大量怪物优化不是简单地“少刷怪”,而是让怪物按 距离、可见性、重要程度 分级。

近处怪物:完整 AI、动画、技能、碰撞。 中距离怪物:降低 AI Tick 频率,动画简化。 远处怪物:只保留位置同步或简单移动。 不可见怪物:暂停动画、降低逻辑频率,甚至只保留数据。

主要优化方向

  1. 减少大量 Update 不要每只怪物都挂一个 Update() 做 AI、感知、寻路。 改成统一 MonsterManager 调度,按帧预算更新一部分怪物。
  2. AI 分帧执行 比如一共有 300 只怪,不要一帧全思考。 可以每帧只更新 30 只,10 帧轮完一轮。 玩家基本感知不到,但 CPU 峰值会明显下降。
  3. 距离 LOD 离玩家近的怪物每帧更新。 中距离怪物 0.2 秒更新一次。 远距离怪物 1 秒更新一次。 不可见怪物可以只做极低频状态更新。
  4. 寻路限流 寻路通常很贵,尤其大量怪物同时重新寻路。 做法是路径请求排队,每帧只处理固定数量。 同目标、同区域的怪物可以复用路径或使用局部避障。
  5. 动画优化 大量 Animator 很容易吃 CPU。 远处怪物可以降低 Animator 更新频率。 不可见怪物用 Animator Culling Mode。 普通小怪可以用简单状态动画,不一定每个都上复杂动画层。
  6. 物理优化 减少 Rigidbody 数量。 减少复杂 Collider。 怪物感知不要全场 Physics.OverlapSphere。 可以用网格、四叉树、空间分桶先筛选附近单位。
  7. 渲染优化 同类怪物尽量共用材质。 远处使用 LODGroup。 不可见对象剔除。 大量相同模型可以考虑 GPU Instancing。 阴影、透明、后处理影响也要控制。
  8. 对象池 怪物、血条、伤害数字、死亡特效、子弹、掉落物都适合池化。 避免战斗中频繁 InstantiateDestroy 造成 GC 和主线程尖峰。

AI 分帧管理器示例

c
using System.Collections.Generic; // 引入 List 集合类型

using UnityEngine; // 引入 Unity 引擎命名空间

public interface IMonsterTick // 定义怪物分帧更新接口
{ // 接口开始
    void ManagedTick(float deltaTime); // 定义由管理器调用的怪物逻辑更新方法
} // 接口结束

public class MonsterUpdateManager : MonoBehaviour // 定义怪物统一更新管理器
{ // 类开始
    public int TickCountPerFrame = 30; // 每帧最多更新多少只怪物

    private readonly List<IMonsterTick> _monsters = new List<IMonsterTick>(); // 保存当前活跃怪物列表

    private int _cursor; // 保存本帧从哪个怪物索引开始更新

    public void Register(IMonsterTick monster) // 定义注册怪物的方法
    { // 方法开始
        if (monster == null) // 判断传入怪物是否为空
        { // if 开始
            return; // 空对象不加入列表
        } // if 结束

        if (_monsters.Contains(monster)) // 判断怪物是否已经注册过
        { // if 开始
            return; // 已注册就不重复加入
        } // if 结束

        _monsters.Add(monster); // 把怪物加入统一更新列表
    } // 方法结束

    public void Unregister(IMonsterTick monster) // 定义注销怪物的方法
    { // 方法开始
        _monsters.Remove(monster); // 从列表中移除怪物
    } // 方法结束

    private void Update() // Unity 每帧调用
    { // Update 开始
        if (_monsters.Count == 0) // 判断当前是否没有怪物
        { // if 开始
            return; // 没有怪物就不做任何更新
        } // if 结束

        int tickCount = Mathf.Min(TickCountPerFrame, _monsters.Count); // 计算本帧实际需要更新的怪物数量

        for (int i = 0; i < tickCount; i++) // 循环更新本帧预算内的怪物
        { // for 开始
            if (_cursor >= _monsters.Count) // 判断游标是否超过列表末尾
            { // if 开始
                _cursor = 0; // 超过末尾后从头开始轮询
            } // if 结束

            IMonsterTick monster = _monsters[_cursor]; // 取出当前要更新的怪物

            monster.ManagedTick(Time.deltaTime); // 调用怪物的受管理更新逻辑

            _cursor++; // 游标移动到下一只怪物
        } // for 结束
    } // Update 结束
} // 类结束

面试高分回答

IMPORTANT

大量怪物优化我会先用 Profiler 定位瓶颈,看是 BehaviourUpdate、Animator、Physics、NavMesh、渲染还是 GC 高。设计上不会让每只怪物独立 Update,而是用管理器统一调度,按距离和可见性分层:近处完整更新,中距离降频,远处只做低频移动或状态同步,不可见对象暂停动画和物理。寻路会做请求队列和每帧预算,避免同一帧大量怪物同时算路径;渲染上用 LOD、剔除、材质复用、GPU Instancing;内存上怪物、特效、血条、伤害数字全部对象池化。最后用数据证明,比如怪物 300 只时主线程从 28ms 降到 14ms,GC Alloc 接近 0,Animator 和 Physics 峰值明显下降。

大量 UI 优化

unity-large-ui-optimization

大量 UI 优化的核心

Unity UGUI 大量 UI 卡顿,通常不是因为 UI “看起来多”,而是因为这些成本叠在一起:

Canvas 重建。 Layout 重建。 Graphic Raycast 检测。 Overdraw 半透明层叠。 大量 Item 创建销毁。 Text 频繁刷新。 图集和材质打断合批。

1. Canvas 拆分

UGUI 的 Canvas 有个重要特点:

Canvas 下某个 UI 变化,可能导致整个 Canvas 重新构建。

所以不要把所有 UI 都放在一个大 Canvas 里。

常见拆法:

静态背景一个 Canvas。 动态血条一个 Canvas。 滚动列表一个 Canvas。 弹窗一个 Canvas。 频繁变化的倒计时、数字、红点单独拆出去。

这样一个小数字变化,不会把整屏 UI 都拖去重建。

2. 减少 Layout 重建

HorizontalLayoutGroupVerticalLayoutGroupContentSizeFitter 很方便,但大量嵌套时会很贵。

大量 UI 里要注意:

不要深层嵌套自动布局。 滚动列表里少用 ContentSizeFitter。 频繁变化的列表尽量手动计算位置。 初始化完成后,不再变化的布局可以关闭 Layout 组件。

3. ScrollView 使用虚拟列表

如果背包有 1000 个物品,不应该创建 1000 个 Item。

正确做法:

只创建屏幕可见的十几个 Item。 滚动时复用 Item。 Item 不变,只刷新显示数据。 这就是虚拟列表。

这样节点数量、Canvas 重建、GC、布局计算都会下降。

4. 关闭不必要的 Raycast Target

很多 Image、Text 只是展示,不需要点击。 如果它们开着 Raycast Target,点击时 GraphicRaycaster 会遍历更多 UI。

优化方式:

不可点击 Image 关闭 Raycast Target。 不可点击 Text 关闭 Raycast Target。 装饰图、背景图、分割线都关掉。 只保留 Button、Toggle、Slider 这类真正交互控件。

5. 减少 Overdraw

UI 大部分是半透明渲染,层叠越多,像素重复绘制越多。

常见优化:

减少全屏半透明遮罩。 关闭被遮挡的大背景。 弹窗打开时隐藏下面复杂界面。 减少多个透明 Image 叠在一起。 移动端 UI 特效不要太多。

6. 图集和材质合批

UI 合批容易被这些东西打断:

不同贴图。 不同材质。 不同 Mask。 不同 Canvas。 不同 Shader。 层级顺序插入了不同材质元素。

所以 UI 图片尽量进图集,同一界面尽量使用相同材质。 常驻 UI 和活动 UI 可以分图集,避免一次加载太大。

虚拟列表简化代码

c
using System.Collections.Generic; // 引入 List 集合类型
using UnityEngine; // 引入 Unity 引擎命名空间
using UnityEngine.UI; // 引入 Unity UI 命名空间

public class SimpleVirtualList : MonoBehaviour // 定义一个简单虚拟列表脚本
{ // 类开始
    public RectTransform Content; // 保存 ScrollView 的 Content 节点
    public GameObject ItemPrefab; // 保存列表 Item 预制体
    public float ItemHeight = 80f; // 保存每个 Item 的高度
    public int TotalCount = 1000; // 保存数据总数量
    public int VisibleCount = 12; // 保存屏幕上需要复用的 Item 数量
    private readonly List<Text> _itemTexts = new List<Text>(); // 保存可见 Item 上的文本组件
    private int _lastStartIndex = -1; // 保存上一次刷新时的起始数据索引

    private void Start() // Unity 初始化后调用
    { // Start 方法开始
        Content.sizeDelta = new Vector2(Content.sizeDelta.x, TotalCount * ItemHeight); // 根据数据总数设置 Content 高度

        for (int i = 0; i < VisibleCount; i++) // 创建固定数量的可见 Item
        { // for 开始
            GameObject item = Instantiate(ItemPrefab, Content); // 实例化一个 Item 并挂到 Content 下

            RectTransform rect = item.GetComponent<RectTransform>(); // 获取 Item 的 RectTransform 组件

            rect.anchorMin = new Vector2(0f, 1f); // 设置锚点最小值到左上

            rect.anchorMax = new Vector2(1f, 1f); // 设置锚点最大值到右上

            rect.pivot = new Vector2(0.5f, 1f); // 设置 Pivot 到顶部中心

            Text text = item.GetComponentInChildren<Text>(); // 获取 Item 内部的 Text 组件

            _itemTexts.Add(text); // 把 Text 组件保存到复用列表中
        } // for 结束

        RefreshVisibleItems(); // 初始化时刷新一次可见 Item
    } // Start 方法结束

    private void Update() // Unity 每帧调用
    { // Update 方法开始
        RefreshVisibleItems(); // 根据滚动位置刷新可见 Item
    } // Update 方法结束

    private void RefreshVisibleItems() // 定义刷新可见 Item 的方法
    { // 方法开始
        float scrollY = Content.anchoredPosition.y; // 读取 Content 当前向上滚动的距离

        int startIndex = Mathf.FloorToInt(scrollY / ItemHeight); // 根据滚动距离计算第一个可见数据索引

        startIndex = Mathf.Clamp(startIndex, 0, Mathf.Max(0, TotalCount - VisibleCount)); // 限制起始索引不越界

        if (startIndex == _lastStartIndex) // 判断起始索引是否没有变化
        { // if 开始
            return; // 没有变化就不重复刷新,减少 UI 更新
        } // if 结束

        _lastStartIndex = startIndex; // 记录新的起始索引

        for (int i = 0; i < _itemTexts.Count; i++) // 遍历所有复用 Item
        { // for 开始
            int dataIndex = startIndex + i; // 计算当前 Item 对应的数据索引

            RectTransform rect = _itemTexts[i].transform.parent.GetComponent<RectTransform>(); // 获取当前 Item 的 RectTransform

            rect.anchoredPosition = new Vector2(0f, -dataIndex * ItemHeight); // 把 Item 放到对应数据位置

            _itemTexts[i].text = "Item " + dataIndex; // 刷新 Item 显示的数据文本
        } // for 结束
    } // 方法结束
} // 类结束

面试高分回答

TIP

大量 UI 优化我会先用 Profiler 看 Canvas.BuildBatchLayout rebuildGraphicRaycaster 和 GC Alloc,再用 Frame Debugger 看 UI 是否被材质、贴图、Mask 打断合批。优化上会先拆 Canvas,把静态 UI、动态数字、滚动列表、弹窗分开,缩小重建范围;大量列表用虚拟列表,只保留可见 Item 并复用;纯展示 Image 和 Text 关闭 Raycast Target;减少嵌套 LayoutGroup 和 ContentSizeFitter;图集和材质统一,减少合批打断;移动端还要控制半透明层叠和 UI 特效 Overdraw。最后用优化前后的 Canvas.BuildBatch 耗时、DrawCall、GC Alloc 和节点数量来证明收益。

大量特效优化

unity-large-vfx-optimization

大量特效优化的核心

大量特效贵,通常不是因为“一个特效很贵”,而是因为很多成本叠加:

粒子数量多。 透明 Overdraw 高。 材质和贴图太分散。 频繁 Instantiate / Destroy。 粒子碰撞、光照、噪声模块开太多。 同屏播放数量没有上限。 Shader 太复杂,移动端吃不住。

1. 控制粒子数量

粒子越多,CPU 模拟和 GPU 绘制都越贵。

优化方式:

降低 Emission Rate。 缩短 Start Lifetime。 限制 Max Particles。 减少 Sub Emitters。 远处特效使用低配版本。 不重要的小特效可以直接丢弃。

2. 降低透明 Overdraw

特效多数是透明 Blend。 透明物体通常不能像不透明物体那样高效利用深度剔除,所以非常容易 Overdraw。

优化方式:

少用大面积透明贴片。 贴图透明区域尽量裁紧。 减少多层叠加的光晕。 少用全屏泛光类特效。 移动端慎用软粒子、扭曲、深度采样。

面试里可以说:

特效优化最重要的不是只看粒子数,还要看屏幕上重复绘制了多少像素。

3. 对象池复用

爆炸、命中、技能、脚印、受击火花这类特效会频繁播放。 如果每次都 Instantiate,播放完再 Destroy,会带来主线程尖峰和 GC 压力。

正确方式:

初始化时预创建一批特效。 播放时从池里取。 播放完 Stop 后回收。 复用时重置位置、旋转、缩放和粒子状态。

4. 统一材质和贴图

如果每个特效都用不同材质、不同贴图,就会增加 DrawCall 和 SetPass。

优化方式:

同类特效复用材质。 贴图做图集。 把遮罩、溶解、噪声图合并通道。 减少 Shader Keyword。 不要每个实例都 renderer.material 生成新材质。

5. 特效 LOD

近处技能可以完整播放。 中距离减少粒子数量和光晕。 远距离只保留核心形状。 屏幕外或距离太远的特效直接不播放。

比如:

Boss 大招近处播放完整版本。 远处只播放一个简化爆点。 小怪受击特效同屏太多时只保留高优先级目标。

6. 关闭昂贵模块

Particle System 里这些模块要谨慎:

Collision。 Lights。 Trails。 Noise。 Sub Emitters。 External Forces。 复杂 Custom Data。 高频脚本控制粒子。

移动端项目里,粒子光照、碰撞和复杂透明 Shader 都要特别小心。

特效对象池示例

c
using System.Collections.Generic; // 引入 Queue 队列集合类型
using UnityEngine; // 引入 Unity 引擎命名空间

public class VfxPool : MonoBehaviour // 定义一个特效对象池类
{ // 类开始
    public GameObject Prefab; // 保存需要复用的特效预制体

    public int PreloadCount = 20; // 保存预创建的特效数量

    private readonly Queue<GameObject> _pool = new Queue<GameObject>(); // 保存可复用特效对象的队列

    private void Awake() // Unity 初始化时调用
    { // Awake 方法开始
        for (int i = 0; i < PreloadCount; i++) // 循环预创建指定数量的特效
        { // for 开始
            GameObject item = CreateNewItem(); // 创建一个新的特效对象

            _pool.Enqueue(item); // 把新特效放入对象池队列
        } // for 结束
    } // Awake 方法结束

    public GameObject Play(Vector3 position, Quaternion rotation) // 定义播放特效的方法
    { // Play 方法开始
        GameObject item = _pool.Count > 0 ? _pool.Dequeue() : CreateNewItem(); // 优先从池里取对象,没有就临时创建

        item.transform.SetPositionAndRotation(position, rotation); // 设置特效播放位置和旋转

        item.SetActive(true); // 激活特效对象

        ParticleSystem particle = item.GetComponent<ParticleSystem>(); // 获取特效上的粒子系统组件

        particle.Clear(true); // 清理上一次残留的粒子状态

        particle.Play(true); // 从头开始播放粒子特效

        StartCoroutine(RecycleWhenFinished(item, particle)); // 启动协程,在播放结束后回收对象

        return item; // 返回当前播放的特效对象
    } // Play 方法结束

    private GameObject CreateNewItem() // 定义创建新特效对象的方法
    { // CreateNewItem 方法开始
        GameObject item = Instantiate(Prefab, transform); // 实例化一个特效对象并挂到对象池节点下

        item.SetActive(false); // 创建后先隐藏,等待后续复用

        return item; // 返回创建好的特效对象
    } // CreateNewItem 方法结束

    private System.Collections.IEnumerator RecycleWhenFinished(GameObject item, ParticleSystem particle) // 定义等待播放结束并回收的协程
    { // 协程开始
        while (particle != null && particle.IsAlive(true)) // 当粒子系统仍然存活时持续等待
        { // while 开始
            yield return null; // 等待下一帧再检查
        } // while 结束

        item.SetActive(false); // 播放结束后隐藏特效对象

        _pool.Enqueue(item); // 把特效对象放回对象池等待下次复用
    } // 协程结束
} // 类结束

面试高分回答

IMPORTANT

大量特效优化我会先用 Profiler、Frame Debugger 和 Overdraw 视图定位问题,看是粒子模拟、透明 Overdraw、DrawCall、Shader 还是频繁创建销毁造成的。优化上会限制同屏特效数量和粒子数,控制发射率、生命周期、Max Particles;透明特效减少大面积半透明贴片和多层 Blend;常用命中特效、爆炸、技能特效用对象池复用;同类特效复用材质和贴图,减少 SetPass;远距离或低优先级特效用 LOD 或直接丢弃。最后用数据验证,比如同屏 100 个特效时,主线程、Render Thread、DrawCall、Overdraw 和 GC Alloc 优化前后分别下降多少。

低端机适配

unity-low-end-device-adaptation

低端机适配是什么?

低端机适配不是简单把画质调低,而是根据设备能力做一整套分档策略。

低端机常见问题是:

CPU 弱,逻辑和 AI 跑不动。 GPU 弱,阴影、后处理、透明特效压力大。 内存小,容易闪退或被系统杀进程。 存储慢,加载和解压资源更慢。 散热差,长时间运行容易降频掉帧。

核心策略

  1. 设备分级 根据 SystemInfo.systemMemorySizeprocessorCountgraphicsMemorySize、设备型号、GPU 名称分成低中高档。 低端机默认低画质,高端机默认高画质,同时允许玩家手动调整。
  2. 画质降级 低端机优先关闭或降低:

实时阴影。 后处理。 Bloom。 SSAO。 高精度透明特效。 高分辨率贴图。 高质量抗锯齿。 远距离 LOD。

  1. 帧率策略 低端机不一定追求 60 FPS。 很多移动端项目会让低端机稳定 30 FPS,因为稳定 30 比抖动的 45 更舒服,也更省电、更不容易发热降频。
  2. 内存控峰 低端机最怕峰值内存。 切场景时要分批加载,及时释放旧场景资源。 贴图用低清版本,Audio、Mesh、AnimationClip 都要控制常驻量。
  3. 加载分帧 不要一帧里加载、解压、实例化大量对象。 资源异步加载,Prefab 分帧实例化,大量 UI Item 和怪物对象池化。
  4. 动态降级 如果运行中连续掉帧,可以动态降低画质。 比如降低特效数量、关闭部分后处理、降低渲染比例、减少远处单位 Tick 频率。

低端机自动分档示例

c
using UnityEngine; // 引入 Unity 引擎命名空间

public class LowEndDeviceAdapter : MonoBehaviour // 定义一个低端机适配脚本
{ // 类开始
    private enum DeviceTier // 定义设备档位枚举
    { // 枚举开始
        Low, // 表示低端机档位
        Middle, // 表示中端机档位
        High // 表示高端机档位
    } // 枚举结束

    private void Awake() // Unity 初始化时调用
    { // Awake 方法开始
        DeviceTier tier = DetectDeviceTier(); // 检测当前设备属于哪个档位

        ApplyQuality(tier); // 根据设备档位应用画质配置
    } // Awake 方法结束

    private DeviceTier DetectDeviceTier() // 定义检测设备档位的方法
    { // 方法开始
        int memoryMB = SystemInfo.systemMemorySize; // 获取系统内存大小,单位是 MB

        int cpuCount = SystemInfo.processorCount; // 获取 CPU 核心数量

        int gpuMemoryMB = SystemInfo.graphicsMemorySize; // 获取显存大小,单位是 MB

        if (memoryMB <= 3072 || cpuCount <= 4 || gpuMemoryMB <= 1024) // 判断是否满足低端机条件
        { // if 开始
            return DeviceTier.Low; // 返回低端机档位
        } // if 结束

        if (memoryMB <= 6144 || cpuCount <= 6 || gpuMemoryMB <= 2048) // 判断是否满足中端机条件
        { // if 开始
            return DeviceTier.Middle; // 返回中端机档位
        } // if 结束

        return DeviceTier.High; // 其他情况按高端机处理
    } // 方法结束

    private void ApplyQuality(DeviceTier tier) // 定义应用画质配置的方法
    { // 方法开始
        QualitySettings.vSyncCount = 0; // 关闭垂直同步,让 targetFrameRate 生效

        if (tier == DeviceTier.Low) // 判断是否是低端机档位
        { // if 开始
            Application.targetFrameRate = 30; // 低端机锁定 30 帧,优先稳定和省电

            QualitySettings.SetQualityLevel(0, true); // 设置到最低画质档位

            QualitySettings.masterTextureLimit = 1; // 纹理降低一级,减少显存占用

            QualitySettings.shadows = ShadowQuality.Disable; // 关闭实时阴影,降低 GPU 压力

            QualitySettings.shadowDistance = 0f; // 把阴影距离设为 0,避免远处阴影开销

            QualitySettings.lodBias = 0.6f; // 降低 LOD 偏移,让模型更早切到低模

            QualitySettings.anisotropicFiltering = AnisotropicFiltering.Disable; // 关闭各向异性过滤,减少采样压力

            return; // 低端机配置完成后直接返回
        } // if 结束

        if (tier == DeviceTier.Middle) // 判断是否是中端机档位
        { // if 开始
            Application.targetFrameRate = 45; // 中端机可以设置为 45 帧作为折中

            QualitySettings.SetQualityLevel(1, true); // 设置到中等画质档位

            QualitySettings.masterTextureLimit = 0; // 中端机使用原始纹理等级

            QualitySettings.shadows = ShadowQuality.HardOnly; // 中端机只开启硬阴影

            QualitySettings.shadowDistance = 25f; // 控制阴影距离,避免阴影覆盖过远

            QualitySettings.lodBias = 0.8f; // 设置中等 LOD 偏移

            QualitySettings.anisotropicFiltering = AnisotropicFiltering.Enable; // 中端机允许开启各向异性过滤

            return; // 中端机配置完成后直接返回
        } // if 结束

        Application.targetFrameRate = 60; // 高端机目标帧率设置为 60 帧

        QualitySettings.SetQualityLevel(2, true); // 设置到较高画质档位

        QualitySettings.masterTextureLimit = 0; // 高端机使用完整纹理质量

        QualitySettings.shadows = ShadowQuality.All; // 高端机允许完整阴影质量

        QualitySettings.shadowDistance = 50f; // 高端机使用更远阴影距离

        QualitySettings.lodBias = 1.2f; // 高端机延后 LOD 降级,提升画面质量

        QualitySettings.anisotropicFiltering = AnisotropicFiltering.ForceEnable; // 高端机强制开启各向异性过滤
    } // 方法结束
} // 类结束

面试高分回答

TIP

低端机适配我会先做设备分档,根据内存、CPU、GPU、机型白名单和线上数据把设备分成低中高档。低端机优先保证稳定帧率和不闪退,而不是追求画质,所以会锁 30 FPS,降低渲染分辨率、阴影距离、后处理、特效数量、LOD 距离和贴图质量;CPU 侧减少大量 Update、AI 分帧、寻路限流;内存侧使用低清资源、控制常驻资源和场景切换峰值;加载侧用异步加载、分帧实例化和对象池。上线后还要采集 FPS、内存峰值、崩溃率、发热掉帧数据,根据真实机型继续调整配置。

热更新流程

unity-hot-update-flow

热更新流程是什么?

Unity 热更新一般分两类:

资源热更新:更新 AssetBundle、Addressables Catalog、贴图、Prefab、音频、配置表。 代码热更新:更新 Lua、ILRuntime、HybridCLR 的脚本或热更程序集。

完整流程不是“下载 AB 包”这么简单,而是:

启动游戏。 读取本地版本和本地 Manifest。 请求服务器最新版本。 判断是否需要强更。 对比本地和远程 Manifest。 找出 Hash 不一样的资源。 下载差异补丁。 校验 Hash、CRC、签名。 写入临时目录。 全部成功后原子替换 Manifest。 加载新资源或新脚本。 失败时回滚到旧版本。

为什么要 Manifest?

Manifest 就是一张资源清单,通常记录:

资源名。 资源路径。 资源版本。 Hash。 大小。 CRC。 依赖关系。 下载地址。

客户端不用猜哪些资源变了,而是比较本地 Manifest 和远程 Manifest。 Hash 不一样,就说明这个资源需要更新。

为什么要先写临时目录?

不能边下载边覆盖旧资源。

否则下载到一半断网,旧资源也被破坏了,玩家可能进不了游戏。

更安全的做法是:

下载到临时目录。 全部文件校验通过。 备份旧 Manifest。 替换新 Manifest。 切换到新资源目录。 失败就删除临时文件,继续用旧版本。

简化代码示例

c
using System.Collections; // 引入协程相关命名空间
using UnityEngine; // 引入 Unity 引擎命名空间
using UnityEngine.Networking; // 引入 Unity 网络请求命名空间

public class HotUpdateDemo : MonoBehaviour // 定义一个热更新流程示例类
{ // 类开始
    private string _remoteVersionUrl = "https://cdn.xxx.com/version.json"; // 保存远程版本文件地址

    private void Start() // Unity 启动时调用
    { // Start 方法开始
        StartCoroutine(CheckHotUpdate()); // 启动热更新检查协程
    } // Start 方法结束

    private IEnumerator CheckHotUpdate() // 定义热更新检查流程
    { // 协程开始
        UnityWebRequest request = UnityWebRequest.Get(_remoteVersionUrl); // 创建远程版本信息请求

        yield return request.SendWebRequest(); // 等待网络请求完成

        if (request.result != UnityWebRequest.Result.Success) // 判断版本请求是否失败
        { // if 开始
            Debug.LogError("版本检查失败,使用本地资源启动"); // 输出失败日志并准备走本地资源

            yield break; // 结束热更新流程
        } // if 结束

        string remoteVersionJson = request.downloadHandler.text; // 读取服务器返回的版本 JSON 字符串

        Debug.Log("远程版本信息:" + remoteVersionJson); // 输出远程版本信息,真实项目会解析 JSON

        bool needUpdate = true; // 模拟对比版本后发现需要更新

        if (!needUpdate) // 判断是否不需要热更新
        { // if 开始
            Debug.Log("资源已经是最新版本"); // 输出不需要更新的日志

            yield break; // 结束热更新流程
        } // if 结束

        yield return StartCoroutine(DownloadPatch()); // 下载补丁资源

        bool verifySuccess = VerifyPatch(); // 校验补丁 Hash、CRC 或签名

        if (!verifySuccess) // 判断补丁是否校验失败
        { // if 开始
            Debug.LogError("补丁校验失败,回滚到旧版本"); // 输出校验失败日志

            Rollback(); // 执行回滚逻辑

            yield break; // 结束热更新流程
        } // if 结束

        ApplyPatch(); // 应用补丁并切换到新版本

        Debug.Log("热更新完成,进入游戏"); // 输出热更新完成日志
    } // 协程结束

    private IEnumerator DownloadPatch() // 定义下载补丁的方法
    { // 方法开始
        Debug.Log("下载差异资源到临时目录"); // 输出下载提示日志

        yield return null; // 模拟异步下载过程
    } // 方法结束

    private bool VerifyPatch() // 定义补丁校验方法
    { // 方法开始
        return true; // 模拟 Hash、CRC、签名全部校验通过
    } // 方法结束

    private void ApplyPatch() // 定义应用补丁的方法
    { // 方法开始
        Debug.Log("替换 Manifest,并切换到新资源版本"); // 输出应用补丁日志
    } // 方法结束

    private void Rollback() // 定义回滚方法
    { // 方法开始
        Debug.Log("删除临时补丁,恢复旧 Manifest"); // 输出回滚日志
    } // 方法结束
} // 类结束

面试高分回答

TIP

热更新流程我会设计成一个完整闭环:客户端启动后先读取本地版本和 Manifest,再请求服务器版本信息;如果客户端大版本低于最低兼容版本,就走商店强更;否则下载远程 Manifest,和本地 Manifest 按 Hash 对比,只下载变化的 Bundle、配置或代码包。下载时写入临时目录,支持断点续传和失败重试;下载完成后校验 Hash、CRC、签名,全部通过才替换本地 Manifest 并切换资源版本。失败时不能影响玩家进游戏,要保留上一个稳定版本并自动回滚。线上还要支持灰度发布、渠道控制、CDN、错误上报和版本兼容,不能只做一个简单下载器。

资源分包策略

unity-resource-bundling-strategy

资源分包策略是什么?

资源分包不是按文件夹随便切,而是按 使用时机、依赖关系、更新频率、平台差异 来切。

目标是:

首包更小。 下载更少。 加载更快。 重复资源更少。 热更新更稳定。 卸载和缓存更可控。

常见分包维度

按使用时机分

首包:登录、基础 UI、公共 Shader、字体、基础配置。 常驻包:全局 UI、通用音效、公共图集。 场景包:地图、关卡、场景装饰、环境音。 活动包:限时活动 UI、活动怪物、活动特效。 角色包:角色 Prefab、动作、材质、语音。

这样玩家不需要一开始下载全部资源。

按依赖关系分

公共依赖要单独拆出来。

比如多个角色都用同一张公共贴图、同一个 Shader、同一个字体,如果不拆公共包,可能每个角色包都重复带一份。

这会导致:

包体变大。 下载量变大。 内存占用变高。 热更新时改一个公共资源,影响多个包。

按更新频率分

稳定资源:公共 Shader、基础字体、通用 UI,少更新。 高频资源:活动 UI、运营图、配置表,经常更新。 大资源:语音、CG、大地图,按需下载。

高频变化的资源不要和稳定资源混在一个包里。 否则改一张活动图,可能导致整个大包都要重新下载。

按平台和质量分

Android 和 iOS 的纹理压缩格式可能不同。 低端机和高端机可能需要不同清晰度资源。 多语言语音也应该分语言包。

比如:

ui_common_android.abui_common_ios.abvoice_cn.abvoice_jp.abscene_forest_low.abscene_forest_high.ab

玩家只下载自己平台和配置需要的资源。

推荐分包结构

首包:
登录 UI、基础配置、公共字体、公共 Shader、基础图标。

公共包:
公共材质、公共贴图、公共音效、公共动画、通用图集。

场景包:
每个地图或关卡一个主包,必要时再拆地形、建筑、怪物、特效。

角色包:
角色 Prefab、模型、动作、材质、专属特效、专属语音。

活动包:
活动界面、活动配置、活动怪物、活动奖励图标。

语言包:
中文语音、日文语音、英文语音分别拆开。

平台包:
Android、iOS 使用不同压缩格式时分开构建。

简单分包配置代码

c
using System; // 引入 Serializable 特性所在命名空间
using System.Collections.Generic; // 引入 List 集合命名空间
using UnityEngine; // 引入 Unity 引擎命名空间

[CreateAssetMenu(menuName = "Build/Bundle Group Config")] // 在 Unity 菜单中添加创建配置资产的入口
public class BundleGroupConfig : ScriptableObject // 定义一个资源分包配置资产
{ // 类开始
    public List<BundleGroupRule> Rules = new List<BundleGroupRule>(); // 保存所有分包规则
} // 类结束

[Serializable] // 允许这个类被 Unity 序列化并显示在 Inspector
public class BundleGroupRule // 定义单条分包规则
{ // 类开始
    public string GroupName; // 保存分包组名称,例如 scene_forest 或 ui_common

    public string AssetFolder; // 保存这个分包规则对应的资源目录

    public string Label; // 保存资源标签,例如 first、common、scene、event

    public bool IsRemote; // 标记这个包是否放在远程下载

    public bool IsSharedDependency; // 标记这个包是否是公共依赖包

    public int Priority; // 保存加载优先级,数字越小越优先
} // 类结束

面试高分回答

CAUTION

资源分包我会先做资源盘点和依赖分析,而不是按文件夹随便切。整体上按使用时机、依赖关系、更新频率和平台差异来分:登录、基础 UI、公共 Shader 和字体放首包;公共材质、公共贴图、公共图集单独做公共依赖包;场景、角色、活动、语音按需拆包;高频更新资源不要和稳定资源混包;Android 和 iOS 因为压缩格式不同通常分平台构建。分包后会用 Manifest 管理版本、Hash 和依赖关系,并用 Analyze 或构建报告检查重复资源,避免公共依赖被多个业务包重复打进去。真正好的分包策略要同时兼顾首包大小、下载量、加载速度、热更新粒度和资源卸载。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0