UPL9-10——减少 Shader 采样开销

减少纹理采样

纹理采样是所有内存带宽开销的核心消耗

  • 使用的纹理越少、分辨率越合适,缓存命中率更高,带宽占用更低,性能更好
  • 使用的纹理越多,则在调用过程中缓存丢失的情况就越多
  • 制作的纹理越大,将它们传输到纹理缓冲消耗的内存带宽就越多

因此我们应该尽量的杜绝这些情况的发生,避免产生 GPU 瓶颈

比如我们可以通过把多个纹理合并成一张纹理的方式,
有效的减少纹理采样:可以把 金属度、粗糙度、环境光遮蔽 三张黑白图 打包到一张 RGBA 纹理的 RGB 通道中

使用更少的纹理数据

每次采样传输的数据量越小,显存带宽和缓存压力就越小
我们可以通过降低分辨率、减少通道的方式,去掉用不到的高精度

比如我们可以用 R8 格式代替 RGBA32 格式
遮罩贴图只需要一个灰度通道,用 R8 格式即可

  • R8:

    每个像素只存储一个通道(R 通道)
    每个像素 8 位 = 1 字节

  • RGBA32:

    每个像素存储四个通道(RGBA 通道)
    每个像素 32 位 = 4 字节

测试不同 GPU 纹理压缩格式

Unity 中不同的纹理压缩格式,能减少程序的磁盘空间以及运行时 CPU、内存使用率
这些压缩格式都是为具体平台的 GPU 架构设计的,压缩格式有很多,比如:DXT、PVRTC、ETC、ETC2、ASTC 等等

image

但是在不同的平台上能选择的压缩格式有所不同,比如:

  • PC:BC 系列
  • Android:ETC2 / ASTC
  • iOS:PVRTC / ASTC

我们应该在不同平台测试不同格式,选出兼顾视觉和性能表现的格式进行使用
虽然 Unity 在不同平台都推荐了对应的格式,但是我们完全可以根据自己的需求
局部性的修改部分纹理的压缩格式,能带来更多的性能提升!

Unity 压缩格式说明:https://docs.unity3d.com/cn/current/Manual/class-TextureImporterOverride.html

最小化纹理交换

如果内存带宽存在问题,就需要减少正在进行的纹理采样量
并且频繁绑定不同纹理会刷新 GPU 的纹理缓存,导致 缓存丢失,更耗带宽
我们可以通过以下方式降低纹理采样量和交换频率

  1. 降低纹理分辨率(可能会牺牲表现效果,谨慎使用)
  2. 重复使用纹理(利用不同着色器渲染不同效果)
  3. 纹理合并图集(减少纹理交换次数)
  4. 减少在一个 Pass 或者 DrawCall 间频繁切换大纹理

等等

VRAM(显存)限制

显存不足会导致驱动把纹理换入换出(释放显存中老纹理,载入新纹理),会造成采样时卡顿,即便没有内存溢出,大纹理也会拖慢显存带宽
我们应该控制项目的显存预算,避免大纹理和冗余贴图的使用

比如:移动端项目尽量把总贴图内存控制在 500MB 以下

使用 Mipmap 并正确过滤

对于离摄像机很远的对象,如果一直使用大分辨率纹理,会带来多余带宽消耗,甚至带来画面闪烁
我们可以通过开启 Mipmap,在采样时使用线性(linear)或者三线性(trilinear)
这样不仅可以降低显存的带宽消耗,还可以减少锯齿,提升画质

为何会画面闪烁:
当物体离得很远时,一个屏幕像素可能对应 很多个纹理像素,如果没有 Mipmap,GPU 仍然从 最高分辨率的原始贴图里采样
就可能出现 在像素点和纹理点之间采样跳来跳去,会出现 锯齿、闪烁、莫尔条纹,
Mipmap 实际上起到了 低通滤波 的作用,去掉了超过屏幕采样率的高频细节

莫尔条纹:当两个 高频的规则图案(例如网格、条纹、点阵)叠加时,因采样不足或频率接近而产生的 干涉花纹

比如:

  • 电视、电脑屏幕中出现的波浪纹
  • 在游戏里看远处的格子地砖、栅栏,会出现水波纹状的图案

image

采样复用

建议一次纹理采样的结果尽量多次使用,而不是重复采样
或者把多个数据打包到同一张贴图中,一次采样取多种信息(知识点一中的减少纹理采样方式)

我们每次从纹理中采样都可能走一个完整的流程:显存 → 缓存 → 纹理单元 → 插值滤波
即使从缓存中获取内容也是有开销的,因此我们应该避免重复采样

比如:

1
2
float4 albedo1 = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
float rough1 = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv).a;

应该改为

1
2
3
float4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
float3 albedo = tex.rgb;
float rough = tex.a;

避免动态分支选择纹理

建议不要在 if/else 分支语句中进行采样,比如:

1
2
3
4
if (条件)
tex2D(texA, uv);
else
tex2D(texB, uv);

因为由于 GPU 的并行架构,动态分支可能会导致两个分支都执行,就会造成两个纹理都采样,反而会更慢
动态分支选择纹理 ≈ GPU 并不会真的只执行一条分支,所以会浪费采样

我们应该通过

  1. 纹理图集

    把多张小贴图打到一张大图里,CPU 侧把本次要用的子矩形转换成 scale、offset 传给材质
    着色器直接用重映射后的 UV 采样 同一张纹理

  2. Texture2DArray

    把同尺寸、同格式的多张纹理打包成 Texture2DArray​,着色器用 float3(uv, layerIndex) 采样

  3. 关键字产生变体

    利用 Shader 中的关键字生成 Shader 变体,编译两个版本,运行时根据关键字选择执行的 Shader 版本

    具体可见:US5L3——Shader变体和关键字

其中,2 和 3 是为了解决并行性的问题,和 1 的原理不一样

等等

压缩法线纹理

法线贴图通常是 RGB 或 RGBA32,每像素 24 位 3 字节 或 32 位 4 字节,开销大
但是法线数据的特点是:只需要保持方向向量的精度,不需要颜色准确
所以可以用专门的纹理压缩格式来大幅减少显存占用和带宽

基本原理:法线其实是一个三维向量 (x,y,z)​,但可以只存两个分量 (x,y)
再在 Shader 里用公式重建,公式 n.z = sqrt(saturate(1 - n.x * n.x - n.y * n.y));
这样可以用 RG 两通道存储,而不是 RGB(A)

Unity 内的专用压缩格式:

  • BC5、ATI2:两个通道独立压缩,精度足够恢复法线方向,常用,主要针对 PC 和主机
  • ASTC、ETC2_RG:支持更灵活的压缩方式,能兼顾效果和性能,主要针对移动端

等等

通过压缩法线贴图可以减少 50%~75% 的存储和带宽

流式加载

对于一些大型游戏来说,单个场景中可能会有几个 GB 的贴图
如果我们一次性的全部加载进显存,可能会造成卡顿甚至内存溢出直接闪退
而流式加载就是来解决这个问题的

  1. 虚拟纹理

    将超大纹理分割成很多小块,运行时只加载摄像机能看到的块

    详细可见:UPL2-13——Virtual Texturing 模块

  2. Unity 自带的 Streaming Mipmaps 功能

    GPU 只保留必要的 mip 层,远处加载低分辨率 mip,近处加载高分辨率 mip

并且这些流式加载功能往往是通过异步加载的方式进行的,这样可以避免主线程卡顿