在 Unity Shader 的 HLSL 中,ddx 和 ddy 是用于计算屏幕空间偏导数 的函数。它们只能在片段着色器(Fragment Shader) 中使用,用于衡量某个变量在屏幕上沿 X 或 Y 方向的变化速率。
工作原理
GPU 执行片段着色器时,通常将相邻的 2×2 像素块(称为 quad)打包在一起并行处理。
-
ddx(x):计算当前变量x在屏幕水平方向上(X 轴)的偏导,近似为右方像素的值 - 左方像素的值。 -
ddy(y):计算当前变量y在屏幕垂直方向上(Y 轴)的偏导,近似为下方像素的值 - 上方像素的值。
由于 2×2 quad 提供了一阶差分所需的最少像素数,这个计算非常高效且精确。
主要用途
-
自动计算纹理 Mip 等级
纹理采样函数(如
tex2D)内部使用ddx(uv)/ddy(uv)来确定应该采样的 mip 级别。手动计算时也可用于自定义各向异性滤波。 -
法线贴图的切线空间转换
通过
ddx(worldPos)/ddy(worldPos)可以得到平面局部梯度,用于生成无需切线向量的法线贴图算法(如三平面映射)。 -
边缘检测与轮廓渲染
对比不同像素之间的深度值、法线或 UV 的偏导数,可识别出几何边缘或纹理接缝。
-
屏幕空间特效
例如实现屏幕空间的抗锯齿(FXAA)、景深模糊的采样范围计算,或基于屏幕位置的自适应效果。
注意事项
-
仅片段着色器可用:顶点着色器中无法调用。
-
分支敏感 :如果在动态分支(
if/else)内部使用,且分支的 2×2 quad 中部分像素走不同分支,则导数结果可能未定义或产生异常值。建议提前计算好再进入分支。 -
DX vs OpenGL :在 HLSL 中习惯用
ddx/ddy(对应 DX);在 GLSL 中对应dFdx/dFdy。Unity 跨平台时推荐使用 HLSL 写法。 -
无精度修饰要求 :
ddx/ddy返回的类型与输入一致,可处理float、half、float2等向量类型。
简单示例
cs
float2 uv = i.uv;
float2 uv_dx = ddx(uv);
float2 uv_dy = ddy(uv);
// 基于屏幕空间的 UV 变化率,可以自定义 mip 级别
float mip = 0.5 * log2( dot(uv_dx, uv_dx) + dot(uv_dy, uv_dy) );
类比理解
可以把屏幕当作一个连续坐标系,ddx / ddy 就相当于二元函数 f(x,y) 对 x 和 y 的偏导数
只不过这里的差分是在离散的相邻像素之间进行。
在实际项目开发中,ddx 和 ddy 主要用于那些需要感知屏幕空间变化率的效果,以下是我在各类渲染场景中遇到的典型用法:
1. 自动估算纹理 Mip 等级(各向异性或自定义)
纹理采样内部原本就依赖 ddx(uv) / ddy(uv) 来决定 mip 等级。但在某些自定义采样中(例如虚拟纹理 、贴花投影 、体积纹理),你需要手动控制 mip 级别来避免闪烁或过度模糊。
cs
float2 uv = i.uv;
float2 dx = ddx(uv) * _TextureSize; // 屏幕空间 UV 变化率(纹素单位)
float2 dy = ddy(uv) * _TextureSize;
float mip = 0.5 * log2( max( dot(dx, dx), dot(dy, dy) ) );
float4 color = tex2Dlod(_MainTex, float4(uv, 0, mip));
这对于大地形漫游 或UI 九宫格拉伸时保持纹理清晰度非常有用。
2. 无需切线空间的法线重建(三平面映射 / 水面波浪)
当你在世界空间或对象空间中直接计算法线(如三平面贴图、过程化地形、动态水体),可以利用 ddx(P) / ddy(P) 得到表面的局部梯度,从而重建法线。
cs
float3 P = i.worldPos;
float3 dPdx = ddx(P);
float3 dPdy = ddy(P);
float3 normal = normalize(cross(dPdy, dPdx)); // 注意顺序
实际项目:
-
大型开放世界中,地形使用多张纹理混合,且没有预存切线空间,就用此法在着色器中动态计算法线,节省内存带宽。
-
动态水面:通过波浪函数修改顶点位置后,片段着色器中用
ddx/ddy重新计算复杂波形的法线,避免顶点着色器传递大量数据。
3. 轮廓线渲染 / 边缘检测
通过比较相邻像素的深度 、法线 或UV 的偏导数,可以判断是否属于几何边缘,用于描边效果 或X-Ray透视。
cs
float depth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(...));
float dx = abs(ddx(depth));
float dy = abs(ddy(depth));
float edge = saturate( (dx + dy) * _Sensitivity );
实际应用:
-
《英雄联盟》式卡通描边(屏幕空间后处理版)。
-
工业检测模拟中高亮显示物体交界处。
-
水中折射的边缘加深效果。
4. 屏幕空间抗锯齿(FXAA)
FXAA 需要检测画面的亮度梯度 ,以决定哪些地方需要平滑处理。ddx/ddy 可以高效计算相邻像素的亮度变化。
cs
float luma = Luminance(color.rgb);
float lumaDx = ddx(luma);
float lumady = ddy(luma);
float contrast = abs(lumaDx) + abs(lumady);
if(contrast > threshold) // 可能产生锯齿,执行模糊
几乎所有现代游戏后期管线中的 FXAA 实现都会用到偏导数。
5. 屏幕空间扭曲(热畸变、水波效果)
想要在屏幕空间产生偏移扭曲 时,可以用 ddx(uv) / ddy(uv) 作为动态强度的参考,使得扭曲强度与屏幕缩放解耦,避免在不同分辨率下效果不一致。
cs
float2 distort = sin(uv * 50 + _Time.y) * 0.02;
float2 scaleFactor = float2( length(ddx(uv)), length(ddy(uv)) );
distort *= scaleFactor; // 保证像素采样偏移量相对于纹素变化率稳定
6. 自适应采样(SSAO / SSR / 体积光)
屏幕空间的环境光遮蔽或反射追踪中,会根据视角深度梯度决定采样步长。
例如 SSAO 采样核可以根据深度变化率动态缩小半径,避免在边缘出现光晕伪影。
cs
float centerDepth = LinearEyeDepth(...);
float depthDelta = max( abs(ddx(centerDepth)), abs(ddy(centerDepth)) );
float radius = _BaseRadius / (1.0 + depthDelta * _RadiusScale);
实际项目中的坑点
-
分支内部慎用 :若在
if/else分支里调用ddx/ddy,且该分支对 quad 内四个像素并不统一,结果会是未定义的(某些平台会报错或产生鬼影)。解决方案:把需要的变量提前计算好,再进入分支。 -
移动端性能:在某些 PowerVR GPU 上,偏导数指令开销略高,但通常可以接受;避免在复杂循环内反复调用。
-
不适合顶点着色器:顶点阶段没有像素 quad 概念,无法使用。
一句话总结
ddx/ddy让你在片段着色器中"感知"屏幕空间的局部变化,是实现众多高质量自适应效果(纹理过滤、边缘检测、法线重建、抗锯齿、扭曲)的核心工具。