UPL5-12——资源加载(反序列化)优化和预渲染

本章代码关键字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 着色器变体集合相关
ShaderVariantCollection // 着色器变体集合
shaderVariantCollection.isWarmedUp // 判断着色器变体集合是否已经预热
shaderVariantCollection.WarmUp() // 预热着色器变体集合内所有着色器变体
// 渲染指令缓冲区相关
material.FindPass() // 通过渲染通道名字获取此渲染通道在材质中的索引
CommandBuffer // 指令缓冲区
Shader.PropertyToID() // 通过字符串指定一个唯一的着色器属性整数ID
commandBuffer.GetTemporaryRT() // 申请一个临时RenderTexture(指定ID、宽高、指定其 MSAA、ARGB32 格式)
commandBuffer.SetRenderTarget() // 为指令缓冲区设置渲染目标
commandBuffer.ClearRenderTarget() // 将渲染目标上的颜色和深度清空
commandBuffer.DrawRenderer() // 指定渲染器,材质,子网格索引,渲染通道,对渲染目标渲染内容
Graphics.ExecuteCommandBuffer() // 执行指令缓冲区,将指令提交给GPU
commandBuffer.ReleaseTemporaryRT() // 释放指令缓冲区临时申请的RenderTexture
commandBuffer.Release() // 释放指令缓冲区

资源加载或反序列化为什么会带来开销

  • 资源加载:Resources​ 加载、AB 包加载、Addressables​ 加载、UnityWebRequest​ 加载、WWW​ 加载、File 加载 等等
  • 反序列化数据:Json、XML、二进制、ScriptableObject 等等

在进行资源加载或反序列化时带来的主要开销有:

  1. I/O 开销

    磁盘或网络读取会有 I/O 等待,等待数据从磁盘或网络加载到内存
    这个过程会阻塞 CPU(特别是同步加载时)

  2. 解析开销

    加载成功后又需要进行数据解析或反序列化,用于匹配内部类的结构
    对于部分资源可能还会存在解压缩过程(比如 AssetBundle 包的 LZ4 和 LZMA 格式)
    反序列化时可能还会用到字符串解析、反射等等手段进行处理

  3. 内存分配初始化开销

    对于一些资源对象,我们需要分配 C++ 引擎对象(比如 Mesh​、Texture​、AnimationClip 等等)
    并且还需要对应对象进行初始化,这个过程也会带来消耗

总的来说
资源加载和数据反序列化会吃 CPU,是因为它们本质上是把文件里的原始字节流转化为可用对象的过程
涉及 I/O、解压、解析、内存分配和类型初始化
这些工作目前只能由 CPU 完成,而且往往集中在主线程执行,所以会造成明显的性能开销
这个过程可以简单整理为 读文件(读取) —> 变对象(解析) —> 进场景(实例化)
因此我们主要的优化思路就是把这个过程做得更轻、更分散、更可复用

基本优化思路

减小序列化对象

主要思路:让需要被读出来的对象体积更小、结构更简单,这样解析就会更快,计算压力就会更低

举例:

  1. Unity 中的预设体尽量做得简单,通过组合的形式加载,让预设体更精简,可以提升解析、实例化速度

  2. 配置文件尽量用二进制代替文本配置(Json、XML 等),让配置文件体积更小,结构更简单,可以提升处理效率

    二进制文件相关内容,可见:UD4——二进制系列

公共数据配置化

主要思路:
把游戏中会常用的公共数据做成配置文件的形式,游戏中保证只存在一份,并一般在游戏初期加载解析他们
当有模块或对象要使用这些数据时直接使用它们即可,可以避免重复加载,并且可以减少数据冗余

举例:

  • 物品配置中包含所有物品的各种信息(ID、图标、模型、名称、价格、品质、各属性 等等)
  • 游戏中表示物品信息时只需要一个 ID,通过 ID 在该表中找到对应的各种数据来使用

异步加载 + 分帧实例化

主要思路:不要在主线程同步卡着等待,而是通过异步 API 加载资源(避免主线程阻塞卡顿),并且加载成功要实例化时分帧处理(避免主线程阻塞卡顿)

举例:
利用 Resources​、AB包、Addressables​、UnityWebRequest 等加载方式中的异步方法加载资源,
资源加载成功后,当需要实例化 n 个对象时分多帧去进行处理

缓存

主要思路:把付出昂贵代价加载出来的结果暂存在内存中,下次直接复用,相当于用内存换性能

举例:
对于资源对象,我们可以用合适的容器将其缓存在内存中,下次如果还要使用,直接获取使用即可
对于已经实例化的场景中的对象,我们可以利用缓存池不停复用它们

关于缓存池实现,可见:UFL3——缓存池(对象池)模块

注意:缓存内容要在合适的时候进行清理,避免内存泄漏

预热

主要思路:在玩家不敏感的时机(过场景的加载界面等),提前准备将要使用的资源

  1. 预加载资源:解决 I/O 和反序列化消耗
  2. 预实例化资源:解决内存分配和初始化脚本的消耗
  3. 预渲染资源:解决 CPU 到 GPU 的首帧上传和 Shader 编译(把内存中纹理、网格、Shader 等上传到 GPU)消耗

举例:

  1. 预加载资源:在加载界面时,利用同步或异步 API 预加载资源(可缓存下来)

  2. 预实例化资源:利用预加载的资源将即将使用的预设体预实例化出来,并且最好放入缓存池(对象池)中

  3. 预渲染资源:利用预实例化的资源进行预先渲染

    为了避免预渲染时被主摄像看到,影响玩家体验,一般会采用:

    1. 隐藏相机 + RenderTexture

      用一个摄像机使用渲染纹理进行渲染,将想要预渲染的对象放在该摄像机可视范围内
      在加载协程中多等待几帧,用于渲染对象
      比较适用于动画、粒子等资源预渲染,因为可以进行多帧渲染

    2. 利用 Unity API 进行预渲染

      利用 CommandBuffer​ 和 Graphics.DrawMesh 进行预渲染
      比较适用于只想上传网格、纹理、Shader变体、材质的情况

叠加、异步加载场景

主要思路:

  • 方式一:利用 Unity 自带API

    把大关卡拆成多个子场景,加载场景时按需加载、分时加载、并且在合适的时机激活或卸载场景
    可以利用 Unity 中 SceneManager 中的异步加载场景API,并通过加载模式参数控制是否叠加加载

  • 方式二:自定义场景编辑器

    场景为空场景,场景中物件都是通过场景编辑器记录数据,进入场景时所有对象都是动态加载的
    自定义加载策略,在合适的距离时机加载对应场景中物件对象,在合适的时机卸载场景中物件对象

采用合理资源布局

主要思路:在选择 Resources​、AB 包、Addressables 等资源加载方式时,应采用适合自己项目情况的方案进行资源布局

比如:

  • Resources:只适合小体量或原型项目开发,它会打进包、无法热更、依赖不可控、启动时会做索引扫描,建议大项目尽量少用甚至不用
  • AssetBundle:需要完全自定义下载、缓存、版本策略;工程复杂度较高,但可控性强
  • Addressables:官方维护、热更与缓存策略完整、使用方便,可控性不如 AssetBundle

资源布局方面

  1. 使用 AB 包或 Addressables 时应合理分包

    减少跨包依赖,包内资源应高度内聚,并且单包大小要适中(移动端常见 1050 MB),并发下载不要超过 35 个

  2. Shader 应该剔除无用关键字,避免过多变体的产生

    尽量复用材质,可以减少实例化时消耗

  3. 配置表尽量集中化,预设体中不要太多可配置属性,尽量都通过 ID 在配置表中获取数据来复用

等等

预渲染

预渲染的本质是提前生成某些画面结果,然后在需要的时候直接使用结果,主要目的是为了解决:

  1. CPU 到 GPU 的首帧数据上传

    当一个模型、材质、纹理、网格第一次被真正用于渲染时,CPU 会把它的网格数据、纹理数据、常量缓冲等上传到 GPU 内存
    这个过程是异步的,但首次调用可能会导致主线程等待数据上传完成,玩家可能会在第一次看到该对象时感到掉帧,即首帧卡顿

  2. Shader(着色器)首次编译

    Shader 是按需编译的(尤其在多变体 Shader 、URP 、 HDRP 中),
    第一次用到某个 Shader Pass + Keyword 组合 时,Unity 会在运行时编译它,编译过程可能会卡 CPU,尤其在移动设备或低端机上

所以总的来说,如果进行了预渲染,真正进入游戏场景时,玩家第一次看到预渲染的游戏对象就不会有卡顿感了

隐藏相机 + RenderTexture 进行预渲染

一般在合适的时机,比如在过场景显示加载界面时(读条界面),我们可以利用 隐藏相机 + RenderTexture 的形式偷偷的在后台预渲染目标对象
这样做的目的是不仅可以预渲染目标对象,还可以避免影响玩家体验,基本做法:

  1. 创建一个新的摄像机,并创建一个专门用于预渲染的层(预渲染摄像机最好只渲染一个专门用于预渲染的层,同时其他摄像机不勾选预渲染层)

    imageimage

  2. 设置新的摄像机的 targetTexture​ 为一个 RenderTexture​(如果是 URP 项目,则 Inspector 窗口内需要设置在 Output​ 下的 OutputTexture 上)

    image

  3. 预渲染时,激活摄像机

    让它渲染你想要预渲染的对象实例化到对应的位置

    1. 预加载(Resources​、AB 包、Addressables​、UnityWebRequest 等等)
    2. 预实例化(把想要预渲染的对象实例化到场景中,最好设置到一个专门用于预渲染的层级)
  4. 预渲染结束后,失活摄像机,避免额外开销

关键注意点:

  1. 使用新的专门用于预渲染的摄像机
  2. 创建新的专门用于预渲染的层级
  3. 需要一个 RenderTexture 关联在用于预渲染的摄像机

通过该方法预渲染的脚本思路如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PreRenderHelper : MonoBehaviour
{
public Camera hiddenCamera;
public RenderTexture renderTex;

public void PreRender(List<string> resNameList)
{
StartCoroutine(PreRenderCoroutine(resNameList));
}

private IEnumerator PreRenderCoroutine(List<string> resNameList)
{
// 目前renderTex是创建好的,以后为了节约内存,可以动态创建一个临时的RenderTexture
if (hiddenCamera == null || renderTex == null)
{
Debug.LogWarning("用于预渲染的摄像机和渲染文理未设置");
yield break;
}
hiddenCamera.targetTexture = renderTex;
hiddenCamera.enabled = true;

foreach (var resName in resNameList)
{
// 预加载资源,Resources, AssetBundle, Addressable...
// 预实例化逻辑
var obj = new GameObject(resName);
// 需要把实例化的内容层级改到对应的预渲染层,并且要把它的位置放到预渲染摄像机能看到的位置
obj.layer = LayerMask.NameToLayer("PreRender");
}

// 等待摄像机和GUI渲染完成后执行
yield return new WaitForEndOfFrame();

hiddenCamera.enabled = false;
hiddenCamera.targetTexture = null;
// 把代码动态创建的RenderTexture给释放移除掉
renderTex.Release();
renderTex = null;
}
}

Unity 预渲染相关 API

Shader 关键字和变体

关于 Shader 关键字和变体,详情可见:US5L3——Shader变体和关键字

Shader Variant(变体)指的是一个 Shader 的特定配置,通过不同的 关键字(Keywords)设置组合 来实现不同的效果
每一种关键字组合或设置都会生成一个独立的 Shader 变体,最终以二进制形式存储在构建文件中供运行时使用

说人话:Shader 变体就是基于一个 Shader 文件当中的代码,编译生成多个版本的 Shader 它基于关键字来生成各种不同的版本

举例说明:一个 Shader 中有两个关键字,每个关键字有两种状态(启用或禁用),最终生成的变体数量就是 22 = 4

  • 变体 1:没有启用任何关键字(默认)。
  • 变体 2:启用了 关键字1
  • 变体 3:启用了 关键字2
  • 变体 4:同时启用了 关键字1 和 关键字2

运行时,Unity 会根据具体设置选择与之匹配的变体来使用,Unity 的一些内置功能会隐式的生成变体,比如:

  • 光照模式 —— US3S5L6——多种光源综合实现
  • 雾效 —— US3S11L5——Unity自带全局雾效
  • 渲染管线 —— US1L1——渲染管线概述

等等

预热 Shader 变体

第一次用到某个 Shader 的变体可能会卡顿,提前编译可以避免第一次使用时的卡顿

预热方式:利用 Shader Variant Collection(着色器变体集合)进行着色器预编译

  1. 在 Project 窗口 右键 → Create → Shader → Shader Variant Collection(着色器变体集合)

  2. 在 Inspector 窗口中手动添加想要预热的 Shader,点击 + 号选择想要预热的Shader变体

    image

  3. 在脚本中获取到 Shader Variant Collection 利用其中的 API WarmUp() 进行预编译

    一般是加载场景时才去进行预编译,预编译前可以通过 isWarmedUp 属性判断变体集合是否已经预热

1
2
3
4
5
6
7
8
9
public ShaderVariantCollection shaderVariantCollection;

private void Start()
{
if (shaderVariantCollection != null && !shaderVariantCollection.isWarmedUp)
{
shaderVariantCollection.WarmUp();
}
}

在实际项目中,我们更多可以在编辑器环境下通过代码动态收集 Shader 变体到 Shader Variant Collection 内,然后打包后在合适的时机一次性预热好

注意:预热 Shader 还是需要在合适的时机进行,比如加载界面时

利用 Unity API 进行预渲染

如果我们不希望创建隐藏摄像机,那么我们可以利用这种方式进行预渲染,可以直接以脚本命令触发一次 CPU 到 GPU 的通信

预渲染方式:
利用 Unity 中的 CommandBuffer​(命令缓冲区)进行一次 CPU 到 GPU 的数据上传
通过 Renderer​(渲染器)获取到材质,在利用材质获取到 Pass(渲染通道)
利用这些数据进行一次预热,可以有效避免首次渲染时造成的卡顿,这样做的好处是

  1. 不需要创建相机,直接用 CommandBuffer 提交绘制命令
  2. 用很小的 RT(默认 64×64)减少性能消耗
  3. 不会影响游戏画面
  4. 可批量调用,对不同 Renderer​ 或 Pass 预热

渲染的大致思路如下:

  1. 获取需要预渲染对象的渲染器 Renderer​,并指定预渲染的渲染通道 Pass 名字
  2. 通过渲染器获取其关联的材质,通过预渲染的 Pass 名字找到索引
  3. 创建指令缓冲区 CommandBuffer​,通过 CommandBuffer​ 申请一个临时的 RenderTexture​,并指定 RenderTexture​ 作为 CommandBuffer 的渲染目标
  4. 准备 RenderTexture​,清空其中的颜色和深度,然后使用 Renderer​ 及其材质,指定子网格索引和渲染通道 Pass​ 索引,将内容绘制在 RenderTexture
  5. 通过 Graphics.ExecuteCommandBuffer​ 立刻执行指令缓冲区 CommandBuffer​,执行完毕后释放 RenderTexture​ 和 CommandBuffer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/// <summary>
/// 对指定 Renderer 使用 CommandBuffer 进行一次离屏渲染(哑渲染),
/// 以触发指定 Pass 的 Shader 编译、资源上传等预热操作。
/// </summary>
/// <param name="r">需要预热的 Renderer(其材质会被用来渲染)</param>
/// <param name="passName">Shader Pass 名(如 "ForwardBase", "ShadowCaster" 等)</param>
/// <param name="width">临时 RenderTexture 宽度(默认 64),越小越节约性能</param>
/// <param name="height">临时 RenderTexture 高度(默认 64),越小越节约性能</param>
public static void WarmUpRenderer(Renderer renderer, string passName, int width = 64, int height = 64)
{
// 获取 Renderer 使用的共享材质(不会实例化)
Material material = renderer.sharedMaterial;
// 查找该材质中指定 Pass 的索引
int pass = material.FindPass(passName);
if (pass < 0)
{
// 如果没找到该 Pass,打印警告并退出
Debug.LogWarning($"没有找到 Pass: {passName}");
return;
}
// 创建一个 CommandBuffer(命令缓冲区),用来批量提交 GPU 绘制指令
// name 名字可以随意自定义,它的作用只是在调试工具中可以显示出来,方便你知道这个缓冲区的作用
CommandBuffer commandBuffer = new() { name = $"Warmup Renderer: {renderer.name}_{passName}" };
// 为临时 RenderTexture 申请一个 ID(Shader 属性 ID)
// 把字符串转换成一个唯一的整数ID,这个字符串也可以自定义
int tempID = Shader.PropertyToID("_WarmupRT");
// 申请一个临时 RenderTexture(指定ID、宽高、无 MSAA、ARGB32 格式)
commandBuffer.GetTemporaryRT(tempID, width, height, 0, FilterMode.Point, RenderTextureFormat.ARGB32);
// 设置该临时 RenderTexture 作为渲染目标
commandBuffer.SetRenderTarget(tempID);
// 清空 RenderTexture(清颜色 & 深度)
commandBuffer.ClearRenderTarget(true, true, Color.clear);
// 使用该 Renderer 和材质,使用指定索引子网格(第三个参数),指定渲染通道索引 进行绘制
commandBuffer.DrawRenderer(renderer, material, 0, pass);

// 立即执行这个 CommandBuffer(提交给 GPU)
// 关键步骤
Graphics.ExecuteCommandBuffer(commandBuffer);

// 释放临时 RenderTexture 资源(避免显存泄漏)
commandBuffer.ReleaseTemporaryRT(tempID);
// 释放命令缓冲区本身
commandBuffer.Release();
}