深入底层:Adreno GPU 的 GMEM Tiling 机制是如何让 Vertex Shader 执行两次的?
在 PC 端显卡(IMR 架构)上,顶点着色器(Vertex Shader, 简称 VS)的执行逻辑非常直观:每个顶点进,VS 跑一次,输出结果直接送去光栅化。
但在移动端,以高通 Adreno 为代表的 GPU 普遍采用 TBR(Tile-Based Rendering,分块渲染)或 TBDR 架构。为了极致地节省带宽,Adreno 引入了 GMEM(高速片上图形内存) 和 Tiling(分块) 机制。
这种机制在带来带宽红利的同时,也彻底改变了 Vertex Shader 的生命周期,直接导致 Vertex Shader 在底层往往会被执行两次。本文将从底层硬件架构出发,拆解这一机制对 VS 执行频率的影响。
一、 TBR 架构的硬伤:为什么要分阶段?
移动端 GPU 最大的瓶颈不是算力,而是功耗和带宽。直接读写系统内存(System RAM)极其耗电。
Adreno 的解法是:在 GPU 芯片内部集成一块极快、极省电但容量很小(通常几 MB)的 GMEM。
为了把整张渲染目标(Render Target)塞进这几 MB 的空间里,GPU 必须把屏幕切成一个个小块(Tiles,例如 $32 \times 32$ 或 $64 \times 64$ 像素),逐个 Tile 进行渲染。
为了知道“哪些三角形落在哪个 Tile 里”,GPU 必须引入一个前置步骤——Binning Pass(分箱阶段)。
因此,整个渲染管线被切分裂为两个物理阶段:
- Binning Pass:生成可见性流(Visibility Stream)。
- Rendering Pass(Color Pass):逐 Tile 进行光栅化和像素着色。
正是这个切分,重塑了 Vertex Shader 的执行频率。
二、 VS 的“双重人格”:两次执行的本质
在 Adreno 架构中,为了配合上述两个阶段,Vertex Shader 被拆分成了两个版本在不同的时间点运行:
[顶点数据输入]
│
├─► 阶段 1:Binning Pass ──► 仅执行 Position-only VS (只计算 gl_Position)
│ │
│ ▼ 生成 Visibility Stream (确定三角形与 Tile 关系)
│
└─► 阶段 2:Rendering Pass ─► 仅对可见三角形执行 Full VS (计算 Varyings / UV / Normal)
1. 第一阶段:Position-only VS(只算位置)
在 Binning Pass 阶段,GPU 的核心目的是进行几何分类(Tiling)和裁剪(Culling)。此时,GPU 根本不需要知道顶点的纹理坐标(UV)、法线(Normal)或是顶点颜色,它只需要知道顶点的 屏幕空间坐标(gl_Position)。
因此,Adreno 驱动会动态生成一个简化版的 VS,称为 Position-only VS(或 Coordinate Shader)。
- 执行频率:所有被 Draw Call 提交的顶点,在这个阶段全部都会执行一次 Position-only VS(除非在输入端被 Index Buffer 过滤)。
- 行为:只计算与
gl_Position相关的指令,其余所有的 Varying 输出、纹理采样等无关代码全部被剥离。
2. 第二阶段:Full VS(完整计算)
到了 Rendering Pass,GPU 开始逐个 Tile 渲染。此时要跑 Pixel Shader,必须要对顶点属性(Varyings)进行插值。
- 执行频率:只有在 Binning Pass 中被判定为可见(通过了视锥体裁剪、背面剔除,且其所在的三角形至少覆盖了当前渲染 Tile)的顶点,才会触发 Full VS。
- 行为:执行完整的 Vertex Shader 代码,计算 UV、法线、切线等所有 Varying 属性,并将数据存入 VPC(Vertex Parameter Cache,顶点参数缓存),供后续光栅化和插值使用。
三、 Tiling 机制对 VS 执行频率的边际效应
这种“拆成两次跑”的机制,对 VS 整体执行频率和开销带来了极其微妙的影响,主要体现在以下三个维度:
1. 裁剪率(Culling Rate)带来的负反馈
- 高裁剪率场景:如果场景中存在大量视锥体外、或者背面的三角形,它们在第一阶段(Binning Pass)就会被无情剔除。这意味着这部分顶点永远不会执行第二阶段的 Full VS。此时,总体 VS 算力消耗被大幅节省。
- 低裁剪率场景:如果几乎所有三角形都可见,那么绝大多数顶点都会切实地跑满一次 Position-only VS + 一次 Full VS。这无形中增加了 GPU 的 ALU(算术逻辑单元)压力。
2. 顶点参数缓存(VPC)溢出的灾难
Full VS 计算出来的 Varyings 是要存在片上 VPC 中的。
- 如果你的 Shader 输出了太多的 Varying 属性(例如大量的
highp vec4),或者一个 Tile 内的三角形数量过多,导致 VPC 容纳不下这些顶点数据。 - 此时,Adreno 无法将数据保留在片上,被迫将未处理完的数据溢写(Spill)到系统内存(System RAM),或者在需要时强行重新运行 Full VS。
- 这会导致可见顶点的 Full VS 执行频率由于 Cache Miss 再次倍增,造成严重的性能掉帧(Stall)。
3. 顶点不连续性(Index Buffer 优化)
如果顶点索引(Index Buffer)杂乱无章,GPU 的 Vertex Cache 命中率极低,在 Binning 阶段同一个顶点可能会被多次拉取并重复执行 Position-only VS,这会直接拉高第一阶段的执行频率基数。
四、 针对 Adreno 架构的 Vertex Shader 优化指南
理解了 GMEM Tiling 对 VS 频率的影响,我们在编写 Shader 和准备美术资源时,就能进行极其精确的逆向优化:
1. 彻底分离顶点属性(Position Split / Stream Out)
既然 Binning Pass 只需要位置信息,我们就应该让 GPU 读取位置信息时尽可能快。
- 做法:在应用层,将顶点的 Position 属性和 UV/Normal 属性分开存放(使用两个独立的 Vertex Buffer 绑定,即 Non-interleaved 格式)。
- 原理:在 Binning 阶段,GPU 只需要加载 Position Buffer。由于去掉了冗余的 UV/Normal 数据,Cache 命中率极高,带宽消耗降到最低。
// 不推荐:交错布局(Interleaved)
[ X, Y, Z, U, V, Nx, Ny, Nz ] [ X, Y, Z, U, V, Nx, Ny, Nz ]
// 推荐:分离布局(Split-stream)
Buffer 1: [ X, Y, Z ] [ X, Y, Z ] <-- Binning Pass 仅读取此 Buffer,极快
Buffer 2: [ U, V, Nx, Ny, Nz ] [ U, V, Nx, Ny, Nz ]
2. 控制 gl_Position 的计算复杂度
在 VS 中,尽量让计算 gl_Position 的路径简单。
- 不要在计算
gl_Position时去采样纹理(例如顶点位移贴图 Vertex Texture Fetch)。如果必须采样,这会导致 Position-only VS 也要执行昂贵的纹理采样,彻底毁掉 Binning Pass 的速度。 - 将那些与位置无关的复杂数学计算(如复杂的法线变换、复杂的动画混合)放到计算 Varying 的逻辑中去。
3. 极力精简 Varying 数量与通道数
Varying 直接占用 VPC 空间。
- 砍掉不必要的 Varying 输出。如果能把两个
vec2合并为一个vec4,尽量合并。 - 适当降低 Varying 的精度(例如在支持的平台上使用
mediump),这能直接减少 VPC 的物理占用,避免因 VPC 溢出导致的 Full VS 额外重跑。
4. 离线顶点 Cache 优化
使用工具(如 meshoptimizer)对导出的 Mesh 进行顶点 Cache 优化(Vertex Cache Optimization)。
- 这样可以确保在 Binning Pass 中,相邻三角形复用相同顶点时,Position-only VS 能够直接命中后变换缓存(Post-Transform Cache),从而将第一阶段的执行频率逼近理论最小值(即顶点数量,而非三角形数量 $\times 3$)。
总结
在 Adreno GPU 上,GMEM Tiling 机制通过 Binning Pass 将 Vertex Shader 的执行拆解为 “快速粗筛(Position-only)” 与 “精细重塑(Full)” 两个阶段。
这一机制对 VS 执行频率的直接影响是:位置计算高频发生(100% 顶点覆盖),而完整计算低频发生(仅覆盖未被剔除的可见顶点)。通过分离顶点流、精简 Varying 以及避免在位置计算中引入复杂逻辑,可以最大化地释放 TBR 架构的性能红利。