Skip to content

Shader 进阶:深入理解 GPU 2x2 Pixel Quad、屏幕空间导数与纹理采样指令

标签渲染引擎 GPU 架构 HLSL / GLSL Mipmap Shader 优化


1. 概述

在 Shader 开发中,纹理采样(Texture Sampling)是最基础但也最容易被忽视的操作之一。我们常用的 tex2DSample 底层究竟是如何工作的?为什么在动态分支(if)或循环(for)中采样贴图会导致黑边与异常模糊?SampleLevelSampleGrad 又该在何时登场?

本文将从 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)会自动执行以下步骤:

mermaid
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$)的数学推导

  1. 计算 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} ]

  2. 取最大跨度确定 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 内部的 ddxddy 计算 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)

  • 定义:显式梯度采样。
  • 机制:手动提供自定义的 ddxddy 导数向量给 GPU,GPU 保留硬件各向异性过滤与三线性插值能力,但使用你传入的梯度来定 Mipmap。
  • 代码示例
    hlsl
    // 在动态分支前先提取合法的全屏导数
    float2 dx = ddx(uv);
    float2 dy = ddy(uv);
    
    if (someDynamicCondition)
    {
        // 传入预存的梯度,规避动态分支破坏差分的问题
        float4 color = MyTexture.SampleGrad(MySampler, uv, dx, dy);
    }

6. 全维度特性对比表

特性SampleSampleLevelSampleGrad
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:动态分支与循环中的“黑边/接缝异常”

hlsl
// ❌ 错误做法:在动态分支中直接 Sample
if (uv.x > 0.5) {
    color = MyTexture.Sample(MySampler, uv * 2.0); // 边界处像素计算 ddx 发生跳变!
}
  • 现象:在 uv.x = 0.5 的分界线上出现一条明显的模糊带或黑线。
  • 原因:Quad 内相邻像素一个走 true 一个走 false,差分求出的导数瞬时无穷大,GPU 误以为纹理被无限缩小,采样了最模糊的一级 Mipmap。
  • 解决方案
    1. 在分支外统一计算导数,分支内改用 SampleGrad
    2. 或使用 SampleLevel 显式固定 Mip 等级。

7.2 踩坑 2:极坐标(Polar Coordinates)与 UV Wrap 环绕跳变

当从笛卡尔坐标转换到极坐标($\text{atan2}(y, x)$)时,角度在 $[-\pi, +\pi]$ 边界处会发生 $1.0 \rightarrow 0.0$ 的突变:

hlsl
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】