1.TextureQuality
1.1 TextureQuality 的作用![]()
Quality 设置里的 Texture Quality 只对开启了 mipmap 的贴图生效,具体表现如下:
- 若贴图没有开启 mipmap,则无论是否设置该属性,贴图都以全分辨率渲染。
- 若贴图开启了 mipmap,则贴图才会以该选项设置的分辨率渲染。
该选项表示开启 mipmap 之后,渲染时允许使用的最大 mip level。level 越大(指的是枚举值,Full Res 是0),代表采样的是分辨率更低的 mip 层级,因此画面越模糊。
需要区分的是:Texture Quality 控制的是渲染时使用的 mip 层级上限,而下一节的 Texture Streaming 控制的是加载哪些 mip 层级,两者分工不同,不要混淆。
2. Texture Streaming:为 mipmap 而生的显存管理机制
Texture Streaming 同样是围绕 mipmap 设计的。它的核心目的是通过Memory Budget和Max Level Reduction两个参数来节省 CPU 和 GPU 资源,本质上控制的是加载 mipmap 的哪些层级,也就是决定一张贴图在运行时究竟把哪几级 mip 真正加载进显存。
其中,Memory Budget 只是一个阈值。当所有开启了 mipmap 的贴图所占用的内存超过这个阈值之后,引擎就会在加载这些贴图时,按照 Streaming 的策略丢弃部分高精度的 mip 层级,只保留较低分辨率的层级,从而把显存占用压回预算之内。对于贴图占用内存很大的游戏来说,这个选项非常有用,可以在几乎不影响观感的前提下显著降低显存压力。
通过该选项,还可以直观地查看当前贴图 mipmap 的加载情况,判断哪些贴图被降级、哪些仍然保持全分辨率,方便定位显存占用异常的问题。
关于 Streaming Mipmap 的实际表现,也可以参考这篇讨论:【厚积薄发】使用 Streaming Mipmap 后纹理内存没有下降的疑问。
3. C# Task
参考资料:使用 Async 和 Await 的任务异步编程 (TAP) 模型 (C#) | Microsoft Docs
用async修饰的方法即为异步方法,这类方法通常包含一个或多个由await修饰的调用;如果方法体内没有任何await,它实际上仍是同步执行的。反过来,同步方法则不需要async修饰。
需要明确的是,async与await组合实现的异步编程,并不会额外开启一个新线程。
下面梳理几个容易混淆的要点:
- 关于
Task.Run():它适合用来执行计算量较大的工作,这部分逻辑会在后台线程中运行。 - 关于
await:它表示等待,在返回结果之前无法访问该结果。 Task与await是两件事:Task表示任务的启动,可以同时启动多个任务,这正是异步的意义所在——它不会阻塞线程,而不是等一个任务完成后再启动下一个。await等待的是Task的结果:你启动的任务可能很早就已经完成,此时await只是在你真正需要用到结果时取回它。
此外,它也可以用来替代协程。
4. Stopwatch 类
Stopwatch 类 (System.Diagnostics) | Microsoft Docs
用来计时用的
5. SRPBatch
参考资料:从DX角度看SRP Batcher
通过开启该选项,来降低 CPU 的负载,原理就是把材质球一些不变的属性,缓存到 GPU 上,只有在改变的时候,CPU 才会重新收集这些,发送给 GPU。
5.1 为什么需要 SRP Batcher
在传统渲染流程里,CPU 每绘制一个物体,都要把这一帧需要的材质属性、变换矩阵、光照参数等数据重新整理一遍,再通过 SetPass Call 提交给 GPU。当场景里的物体数量很多、材质种类又比较杂时,这部分 CPU 开销会非常可观,往往成为 Draw Call 之外的另一大瓶颈。
SRP Batcher(Scriptable Render Pipeline Batcher)是 Unity 在可编程渲染管线(URP/HDRP)下提供的批处理优化手段。它并不减少 Draw Call 的数量,而是减少每次绘制时 CPU 重新收集和上传材质数据的开销,从而把 CPU 从重复的数据搬运中解放出来。
5.2 核心原理:把不变的属性常驻在 GPU 上
SRP Batcher 的关键思路是「数据常驻 + 按需更新」:
- 常驻显存:把材质(Material)上那些在一帧内不会变化的属性,例如颜色、贴图引用、浮点参数等,预先组织成一块结构化的数据,缓存在 GPU 的常量缓冲区(Constant Buffer)中。
- 只在变化时更新:只有当材质属性真正发生改变时,CPU 才会重新收集这部分数据并发送给 GPU;属性没变,就直接复用显存里已有的那份数据。
- 批量提交:由于同一 Shader 变体下的材质数据在显存中按固定布局连续排列,CPU 可以把多个物体的绘制命令合并成一批提交,GPU 侧按偏移量直接读取对应数据,省去了逐物体绑定材质的过程。
换句话说,SRP Batcher 把「每帧重复上传」变成了「一次上传、多次复用」,CPU 端的 SetPass 开销因此大幅下降。
5.2.1 用一个具体例子理解「按偏移量读取」
假设场景里有 10 个物体,它们用的是同一个 Shader、同一个 Shader 变体(关键字组合完全一致),只是各自的材质参数不同,比如颜色、粗糙度、贴图引用不一样。开启 SRP Batcher 后,这 10 份材质数据会被排成一块连续的内存,可以理解为下面这样:
常量缓冲区(按 Shader 变体划分的一块连续显存) +--------------------------------------------------+ | 偏移 0 | 物体 A 的材质数据(颜色、粗糙度、贴图引用…) | | 偏移 256 | 物体 B 的材质数据 | | 偏移 512 | 物体 C 的材质数据 | | ... | ... | | 偏移 2304 | 物体 J 的材质数据 | +--------------------------------------------------+每个物体的数据块大小是固定的(由 Shader 里CBUFFER的布局决定),所以第 N 个物体的数据起始位置就是N × 块大小。CPU 提交这一批绘制时,只需要告诉 GPU「这批用同一个 Shader 变体、常量缓冲区基址是这块、每个物体的偏移分别是多少」,GPU 在着色时按偏移量去读对应那一段即可,不需要每个物体都重新绑定一次材质。
这里要特别注意:变体不一样就不能合并。如果这 10 个物体里有一个用了「开启阴影」的变体、另一个用了「关闭阴影」的变体,它们编译出来的 GPU 程序不同,CBUFFER布局也可能不同,就无法共用同一块常量缓冲区,只能拆成不同的批次。所以「同一个 Shader」还不够,必须是「同一个 Shader 变体」。
5.2.2 材质数据和常量缓冲区里的数据是一回事吗?
这是很容易混淆的一点,需要拆开看:
- 材质数据是「源头」:指的是 Material 上设置的那些属性值,比如
_BaseColor、_Smoothness、贴图引用等,它们平时存在 CPU 侧的内存里。 - 常量缓冲区是「搬运后的落脚点」:CPU 把这些属性按 Shader 里
CBUFFER声明的顺序和类型打包成一块二进制数据,上传到 GPU 的常量缓冲区。GPU 读的是这块缓冲区,而不是 CPU 侧的 Material 对象。 - 不是所有材质数据都会进常量缓冲区:贴图本身(Texture2D)不会塞进常量缓冲区,常量缓冲区里放的只是贴图的引用/句柄以及采样参数;真正的大块贴图数据走的是纹理采样通道。
所以更准确的说法是:材质数据经过打包后,被写进了常量缓冲区。常量缓冲区是材质数据在 GPU 侧的一份「快照」,布局由 Shader 的CBUFFER决定,SRP Batcher 正是靠这份固定布局才能按偏移量寻址。
5.2.3 GPU 的常量缓冲区在显存里吗?
是的,常量缓冲区本质上就是显存(VRAM)里的一块缓冲区,只是它的用途和访问方式比较特殊:
- 物理位置:常量缓冲区由图形 API(DX11/DX12、Vulkan、Metal 等)分配,最终落在 GPU 可访问的显存中,CPU 通过映射(map)或拷贝的方式把数据写进去。
- 和纹理、顶点缓冲区的区别:它们都在显存里,但用途不同。纹理走采样单元,顶点/索引缓冲区喂给输入装配阶段,常量缓冲区则是给 Shader 提供 uniform 参数,通常有大小限制(比如单个 CB 一般不超过 64KB)。
- 「常驻」的含义:SRP Batcher 说的常驻,是指这块缓冲区在材质属性不变时不会被反复重写,而不是说它不在显存里。它一直在显存里待着,CPU 只在数据变脏时才更新对应片段。
可以这样记:显存是「地方」,常量缓冲区是「这个地方里的一块专用区域」,材质数据是「被搬进这块区域的内容」。三者是位置、容器和内容的关系,不是并列关系。
5.2.4 不用 SRP Batcher 时,CPU 每帧还要收集数据吗?
要,而且这正是传统路径的主要开销来源。Draw Call 每帧都会产生,即使材质属性一个都没改,CPU 在准备绘制时仍然要做这些事:
- 逐物体整理参数:把该物体这一帧需要的材质属性、变换矩阵、光照参数等收集起来,打包成 GPU 能读的格式。
- 逐物体上传:通过
SetPass Call或逐物体的常量更新接口,把这些数据提交给 GPU,切换材质时还要重新绑定。 - 状态切换开销:材质种类越多、切换越频繁,这部分开销越大,往往比 Draw Call 本身还贵。
也就是说,「属性没变」并不等于「CPU 不用干活」。传统路径下,CPU 每帧都要为每个物体重新走一遍收集和上传流程,只是数据内容相同而已;SRP Batcher 的价值就在于把这部分重复劳动省掉——属性没变就跳过收集和上传,直接复用显存里已有的那份数据。
反过来说,如果属性每帧都在变(比如每帧改颜色做动画),脏标记会一直被置位,SRP Batcher 就退化成了「每帧都要重新上传」,优化效果自然大打折扣。这也是为什么实践建议里会提到:频繁改属性的逻辑要考虑换一种实现方式。
5.3 生效条件
SRP Batcher 并不是无条件生效的,需要同时满足以下条件:
- 使用 SRP 渲染管线(URP 或 HDRP),内置渲染管线不支持。
- 在管线资源(URP Asset / HDRP Asset)中勾选SRP Batcher选项。
- Shader 必须兼容 SRP Batcher,即所有材质属性都声明在统一的
CBUFFER(常量缓冲区)中,且布局一致。自定义 Shader 若未按规范声明,会退化为普通渲染路径。 - 参与批处理的物体需要共享同一个 Shader 变体,不同变体之间无法合并。
5.4 与 Static Batching、Dynamic Batching、GPU Instancing 的区别
| 方式 | 优化目标 | 适用场景 |
|---|---|---|
| Static Batching | 合并静态物体的 Draw Call | 不移动的场景物件 |
| Dynamic Batching | 合并小网格的 Draw Call | 顶点数很少的动态物体 |
| GPU Instancing | 一次提交绘制多个相同网格 | 大量重复模型,如草、树 |
| SRP Batcher | 降低材质数据上传与 SetPass 开销 | 材质种类多、Shader 变体统一的场景 |
需要注意,SRP Batcher 与 GPU Instancing 是互补关系:Instancing 解决「同一网格大量重复」的问题,SRP Batcher 解决「不同材质频繁切换」的问题,两者可以同时开启。
5.5 实践建议
- 优先在 Frame Debugger 中确认 SRP Batcher 是否真正生效,观察SRP Batch与SetPass Calls两个指标的变化。
- 如果发现某个材质没有进入批处理,多半是 Shader 的
CBUFFER声明不规范,或使用了不被支持的属性类型。 - 对于频繁修改材质属性的逻辑(例如每帧改颜色做动画),可以考虑改用 MaterialPropertyBlock,避免破坏批处理。
- SRP Batcher 优化的是 CPU 侧开销,如果瓶颈在 GPU(填充率、Overdraw),开启它并不会有明显收益。
5.6 常见疑问解答
下面把几个容易混淆的问题单独拆开说明,方便对照理解 SRP Batcher、Shader 变体、MaterialPropertyBlock 与 GPU Instancing 之间的关系。
5.6.1 CPU 怎么知道材质属性有没有变化?
不是靠“每帧打标记”来判断的,而是靠属性写入时的脏标记(dirty flag)。以 Unity 为例,材质属性的修改最终都会走到类似Material.SetFloat、SetColor、SetTexture这样的接口,这些接口在写入属性值的同时,会把该材质标记为“已修改”。渲染线程在准备这一帧的数据时,只需要检查这个标记:
- 标记为已修改:重新把该材质的属性打包进常量缓冲区,并上传给 GPU。
- 没有标记:直接复用 GPU 上已有的那份数据,跳过收集和上传。
所以这里的“变化”指的是属性值被真正写入过,而不是每帧去逐字段比较。也正因为如此,如果每帧都调用SetColor做颜色动画,即使颜色值没变,也会被判定为“脏”,从而破坏 SRP Batcher 的复用效果——这也是实践建议里提到“频繁改属性考虑用 MaterialPropertyBlock”的原因。
5.6.2 什么算 Shader 变体?是 Shader 里的 if else 分支吗?
不是运行时执行的if else分支,而是编译期由关键字(Keyword)和宏组合出来的不同编译产物。
一个 Shader 源码里通常会有大量#pragma multi_compile或#pragma shader_feature声明,例如是否启用阴影、是否启用雾效、是否使用法线贴图等。Unity 在打包或运行时,会针对这些关键字的每一种组合分别编译出一份 GPU 程序,每一份就是一个 Shader 变体(Shader Variant)。
- Shader 变体:同一份 Shader 源码,因关键字组合不同而编译出的不同 GPU 程序。
- Shader 实例:运行时由材质创建出来的、绑定了一组具体属性值的对象,它引用某个 Shader 变体。
对 SRP Batcher 来说,只有引用同一个 Shader 变体的材质才能被合并到同一批里;不同变体意味着 GPU 程序不同,自然无法共用同一套常量缓冲区布局。
5.6.3 MaterialPropertyBlock 的作用是什么?
MaterialPropertyBlock(简称 MPB)是一个不修改材质本身、只覆盖单次绘制属性的容器。它的典型用途是:多个物体共享同一个 Material,但每个物体需要不同的颜色、缩放或偏移等参数。
如果直接改material.color,Unity 会为这个渲染器复制出一份新的材质实例(Material Instance),既增加内存,又会让这些物体不再共享同一份材质数据,从而破坏合批。而用 MPB 时,属性覆盖只作用于这一次绘制,原材质保持不变。
// 共享同一个材质,但每个物体用不同的颜色 public class ColorVariation : MonoBehaviour { private static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"); private MaterialPropertyBlock _block; private void Awake() { _block = new MaterialPropertyBlock(); } private void Start() { var renderer = GetComponent<Renderer>(); renderer.GetPropertyBlock(_block); _block.SetColor(BaseColorId, Random.ColorHSV()); renderer.SetPropertyBlock(_block); } }关于“不同物体的 MPB 属性值不一样,算不同变体、不同 Shader,还是相同 Shader 所以能合批”,可以这样理解:
- 不算不同 Shader 变体:MPB 只改属性值,不改关键字,Shader 变体仍然是同一个。
- 不算不同 Shader 实例:材质本身没有被复制,仍然是共享的那一份。
- 但会打断 SRP Batcher 的批处理:SRP Batcher 依赖“材质属性常驻在常量缓冲区”这一前提,而 MPB 的属性是逐绘制单独传入的,走的是另一条数据通道。因此使用 MPB 的渲染器通常会被排除在 SRP Batcher 之外,转而依赖 GPU Instancing 或 Dynamic Batching 来合批。
换句话说,MPB 解决的是“共享材质 + 个体差异”的问题,代价是放弃 SRP Batcher 的复用;如果这些物体本身网格相同、数量很大,用 GPU Instancing 配合MaterialPropertyBlock反而是更合适的组合。
5.6.3.1 MPB 的原理:它到底把数据存在哪里?
要理解 MPB 为什么“不脏材质”,先要看清它的数据归属。MPB 本质上是一个挂在 Renderer 上的属性覆盖表,而不是材质的一部分:
- 数据存在 Renderer 侧:调用
renderer.SetPropertyBlock(block)时,MPB 里的键值对会被拷贝并绑定到这个 Renderer 上,Material 对象本身完全不受影响,material的脏标记也不会被置位。 - 绘制时临时合并:真正提交绘制时,引擎会以 Material 的属性为底,再把该 Renderer 的 MPB 覆盖项叠加进去,形成这一次绘制专用的属性集合。
- 生命周期跟随 Renderer:MPB 的覆盖只在 Renderer 存活期间有效,Renderer 被销毁或重新
SetPropertyBlock后,旧覆盖即失效。
所以 MPB 并不是“偷偷改材质”,而是在材质之外多了一层逐物体的属性覆盖。这也解释了为什么它不会触发材质实例化:材质始终只有一份。
5.6.3.2 既然不脏材质,CPU 还会收集数据吗?
会,但收集的对象和时机与常规材质完全不同。可以分两种情况看:
- 常规材质路径:属性写入时给 Material 打脏标记,渲染线程准备这一帧时检查标记,脏了就重新打包整块常量缓冲区并上传 GPU;不脏就复用显存里已有的数据。
- MPB 路径:材质不脏,所以材质那份常量缓冲区不会重新收集、也不会重新上传;但 MPB 的覆盖值仍然需要在每次绘制时被读取并传给 GPU,这部分数据是逐绘制单独提交的,走的是另一条通道(通常是逐实例/逐绘制的常量数据或 uniform 更新),而不是材质常量缓冲区。
换句话说,MPB 省掉的是“材质数据的重复收集与上传”,并没有省掉“把个体差异送到 GPU”这一步。差异数据该传还是得传,只是它不再污染共享材质,也不会让所有使用该材质的物体一起被判定为脏。
这里还有一个实践上的细节:不要在每帧循环里反复 new 一个 MPB。推荐复用一个成员变量,先GetPropertyBlock拿到当前值,再修改需要的字段,最后SetPropertyBlock写回,避免覆盖掉其它系统已经设置过的属性。
5.6.3.3 和 SRP Batcher 的关系:为什么用了 MPB 反而更慢?
这正是很多人困惑的地方:MPB 明明避免了材质实例化,为什么在开启 SRP Batcher 的项目里反而可能更慢?原因在于两者优化的不是同一段开销:
| 对比项 | SRP Batcher | MaterialPropertyBlock |
|---|---|---|
| 优化目标 | 减少材质常量缓冲区的重复收集与上传 | 避免材质实例化,实现共享材质下的个体差异 |
| 数据存放位置 | GPU 常量缓冲区,按材质常驻 | Renderer 侧覆盖表,逐绘制提交 |
| 是否打断批处理 | 本身即批处理机制 | 会将该渲染器排除在 SRP Batcher 之外 |
| 适用场景 | 材质种类多、属性基本不变 | 共享材质但每个物体参数不同 |
所以选择逻辑可以归纳为:
- 属性基本不变:优先让材质走 SRP Batcher,不要用 MPB 去“优化”,否则等于主动放弃批处理。
- 属性需要逐物体不同且数量不大:用 MPB 比复制材质实例更划算,至少省下了材质内存和实例化开销。
- 属性逐物体不同且数量很大、网格相同:用 GPU Instancing,把差异放进实例缓冲区,一次提交完成,比逐个 SetPropertyBlock 更高效。
- 属性需要每帧动画:先评估是否能用 Shader 里的时间参数或顶点动画实现,避免 CPU 侧每帧写属性。
一句话总结:MPB 不脏材质,所以材质那份数据不会被重新收集上传;但个体差异数据依然要逐绘制送到 GPU,它换来的是“共享材质 + 个体差异”的灵活性,代价是放弃 SRP Batcher 的复用。用不用它,取决于你更在意材质内存,还是更在意 CPU 的批处理效率。
5.6.4 GPU Instancing 的原理是什么?优点只是合批吗?
GPU Instancing 的核心是一次 Draw Call 提交多个相同网格的实例。CPU 只发一条绘制命令,同时把每个实例的差异数据(变换矩阵、颜色等)放进一个实例化缓冲区(Instance Buffer)一起传给 GPU;GPU 在顶点着色器阶段根据SV_InstanceID取出对应实例的数据,分别完成每个实例的顶点变换和着色。
// 开启 GPU Instancing 的材质,配合 Graphics.DrawMeshInstanced 批量绘制 public class InstancedDraw : MonoBehaviour { public Mesh mesh; public Material material; // 需在 Inspector 中勾选 Enable GPU Instancing public int count = 1000; private Matrix4x4[] _matrices; private void Start() { _matrices = new Matrix4x4[count]; for (int i = 0; i < count; i++) { _matrices[i] = Matrix4x4.TRS( new Vector3(i % 50, 0, i / 50), Quaternion.identity, Vector3.one); } Graphics.DrawMeshInstanced(mesh, 0, material, _matrices, count); } }它的优点不只是“合批”,具体包括:
- 大幅减少 Draw Call:上千个相同物体可以压缩到一次提交,CPU 侧提交开销显著下降。
- 减少状态切换:同一批次内材质和 Shader 不变,SetPass Call 次数随之减少。
- 数据上传更紧凑:实例差异数据集中放在一块缓冲区里连续上传,比逐个物体设置属性更高效。
- GPU 侧并行度高:每个实例的顶点处理相互独立,能充分利用 GPU 的并行能力。
需要注意的是,GPU Instancing 要求网格相同、材质相同(同一个 Shader 变体),差异只能通过实例数据表达;如果每个物体的网格或材质都不同,就无法实例化。它与 SRP Batcher 并不冲突:Instancing 解决“同网格大量重复”,SRP Batcher 解决“不同材质频繁切换”,实际项目中常常同时开启,由引擎按场景自动选择更合适的路径。