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 有默认推荐,仍可根据项目需要对部分纹理单独设置压缩格式以进一步优化。

最小化纹理交换
若存在内存带宽问题,需要减少纹理采样量。频繁切换绑定的纹理会刷新 GPU 纹理缓存,增加未命中与带宽消耗。可采取:
- 适当降低纹理分辨率(可能影响画质,需权衡)。
- 重复使用同一张纹理配合不同 Shader 表现不同效果。
- 使用纹理图集,减少纹理切换次数。
- 避免在同一 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 的并行架构,动态分支可能导致两个分支都执行,两套纹理都被采样,反而更慢。
替代思路:
- 纹理图集:多张小图合成一张大图,CPU 把当前需要的子区域用 scale/offset 传给材质,Shader 用重映射后的 UV 对同一张纹理采样。
- Texture2DArray:同尺寸、同格式的多张纹理打成 Texture2DArray,Shader 用
float3(uv, layerIndex)采样。 - 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,若一次性全部加载进显存,易卡顿或内存溢出。流式加载用于缓解该问题,例如:
- 虚拟纹理(Virtual Texturing):将超大纹理切成小块,运行时只加载相机可见的块(可参考本系列 Lesson 19 等)。
- 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