4. 边缘检测基础:Sobel算子原理、基于深度的边缘检测、法线辅助边缘检测、边缘强度计算
边缘检测,说白了就是让计算机找到画面中「哪里变了」。在深度图后处理里,我们不是找颜色边缘,而是找深度跳变的地方。这活儿看着简单,做起来坑不少。我最早做这个的时候,以为套个Sobel算子就完事了,结果满屏噪点,根本没法看。
今天咱们就把边缘检测这块地基打牢。我会从最经典的Sobel算子讲起,然后延伸到深度边缘检测,再聊聊法线怎么帮我们补漏,最后说说边缘强度怎么算才靠谱。
核心思路:边缘 = 信号突变的地方。在图像里用微分算子去捕捉这种突变,就是边缘检测的本质。
4.1 Sobel算子原理
Sobel算子是最常用的边缘检测算子之一。它结合了高斯平滑和微分,对噪声有一定抑制能力。我个人习惯把它当作「入门级但够用」的方案。
Sobel算子有两个核心卷积核:
- 水平方向(Gx):检测垂直边缘
- 垂直方向(Gy):检测水平边缘
这两个核长这样:
Gx = | -1 0 +1 | Gy = | -1 -2 -1 |
| -2 0 +2 | | 0 0 0 |
| -1 0 +1 | | +1 +2 +1 |
为什么中间权重更大?因为离中心像素越近,贡献越大。这其实就是加权平均的思想。你想想看,如果所有位置权重一样,那边缘会被模糊掉。
计算时,我们用这两个核分别对图像做卷积:
// 伪代码示意
float Gx = 0, Gy = 0;
for(int y = -1; y <= 1; y++) {
for(int x = -1; x <= 1; x++) {
float pixel = SampleTexture(uv + float2(x, y) * texelSize);
Gx += pixel * sobelKernelGx[y+1][x+1];
Gy += pixel * sobelKernelGy[y+1][x+1];
}
}
float edgeStrength = sqrt(Gx*Gx + Gy*Gy);
嗯,这里要注意:sqrt 在Shader里开销不小。如果只是做二值化判断,可以用 abs(Gx) + abs(Gy) 近似,效果差不太多。
我的经验:在移动端做后处理时,我经常用 abs 近似代替 sqrt。帧率能提升 5-10%,肉眼几乎看不出区别。当然,PC 端随意。
4.2 基于深度的边缘检测
深度边缘检测,就是直接在深度图上跑Sobel。但这里有个大坑——深度值是非线性的。
为什么会这样?因为透视投影的深度缓冲存储的是 1/z 的映射值。近处精度高,远处精度低。直接对非线性深度做微分,结果会严重偏向近处物体。
我曾经踩过这个坑:在室内场景里,近处的椅子边缘检测得清清楚楚,远处的墙壁边缘几乎看不到。后来才发现是深度非线性搞的鬼。
解决方案:把深度还原到线性空间再检测。
// 从深度缓冲还原线性深度
float LinearizeDepth(float depth, float near, float far) {
float z = depth * 2.0 - 1.0; // 转到NDC
return (2.0 * near * far) / (far + near - z * (far - near));
}
// 在线性深度上做Sobel
float depth = LinearizeDepth(tex2D(_CameraDepthTexture, uv), _Near, _Far);
// ... 然后对 depth 做 Sobel 卷积
另一个常见问题是:深度边缘在物体交界处很清晰,但在同一物体内部(比如一面平整的墙)几乎没有响应。这其实是个优点——我们正好可以利用它来区分物体边界。
| 深度类型 | 边缘响应 | 适用场景 |
|---|---|---|
| 非线性深度 | 近处强,远处弱 | 不推荐直接使用 |
| 线性深度 | 均匀响应 | 通用场景 |
| 对数深度 | 远处更敏感 | 大世界场景 |
注意:线性深度还原需要传入 near/far 值。在 Unity 里可以用 _ProjectionParams 获取,Unreal 里用 GetWorldPosition 反推。千万别写死数值。
4.3 法线辅助边缘检测
深度边缘有个硬伤:它只能检测深度跳变。如果两个物体深度相同但朝向不同(比如地面和墙面在墙角处),深度边缘就检测不到了。
这时候就需要法线来帮忙。法线记录了表面的朝向信息。两个面朝向不同,法线就会变化,我们就能检测到边缘。
我做过一个项目,场景里有很多装饰线条,深度上完全平滑,但法线方向变化剧烈。只用深度检测,线条全丢了。加上法线辅助后,效果立竿见影。
法线边缘检测的思路:
- 从 GBuffer 或解码得到世界空间法线
- 对法线的每个分量(x, y, z)分别做 Sobel
- 合并三个方向的结果
// 法线边缘检测
float3 normal = tex2D(_CameraNormalsTexture, uv);
float3 normalGx = 0, normalGy = 0;
// 对法线每个分量做 Sobel
for(int y = -1; y <= 1; y++) {
for(int x = -1; x <= 1; x++) {
float3 n = tex2D(_CameraNormalsTexture, uv + float2(x, y) * texelSize);
normalGx += n * sobelKernelGx[y+1][x+1];
normalGy += n * sobelKernelGy[y+1][x+1];
}
}
float normalEdge = length(normalGx) + length(normalGy);
这里有个细节:法线是单位向量,直接做线性运算没问题。但如果法线是编码过的(比如 DXT5nm 压缩),记得先解码。
我的习惯:深度边缘和法线边缘按权重混合。一般深度权重 0.6,法线权重 0.4。具体比例看场景,我通常会留一个参数让美术调。
4.4 边缘强度计算
算出了梯度,怎么转成最终的边缘强度?这里有几个常用方案:
- L2范数:
sqrt(Gx² + Gy²),物理意义最准确,但开销大 - L1范数:
|Gx| + |Gy|,速度快,效果接近 - 最大值:
max(|Gx|, |Gy|),只保留最强方向,适合做轮廓
我个人推荐在 Shader 里用 L1 范数。原因很简单:省指令。而且后处理阶段我们通常还要做模糊、二值化等操作,L1 的误差会被后续处理掩盖掉。
还有一个容易被忽略的点:阈值处理。直接输出的边缘强度会有很多噪声,需要做二值化或平滑过渡。
// 带阈值的边缘强度
float edge = abs(Gx) + abs(Gy);
edge = smoothstep(_EdgeThreshold - _EdgeSoftness,
_EdgeThreshold + _EdgeSoftness, edge);
smoothstep 的好处是让边缘有一个平滑的过渡带,不会出现硬切边。我习惯把 _EdgeSoftness 设成 0.05 左右,效果比较自然。
避坑指南:我曾经把阈值设得太低,结果满屏都是「边缘」,连纹理细节都被检测出来了。后来加了一个高斯预模糊,把高频纹理滤掉,只保留几何边缘,效果才正常。
知识体系总览
下面这张图总结了本章的核心逻辑,从输入到输出,每一步的关键操作都标出来了:
从图上可以看得很清楚:深度和法线两条路径各自走完 Sobel 后,在最后一步加权混合。这个「加权」就是调参的关键——场景不同,比例不同。室内场景法线权重可以高一些,室外大场景深度权重更重要。
好了,边缘检测的基础就这些。下一节我们会把这些技术组合起来,做一个完整的深度图后处理管线。到时候你会看到,这些基础操作是怎么串成一条流水线的。