💡 本系列文章收录于个人专栏 ShaderMyHead。
💡 本文案例可以在 Github 上进行演示。
一、什么是有向距离场?
1.1 介绍
有向距离场(Signed Distance Field,简称 SDF)是一种用函数来描述形状的方式:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> S D F ( p ) = ± min q ∈ s u r f a c e ∣ ∣ p − q ∣ ∣ SDF(p) = \pm \min_{q \in surface} ||p - q|| </math>SDF(p)=±q∈surfacemin∣∣p−q∣∣
其中 p
表示空间中的某个点,q
表示指定目标形状表面上的点,SDF(p)
的值表示点 p
到目标形状表面的最近距离。
SDF(p)
的值是「有符号 / 有向」的:
- 值为负时,表示点
p
处于形状内部; - 值为正时,表示点
p
处于形状外部; - 值为
0
时,表示点p
正好是形状表面上的点;
想象一张等高线地图,但地图上标注的不是海拔高度,而是「你距离最近的海岸线有多远」 ------ 如果你在陆地内部,这个距离是正的;如果你在海洋中,这个距离是负的;而海岸线本身就是那条「零值」的等高线。
下方为一个将距离信息可视化的示意图:

通过将 SDF 的值映射为灰阶颜色分量,可以获得一张灰阶的 SDF 纹理图:

一般来说,SDF 纹理通常在生成时,其 Alpha 通道会被归一化到 [0, 1]
的范围:
0
,纯白色(完全透明,看不到黑色像素),代表距离形状内部最远的点(在背景中,且离边界最远)。1
,纯黑色(完全不透明),代表距离形状内部最近的点(在形状内部的核心区域)。0.5
,灰色(50% 的黑色),代表形状的原始边界。
💡 线上有不少开源的 SDF 纹理生成工具,前端可以尝试使用 image-sdf 来生成纹理:
留意此工具只嗅探白色像素,故需要先把原图染上 100% 的白色,再使用染色的新图去生成 SDF 纹理图。
1.2 使用场景
SDF 在着色器中的应用极其广泛,其核心思想是利用距离信息来实现各种平滑、高效的图形效果。下方罗列了常见的一些使用场景。
⑴ 矢量文字渲染

GPU 无法直接高效绘制字体的矢量曲线,所以常见的技巧是用 SDF 纹理来存储字体 。渲染时,通过 SDF 纹理获取每个像素离字体形状边界的距离,再使用 smoothstep
函数来消除边缘锯齿,就能得到在任何缩放比例下都边缘平滑的字体:
js
float edge = 0.5; // 原始边界值
float width = 0.25; // 控制边缘平滑度
float alpha = smoothstep(edge - width, edge + width, d);
⑵ 形状融合
由于 SDF 是数学化的表示,两个 SDF 可以很容易地进行数学操作,从而实现平滑的融合和变形效果。常见操作有:
- 并集(Union) :
min(sdf1, sdf2)
; - 交集(Intersection) :
max(sdf1, sdf2)
; - 差集(Difference) :
max(sdf1, -sdf2)
; - 平滑融合(Smooth Blending) : 使用
mix
或更复杂的函数来让两个形状的边界平滑过渡。
通过这些方法,可以在着色器中进行形状建模,或者创建各种有机的、流动的视觉效果,比如熔岩流动、魔法特效、水洼融合等。
⑶ 动态效果
借助 SDF 的距离信息,可以更高效地做出文字描边、发光、模糊、膨胀等特效。之所以「更高效」是因为它只需要额外采样一次 SDF 纹理,而不像《描边和发光效果的实现》那样需要进行各方向多次采样。
⑷ 光线步进(Ray Marching)
在 3D 游戏场景中,当计算一个点的间接光照时,着色器会从该点向各个方向发射光线。利用 SDF,可以非常高效地进行光线步进 ------ SDF 的值 d
保证了到最近物体表面至少还有距离 d
,因此光线可以安全地前进 d
步,而不会错过任何物体,这比传统的射线求交测试快得多。
二、基于 SDF 的发光与描边
假设存在两张 PNG 图片,后者是前者的 SDF 纹理图:

我们可以在片元着色器中,对这两张图片进行采样和处理,来实现一个高效的发光/描边效果:
js
in vec2 uv;
uniform sampler2D sdfTexture; // SDF 纹理图,从材质传入
vec4 frag () {
// 原图
vec4 baseColor = texture(cc_spriteTexture, uv);
// 原图非镂空部分直接返回
if (baseColor.a > 0.5) {
return baseColor;
}
// 采样 SDF(A 通道)
float sdf = texture(sdfTexture, uv).a;
// SDF 纹理中 0.5 代表边界
float dist = sdf - 0.5;
// TODO - 实现发光/描边效果
}
由于此处使用的 SDF 纹理图是基于 Alpha 透明度来展示灰阶的,因此取用的 A 通道做为当前像素的 SDF 纹理值。再将该值减去代表边界的 0.5
,即可获得像素距离边界的最近距离值。
我们补充发光/描边特效需要的 uniform
参数:
js
CCEffect %{
techniques:
- name: sdf-glow
passes:
- vert: sdf-vs:vert
frag: sdf-fs:frag
# 略...
properties:
sdfTexture: { value: white } # SDF 纹理
isGlow: { value: 1, editor: { range: [0, 1, 1]} } # 1 表示发光,0 表示实心描边
glowColor: { value: [1.0, 1.0, 0.0, 1.0], editor: { type: color} } # 默认黄色发光
glowWidth: { value: 0.1, editor: { range: [0.0, 0.4, 0.01]} } # 基于 UV 的发光范围,上限基于你的 SDF 纹理步长
glowOpacity: { value: 1.0, editor: { range: [0.0, 1.0, 0.1]} } # 发光强度(透明度)
}%
CCProgram sdf-vs %{
#include "../../resources/chunk/normal-vert.chunk"
}%
CCProgram sdf-fs %{
precision highp float;
#include <sprite-texture>
in vec2 uv;
uniform UBO {
vec4 glowColor;
float glowWidth;
float glowOpacity;
int isGlow;
};
uniform sampler2D sdfTexture;
vec4 frag () {
vec4 baseColor = texture(cc_spriteTexture, uv);
if (baseColor.a > 0.5) {
return baseColor;
}
float sdf = texture(sdfTexture, uv).a;
float dist = sdf - 0.5;
vec4 result = baseColor;
if (isGlow == 0) {
// TODO - 实现实心描边逻辑
} else {
//TODO - 实现发光逻辑
}
return result * glowOpacity;
}
}%
实心描边部分的逻辑,只需要使用 step
函数判断当前像素是否落在描边范围内(即从形状边界开始,扩展到 glowWidth
的区间)即可:
js
if (isGlow == 0) {
// ---- 实心描边:dist ∈ [-glowWidth, 0] ----
float inOutline = step(-glowWidth, dist) * step(dist, 0.0); // 是否处于描边范围内,1.0 表示是,0.0 表示否
if (inOutline > 0.0) {
// 处于描边范围内,直接返回描边颜色
result = glowColor;
}
}
发光部分逻辑实现,其关键在于光效衰减因子的计算:
js
} else {
// ---- 发光:边缘最亮,向外在同带宽内衰减到 0 ----
float t = clamp(1.0 + dist / glowWidth, 0.0, 1.0); // 线性衰减因子
vec4 glow = glowColor * t;
result = glow;
}
其中 dist / glowWidth
表示当前像素在发光范围内的相对位置,1.0 + dist / glowWidth
则表示为:
- 当
dist = 0
(边缘)时,t = 1.0
(最亮); - 当
dist = -glowWidth
(发光边界)时,t = 0.0
(完全衰减); - 当
dist
在(-glowWidth, 0)
范围内时,t
从0.0
到1.0
线性变化。
然而 dist
当然可能落在其它地方(例如其值小于 -glowWidth
时,表示落在了发光边界外部),导致 t
的值没有落在预期的 [0.0, 1.0]
区间。我们通过 clamp
方法,让 t
值限定在 [0.0, 1.0]
的区间内即可。
此时执行代码,便可获得一共仅采样两次的发光特效:

仔细看会发现发光的过渡比较生硬,特别是贴近边缘的部分过于明亮。这是由于衰减因子 t
是线性均匀递减的,然而人眼对亮度的感知并不是线性的,这也是相机需要「伽马校正」的原因。
我们可以通过 smoothstep
方法,将线性的 t
值映射为 S 型曲线:
js
float t = clamp(1.0 + dist / glowWidth, 0.0, 1.0); // 线性衰减因子
t = smoothstep(0.0, 1.0, t); // 让衰减更柔和
vec4 glow = glowColor * t;
result = glow;
此时发光的衰减在视觉上会变得更为自然:

💡 调试过程若发现光效会莫名裁剪了(即使 SpriteFrame 组件并未选中
Trim
):需要在原图的属性检查器处,将
Trim Type
设为none
来解决此问题:
扩展 ------ 效果增强
通过利用正弦函数(sin
),可为发光效果添加周期性的呼吸动画,另外新增一个 glowEndColor
参数来实现光效色值的渐变,能让发光效果更自然和动感:

读者可以自行实现相应功能,或者参考演示案例源码,鉴于篇幅原因此处不展开。
三、基于 SDF 的形状转换
通过在两个不同的 SDF 纹理之间进行线性插值,可以轻松实现两个图形的形状转换。
假设存在图 A、图 B,以及它们相对应的 SDF 纹理图:

我们可以通过着色器,让图 A 逐步变换为图 B:
js
uniform UBO {
float progress; // 形变进度,从 0.0 到 1.0,通过外部组件脚本动态修改
};
uniform sampler2D sdfTextureA;
uniform sampler2D textureB;
uniform sampler2D sdfTextureB;
vec4 frag () {
float sdfA = texture(sdfTextureA, uv).a;
float sdfB = texture(sdfTextureB, uv).a;
// SDF 插值
float sdfMix = mix(sdfA, sdfB, progress);
// 透明度
float alpha = sdfMix >= 0.5 ? 1.0 : 0.0;
// 原图颜色
vec4 colA = texture(cc_spriteTexture, uv);
return vec4(colA, alpha);
}
其中第 14 行使用了 mix
方法对两图的 SDF 纹理值做了插值处理:
progress = 0
时完全是形状 A 的 SDF;progress = 1
时完全是形状 B 的 SDF;- 其它情况介于两个状态线性渐变。
接着只需判断插值 sdfMix
是否处于形状边界内部,是的话保留像素,否则抛弃像素(将透明度设为 0
):
js
float alpha = sdfMix >= 0.5 ? 1.0 : 0.0;
此时执行效果如下:

我们接着把 B 图的颜色也按比例混合进来:
js
vec4 colA = texture(cc_spriteTexture, uv);
vec4 colB = texture(textureB, uv);
vec4 colMix = mix(colA, colB, progress); // 按比例混合 A 跟 B 的颜色
return vec4(colMix.rgb, alpha * colMix.a);
最终的形变效果如下:

💡 通过同样的方式,可以给一个序列帧动画进行「插帧」(每帧的图片都需要预先生成对应的 SDF 纹理,再在运行时通过着色器做前后两帧 SDF 的插值,进而伪造出「新的一帧」做为过渡帧),可以让序列帧动画在视觉上变得更流畅。