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 的并行执行方式有了更具体的理解。它们不算难但用得好不好往往就决定了你的渲染效果是“边缘毛躁”还是“平滑自然”。希望这篇文章能帮你少走一些我曾经走过的弯路。