UPL5-12——资源加载(反序列化)优化和预渲染
UPL5-12——资源加载(反序列化)优化和预渲染
本章代码关键字
1 | // 着色器变体集合相关 |
资源加载或反序列化为什么会带来开销
- 资源加载:
Resources 加载、AB 包加载、Addressables 加载、UnityWebRequest 加载、WWW 加载、File加载 等等 - 反序列化数据:Json、XML、二进制、
ScriptableObject等等
在进行资源加载或反序列化时带来的主要开销有:
-
I/O 开销
磁盘或网络读取会有 I/O 等待,等待数据从磁盘或网络加载到内存
这个过程会阻塞 CPU(特别是同步加载时) -
解析开销
加载成功后又需要进行数据解析或反序列化,用于匹配内部类的结构
对于部分资源可能还会存在解压缩过程(比如 AssetBundle 包的 LZ4 和 LZMA 格式)
反序列化时可能还会用到字符串解析、反射等等手段进行处理 -
内存分配初始化开销
对于一些资源对象,我们需要分配 C++ 引擎对象(比如
Mesh、Texture、AnimationClip等等)
并且还需要对应对象进行初始化,这个过程也会带来消耗
总的来说
资源加载和数据反序列化会吃 CPU,是因为它们本质上是把文件里的原始字节流转化为可用对象的过程
涉及 I/O、解压、解析、内存分配和类型初始化
这些工作目前只能由 CPU 完成,而且往往集中在主线程执行,所以会造成明显的性能开销
这个过程可以简单整理为 读文件(读取) —> 变对象(解析) —> 进场景(实例化)
因此我们主要的优化思路就是把这个过程做得更轻、更分散、更可复用
基本优化思路
减小序列化对象
主要思路:让需要被读出来的对象体积更小、结构更简单,这样解析就会更快,计算压力就会更低
举例:
-
Unity 中的预设体尽量做得简单,通过组合的形式加载,让预设体更精简,可以提升解析、实例化速度
-
配置文件尽量用二进制代替文本配置(Json、XML 等),让配置文件体积更小,结构更简单,可以提升处理效率
二进制文件相关内容,可见:UD4——二进制系列
公共数据配置化
主要思路:
把游戏中会常用的公共数据做成配置文件的形式,游戏中保证只存在一份,并一般在游戏初期加载解析他们
当有模块或对象要使用这些数据时直接使用它们即可,可以避免重复加载,并且可以减少数据冗余
举例:
- 物品配置中包含所有物品的各种信息(ID、图标、模型、名称、价格、品质、各属性 等等)
- 游戏中表示物品信息时只需要一个 ID,通过 ID 在该表中找到对应的各种数据来使用
异步加载 + 分帧实例化
主要思路:不要在主线程同步卡着等待,而是通过异步 API 加载资源(避免主线程阻塞卡顿),并且加载成功要实例化时分帧处理(避免主线程阻塞卡顿)
举例:
利用 Resources、AB包、Addressables、UnityWebRequest 等加载方式中的异步方法加载资源,
资源加载成功后,当需要实例化 n 个对象时分多帧去进行处理
缓存
主要思路:把付出昂贵代价加载出来的结果暂存在内存中,下次直接复用,相当于用内存换性能
举例:
对于资源对象,我们可以用合适的容器将其缓存在内存中,下次如果还要使用,直接获取使用即可
对于已经实例化的场景中的对象,我们可以利用缓存池不停复用它们
关于缓存池实现,可见:UFL3——缓存池(对象池)模块
注意:缓存内容要在合适的时候进行清理,避免内存泄漏
预热
主要思路:在玩家不敏感的时机(过场景的加载界面等),提前准备将要使用的资源
- 预加载资源:解决 I/O 和反序列化消耗
- 预实例化资源:解决内存分配和初始化脚本的消耗
- 预渲染资源:解决 CPU 到 GPU 的首帧上传和 Shader 编译(把内存中纹理、网格、Shader 等上传到 GPU)消耗
举例:
-
预加载资源:在加载界面时,利用同步或异步 API 预加载资源(可缓存下来)
-
预实例化资源:利用预加载的资源将即将使用的预设体预实例化出来,并且最好放入缓存池(对象池)中
-
预渲染资源:利用预实例化的资源进行预先渲染
为了避免预渲染时被主摄像看到,影响玩家体验,一般会采用:
-
隐藏相机 +
RenderTexture用一个摄像机使用渲染纹理进行渲染,将想要预渲染的对象放在该摄像机可视范围内
在加载协程中多等待几帧,用于渲染对象
比较适用于动画、粒子等资源预渲染,因为可以进行多帧渲染 -
利用 Unity API 进行预渲染
利用
CommandBuffer 和Graphics.DrawMesh进行预渲染
比较适用于只想上传网格、纹理、Shader变体、材质的情况
-
叠加、异步加载场景
主要思路:
-
方式一:利用 Unity 自带API
把大关卡拆成多个子场景,加载场景时按需加载、分时加载、并且在合适的时机激活或卸载场景
可以利用 Unity 中 SceneManager 中的异步加载场景API,并通过加载模式参数控制是否叠加加载 -
方式二:自定义场景编辑器
场景为空场景,场景中物件都是通过场景编辑器记录数据,进入场景时所有对象都是动态加载的
自定义加载策略,在合适的距离时机加载对应场景中物件对象,在合适的时机卸载场景中物件对象
采用合理资源布局
主要思路:在选择 Resources、AB 包、Addressables 等资源加载方式时,应采用适合自己项目情况的方案进行资源布局
比如:
- Resources:只适合小体量或原型项目开发,它会打进包、无法热更、依赖不可控、启动时会做索引扫描,建议大项目尽量少用甚至不用
- AssetBundle:需要完全自定义下载、缓存、版本策略;工程复杂度较高,但可控性强
- Addressables:官方维护、热更与缓存策略完整、使用方便,可控性不如 AssetBundle
资源布局方面
-
使用 AB 包或 Addressables 时应合理分包
减少跨包依赖,包内资源应高度内聚,并且单包大小要适中(移动端常见 1050 MB),并发下载不要超过 35 个
-
Shader 应该剔除无用关键字,避免过多变体的产生
尽量复用材质,可以减少实例化时消耗
-
配置表尽量集中化,预设体中不要太多可配置属性,尽量都通过 ID 在配置表中获取数据来复用
等等
预渲染
预渲染的本质是提前生成某些画面结果,然后在需要的时候直接使用结果,主要目的是为了解决:
-
CPU 到 GPU 的首帧数据上传
当一个模型、材质、纹理、网格第一次被真正用于渲染时,CPU 会把它的网格数据、纹理数据、常量缓冲等上传到 GPU 内存
这个过程是异步的,但首次调用可能会导致主线程等待数据上传完成,玩家可能会在第一次看到该对象时感到掉帧,即首帧卡顿 -
Shader(着色器)首次编译
Shader 是按需编译的(尤其在多变体 Shader 、URP 、 HDRP 中),
第一次用到某个 Shader Pass + Keyword 组合 时,Unity 会在运行时编译它,编译过程可能会卡 CPU,尤其在移动设备或低端机上
所以总的来说,如果进行了预渲染,真正进入游戏场景时,玩家第一次看到预渲染的游戏对象就不会有卡顿感了
隐藏相机 + RenderTexture 进行预渲染
一般在合适的时机,比如在过场景显示加载界面时(读条界面),我们可以利用 隐藏相机 + RenderTexture 的形式偷偷的在后台预渲染目标对象
这样做的目的是不仅可以预渲染目标对象,还可以避免影响玩家体验,基本做法:
-
创建一个新的摄像机,并创建一个专门用于预渲染的层(预渲染摄像机最好只渲染一个专门用于预渲染的层,同时其他摄像机不勾选预渲染层)


-
设置新的摄像机的
targetTexture 为一个RenderTexture(如果是 URP 项目,则 Inspector 窗口内需要设置在Output 下的OutputTexture上)
-
预渲染时,激活摄像机
让它渲染你想要预渲染的对象实例化到对应的位置
- 预加载(
Resources、AB 包、Addressables、UnityWebRequest等等) - 预实例化(把想要预渲染的对象实例化到场景中,最好设置到一个专门用于预渲染的层级)
- 预加载(
-
预渲染结束后,失活摄像机,避免额外开销
关键注意点:
- 使用新的专门用于预渲染的摄像机
- 创建新的专门用于预渲染的层级
- 需要一个
RenderTexture关联在用于预渲染的摄像机
通过该方法预渲染的脚本思路如下:
1 | using System.Collections; |
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(着色器变体集合)进行着色器预编译
-
在 Project 窗口 右键 → Create → Shader → Shader Variant Collection(着色器变体集合)
-
在 Inspector 窗口中手动添加想要预热的 Shader,点击 + 号选择想要预热的Shader变体

-
在脚本中获取到 Shader Variant Collection 利用其中的 API
WarmUp()进行预编译一般是加载场景时才去进行预编译,预编译前可以通过
isWarmedUp属性判断变体集合是否已经预热
1 | public ShaderVariantCollection shaderVariantCollection; |
在实际项目中,我们更多可以在编辑器环境下通过代码动态收集 Shader 变体到 Shader Variant Collection 内,然后打包后在合适的时机一次性预热好
注意:预热 Shader 还是需要在合适的时机进行,比如加载界面时
利用 Unity API 进行预渲染
如果我们不希望创建隐藏摄像机,那么我们可以利用这种方式进行预渲染,可以直接以脚本命令触发一次 CPU 到 GPU 的通信
预渲染方式:
利用 Unity 中的 CommandBuffer(命令缓冲区)进行一次 CPU 到 GPU 的数据上传
通过 Renderer(渲染器)获取到材质,在利用材质获取到 Pass(渲染通道)
利用这些数据进行一次预热,可以有效避免首次渲染时造成的卡顿,这样做的好处是
- 不需要创建相机,直接用
CommandBuffer提交绘制命令 - 用很小的 RT(默认 64×64)减少性能消耗
- 不会影响游戏画面
- 可批量调用,对不同
Renderer 或Pass预热
渲染的大致思路如下:
- 获取需要预渲染对象的渲染器
Renderer,并指定预渲染的渲染通道Pass名字 - 通过渲染器获取其关联的材质,通过预渲染的 Pass 名字找到索引
- 创建指令缓冲区
CommandBuffer,通过CommandBuffer 申请一个临时的RenderTexture,并指定RenderTexture 作为CommandBuffer的渲染目标 - 准备
RenderTexture,清空其中的颜色和深度,然后使用Renderer 及其材质,指定子网格索引和渲染通道Pass 索引,将内容绘制在RenderTexture上 - 通过
Graphics.ExecuteCommandBuffer 立刻执行指令缓冲区CommandBuffer,执行完毕后释放RenderTexture 和CommandBuffer
1 | /// <summary> |
