前言
承接前文GPU渲染管线和架构整理(一),本文继续对腾讯技术工程号发布的文章《GPU 渲染管线和硬件架构浅谈》的后续部分内容做了梳理,同时也按照立即的理解好恶进行了一定程度补充,其中可能存在谬误的地方还请指正。总的来说这一段内容相当重要,原文花了重墨在Early-Z,光栅化,Tiling等细节上,与曾经所知结合一二,相应之下可谓豁然开朗。好了废话不多说,我们开始吧!
其他重要概念
1)Pixel Quad
- 光栅化阶段离散化输出的单位不是单个“像素”而是“Pixel Quad”(2X2像素大小)
- 参考上图,
2X2
组成一个Quad,三角形只要覆盖其中一个像素,那么整个Quad都要执行像素着色器PS - 那些未覆盖三角形,但是被连带进入ps的像素叫“辅助像素”
- 比较小的三角形渲染时,“辅助像素”占比一般会比较高 -> 这不好
- “辅助像素”带来的问题在于
- 占用SM内计算资源
- 占用 cache line 带宽(沟通GPU和VRAM的通道)<-- 重点
- 结论:尽量避免绘制大量“小图元”
- 粒子系统闻后惊呼不妙(!!)
- 参考上图,
- Early-Z 判定的最小单位也是 Pixel Quad
- 设计“Pixel Quad”的一项作用
- 计算
ddx
和ddy
-> 通过计算相邻像素UV的插值: 帮助GPU判断像素/纹理占比
-> PS阶段mipmap的采样需要使用到此占比
-> 更多细节参考文章: Notes on ddx/ddy
- 计算
2)Early Depth & Early Stencil
- 执行Early-Z/Early-Stencil/Early-MultiSampling的硬件与执行Normal-Z等操作的硬件是同一套
- 所谓Normal-Z就是正常渲染管线中PS后执行的深度测试(Z-Test)
-
Early-Z的执行时机是在完成图元的光栅化之后(After Rasterization)
- 如果有更加“粗粒度”的预剔除技术(诸如Hi-Z),那么Early-Z也在“粗粒度”剔除之后执行
- Early-Z必然在PS/FS之前
- 朴素的流程 (简直和正常Z-Test一样,只是时机不同):
- Read/Load Depth-Buffer
- Do Test!
- Conditional-Write (back to Depth-Buffer)
-
Depth-Test及Depth-Write皆原子操作
- 本质是调用了 interlock opeation
- 在 Read Depth -> Test -> Conditional-Write 期间 interlock 将一直生效
- 注意,这个原子锁不会“横跨”整个PS阶段(即便硬件厂商可以这样做)
- 因为这会导致在当前Pixel Shader完成输出前,系统不得不阻止所有其他渲染核心对当前像素的Shading <- 这不好
- 锁的存在 -> 直接决定了渲染队列中的执行“顺序” : ARB_fragment_shader_interlock
- 这种“顺序”是Depth-Test所要求的 -> 不然会出现错误
- 这种“顺序”可用于解决多Shader处理同一Pixel时顺序不一致引起的问题
-
Depth Test 和 Stencil Test 也是同属一个硬件单元驱动(Raster Operation中的硬件单元)
- 能做 Early-Z,就能做 Early-Stencil -> 所谓的 Early-ZS
- 此外也涉及 Multi-Sampling
- 对比分析Early-Z
- 传统(没有Early-Z):
- 深度测试发生在像素着色(PS)之后,Blend之前,当发现遮挡则Discard
- Discard得越多,Overdraw越多 -> Sooooo bad!
- 引入Early-Z技术之后:
- Early-Z 测试发生在光栅化后,像素着色(PS)之前
- 如果测试失败则直接不进入PS
- 本质是避免Overdraw -> Sweeeeet!
- 但是为了真正做到避免Overdraw,还需要从前(距离Camera近处)往后(距离Camera远处)排序渲染
- 每一帧都需要对待渲染图元进行排序(sorting)
- 排序可能但不限于受到“运动的相机”+“可能运动的Geometry”的影响
- IMR和TBR管线架构:sorting由应用层执行,管线只负责按照提交顺序(submission order)执行
-
TBDR管线架构:sorting “可以”由硬件支持完成
- 以PowerVR为例,完成sorting的主要功臣是: “Hidden Surface Removal” + “Deffered Rendering”两项技术
- 先阅览过所有不透明图元对应位置的像素后信息 -> 对应HSR
- 后放行“可见”的PixelQuad参数(独一份,利于片上缓存)-> 对应Deffered Rendering
- Arm的Mali采用的“FPK”技术则依赖应用层排序,没有硬件级别sorting的支持
- 以PowerVR为例,完成sorting的主要功臣是: “Hidden Surface Removal” + “Deffered Rendering”两项技术
- 传统(没有Early-Z):
-
Early-Z 要点总结
- 尽可能提前剔除无效片元,提高整体渲染性能
- 剔除的最小单位是“Pixel Quad”
-
Early-Z 执行期间能不能"不要"写入深度?
- 当然要,不然拿什么和当前Pixel的深度做比较
- 能否只写入另开辟的临时深度缓冲?-> 会增加带宽和内存消耗,增加复杂度,没必要
- 注意 Early-Z 本质上使用了 Normal-Z 完全一样的物理硬件
-> 因此也很难在不改动电路的前提下,执行区别显著的逻辑操作
-
Early-Z关联的优化不仅仅只对 Depth-Test部分起作用
-> 还涉及Stencil,Multi-Sampling等相关硬件支持的其他测试 -
Early-Z 和 Normal-Z 一样,深度测试通过则触发深度写入,失败则Discard
-> 同理,Stencil-Test 触发Discard后同样不会激活后续的 Depth-Test - 一般只在渲染不透明物,且shader中没有原子操作,没有深度修改,没有Discard(glsl)/Clip(hlsl)指令时
-> 才能顺利激活 Early-Z - 在激活Early-Z的前提下,渲染框架可以选择不再开启 Normal-Z(避免重复劳动)
- 对Alpha-Test 会失活 Early-Z的一点释疑
- 开启Alpha-Test就需要等到PS结束后才能获取到正确的
Alpha
值,并以此决定是否丢弃片元- 通过Early-Z测试,并触发了深度写入的片元,完全可能在 PS 阶段被无情Discard掉
- -> 而这个片元形成的像素,在传统Normal-Z管线下,根本不应该执行后续的Stencil-Test 和 深度写入...
- -> 错误的根源在于 Early-Z 过滤掉了正确的像素片元,唯一通过的像素片元则会在后续PS阶段被剔除,从而产生错误!
- 通过Early-Z测试,并触发了深度写入的片元,完全可能在 PS 阶段被无情Discard掉
- 开启Alpha-Test就需要等到PS结束后才能获取到正确的
- 开启 Alpha-Blend 和 Alpha-Test 会使得管线严格按照应用层提交的队列顺序执行渲染
- 在高度并行化设计的GPU内部,一般通过 interlock 确保这种顺序
- 一个 PS 必须完成深度写入之后,相同位置的后续片元才能继续执行(读取正确的深度缓存,判断并尝试写入自己的深度)
- 如果Shader中存在PS阶段修改了深度的操作,也会失活Early-Z
- 原因显而易见 -> 正确的深度只有在PS结束后才能获得
- 任何之前进行的基于深度的测试都可能导致错误的表现
-
Alpha To Coverage 也会失活 Early-Z
- Alpha to coverage[参考1,参考2,参考3]
- 用于缓解Alpha-Test导致的边缘锯齿
- 基于MSAA + Post PS Alpha Coverage Op
- 渲染API可以通过设置
- layout(early_fragment_tests)
- earlydepthstencil
- 测试工程博客
3)Hierarchical-Z 和 LRZ
-
Hierarchical-Z也称Z-Culling,是Nvidia硬件支持的粗粒度Early-Z裁剪方案
- 精度一般比Early-Z低 -> 精确到
8x8
的像素块 - 但是贵在速度快,效率高
- 作为Early-Z之前的预剔除
- 精度一般比Early-Z低 -> 精确到
- Low-Resolution-Z也称LRZ,是Adreno的类似解决方案
- 基本运行逻辑(以LRZ为例)
- 在 Tile-based-defferred 渲染流水线中的 Binning Pass 阶段产生一张低分辨率的全屏可见性缓冲
- Full-Screen Visibility Table
- Table的分辨率对于 LRZ 来说可以低至
2x2
(一个PixelQuad) - Table中含有
- 来自于场景中图元的顶点信息(Vertex Position Info)
- 其他未知辅助信息(待考)
- 深度值正确性
- 利用建立Tiler过程中的图元转换流程,获得所有覆盖在当前LRZ-Tile上的图元顶点信息
- 在写入Table过程中“可能”附带有可见性排序
- 本质是逐Tile跑了一遍 Vertex Shader 中的 Position 部分,得到低分辨率深度图
- 在Tile-based-defferred渲染流水线的Rendering Pass阶段
- 读取System Memory中存放的Visibility Table
- 判断/确定图元的遮挡关系(可见性)
- 按顺序光栅化图元(Rasterization of Primitives)
- 走正常的Fragment Shading -> Z-Test -> Blending等流程
- 在 Tile-based-defferred 渲染流水线中的 Binning Pass 阶段产生一张低分辨率的全屏可见性缓冲
- 导致LRZ失效的应用场景(主要应为片元着色器存在颜色输出之外的“副作用 Collateral Effects”),可以看出和Early-Z的失效条件基本一致
- writing to SSBO -> 片元着色器读写了SSBO,从而可能影响到其他渲染流程
- atomics op -> 片元着色器激活了原子操作 -> 通常是因为Alpha-Test所致(原因前文已讲)
- enable blending -> 激活了颜色混合 -> 天然要求不丢弃该片元
- modify fragment's depth -> 修改了深度缓冲 -> 同样会影响其他着色器的渲染输出
4)Tile-based Rasteration
- 光栅化是对图元中三角形等基础几何构型(线段,三角形等)在投影平面上的离散化过程
- 光栅化是逐图元逐三角形进行的
- 光栅化输出是一系列PixelQuad,及其关联信息
- 基于Tile的光栅化本质如下 (来源资料):
- 一种理解方式为:
- 先划分Tile(可视为初步光栅化)
- 然后在Tile基础上再次光栅化到正确的分辨率
- 另一种理解方式如下:
- GPU并非是一次性将三角形按照渲染分辨率拆解成为Fragment,而是以更低的分辨率,比如
1/8
目标分辨率,进行光栅化。 - 比如,画面最终是
1920x1080
,则GPU首先是以240x145
这个分辨率进行光栅化,然后再对每个光栅化结果(8x8
像素)进行进一步光栅化。
- GPU并非是一次性将三角形按照渲染分辨率拆解成为Fragment,而是以更低的分辨率,比如
- 一种理解方式为:
- 这种分步光栅化的好处
- 如果低分辨的一个单位整体上在Early-Z测试或者Early-Stencil测试当中被拒绝,那么就没有必要再对其进行更加精细的光栅化。
- 可以认为和上文提及的LRZ/Hi-Z等是“一体两面”的技术
- 关于Rasteration的几点补充
- VS输出的三角形,在光栅化模块进行光栅化之后,形成PS/FS工作量
- 光栅化之前,会进行:
- 三角形级别的背面/正面剔除
- 视锥剔除/裁剪
- 以及零面积/小三角形剔除
-
Early-Stencil 工作在第一次光栅化完成后
- 由于不可能完全与TBDR中的Tile相互对齐,大概率没有办法拒绝任何一个
8X8
的像素块(粗粒度剔除)
- 由于不可能完全与TBDR中的Tile相互对齐,大概率没有办法拒绝任何一个
-
Normal-Stencil 工作在 Fragment Shader 完成后
- 过滤的最小单位就是一个“像素”
- 但是此刻工作负担并没有本质减少
- 一个问题:如何处理大量小三角形绘制
- GPU 非常不擅长这种工作场景
- 优化手段有限
- 光栅化输出的是 PixelQuad -> 极大增加了“辅助像素”占比
- UE5 -> Nanite (UE5 渲染技术简介)
- 使用 Compute Shader 自己实现软光栅
- 替代硬件光栅化处理这些像素级“小三角形”
- 大概率取消了 PixelQuad
- 相对传统光栅化大幅提升了处理性能 <- 特事特办!
- GPU 非常不擅长这种工作场景
5)Active Warp 和 Register Spilling
- GPU一组SM所拥有的寄存器很多,但不是无尽的,所以系统设计时必须考虑到当Shader请求占用过多寄存器时可能遇到的情况和解决方案:这就是所谓的 “Active Warp”和“Register Spilling”的由来
- 寄存器文件用多少,在Shader编译完成后就确定了
- 每个变量、临时变量、部分符合条件的Uniform变量都会占用寄存器文件
-
Active Warp
- 我们知道GPU系统为了最大化利用一组SM的全部
32
个Cuda Core,会把硬件资源分配给若干个逻辑上的线程束“Warp”- 一组正在运行的Warp遇到长耗时内存指令时,为了“遮蔽延迟”,会激活另一个等待中的Warp
-
Warp之间切换的消耗非常小
- -> 因为有大寄存器设计
- -> 无需存档+重置寄存器
- 当运行在一组Warp上的Shader要求了太多的寄存器时,必然会挤占其他Warp的可用资源,导致同一组SM资源只能分配更少的Warp
- 这种视Warp所需资源不同,而动态分配SM上工作的Warp总数的方法就叫做“Active Warp”
- 极端情况下一个SM的所有Register不够一个Warp中的Shader用,那么这个SM就只能包含这一个Warp
- “延迟隐藏”--> 会减弱甚至失效
- “Stall”-> GPU的硬件资源不得不空转等待
- 我们知道GPU系统为了最大化利用一组SM的全部
-
Register Spilling
- 当寄存器不够用,或者Shader要求的寄存器数超过了某个阈值(比如
64
,128
等),就会触发Spilt - GPU会将 Register File 存储到 Local Memory 上
- Local Memory 就是 System Memory 上的一块工作区域,访问速度属于GPU内最慢的一档
- Register Spilling一旦发生,Shader执行性能会大打折扣
- 当寄存器不够用,或者Shader要求的寄存器数超过了某个阈值(比如
- 检测方式
- Mali offline compiler [参考]
6)Mipmap
- 一个Mipmap纹理就像一座金字塔:
- 基座是原始分辨率 (
Lod 0
) - 每往上一层长宽各自减半(
2
的幂律)(Lod + 1
)
- 基座是原始分辨率 (
- GPU在运行时通过
ddx
和ddy
偏导求取当前像素覆盖的面积占图元对应uv的比值,从而估算出合适的Mipmap Level -
Mipmap有利于节省带宽
- 这并不是说我们传递给 GPU 的纹理数据变小的(相反是增加了)
- 具体原因是:
- Mipmap可使得渲染时相邻的像素更加可能处于同一个 Cache Line 里面
- 从而变相提高 Texture Cache 的命中率
- 也就大量减少对主存的交互,减少带宽
- GPU 性能分析工具优化游戏性能的时候 Texture L1/L2 Cache Missing 是一个非常重要的指标,通常要控制在一个很低的数值才是合理的
-
Mipmap只比原始纹理的大小多了
1/3
的空间- 相对的,负责处理各向异性的Mipmap则要多出整整
3
倍 - 具体参考这里
- 相对的,负责处理各向异性的Mipmap则要多出整整
-
Unity Texture Streaming
- 机制:通过引擎动态控制纹理的最高 Mipmap Level,从而控制纹理占用的总内存消耗
- Texture Streaming可以确保纹理占用的内存总量是固定的
- 因为可以把不重要的纹理换出成高Level的Mipmap纹理
- 当需要低Mipmap纹理时触发重新加载过程
- 全部引擎内部实现,上层开发者无感
- 表现:很多3D游戏图片会有一个从模糊到清晰的过程
- 移动端CPU和GPU使用同一块物理内存的不同分段
- 通常情况下,如果纹理从CPU端upload到GPU端后,CPU端的资源会被释放
- 此后我们再将显存中的纹理释放 -> 相当于在内存中释放了这个纹理
- 如果一块纹理是需要CPU端读写数据的,或者编辑器下导入纹理后勾选了Read/Write Enabled
- 即便upload纹理资源到GPU端,在CPU端仍然不会释放这些Texture
- 实质上纹理资源存在两份(CPU一份,GPU一份)
- 通常情况下,如果纹理从CPU端upload到GPU端后,CPU端的资源会被释放
7)纹理采样和纹理过滤
- 纹理过滤的几种模式
- Neaest Point Sampling (临近点采样)
- Bilinear Interpolation(双线性插值)
- Trilinear Interpolation(三线性插值)
- Anisotropic Filter(各向异性过滤)
- 现代GPU都支持在
1x
cycle 内完成一次 Bilinear Sampling- 从性能上说:Point Sampling = Bilinear Filtering
- 较新的高端GPU(如Mali-G78)
- 可以在
0.25x
cycle内完成一次Binlinear Sampling - 也就是可以在
1x
cycle内完成一个 Quad的采样
- 可以在
- 对于三线性插值,由于需要使用双线性采样
2x
Mipmap Texture,然后再做插值- 消耗是 Bilinear 的
2x
倍
- 消耗是 Bilinear 的
-
N
倍各向异性 -> 就是N
倍的 Bilinear Filter 开销
8)硬件视角梳理
- 可编程元件和固定管线元件
- 顶点和像素处理是可编程,在 Shader Core 中执行着色器指令
- 光栅化是不可编程的,由光栅化引擎负责
- Early-Z、Normal-Z、Blend,是固定管线,由 ROP 单元负责
- 固定管线的单元负责特定工作,硬件制作更加简单,性能更好,功耗更低
-
Early-Z
- 每个 DrawCall 有它对应的 RenderState,以此来决定是否是 AlphaBlend、是否要深度写入、是否是AlphaTest等
- 但是对于硬件而言,每个图元并不知道自己是不是 AlphaBlend
- 当前 RenderState 是 AlphaBlend 的话,那么图元就按照 AlphaBlend 绘制
- 当前的 ZWrite 是 Off 的,那么 Normal-Z 就不写深度
- 执行Early-Z 的是硬件单元(ROP)
- 所以不应该用代码的思维去理解 Early-Z 的执行过程
- 更恰当的比喻应该是流水线上的阀门,它可以控制片元是否通过
- 有一些我们用软件实现起来显而易见的算法,在硬件上却是非常昂贵,难以实现的方案
- 每个 DrawCall 有它对应的 RenderState,以此来决定是否是 AlphaBlend、是否要深度写入、是否是AlphaTest等
- GPU核心的乱序执行和保序
- GPU 的计算核心是乱序执行的,不同 Warp 执行耗时不一致。
- 受分支、Cache Missing 等因素影响 -> GPU 会尽可能填充任务到计算核心
- 但是同一个像素的写入顺序是可以得到保证的
- 先执行的 DrawCall 的像素一定是先写入到 FrameBuffer 中的
- 不同像素的写入顺序通常也是有序的
- GPU 在每个阶段的输出结果其实都是有序的
- 不同阶段之间,通过FIFO 队列,保证顺序
- 随着技术的发展,可能使用的技术不限于FIFO,但是最终目的都是保序
- 这个机制是有现实意义的
- 对于半透明物体,如果 ROP 是乱序的,那么得到的是错误的结果
- 而对于不透明物体,虽然有 Depth Test 的机制,乱序也可以保证结果正确
- 但是保序对性能有好处,且可以缓解 Z-Fighting
- GPU 的计算核心是乱序执行的,不同 Warp 执行耗时不一致。
下文承接:GPU渲染管线和架构整理(三)