89.减少着色器采样开销

89.性能优化-GPU-着色器优化-减少采样开销


89.1 知识点

减少纹理采样

纹理采样是内存带宽消耗的主要来源。纹理用得越少、分辨率越合理,缓存命中率越高,带宽和性能越好;纹理越多,缓存未命中越多;纹理越大,传到纹理缓冲的带宽越高。应尽量避免上述情况,防止形成 GPU 瓶颈。

例如可以把多张纹理合并成一张,减少采样次数:将金属度、粗糙度、环境光遮蔽三张灰度图打包到一张 RGBA 的 R、G、B 通道中,一个像素即可存三份数据(若某张图本身是灰度的,R、G、B 可分别存三张不同的图)。

使用更少的纹理数据

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

例如遮罩类贴图只需一个灰度通道,可用 R8 代替 RGBA32

  • R8:每像素只存一个通道(R),8 位 = 1 字节。
  • RGBA32:每像素存 RGBA 四通道,32 位 = 4 字节。

测试不同 GPU 纹理压缩格式

Unity 的纹理压缩能减少磁盘占用和运行时的 CPU、内存占用,这些格式针对各平台 GPU 设计。常见有 DXT、PVRTC、ETC、ETC2、ASTC 等,不同平台可选范围不同:PC 常用 BC 系列,Android 常用 ETC2/ASTC,iOS 常用 PVRTC/ASTC。

应在各平台实际测试不同格式,在画质和性能之间取舍。Unity 有默认推荐,仍可根据项目需要对部分纹理单独设置压缩格式以进一步优化。

参考: Unity 纹理压缩与 Override 说明

最小化纹理交换

若存在内存带宽问题,需要减少纹理采样量。频繁切换绑定的纹理会刷新 GPU 纹理缓存,增加未命中与带宽消耗。可采取:

  1. 适当降低纹理分辨率(可能影响画质,需权衡)。
  2. 重复使用同一张纹理配合不同 Shader 表现不同效果。
  3. 使用纹理图集,减少纹理切换次数。
  4. 避免在同一 Pass 或同一 DrawCall 内频繁切换大纹理。

VRAM(显存)限制

显存不足时,驱动会做纹理换入换出(腾出显存再加载新纹理),导致采样卡顿;即使不溢出,过大的纹理也会占用带宽。应控制显存预算,避免过大或冗余贴图。例如移动端尽量将贴图总占用控制在 500MB 以内。

使用 Mipmap 并正确过滤

对离相机较远的物体,若始终用高分辨率纹理,会浪费带宽并可能引起闪烁。可开启 Mipmap,采样时使用线性(linear)或三线性(trilinear)过滤,既能降低带宽又能减轻锯齿、改善画质。

为何会闪烁: 物体很远时,一个屏幕像素可能对应很多纹素;没有 Mipmap 时 GPU 仍从最高分辨率贴图采样,采样点在纹素间来回跳动,易产生锯齿、闪烁和莫尔条纹。Mipmap 相当于低通滤波,去掉了超出屏幕采样率的高频细节。

莫尔条纹: 两个高频规则图案(如网格、条纹、点阵)叠加时,因采样不足或频率接近而产生的干涉花纹,例如屏幕上的波浪纹、远处格子地砖或栅栏的水波纹状图案。

采样复用

尽量让一次采样的结果被多次使用,而不是对同一纹理、同一 UV 重复采样;也可把多种数据打进一张图,一次采样取多种信息(见上文“减少纹理采样”)。每次采样都会经历显存 → 缓存 → 纹理单元 → 插值滤波,即便命中缓存也有开销,因此应避免重复采样。

错误示例:同一贴图、同一 UV 采样两次。

// 第一次采样:取 rgb 作为 albedo
float4 albedo1 = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
// 第二次采样:同一张图、同一 uv,仅取 .a 作为粗糙度 —— 浪费一次采样
float rough1 = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv).a;

正确示例:采样一次,再从中取 rgb 和 a。

// 一次采样,rgba 分别存 albedo 与粗糙度
float4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
float3 albedo = tex.rgb;
float rough = tex.a;

避免动态分支选择纹理

不要在 if/else 分支里按条件选择不同纹理再采样。由于 GPU 的并行架构,动态分支可能导致两个分支都执行,两套纹理都被采样,反而更慢。

替代思路:

  1. 纹理图集:多张小图合成一张大图,CPU 把当前需要的子区域用 scale/offset 传给材质,Shader 用重映射后的 UV 对同一张纹理采样。
  2. Texture2DArray:同尺寸、同格式的多张纹理打成 Texture2DArray,Shader 用 float3(uv, layerIndex) 采样。
  3. Shader 关键字变体:用关键字生成不同 Shader 变体(如两个版本),运行时通过关键字选择使用哪个版本,避免分支内采样。

压缩法线纹理

法线贴图常用 RGB 或 RGBA32,每像素 24 位或 32 位,占用大。法线只需方向精度,不需颜色精度,可用专用压缩格式显著减少显存和带宽。

思路: 法线是三维向量 (x,y,z),可只存 (x,y),在 Shader 中用 n.z = sqrt(saturate(1 - n.x*n.x - n.y*n.y)) 重建 z,这样用 RG 两通道即可,不必用 RGB(A)。Unity 中常用:

  • BC5 / ATI2:两通道独立压缩,适合 PC/主机。
  • ASTC / ETC2_RG:移动端常用,在效果和性能间较均衡。

压缩法线贴图通常可减少约 50%~75% 的存储与带宽。

流式加载

大型游戏中单场景贴图可能达到数 GB,若一次性全部加载进显存,易卡顿或内存溢出。流式加载用于缓解该问题,例如:

  1. 虚拟纹理(Virtual Texturing):将超大纹理切成小块,运行时只加载相机可见的块(可参考本系列 Lesson 19 等)。
  2. Unity Streaming Mipmap:只保留需要的 Mip 层,远处用低分辨率 Mip,近处再加载高分辨率 Mip。

这类功能多采用异步加载,避免阻塞主线程。


89.2 知识点代码

Lesson89_性能优化_GPU_着色器优化_减少采样开销.cs

public class Lesson89_性能优化_GPU_着色器优化_减少采样开销
{
    #region 知识点一 减少纹理采样

    //纹理采样是所有内存带宽开销的核心消耗
    //使用的纹理越少、分辨率越合适,缓存命中率更高,带宽占用更低,性能更好
    //使用的纹理越多,则在调用过程中缓存丢失的情况就越多
    //制作的纹理越大,将它们传输到纹理缓冲消耗的内存带宽就越多
    //因此我们应该尽量的杜绝这些情况的发生,避免产生GPU瓶颈

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

    #endregion

    #region 知识点二 使用更少的纹理数据

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

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

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

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

    #endregion

    #region 知识点三 测试不同GPU纹理压缩格式

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

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

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

    #endregion

    #region 知识点四 最小化纹理交换

    //如果内存带宽存在问题,就需要减少正在进行的纹理采样量
    //并且频繁绑定不同纹理会刷新 GPU 的纹理缓存,导致 缓存丢失,更耗带宽
    //我们可以通过以下方式降低纹理采样量和交换频率
    //1.降低纹理分辨率(可能会牺牲表现效果,谨慎使用)
    //2.重复使用纹理(利用不同着色器渲染不同效果)
    //3.纹理合并图集(减少纹理交换次数)
    //4.减少在一个Pass或者DrawCall间频繁切换大纹理
    //等等

    #endregion

    #region 知识点五 VRAM(显存)限制

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

    #endregion

    #region 知识点六 使用Mipmap并正确过滤

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

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

    //莫尔条纹:
    //当两个 高频的规则图案(例如网格、条纹、点阵)叠加时,因采样不足或频率接近而产生的 干涉花纹
    //比如
    //电视、电脑屏幕中出现的波浪纹
    //在游戏里看远处的格子地砖、栅栏,会出现水波纹状的图案

    #endregion

    #region 知识点七 采样复用

    //建议一次纹理采样的结果尽量多次使用
    //而不是重复采样
    //或者把多个数据打包到同一张贴图中,一次采样取多种信息(知识点一中的减少纹理采样方式)
    //我们每次从纹理中采样都可能走一个完整的流程
    //显存 → 缓存 → 纹理单元 → 插值滤波
    //即使从缓存中获取内容也是有开销的
    //因此我们应该避免重复采样
    //比如
    //float4 albedo1 = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
    //float rough1 = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv).a;
    //应该改为
    //float4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
    //float3 albedo = tex.rgb;
    //float rough = tex.a;

    #endregion

    #region 知识点八 避免动态分支选择纹理

    //建议不要在 if else 分支语句中进行采样
    //比如
    //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版本
    //等等

    #endregion

    #region 知识点九 压缩法线纹理

    //法线贴图通常是 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%的存储和带宽

    #endregion

    #region 知识点十 流式加载

    //对于一些大型游戏来说
    //单个场景中可能会有几个GB的贴图
    //如果我们一次性的全部加载进显存,可能会造成卡顿甚至内存溢出直接闪退
    //而流式加载就是来解决这个问题的
    //1.虚拟纹理(Virtual Texturing Lesson19中有讲解)
    //  将超大纹理分割成很多小块
    //  运行时只加载摄像机能看到的块
    //2.Unity自带的Streaming Mipmap功能
    //  GPU只保留必要的mip层,远处加载低分辨率mip,近处加载高分辨率mip
    //并且这些流式加载功能往往是通过异步加载的方式进行的
    //这样可以避免主线程卡顿

    #endregion
}


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏