UPL8——影响 GPU 性能的主要因素

影响 GPU 性能的主要因素

在开发时注意这些优化点,可以减少性能问题的发生
当发生性能问题时 先定位问题,再针对性解决问题
结合性能分析窗口、帧调试窗口来分析定位问题,不要凭感觉

填充率

填充率(Fill Rate)是 GPU 的一个性能指标,是最经典的衡量图形处理器片元阶段吞吐能力的指标之一
表示 GPU 在单位时间内能处理(写入)的像素数量
一般用 像素 / 秒 (Pixels per Second,GPixel/s = 十亿像素/秒,MPixel/s = 百万像素每秒) 来衡量
相当于填充率决定了每帧能处理多少个像素

假设 RTX4090 GPU 的理论填充率为 450 Gpixel/s = 4500 亿像素/s
一个 60 FPS的游戏,每帧该 GPU 理论上能处理 4500亿 / 60 = 75亿像素
即 每帧可以处理 75 亿 个片元

如果是一个4K显示器 3840 × 2160 ≈ 829 万个像素
每个像素只处理一次的话
理论上来说,RTX4090 可以每帧处理 4K 画面 75亿 / 829万 ≈ 905次

而一般的高端移动设备的 GPU 填充率 一般是 20 ~ 40 Gpixel/s = 200 ~ 400 亿像素/s
那一个 60 FPS 的游戏,每帧该 GPU 理论上能处理 200 ~ 400亿 / 60 = 3.33 ~ 6.66 亿像素
即 每帧可以处理 3.33 ~ 6.66 亿个片元

如果是一个 4K 显示器 3840 × 2160 ≈ 829 万个像素
每个像素只处理一次的话,理论上来说,高端移动设备的 GPU 可以每帧处理 4K 画面 3.33 ~ 6.66亿 / 829万 ≈ 40 ~ 80次

说人话:GPU 填充率就是 GPU 每秒能画多少个像素

为什么填充率会影响性能

屏幕上每个像素都可能需要执行片元着色器、纹理采样、混合写入等操作,这些工作就是在消耗填充率
如果我们把 GPU 想象成一个喷漆机器,屏幕分辨率越高,需要喷的点就越多,特效越复杂(模糊、透明、Bloom 等),每个点需要喷好几层
当喷漆速度赶不上需求,就会产生性能问题

造成性能瓶颈的原因:

  1. 过高的分辨率

    名称 分辨率 像素总数
    720P 1280 × 720 92 万多
    1080P 1920 × 1080 207 万多
    2K 2560 × 1440 369 万多
    3K 2880 × 1620 466 万多
    4K 3840 × 2160 829 万多
    8K 7680 × 4320 3318 万多

    随着分辨率的增加,GPU 每帧要处理的像素(片元)会指数增加,会大幅消耗填充率

  2. 全屏特效或后处理效果

    如果我们在游戏开发中使用屏幕后处理效果
    比如边缘检测、Bloom、运动模糊、高斯模糊、全局雾效、景深(DOF)等等效果
    它们会对每个像素进行多次采样和计算,会大幅消耗填充率

  3. 多重采样抗锯齿(MSAA)

    4×MSAA 相当于每个像素算 4 次,填充率需求翻倍

    • 普通渲染:1 像素 = 1 个采样点(像素中心)
    • 4×MSAA:1 像素 = 4 个采样点,判断覆盖率,最后合成一个像素颜色
  4. 透明/半透明物体

    每一层都要计算片元,实际填充量成倍增加
    对于不透明物体来说,可以通过深度写入丢弃大量遮挡的片元,只用算一次
    但是对于透明/半透明物体来说,必须逐层混合,每个片元需要重复计算多次

  5. 多 Pass Shader

    每个 Pass 都会单独进行计算,Pass 中会重算片元(像素)
    假设一个物体表面有 1000 个可见片元

    • 1 个 Pass 带来 1000 次片元着色计算
    • 3 个 Pass 带来 3000 次片元着色计算

    相当于在同一像素上多次覆盖绘制

等等

说人话:填充率始终有上限,当单位时间内处理的像素(片元)数量增加,就会消耗填充率,当达到一定瓶颈,就可能会给GPU带来压力

一般增加压力的情况为:分辨率高、屏幕后处理多、半透明多、多 Pass Shader 、抗锯齿 等
一切会增加片元计算次数的行为都增加 GPU 开销

关于填充率的优化思路

了解了填充率影响性能的原理,那么优化思路就很容易得到了

  1. 降低要处理的像素(片元)数量

    1. 降低分辨率

      1. 动态分辨率(Dynamic Resolution Scaling)

        基本原理:
        在 GPU 压力大时,降低渲染分辨率,然后再放大到屏幕分辨率输出
        在 GPU 空闲时,提高渲染分辨率,获得更清晰的画面
        目标是保持 稳定帧率,URP 项目中有动态分辨率功能开关

      2. 注视点渲染(Foveated Rendering)

        基本原理:
        人眼的视觉分辨率,中央注视点区域最清晰,周边区域分辨率敏感度低
        在中央区域渲染高分辨率,在边缘区域渲染低分辨率,从而减少像素处理量
        一般 VR 项目中会使用
        一般 VR 设备会提供对应功能,通过设备 API 开启

      3. 降低项目分辨率

        基于目标设备,合理设定项目目标分辨率

    2. 减少或优化屏幕后处理效果的使用

      在 1/2 或 1/4 分辨率下渲染,或直接不使用

    3. 降低抗锯齿采样次数或关闭

      4×MSAA ——> 2×MSAA 或关闭

  2. 减少单个像素(片元)计算量

    1. 控制 Shader Pass 数量

      能合并的效果放在一个 Pass,避免片元被重复算

    2. 减少半透明的使用

      减少层数,必要时用 透明测试(AlphaTest)
      可见:US3S3L4——透明度测试

    3. 合理使用光照模式

      少用逐像素实时光照

    4. 减少纹理采样次数

      合并贴图、使用 Mipmap

  3. 避免无效像素(片元)计算

    1. 减少 OverDraw

    2. 优化UI系统

      减少叠层,避免全屏大半透明面板,合并图集减少 DrawCall

    3. 剔除不可见对象

      用 Occlusion Culling 和 视锥剔除

OverDraw

OverDraw(过度绘制、叠加绘制) 指同一个屏幕像素在一帧中被重复绘制多次,上一次计算结果会被覆盖或叠加

举例:

  1. 不透明物体

    后面被挡住的像素,仍然会运行片元着色器,只是最后写入时可能被深度测试(ZTest)丢弃

  2. 半透明物体

    因为需要 叠加混合 (Alpha Blend),所以每个透明层都会真实运行片元着色器并参与混合

  3. UI、粒子、屏幕特效

    当 UI 元素、粒子特效、屏幕后处理效果层层叠加时,OverDraw 现象更严重

为什么 OverDraw 会影响性能

之所以 OverDraw 会影响性能,主要原因就是会增加计算开销,消耗内存带宽,消耗填充率

  1. 片元着色器重复执行

    不透明物体即便被遮挡,也需要跑一次片元着色器
    半透明物体一定要逐层计算,无法依赖深度丢弃

  2. 带宽浪费

    每次片元计算都会 读取纹理 并 写入帧缓冲,增加显存带宽消耗

  3. 填充率压力

    屏幕像素总数固定,但 OverDraw 会造成 每个像素要算多次
    GPU 的填充率预算会被快速耗尽

在以下场景时,很容易出现 OverDraw 现象

  1. UI 界面
  2. 粒子特效
  3. 密集树叶

等等

这些画面看起来挺简单,但是其中隐藏的 OverDraw 会让 GPU 消耗暴涨

关于 OverDraw 的优化思路

根据刚才的知识学习,优化 OverDraw 的主要思路为:

  1. 针对不透明物体优化

    1. 从前向后渲染

      Unity 内部处理不透明物体就已经采用了这种方案,会自动按 相机到物体的距离,从近到远 排序进行渲染
      我们也可以手动调整渲染队列或排序层,让对象先渲染,这样后面挡住的片元会被 Early-Z 丢弃,不跑片元 Shader

      Early-Z(提前深度测试)

      在片元着色器运行之前,GPU 先把即将绘制的片元的深度和 Z-Buffer 比较
      如果判定它在后面(被遮挡),直接丢弃,根本不跑片元 Shader,这样可以节省大量 GPU 开销
      当透明或半透明物体时,关闭深度写入等情况,它会失效

    2. 遮挡剔除(Occlusion Culling)

      使用 Unity 提供的 Occlusion Culling,可避免完全不可见的物体进入渲染

    等等

  2. 针对半透明物体优化

    1. 减少透明层数、面积

      UI 背景、HUD 尽量合并图层,少用大面积 透明混合

    2. Alpha Test 替代 Alpha Blend

      树叶、栅栏,用 Alpha Test(Clip)代替 Alpha Blend,让深度测试能提前丢弃片元

    3. 排序优化

      透明物体按从远到近绘制,减少不必要的重复混合

    4. 特殊替代方案

      粒子系统远处改用 广告牌效果,降低粒子数量和透明叠加

    等等

  3. 针对UI系统优化

    1. 图集 + 批处理

      减少 DrawCall,但也要避免大图集带来过多 OverDraw

    2. 减少叠层

      UI 设计时避免多层全屏半透明面板

    3. RectMask2D​ / Stencil​ 替代全屏 Mask

      用普通 Mask​ 相当于多了一层全屏绘制,浪费填充率,如果只需要矩形裁剪,使用 RectMask2D

      RectMask2D​ 是 Unity 提供的一个 更轻量级的 UI Mask
      它不是通过额外绘制 Mask 图层,而是用 矩形裁剪(裁剪坐标系)
      不会产生额外的全屏 OverDraw,性能比普通 Mask 好很多

      如果想要复杂形状裁剪,用 Stencil
      Stencil Buffer(模板缓冲)是 GPU 提供的一种逐像素标记机制
      我们可以在自定义 Shader 中利用它来实现

    4. UI Renderer 独立渲染

      避免和 3D 场景互相叠加 OverDraw,相当于 UI 和场景用两个摄像机单独进行处理

等等

内存带宽

内存带宽指的是,GPU 在单位时间内,显存(VRAM)和 GPU 内部之间能传输的数据量,常用单位:GB/s(每秒多少 GB)

  • PC显卡:100 GB ~ 1 TB/s
  • 游戏主机:200 ~ 600 GB/s
  • 移动设备:30 ~ 200 GB/s

GPU 内部

  1. ALU(算数逻辑单元,Arithmetic Logic Unit)

    最基础的计算单元,负责加减乘除、位运算、逻辑运算等
    比如着色器中的数学计算,就是由它来处理的
    它 是 GPU 的最小 “工人”,做具体数学计算的

  2. SM(流式多处理器,Streaming Multiprocessor)

    NVIDIA 架构里的术语(AMD 里叫 CU (计算单元, Compute Unit) )
    一个 SM 中包含很多 ALU(算数逻辑单元)、寄存器、共享内存、调度器 等等
    它的主要功能就是负责调度和并行执行成千上万个线程
    它 是 GPU 里的一个小工厂,里面有很多工人(ALU) 和 管理员(调度器) 以及仓库(寄存器、缓存)

  3. 着色器核心

    厂商宣传时最常说的单位

    • NVIDIA 叫 CUDA Core(统一计算设备架构核心)
    • AMD 叫 Stream Processor(流处理器)
    • Intel 或 移动设备 GPU 叫 Shader Core(着色器核心)

    它是 ALU(算数逻辑单元)集合体,负责运行着色器指令(顶点、片元、计算等)
    它是 GPU 对外宣传的工人数目,里面的工人就是 ALU(算数逻辑单元)

GPU 外部

显存(VRAM)一般是 GPU 封装在一起的 独立芯片

  • 独显:显存独立,紧贴 GPU 芯片
  • 集显 / 移动端(大部分游戏主机):显存 = 系统内存,GPU 和 CPU 共用同一内存池

显存是 GPU 外部大仓库,里面存储着 顶点数据、纹理、Shader 常量、渲染目标(RenderTarget)、深度/颜色缓冲等
SM(流式多处理器)需要从显存中搬运数据过来(会放在寄存器或共享内存中),然后把数据分给 着色器核心 进行计算
着色器核心中的 ALU(算数逻辑单元)需要不断从寄存器、缓存,必要时还会直接从显存中拿数据进行计算

说人话:

  • ALU(算数逻辑单元)是一个工人,只会做加减乘除、逻辑运算
  • Shader Core (着色器核心) 是一组工人,负责跑一条着色器指令
  • SM(流式多处理器)是一个小工厂,里面有成百上千个工人,还有仓库(寄存器、共享内存),管理器(调度器)

GPU 就是一个大工厂,里面有几十到上百个小工厂(SM),可以同时开工,显存 就是存储原料(各种数据)的一个大仓库
内存带宽 就是一条从大仓库运原料到小工厂的输送带,决定单位时间内可以运送多少原料(数据)

为什么内存带宽会影响性能

GPU 渲染过程中会频繁的访问显存读取或写入数据
GPU 会读取:顶点数据、纹理、贴图、深度缓冲 等等数据
GPU 会写入:颜色缓冲、深度缓冲、后处理结果 等等数据

GPU 内部的计算单元(ALU、SM、着色器核心)执行速度是很快的
但它们需要不断从显存中存取数据,如果内存带宽不足,数据就不能及时送到计算单元,让内存带宽成为性能瓶颈

相当于 GPU 中计算单元计算很快,但是迟迟拿不到数据,GPU 就会游手好闲的等待数据
其实并不是 GPU 算力不足,而是由于内存带宽造成了 GPU 的性能浪费,不能达到最好的状态

说人话:内存带宽带来性能影响的原因是,输送带(内存带宽)数据搬运速度跟不上工厂中工人(Shader Core / ALU)的计算处理速度,导致 GPU(大工厂)算力浪费

什么原因可能造成内存带宽瓶颈

  1. 分辨率和填充率过高

    1. 高分辨率渲染(4k、8k)

      像素数巨增,颜色缓冲、深度缓冲数据量暴涨

    2. 多重采样抗锯齿(MSAA 2× / 4× / 8×)

      每像素要写多份数据,带宽需求成倍增加

    3. 大量 OverDraw

      一个像素被写多次,重复读写显存,
      一般 UI 半透明、粒子特效、后处理效果叠加都会导致带宽消耗剧增

  2. 纹理访问压力大

    1. 使用 超高分辨率纹理,GPU 每次采样需要从显存搬很多数据

    2. 缺少 Mipmap 或 错误采样(远处物体还用 4K 贴图)

    3. 未压缩纹理格式(RGBA32:4 字节/像素,ASTC 压缩:可能 <1 字节/像素)

    4. 多贴图采样(法线、粗糙度、金属度分开存,采样次数多)

      移动 GPU 尤其容易因此卡死

  3. RenderTarget 过大 / 过多

    RenderTarget​ 指 GPU 渲染管线里 片元着色器输出结果要写入的缓冲区,Unity 里常用的就是 RenderTexture
    任何 GPU 可以写入的缓冲区都可以称为 RenderTarget
    使用高精度 RenderTexture​,每像素字节数非常高,使用延迟渲染时,一次写多个缓冲(法线、深度、材质参数)
    屏幕后处理频繁 Blit​ 全屏,每一步都要读写一整张 RenderTexture

  4. 硬件和系统限制

    显存位宽不足,显存频率较低

等等

说人话:只要是游戏中单位时间内当 GPU 的数据读写需求大于显存带宽承载能力时,就会形成内存带宽瓶颈

  • 4K 显示器:830 万像素

  • 帧率:60FPS

  • 渲染目标格式(一帧的颜色缓冲所占大小):

    • RGBA8:4 字节/像素 = 33MB
    • RGBA16F:8 字节/像素 = 66MB
    • RGBA32F:16 字节/像素 = 133MB
  • 1s 带宽需求(假设一秒 60 帧)

    • RGBA8:33MB * 60 = 2GB/s
    • RGBA16F:66MB * 60 = 4GB/s
    • RGBA32F:133MB * 60 = 8GB/s

高画质游戏、各种缓冲区就可能吃掉几十 GB/s 内存带宽,
再加上 纹理、深度、后处理等等 渲染处理内容的话,带宽需求轻轻松松就会超过 100 GB/s

关于内存带宽的优化思路

内存带宽的主要优化思路就是:减少显存的读写量,让 GPU 少搬运,多干活

  1. 减少分辨率和像素相关的开销

    1. 降低渲染分辨率(动态分辨率、注视点渲染)
    2. 降低抗锯齿采样
    3. 减少 OverDraw
  2. 优化纹理相关的开销

    1. 使用 Mipmap

      远处物体采样低分辨率贴图,减少带宽浪费

    2. 压缩纹理格式

      ASTC / ETC2等压缩格式,可以显著减少带宽占用

    3. 贴图合并

      可以把粗糙度、金属度纹理合并,减少采样次数

    4. 合理分辨率

      角色贴图、UI 纹理尽量在满足需求的情况下使用小分辨率图片

  3. 提高缓存利用率

    1. 访问连续化

      避免 Shader 中随机纹理采样,尽量连续访问,增加 缓存 命中率

    2. 减少冗余读取

      能一次采样合并的就合并;不要重复读取深度、颜色等数据

    3. 前向渲染排序

      不透明物体 从前到后 排序,让 Early-Z(提前深度测试)提前丢掉被挡片元,减少显存读写

  4. 减少不必要的数据存储

    1. 避免冗余拷贝

      有时项目里会把同一贴图拷到多个 RT,检查是否必要

    2. 利用 GPU 共享内存

      一些重复计算的数据,可以在共享内存里缓存,避免回显存,可以在 Shader 代码中声明共享内存

    3. 压缩 G-Buffer(延迟渲染阶段 GPU 用来存储几何信息的一组缓冲区)

      通过编码/优化,把原来需要 4~6 张全精度 RT 的数据,压缩到更少的通道/更低的位宽里,从而减少显存和带宽开销
      比如法线压缩、八面体编码、位置压缩、材质参数压缩,降低精度等等

  5. 优化 RenderTarget

    1. 降低精度

      使用 RenderTexture 时可以用低精度格式替代,SRP 项目中可以设置缓冲区精度,可以在 Shader 中设置更低精度缓冲区

    2. 半分辨率渲染屏幕后处理效果

      屏幕后处理效果可以用 1/2、1/4 分辨率处理后放大

    3. 减少全屏 Blit

      多个后处理效果尽量合并到一个 Pass,避免重复拷贝整屏

  6. 平台相关优化

    • 移动端:纹理压缩、半分辨率后处理、避免大面积透明 UI 等等

    • PC/主机:带宽相对高,但高分辨率、高精度 HDR、高 MSAA 下仍容易成为瓶颈

      可通过 降低 RT 精度 + 减少全屏 Pass(屏幕后处理效果) 缓解

等等

顶点和片元

  • 顶点:就像建房子的“钢筋骨架”。
  • 片元:就是把框架之间的“砖块”一块块填上

GPU渲染时:先有骨架(顶点),再填满砖块(片元),最后变成完整画面

顶点和片元如果单独理解

顶点

一般指模型的几何数据,是组成三角形网格的点,一个 3D 模型实际上就是由很多三角形拼接而成的,而三角形的角就是顶点

作用:是网格的框架点,决定了模型的形状

着色器阶段:一般会在着色器中的 顶点着色器 中进行处理和计算

顶点着色器 主要任务

  1. 将模型空间的顶点转换到裁剪空间、屏幕空间
  2. 把需要的数据(UV、法线、颜色等)传递给片元阶段

片元

一般指光栅化阶段产生的、候选的像素点
当三角形被投影到屏幕后,会覆盖一片像素区域
每个像素位置对应一个片元,GPU 会为它准备一份数据(位置、UV、颜色等)

作用:决定屏幕上每个像素的最终颜色

着色器阶段:一般会在着色器中的 片元着色器 中进行处理和计算

主要任务

  1. 计算像素颜色(光照、材质、纹理采样)
  2. 执行深度测试、混合、透明处理
  3. 最终写入帧缓冲,形成屏幕像素

为什么顶点和片元会影响性能

  • 顶点:

    由于顶点数据需要进行计算,因此以下因素会影响性能开销

    1. 模型的顶点数越多,计算量越大

      1. 高顶点数模型,如未优化的高模、过密网格
      2. 复杂骨骼蒙皮,需要大量骨骼权重计算
    2. 复杂的顶点着色器逻辑会放大开销

      比如顶点动画(流体波动、噪声扰动、形变等等)

  • 片元:

    由于片元数据需要进行计算,因此以下因素会影响性能开销

    1. 分辨率和覆盖率

      片元数直接与屏幕分辨率和绘制面积成正比

      1. 屏幕分辨率越高,片元数就越多,那么计算消耗就越大
      2. 全屏效果(后处理、屏幕空间特效)通常直接消耗片元性能
    2. 过度绘制(OverDraw)

      一个像素被多个物体反复覆盖计算

    3. 高开销片元运算

      复杂的片元着色器逻辑(多层纹理采样、分支、循环、PBR、阴影采样、SSR、体积雾、全屏后处理),非常吃性能

顶点和片元的优化思路

顶点的主要优化思路就是:从本质上减少顶点以及顶点相关的计算

  1. 降低顶点数

    1. LOD

    2. 网格简化

    3. 优化远距离对象(用Impostor(替身)技术)

      • Billboard(广告牌)
      • 多角度烘焙(在球面或半球面上采样多个角度,把结果打包到一张大纹理图集)
      • Octahedral Impostor(八面体编码,把球面角度映射到 2D 纹理,通过一种高效编码方式存储多角度外观)
  2. 蒙皮优化

    减少骨骼数量、减少顶点权重、合并骨骼等

  3. 顶点动画

    把复杂的程序形变改为VAT(顶点动画纹理),能贴图查表就别每帧算噪声

    VAT(顶点动画纹理):

    把复杂的顶点动画(顶点位置 / 法线随时间变化)预先烘焙进贴图里,在运行时通过 Shader 从贴图中查表来驱动顶点,而不是实时在 CPU 或 GPU 上做骨骼/物理计算

  4. 顶点着色器输出精简

    减少 v2f​ 里插值数(TEXCOORD 通道),避免为片元传一堆用不到的数据

  5. 顶点着色器精度和数学

    • 移动端着色器中数值类型尽量用 half
    • 避免大量 sin​ / cos​ / pow
    • 能自己写计算过程,就别调用自带的一些高开销函数,比如:pow(x, 2.0)​ → x * x
    • 把复杂函数结果预计算在纹理里,运行时只采样
  6. 合批处理

    使用各种批处理技术(可见:UPL6——图形渲染优化),减少 DrawCall

  7. 剔除先于变化

    使用遮挡剔除、视锥剔除,减少要处理的内容

等等

片元的主要优化思路就是,从本质上减少片元以及片元相关的计算

  1. 降低分辨率

    动态分辨率、注视点渲染等

  2. 降低片元工作量

    尽量减少纹理采样次数与层数(合并贴图通道、使用贴图图集)
    片元着色器中减少循环 / 分支语法的使用,把复杂函数结果预计算在纹理里,运行时只采样
    减少 OverDraw

  3. 减少全屏 Pass

    屏幕后处理中,能半分辨率就半分辨率,
    将多种屏幕后处理效果合并在一个 Pass 中处理

  4. 避免 Early-Z(提前深度测试)失效

    比如透明测试就会导致其失效

  5. Shader 分支收敛

    尽量不要使用 if​ / else 语法,可以用一些函数替代,比如:

    1
    2
    3
    4
    5
    if (mask > 0.5) {
    color = a;
    } else {
    color = b;
    }

    用插值函数替代

    1
    color = lerp(b, a, step(0.5, mask));
  6. 光照和阴影

    降低光照和阴影质量,光照能用逐顶点就别用逐片元

  7. 合理使用 Mipmap 功能

等等

缓存和显存

显存就像仓库,缓存像桌上的小抽屉,去仓库取货很慢,但存储量大,从面前的抽屉里取货快的多,但存储量小

显存

在之前的内存带宽相关知识中,我们已经了解过显存了,它是 GPU 外部的专用大容量存储,主要用于存放

  1. 顶点
  2. 纹理
  3. 渲染目标(RT)
  4. 阴影贴图
  5. 后处理缓冲

等等

特点:

  1. 容量大,以 GB 计,带宽高,但是延迟大(相比缓存慢得多)
  2. GPU 工作时几乎会不停地从显存中读写内容

缓存

它是 GPU 内部的小容量高速存储,主要用于存放从显存取出的热点数据,方便我们重复利用

所谓热点数据(Hot Data),一般在 CPU 和 GPU 的优化领域中指的是:在短时间内被高频访问的数据
因为它被用的多,所以放在更快的存储空间中(比如寄存器、缓存)能极大的提升性能

比如在 GPU 中的常见热点数据为:

  1. 顶点缓存

    渲染一个网格时,某些顶点会被多个三角形复用
    GPU 会把最近变换过的顶点存在 缓存 里,避免重复计算

  2. 纹理缓存

    当片元连续访问相邻的 UV 时,缓存会把周边像素块一起取进来

  3. 常量缓存

    灯光参数、矩阵等,几乎每个片元都要用

特点:

  1. 容量小,以 KB ~ MB 计,速度快
  2. 命中(缓存中找得到对应数据)时快,不命中(找不到)时必须回显存

缓存的工作原理

  1. 缓存

    缓存不会一字节一字节存,而是一次性把一大块连续的数据搬进来,
    原因:利用 空间局部性,取了一个地址,附近的数据很可能也会用

  2. 索引映射

    缓存的容量有限,需要一个规则来决定某个内存地址的数据放在哪个缓存槽里

    常见规则:

    1. 直接映射(Direct Mapped):一个内存块只能放在某个固定的缓存槽
    2. 组相联(Set Associative):一个内存块可以放到一组缓存槽里,组内用替换策略选位置
    3. 全相联(Fully Associative):一个内存块可以放到任何缓存槽(灵活但查找慢)
    4. 高组相联(High Associativity):当一个内存地址映射到某一组时,它有很多个槽位可以选择存放,几乎接近全相联

    GPU 中一般使用 高组相联缓存

  3. 替换策略

    当缓存满了,替换策略常见规则

    1. LRU(Least Recently Used,最近最少使用)
    2. FIFO(先进先出)
    3. Random(随机,GPU 上常见,因为简单+并行)

显存和缓存的关系

注意:GPU 和 CPU 中都存在这个概念,原理是一致的

  1. 取数据时

    当 GPU 或 CPU 访问某个地址

    • 缓存中命中(在缓存中找到了):直接从缓存中读取,速度快
    • 缓存未命中(在缓存中没找到):去显存中去,同时把这块数据搬进缓存
  2. 往缓存里放数据时

    缓存不是一字节一字节放,而是一次性搬一整块(通常 32B / 64B / 128B)
    这样就能利用空间局部性,如果你要用的数据附近的数据也很可能会用,就顺便搬进来

  3. 替换缓存数据时

    • 如果缓存还有空:直接放进去
    • 如果缓存满了:根据数据替换规则直接覆盖之前的数据(GPU 一般采用 Random 随机替换、CPU 一般采用 LRU 最近最少使用的替换)

为什么缓存和显存会影响性能

  • 显存

    性能瓶颈主要来自于:

    1. 内存带宽不足(上文详细讲过)
    2. 容量不足

    说人话:显存决定能装多少,能传多快,当超出上限,就存在性能问题

  • 缓存

    性能瓶颈主要来自于:

    1. 未命中

      比如:在 Shader 中进行随机采样纹理、UV 跨度过大,就会导致缓存命中率低
      当未命中就睡频繁的去显存中取数据,导致获取数据缓慢,延迟变高

    2. OverDraw、重复访问

      一个像素被多次覆盖,导致反复的读取纹理等信息,不停替换缓存中内容,缓存中内容还没有重复使用就被替换了
      让缓存命中率下降,缓存的加速作用大幅削弱

    说人话:缓存决定能不能就近取,取的快不快,当取不到,就存在性能问题

缓存和显存的优化思路

显存主要的优化思路就是 容量 和 带宽 的优化,其中由于在上文内存带宽讲解过带宽的优化思路,这里不赘述,这里主要讲解 容量 优化思路

  1. 资源压缩

    1. 纹理压缩

      Unity 选择更节约空间的压缩格式,比如 ASTC、ETC2 等

    2. 法线压缩等

      双通道压缩,只存法线的 X 和 Y 分量,Z 分量在运行时计算
      可以在 Unity 中选择 ETC2、EAC、RG 等压缩格式

    等等

  2. 降低资源规格

    1. 降低贴图分辨率

    2. 减少 G-Buffer(延迟渲染中缓冲区)和 Render Target(各种缓冲区、颜色、深度、法线等等,包括 RenderTexture)数量

      可以通过合并属性,比如合并金属度、粗糙度等属性
      可以降低 RT 的分辨率,屏幕后处理效果使用半分辨率
      移动平台 使用前向渲染路径,避免延迟渲染 等等

    等等

  3. Mipmap + Streaming

    使用 Mipmap,远处只保留低分辨率层级,在 Texture Importer 面板勾选 Streaming Mipmaps,按需加载纹理(Texture Streaming)
    Unity 会根据相机和屏幕大小,自动决定加载哪些 Mipmap 层,可以减少显存占用和显存带宽消耗

  4. 静态资源烘焙

    • 光照烘焙:将静态光照放入光照烘焙贴图里,避免实时阴影、光照贴图常驻内存
    • 反射探针烘焙:静态场景用烘焙 Cubemap,避免大量实时探针

等等

片元的主要优化思路:

  1. 提高局部性

    1. 纹理访问局部性

      UV 连续,避免大跨度跳跃
      合并图集

    2. 顶点缓存命中

      • 三角形索引优化:相邻三角形尽量复用顶点
      • 避免退化三角形:面积为零的三角形,也就是三个顶点中有两个或三个是重合的情况
  2. 减少缓存未命中

    1. 降低 OverDraw

    2. 避免 依赖采样(所谓 依赖采样 指的是在片元着色器里,纹理采样的坐标不是直接来自插值,而是运行时计算出来的)

      GPU 的纹理单元本来可以提前准备需要取的区域,但依赖采样让它必须等前一步结果

      • 普通采样 UV 连续,相邻像素会访问相邻内容,缓存命中率高
      • 依赖采样 UV 可能随机跳,缓存命中率低,频繁回显存

      我们应该尽量减少采样坐标依赖前一次计算的情况,可以采用预计算预烘焙到纹理中直接获取相关信息,要尽量简化 UV 的计算

  3. 减少数据占用

    1. 数据轻量化

      顶点属性尽量用低精度的,比如用 half​ 替代 float
      顶点着色器的输出结构体中 v2f,只传必要数据

    2. 共享内存

      在 Shader 中,用共享内存替代反复访问

等等

并行度与调度

  • 并行度:表示 有没有足够的线程让 GPU 忙起来
  • 调度:表示 GPU 能否在等待延迟时把空闲单元塞满

GPU 的 执行模型

GPU 的线程不是单独执行的,而是 一批一批 按 线程组(Warp / Wavefront) 执行
NVIDIA:一个 Warp = 32 个线程
AMD:一个 Wavefront = 64 个线程

而一批次内的线程中,同一条指令同时在所有线程上跑,只是每个线程的数据不同
理想情况:所有线程执行同一条路径,利用率 100%

并行度

GPU 天然是 大规模并行处理器,成千上万个小核心(算数逻辑单元)在同时执行,并行度 指的是能同时被 GPU 执行的任务数量和有效利用率

常见的并行度层次(GPU 在不同维度的并行能力分类)有:

  1. 数据并行

    大量像素 / 顶点 / 线程同时计算

  2. 任务并行

    不同 Pass 或 Shader 阶段并行执行

  3. 指令并行

    单个线程里,独立指令是否能被调度器交错执行,从而减少空等、提高 GPU 硬件单元利用率

调度

GPU 内部调度器负责在 不同的线程组(Warp / Wavefront) 之间切换,
把 ALU(算数逻辑单元)、内存访问、纹理采样、光栅化等任务交错执行,
调度的目标是 尽量保持硬件单元忙碌

为什么并行度与调度会影响性能

并行度影响性能的主要原因有:

  1. 并行度不足

    GPU 核心会出现空转(闲置状态),算力浪费,比如:

    • 小批量 DrawCall(每次只处理少量像素/顶点),并行度就会不足

    • Shader 中大量分支,线程分化,造成部分线程空跑

      当 Shader 里有 条件分支 if/else​、switch、loop 时,线程组(Warp)内的线程可能走不同路径

      比如:

      1
      2
      3
      4
      5
      if (color.r > 0.5) {
      doSomething(); // 分支 A
      } else {
      doSomethingElse(); // 分支 B
      }

      假设 Warp 内 32 个线程,有一半满足条件走 A,另一半走 B,GPU 不能同时执行两条不同的指令
      调度器会把分支拆开,先执行 A 的线程,再执行 B 的线程
      当执行 A 时,走 B 的线程空转;当执行 B 时,走 A 的线程空转,这就是所谓的线程分化

      • 分支完全对齐时:32 线程并行, 1 次执行完成
      • 分支分化时:等于串行化,执行时间翻倍(甚至更差),就造成了 ALU(算数逻辑单元) 利用率下降
  2. 并行度过高

    内存带宽、寄存器压力增加,反而造成瓶颈,比如:假设我们在片元着色器中对4张4k纹理进行采样,4K ≈ 830 万像素,如果是 60 FPS的游戏

    那每秒采样 830万 * 60 * 4 = 20 亿次纹理访问,这样 并行度虽然非常高(数百万像素同时跑)
    但是会造成 GPU 的 内存带宽被吃满,ALU(算数逻辑单元)很空闲,还能算更多,但 带宽不足,取不到数据,造成 GPU 利用率下降

调度影响性能的主要原因:如果调度不合理(或没有足够可切换的任务),GPU 会出现 ALU(算数逻辑单元)闲置的情况,比如:

  1. 等待内存,ALU(算数逻辑单元)没事儿干

    比如,我们在片元着色器中进行纹理采样时

    1
    2
    3
    4
    5
    6
    7
    8
    float4 frag(v2f i) : SV_Target
    {
    // 从显存里采样一张大纹理
    float4 texColor = tex2D(_MainTex, i.uv);
    // 得到采样结果才能计算
    float brightness = dot(texColor.rgb, float3(0.3, 0.59, 0.11));
    return float4(brightness.xxx, 1.0);
    }

    当我们从纹理中采样时,需要去缓存或显存中获取数据,那么这里就会存在延迟等待,
    在采样结果没有回来之前,后面的相关计算,都无法执行,相当于就是在等待内存,
    此时的 ALU(算数逻辑单元)在此期间就无事可做

  2. 算数、依赖链过长

    比如 我们执行以下指令

    1
    2
    3
    float a = heavyFunc1(x);
    float b = heavyFunc2(a);
    float c = heavyFunc3(b);

    必须等前一条执行完才能执行下一条,调度器无法插入别的计算

等等

并行度与调度的优化思路

总体思路:让GPU永远有足够的独立的事情可做,避免任何单元长时间空闲

并行度优化主要目标:保证 GPU 有足够多的线程在跑,避免核心闲置

  1. 提高批处理力度

    用之前学习的批处理技术,减少 DrawCall,让每次处理更多的顶点和像素
    GPU Instancing 和 SRP Batcher 还可以让 GPU 同时处理更多对象

  2. 避免 线程组(Warp)内线程分化

    减少 Shader 内部的 if/else​ 的使用
    用数学函数(lerp()​、step())替代分支

  3. 控制寄存器压力

    避免单个 Shader 太臃肿,中间变量过多,用 half​ 代替 float(移动端尤为重要)
    拆分 Shader Pass,降低寄存器占用,让更多 线程组(Warp) 能并行驻留

  4. 保证任务规模足够大

    避免超小网格、超小计算任务(否则并行度不足)
    如果使用 Compute Shader(计算着色器)要合理设计工作组大小,让 GPU 核心填满

调度优化主要目标:让 GPU 能在等待(内存 / 采样 / 依赖)时切换别的任务,避免管线停滞

  1. 内存访问优化

    • 减少带宽需求:纹理压缩(ASTC / BCn / ETC2)、MipMap、合理精度格式
    • 提高缓存命中率:让相邻像素访问相邻内存(UV 连续、把多种需要的数据合并到一张纹理里,而不是分散存放)
    • 减少依赖采样:UV 复杂运算/链式采样容易停顿,尽量预计算。
  2. 指令调度优化

    打散指令依赖链,让调度器有空间插入别的运算

  3. 负载均衡

    避免片元过重、顶点过轻,减少 Overdraw,
    动态分辨率等优化分辨率方案来控制片元数量

等等

影响 GPU 性能的其它因素

目前我们已经了解了:填充率、OverDraw、内存带宽、顶点/片元、缓存与显存、并行度与调度
这些因素都可能会对GPU造成性能影响

除此之外,我们还应该注意以下一些因素,这些因素可能已经被我们在以上因素中提及过,在这里再次进行强调

  1. DrawCall

    每个 DrawCall 都有 CPU 到 GPU 的指令调度开销,频繁切换材质、着色器、渲染目标会造成流水线停滞
    因此减少 DrawCall 不仅仅可以优化 CPU 性能,同样也可以优化 GPU 性能

  2. 纹理采样

    1. 优化采样次数可以提高 GPU 性能

      纹理查询是片元着色器的主要瓶颈之一,Mipmap、LOD 能减少带宽和缓存压力

    2. 有规律的进行采样,可以提高缓存命中率

      如果片元访问的纹理坐标分布离散,纹理缓存命中率下降,会大量访问显存,我们应该有规律的连续采样

    3. 采样模式的复杂度越高,越消耗性能

      在 Inspector > Texture Import Settings 里有一个 Filter Mode:

      • Point(最近点)
      • Bilinear(双线性)
      • Trilinear(三线性)

      性能消耗:三线性 > 双线性 > 最近点

    4. 尽量减少多纹理绑定

      过多材质切换也会增加开销

  3. 着色器

    着色器复杂度越高,分支越多,越耗性能
    我们应该避免或减少分支或循环的使用
    减少高开销的内部函数调用

  4. 精度和数据格式

    1. Shader 中尽量用低精度减少性能消耗

      移动端使用 half 精度可大幅减少寄存器与能耗

    2. RenderTexture 格式在满足需求的情况下,尽量选择精度低的格式

      RGBA32F 带宽和存储开销远高于 RGBA8

    3. 尽量在对应平台上使用合适的压缩格式

    4. 在顶点着色器中输出的结构体 v2f,应该尽量避免在其中包含无意义数据,因为会进行插值计算,消耗性能

  5. 后处理和特效

    1. 尽量减少全屏屏幕后处理效果,它们对带宽和片元算力消耗很大
    2. 半透明效果会大幅提升 OverDraw,需要谨慎使用
    3. 逐像素光照、多阴影贴图采样会显著增加片元着色开销,需要谨慎使用