☰
dFdx与dFdy:GLSL偏导数函数的原理与实战
2026/10/13 12:07:31 网站建设 项目流程

dFdx、dFdy 这两个函数,属于 GLSL 里那种“查文档能看懂,真到了项目里却不知道怎么用”的 API。我第一次真正重视它,是在做地形渲染的时候。当时需要一个从高度图生成法线的方案,又不希望为这个再额外开一个 pass 去跑 CPU 端的计算,就在网上翻到别人提到“derivative functions”这个关键词。试过之后才明白,原来 GPU 一直在后台默默帮我算好了每个像素和相邻像素之间的差值,只是平时没人把它当成显式 API 来调用。

这篇文章打算把 dFdx、dFdy 偏导数这件事彻底讲清楚:它们底层到底做了什么,为什么硬件实现几乎是“免费”的,实战中怎么用来生成法线、做边缘检测、抑制程序纹理的摩尔纹,以及我实际踩过的一堆坑。如果你是刚接触着色器编程的开发者,看懂这篇之后能少走不少弯路;如果你已经在用导数但又总觉得哪里不太对,那后半部分的排查清单应该对你有用。

1. 偏导数到底在算什么东西

1.1 一句话建立“邻居像素”直觉

先抛开数学课本里的定义,用最直白的话来说:dFdx(p) 返回的是“当前像素和它右边那个像素之间,p 这个值差了多少”;dFdy(p) 返回的是“当前像素和它上方那个像素之间,p 这个值差了多少”。

这里的 p 不一定是坐标,它可以是任何一个在片段着色器里有明确定义的表达式,比如颜色值、UV 坐标、世界空间坐标、高度值,甚至你自己写的一个计算函数的结果。GPU 并不是真的去做极限运算,它只是用相邻像素的差值来近似“变化率”。这个“变化率”放到图形学语境里,就是我们常说的梯度,也就是偏导数。

1.2 API 签名:dFdx / dFdy 的准确定义

在 GLSL 中,它们的签名非常简单:

genType dFdx(genType p); genType dFdy(genType p);

genType 可以是 float、vec2、vec3 或 vec4。也就是说你对一个 vec3 向量调用 dFdx,得到的是一个 vec3,每个分量分别求导数,互不干扰。这个设计很实用,比如你想知道世界坐标在屏幕横向上的变化趋势,直接对整个 vec3 求导就行。

从功能上说,它们计算的是 p 在窗口空间(也就是屏幕像素坐标)中关于 x 方向和 y 方向的偏导数。注意,这里的 x、y 是窗口坐标,不是你的 UV 坐标,也不是世界坐标。这一点一开始很容易忽略,后面我会专门讲它带来的陷阱。

1.3 动手做个最小实验

理解导数最直接的方法,就是写一个全屏四边形,在片元着色器里输出这样一组颜色:

// 片段着色器 in vec2 vUv; out vec4 fragColor; void main() { float gx = dFdx(vUv.x); float gy = dFdy(vUv.y); fragColor = vec4(gx * 64.0 + 0.5, gy * 64.0 + 0.5, 0.0, 1.0); }

vUv.x 在屏幕上从左到右从 0 变化到 1,所以每个像素的 vUv.x 增量大约是 1 / 屏幕宽度。乘上 64、再加上 0.5,是为了把很小的数值放大到肉眼可见的色带范围。你会看到画面左边偏暗、右边偏亮,绿色通道由下到上递增。如果改变窗口分辨率,色带的疏密程度也会跟着变,因为每像素的变化量变了。

这个实验虽然简单,但建议亲手做一遍,能一下子建立“导数就是相邻差值”的直觉。我当时做完之后,很多之前想不通的渲染现象突然就解释通了,比如为什么有些边缘会有 1 像素宽的过渡带,为什么程序纹理的摩尔纹和屏幕分辨率有关,本质上都是同一个东西。

2. 硬件为什么天生适合算导数

2.1 GPU 的 2×2 quad 执行模型

你可能会好奇:dFdx 怎么就能知道“右边那个像素”的值?它在硬件上是怎么实现的?

答案是 GPU 的光栅化阶段是按照 2×2 像素块(通常叫 quad)为单位来分配片段任务的。想象一块像素格子,四个相邻像素组成一个小组,GPU 会尽量让同一个着色器程序在这四个像素上并行执行,并且保证执行时它们的输入数据都还在寄存器里。这时候 dFdx 其实就是去读取同一行两个相邻像素的数据,做一个差值;dFdy 则是读取同一列两个相邻像素的数据做差值。这就是为什么它能“免费”获得邻居信息。

这个执行模型还解释了另一个现象:为什么导数只能在片元着色器里用。顶点着色器阶段的执行单元是独立的顶点,它们之间没有像素位置的紧邻关系,自然也就没有现成的“相邻差值”可用。即使某些平台能在顶点阶段强行算出一个近似结果,它也不是 GLSL 标准语义里的 dFdx。

2.2 控制流限制:为什么不能在分支里随便用

GLSL 规范里明确写着:在非统一控制流(non-uniform control flow)中调用 dFdx、dFdy 等导数函数,结果是未定义的。

什么叫非统一控制流?说白了就是你写的 if、else、循环内部,不是所有像素都走同一条路。因为你调用导数的本质是拿当前像素和 2×2 块里的邻居做差值,如果同一个 quad 里两个像素一个进了 if 内部、另一个没进,那硬件在分支路径上能取到的邻居数据可能就是残缺的、过期的,甚至根本没有执行那条指令。结果就是画面出现莫名其妙的闪烁、带状噪点或者“格子状”色块。

其实编译器经常会在一些看似“安全”的分支里也出问题。比如:

if (someCondition) { float g = dFdx(vUv.x); // 某些厂商驱动在这里会给出不稳定结果 }

就算你心里觉得“这个条件在所有像素都一样”,驱动静态分析不一定能证明这一点。所以我现在的习惯是:先把导数计算放到分支外面,用局部变量存好,再进分支用。这个习惯帮我避免了很多只在特定显卡上出现的怪毛病。

2.3 差分代替微分:精度那点事

GPU 的导数本质上是一阶有限差分,不是解析求导。这意味着它天然有误差。比如当你对一个高频噪声函数求导,相邻两个像素的值可能波动剧烈,导数结果也会剧烈跳动,这就是很多程序纹理出现“颗粒闪烁”的原因之一。

精度上还有一个容易踩的问题:如果某个表达式在相邻像素上几乎没变化,那么差值会非常小,甚至因为浮点精度变成 0。反过来,如果在某个边缘处值突然跳变,导数会大得离谱。这种不平稳特性,决定了它通常不适合直接当作高精度数值运算来用,更适合做那些只需要“趋势”或者“变化程度”的渲染决策。

3. 第一个实战:从高度图生成法线

3.1 思路拆解:从高度差到法线方向

高度图是一张灰度纹理,每个像素记录一个高度值。如果我们把它放到三维空间里,就得到一个表面,问题来了:这个表面每个位置的法线应该朝哪?

传统做法是在纹理空间里对高度图做采样,取当前点四周的高度差来估算法线方向。问题是你必须在着色器里多写几次采样,而且 uv 接缝处很容易出问题。用 dFdx、dFdy 就不一样了,我们可以直接把“屏幕 x 方向上高度值的变化量”当成表面在该方向的坡度,把“屏幕 y 方向上的变化量”当成另一个方向的坡度,然后构造法线。

推导也很直观:假设高度场函数是 h(pos),那么表面在某点的梯度向量是 (dH/dx, dH/dy, 1) 的某种变体,只不过我们要的是向上方向为法线正方向,所以用 (-dH/dx, -dH/dy, 1) 再归一化。为什么是负号?想象站在地面上,如果高度从左往右升高,也就是 dH/dx > 0,说明地面向右抬起,此时法线应该向左倾斜,所以 x 分量取负号。

3.2 完整 GLSL 实现

下面给出一套可以直接跑的简化实现,适用于某个平面网格上叠加高度图的情况。

顶点着色器:

#version 330 core layout(location = 0) in vec3 aPos; layout(location = 1) in vec2 aUv; out vec2 vUv; out vec3 vWorldPos; uniform mat4 uModel; uniform mat4 uViewProj; void main() { vUv = aUv; vec4 worldPos = uModel * vec4(aPos, 1.0); vWorldPos = worldPos.xyz; gl_Position = uViewProj * worldPos; }

片段着色器:

#version 330 core in vec2 vUv; in vec3 vWorldPos; uniform sampler2D uHeightMap; uniform float uHeightScale; out vec4 fragColor; void main() { vec2 uv = vUv; // 采样高度图 float h = texture(uHeightMap, uv).r * uHeightScale; // 将高度叠加到世界坐标上 vec3 pos = vWorldPos; pos.y += h; // 屏幕空间导数:相当于当前像素的位置在屏幕水平/垂直方向上的“变化趋势” vec3 dx = dFdx(pos); vec3 dy = dFdy(pos); // 两个切线方向叉积得到法线 vec3 n = normalize(cross(dx, dy)); // 输出可视化法线 fragColor = vec4(n * 0.5 + 0.5, 1.0); }

这段代码里有个细节值得注意:我们没有直接对 h 求导再构造法线,而是先把高度加到世界坐标上,再对世界坐标整体求导,最后叉积。这样做的好处是,当网格本身有坡度、有旋转、有透视变形时,世界坐标的变化量已经包含了这些信息,得到的法线更准确。只对 h 求导的方法适合表面基本水平、相机几乎正对的情况,局限性比较大。

3.3 效果调参与注意事项

实际运行时你会发现几件事。第一,这个法线是屏幕空间导数算出来的,它和画面分辨率有关系。同一个高度图,在远距离观察和近距离观察时,相邻像素对应的世界空间距离不一样,导数结果自然不一样。如果你希望法线效果更像传统法线贴图那样“纹理固定”,更稳定,使用基于 uv 的采样法更合适,但那就需要额外多次采样。这属于典型的“取舍”,没有绝对的对错。

第二,uHeightScale 的取值很影响效果。太小了法线几乎指向天空,太大了法线会剧烈抖动。我通常从 0.02 到 0.1 之间开始调。这个值本质上是把“高度差”和“水平距离”两个不同量纲的东西放在一起比较,所以要结合你的场景尺度来定。

第三,如果你观察边缘处,会发现法线在物体轮廓附近可能会有奇怪的突变。这是因为屏幕空间导数在 silhouette 边缘本来就不连续,前后两个物体的世界坐标突然切换,导数就会巨大。解决办法通常是只在同一表面内部用这个技巧,或者配合深度判断做遮罩。

4. 更多玩法:边缘检测、抗锯齿、程序纹理

4.1 边缘检测与硬边抗锯齿

既然 dFdx、dFdy 能反映“变化率”,那它们天然适合做边缘检测。最常用的组合是 fwidth,它定义为:

float fwidth(float p) { return abs(dFdx(p)) + abs(dFdy(p)); }

fwidth 其实就是两个方向导数绝对值之和,表示“当前像素邻域内,p 的变化总量”。如果这个值很大,说明这里存在剧烈变化,大概率是边缘。

最常见的应用是处理 alpha 裁剪边缘。比如你想画一个带锯齿的植被贴图,又不想走复杂的 SDF 方案,可以直接这样:

float alpha = texture(uAlphaMap, vUv).a; float edgeWidth = fwidth(alpha); float smoothAlpha = smoothstep(edgeWidth, edgeWidth * 2.0, alpha); if (smoothAlpha < 0.01) discard; fragColor = vec4(color.rgb, smoothAlpha);

这样原本生硬的 0 和 1 二值 alpha 边界就变成了 1 像素左右的软边,在远处不会闪,在近处也不会糊成一团。这个思路在毛发、叶片、粒子系统里非常实用。我做过一个粒子特效,一开始用 if (alpha < 0.5) discard,结果粒子边缘像是狗啃的;改成 fwidth + smoothstep 之后,瞬间柔和了很多,性能还几乎没有变化。

4.2 程序纹理的摩尔纹抑制

程序纹理最大的痛点是摩尔纹。当你渲染一个高频条纹图案时,远处采样频率不足,画面会出现杂乱的彩色波纹。普通纹理可以用 mipmap 解决,但程序纹理没有 mipmap。

这时候 dFdx、dFdy 又能派上用场:它们可以估算当前屏幕像素覆盖了多少纹理空间,然后手动去模糊高频信号。举个例子,画一组横向条纹:

float coord = vUv.x * uFrequency; float stripe = sin(coord * 2.0 * 3.14159265); float w = fwidth(coord); // 用邻域变化量做平滑 float value = smoothstep(-w, w, stripe); fragColor = vec4(value);

当摄像机远离时,coord 的变化量变大,w 变大,平滑过渡带变宽,正好等效于 mipmap 的低层级。这个技巧对噪音、网格、棋盘格等程序纹理都有效。关键是你要给“被求导的表达式”加上系数,这样它才能反映“频率”而不是“uv 的绝对位置”。

4.3 屏幕空间羽化与水波法线

还有一个用法是我后来才发现的,就是做屏幕空间的羽化和渐变。比如做雾效边界,或者做水面波浪的法线。

水面波浪其实也是一样的思路:波浪高度函数 h = wave(worldPos.xz),你用 dFdx(h)、dFdy(h) 直接得到屏幕方向的水波坡度,进而构造法线。与传统做法相比,省掉了复杂的切线空间变换。而且因为导数天然是逐像素的,所以水波可以做到非常高频也不会有明显断层。

这个技术唯一的限制还是老问题:它是屏幕空间导数,视角剧烈变化时法线方向也会受投影影响。所以它适合“看起来够自然就行”的场景,不适合对法线精度有严格要求的物理渲染。

5. 我踩过的坑:导数使用注意事项

5.1 顶点着色器里别想用它

我一开始很天真地以为,顶点着色器里也能用 dFdx,毕竟是同一套 GLSL 的 API。结果在某个平台上直接编译报错,在另一个平台上虽然能编译过,但输出完全不对。后来查了规范才知道,导数函数的定义域就是片元着色器(以及部分平台提供的其它阶段扩展,但不在通用标准内)。

如果你真的需要在顶点阶段获得相邻属性,那就只能自己在 CPU 端算好,或者用几何着色器、计算着色器等另想办法。总之不要把 dFdx 当成通用的“求梯度”函数到处用。

5.2 非统一控制流等于自找麻烦

前面讲过,同一 quad 内不同分支会导致未定义行为。它在实际画面上的表现非常隐蔽:可能只在某几帧闪烁,只在特定分辨率下出现,只在某几家显卡上复现。排查起来极其恶心。

我现在总结出的实操法则是:

  • 如果一段代码里需要用到导数,先把输入表达式求导完,把结果存到变量里;
  • 之后的分支、循环里只使用那个已经算好的结果;
  • 绝对不要出现在 if 条件内部再去调用 dFdx 的情况。

这样做可能牺牲一点点代码整洁度,但能换来跨平台稳定,值。

5.3 方向不是你能猜的:屏幕空间坐标

dFdx 是对窗口空间 x 求导,dFdy 是对窗口空间 y 求导。在没做过 uv 翻转的模型上,这通常对应着视觉上的左右和上下。但如果你处理的是纹理镜像、翻转了 v 轴,或者渲染到了视口翻转的 FBO 上,导数的方向就会变得反直觉。

有个典型例子是处理 FlipY 的渲染目标。你可能会发现 dFdy 的符号和预期相反,导致法线方向反了。解决办法不是修改算法,而是在确认 uv 方向时先画一个方向箭头测试图案,确保自己清楚当前管线里“屏幕向上”到底对应哪个坐标方向。

5.4 性能并非绝对免费

大多数桌面 GPU 上,因为 2×2 quad 本来就存在,dFdx 和 dFdy 几乎不增加额外指令。但移动端和某些架构上,情况可能不同。尤其当你调用了导数函数且又处在某些优化不好的路径中,驱动可能不得不强制保证四像素对齐,导致额外的寄存器压力或执行开销。

我见过一个移动端项目,因为在一张全屏后处理里大量使用 fwidth,帧耗时直接涨了 1 毫秒多。虽然 1 毫秒不算夸张,但在本来就紧张的移动端性能预算里,确实会让人头疼。所以建议你在目标平台上实测一下,别想当然地认为导数一定零成本。

6. 常见问题排查实录

6.1 现象速查表

现象可能原因解决思路
画面出现 2×2 像素块状色斑在非统一分支里调用了 dFdx/dFdy把导数计算提到分支外
导数结果始终为 0对常量、uniform 或与像素位置无关的表达式求导确认表达式确实随像素变化
法线方向左右或上下颠倒屏幕空间方向与预期不一致,或叉积顺序反了画 uv 方向测试图,检查叉积顺序
远处闪烁、噪点剧烈对高频函数直接求导,导数值变化过大用 fwidth 做平滑,或对表达式先降频
边缘出现 1 像素黑边/白边导数在轮廓边缘不连续,或者材质的 alpha 测试太硬结合深度/几何体遮罩,或改用 smoothstep
不同分辨率下效果完全不同导数本身就是随分辨率变化的量,这属于正常现象如果希望稳定,改用 uv 空间导数和多次采样
移动端编译失败GLSL 版本太低,或未启用导数扩展升级到 GLSL 300 es 并声明支持

6.2 排查思路分享

遇到导数相关的问题,我建议按这个顺序排查:先确认你是不是对“真的会随像素变化的东西”求导了。对 uniform 求导结果为 0,这是最常见的新手问题,不是 bug。其次确认调用位置是否在分支内,把这句代码移动到分支外再看现象。最后再怀疑方向问题,打印一张明显的渐变测试图,验证 dFdx 输出的符号。

如果现象只在特定距离出现,别急着调代码,先考虑导数表达式是否含有高频项。高频项的导数天然大,解决办法是降频或者平滑,而不是修改导数函数本身。

7. 两个经常被忽略的小技巧

最后分享两个我后来才摸索出来的经验。

第一个技巧:fwidth 是 dFdx 和 dFdy 的“绝佳搭档”,很多问题本质上是“如何把不连续的导数变成连续抗锯齿信息”,fwidth 因为叠加了两个方向的变化量,天然是各向同性的,非常适合做抗锯齿宽度估计。但你也可以自己组合:

float gx = abs(dFdx(p)); float gy = abs(dFdy(p)); float width = max(gx, gy);

max 算子适合做严格的各向异性处理,abs 相加适合做保守的覆盖范围估计。根据效果决定用哪个,别死记公式。

第二个技巧:如果你需要在非屏幕空间的坐标里获得导数,比如纹理空间,可以用链式法则调整:

float dh = dFdx(h); vec2 dUVdx = dFdx(vUv); float dh_dU = dh / dUVdx.x; // 近似估算纹理 U 方向的变化率

这相当于是给导数换了个“坐标系”。不过要注意当 dUVdx 接近 0 时会出问题,通常需要加一个小偏移量保护。这也算是我自己推出来的土办法,网上不太有人提,但实际项目中确实能用。

dFdx 和 dFdy 学完之后,你会发现自己对 GPU 的并行执行方式有了更具体的理解。它们不算难,但用得好不好,往往就决定了你的渲染效果是“边缘毛躁”还是“平滑自然”。希望这篇文章能帮你少走一些我曾经走过的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询