Shader 进阶:深入理解 GPU 2x2 Pixel Quad、屏幕空间导数与纹理采样指令
标签:
渲染引擎GPU 架构HLSL / GLSLMipmapShader 优化
1. 概述
在 Shader 开发中,纹理采样(Texture Sampling)是最基础但也最容易被忽视的操作之一。我们常用的 tex2D、Sample 底层究竟是如何工作的?为什么在动态分支(if)或循环(for)中采样贴图会导致黑边与异常模糊?SampleLevel 与 SampleGrad 又该在何时登场?
本文将从 GPU 硬件架构(2x2 Pixel Quad)、屏幕空间导数数学原理、Mipmap 自动选择 以及 三大采样指令对比 展开系统解析,并附带引擎开发中的经典避坑实操。
2. GPU 底层架构:2x2 Pixel Quad(像素四元组)
现代 GPU 在执行像素/片元着色器(Pixel / Fragment Shader)时,并非完全独立地以单个像素为单位运行,而是强制以 2x2 的像素块(称为 Quad / Pixel Quad) 为最小并行单元进行调度。
屏幕空间像素网格 (2x2 Quad)
+-------------------+-------------------+
| Pixel(0, 0) | Pixel(1, 0) |
| UV: (u0, v0) | UV: (u1, v0) |
+-------------------+-------------------+
| Pixel(0, 1) | Pixel(1, 1) |
| UV: (u0, v1) | UV: (u1, v1) |
+-------------------+-------------------+2.1 辅助像素(Helper Lanes / Inactive Pixels)
即使一个微小的多边形三角形只覆盖了 2x2 区域中的 1 个像素,GPU 也会强行启动 Quad 内所有 4 个像素的计算。
- 未被光栅化覆盖的像素被称为 Helper Lane(辅助通道)。
- 它们参与运算以协助临近像素计算差分,但最终的渲染输出会被丢弃,不写入 Framebuffer。
3. 数学原理:屏幕空间偏导数(Screen-Space Derivatives)
GPU 之所以必须以 2x2 Quad 运行,核心目的之一就是在硬件级别以极低代价计算屏幕空间偏导数。
3.1 有限差分计算公式
在同一个 Quad 内,四个像素在同一时刻计算得到了各自的变量值(如 UV 坐标):
水平方向导数(X 方向变化率): $$\frac{\partial \text{UV}}{\partial x} \approx \text{Pixel}(1,0).\text{UV} - \text{Pixel}(0,0).\text{UV}$$ 在 HLSL 中对应
ddx(uv)(或ddx_fine/ddx_coarse),GLSL 中对应dFdx(uv)。垂直方向导数(Y 方向变化率): $$\frac{\partial \text{UV}}{\partial y} \approx \text{Pixel}(0,1).\text{UV} - \text{Pixel}(0,0).\text{UV}$$ 在 HLSL 中对应
ddy(uv),GLSL 中对应dFdy(uv)。
NOTE
几何物理意义:导数表示 “当屏幕坐标移动 1 个像素时,纹理 UV 在纹理空间中跨越了多大距离(Texel 跨度)”。
4. 硬件机制:GPU 如何利用导数选择 Mipmap?
当我们在 Shader 中调用默认的 Sample(sampler, uv) 时,GPU 纹理单元(TMU)会自动执行以下步骤:
flowchart LR
A["2x2 Quad 采样 UV"] --> B["硬件计算 ddx(uv) 与 ddy(uv)"]
B --> C["计算各向异性度与最大 Footprint"]
C --> D["确定 LOD / Mipmap Level"]
D --> E["双线性 / 三线性 / 各向异性过滤采样"]4.1 Mipmap 等级($\lambda$)的数学推导
计算 UV 在屏幕 X、Y 方向上的变化幅度向量长: [ \rho_x = \sqrt{ \left(\frac{\partial u}{\partial x}\right)^2 + \left(\frac{\partial v}{\partial x}\right)^2 } \times \text{TextureWidth} ] [ \rho_y = \sqrt{ \left(\frac{\partial u}{\partial y}\right)^2 + \left(\frac{\partial v}{\partial y}\right)^2 } \times \text{TextureHeight} ]
取最大跨度确定 LOD 等级 $\lambda$: [ \rho_{\max} = \max(\rho_x, \rho_y) ] [ \lambda = \log_2(\rho_{\max}) ]
- 近处/平视视角:1 个屏幕像素对应 $< 1$ 个纹理像素 $\rightarrow \lambda \le 0 \rightarrow$ 选用 Mipmap Level 0(全分辨率原图)。
- 远处/倾斜视角:1 个屏幕像素覆盖了 $64 \times 64$ 个纹理像素 $\rightarrow \lambda \approx 6 \rightarrow$ 自动选用 Mipmap Level 6(低分辨率模糊图),避免产生严重的莫尔纹与高频闪烁(Aliasing)。
5. 三大采样指令详解:Sample vs SampleLevel vs SampleGrad
针对不同的控制需求和着色器阶段,图形 API 提供了不同的采样函数:
5.1 Sample (GLSL: texture)
- 定义:标准隐式导数采样。
- 机制:完全依赖 GPU 自动获取 Quad 内部的
ddx和ddy计算 LOD。 - 局限:仅能在 Pixel Shader 中有效执行;在非均匀分支中会导致未定义行为。
5.2 SampleLevel (GLSL: textureLod)
- 定义:显式 LOD 采样。
- 机制:绕过任何导数运算,直接指定确切的 Mipmap 层级。
- 代码示例:hlsl
// 强制采样 Base 原图 (Level 0) float4 baseColor = MyTexture.SampleLevel(MySampler, uv, 0.0); // 根据粗糙度 Roughness 计算 IBL 预过滤环境贴图 LOD float mip = roughness * MAX_MIP_LEVEL; float4 envColor = SpecularEnvTexture.SampleLevel(MySampler, reflectDir, mip);
5.3 SampleGrad (GLSL: textureGrad)
- 定义:显式梯度采样。
- 机制:手动提供自定义的
ddx和ddy导数向量给 GPU,GPU 保留硬件各向异性过滤与三线性插值能力,但使用你传入的梯度来定 Mipmap。 - 代码示例:hlsl
// 在动态分支前先提取合法的全屏导数 float2 dx = ddx(uv); float2 dy = ddy(uv); if (someDynamicCondition) { // 传入预存的梯度,规避动态分支破坏差分的问题 float4 color = MyTexture.SampleGrad(MySampler, uv, dx, dy); }
6. 全维度特性对比表
| 特性 | Sample | SampleLevel | SampleGrad |
|---|---|---|---|
| Mipmap 决定方式 | GPU 硬件自动求导算 LOD | 开发者手动指定绝对 LOD 值 | 开发者手动提供偏导数,GPU 算 LOD |
| 输入参数 | (sampler, uv) | (sampler, uv, float lod) | (sampler, uv, float2 dx, float2 dy) |
| 着色器支持阶段 | 仅 Pixel / Fragment Shader | 所有阶段(Vertex, Compute, Pixel 等) | 所有阶段(Vertex, Compute, Pixel 等) |
动态分支 (if/loop) 安全性 | ❌ 不安全(可能产生缝隙或崩溃) | 绝对安全 | 绝对安全 |
| 各向异性过滤 (Anisotropic) | 支持 | ❌ 不支持(仅标量 LOD 过滤) | 支持 |
| 典型应用场景 | 大多数常规不透明几何体着色 | 顶点位移、Compute Shader、粗糙度模糊 | 动态分支采样、极坐标/无缝拼接 UV、视差映射 |
7. 引擎开发实战:经典踩坑与避坑指南
7.1 踩坑 1:动态分支与循环中的“黑边/接缝异常”
// ❌ 错误做法:在动态分支中直接 Sample
if (uv.x > 0.5) {
color = MyTexture.Sample(MySampler, uv * 2.0); // 边界处像素计算 ddx 发生跳变!
}- 现象:在
uv.x = 0.5的分界线上出现一条明显的模糊带或黑线。 - 原因:Quad 内相邻像素一个走
true一个走false,差分求出的导数瞬时无穷大,GPU 误以为纹理被无限缩小,采样了最模糊的一级 Mipmap。 - 解决方案:
- 在分支外统一计算导数,分支内改用
SampleGrad。 - 或使用
SampleLevel显式固定 Mip 等级。
- 在分支外统一计算导数,分支内改用
7.2 踩坑 2:极坐标(Polar Coordinates)与 UV Wrap 环绕跳变
当从笛卡尔坐标转换到极坐标($\text{atan2}(y, x)$)时,角度在 $[-\pi, +\pi]$ 边界处会发生 $1.0 \rightarrow 0.0$ 的突变:
float angle = atan2(dir.y, dir.x); // [-PI, PI]
float u = angle / (2.0 * PI) + 0.5; // [0, 1] 突变边界
// ❌ 突变边界处 ddx(u) 接近 1.0(巨大),导致接缝处出现一条模糊线
float4 col = MyTexture.Sample(MySampler, float2(u, v));
// 正确做法:修正导数跳变
float2 uv = float2(u, v);
float2 dx = ddx(uv);
float2 dy = ddy(uv);
// 消除 1.0 -> 0.0 的跳变导数
dx.x = frac(dx.x + 0.5) - 0.5;
float4 col = MyTexture.SampleGrad(MySampler, uv, dx, dy);8. 总结与选型决策树
需要纹理采样?
│
├── 在 Vertex / Compute Shader 中?
│ ├── 需要特定 LOD 或原图 ──> 【SampleLevel】
│ └── 需要各向异性或手动梯度 ──> 【SampleGrad】
│
└── 在 Pixel Shader 中?
├── 在动态 if / for 循环内?
│ ├── 已知精确 Mip ──> 【SampleLevel】
│ └── 需要自适应滤波抗锯齿 ──> 【SampleGrad(分支外求导)】
├── 存在 UV 不连续/跳变(极坐标、Atlas 裁剪) ──> 【SampleGrad(修补导数)】
└── 常规静态 UV 采样 ──> 【Sample】

