
为什么有些游戏中的字体无论怎么放大都边缘平滑?为什么那些炫酷的融合特效看起来如此自然?
这背后,藏着一个强大的数学工具:有向距离场(Signed Distance Field,简称 SDF)。
本文将带你深入了解 SDF 的工作原理,并手把手教你在 Cocos Creator 中实现发光、描边和形状转换等特效。

什么是 SDF?
SDF 在着色器(Shader)中的应用极其广泛,其核心思想是利用距离信息来实现各种平滑、高效的图形效果。

它是一种用函数来描述形状的方式:
其中:
-
p表示空间中的某个点; -
q表示指定目标形状表面上的点; -
SDF(p)的值表示点p到目标形状表面的最近距离。
SDF(p) 的值是「有符号 / 有向」的:
-
SDF(p)值为负时:表示点p处于形状内部; -
SDF(p)值为正时:表示点p处于形状外部; -
SDF(p)值为0时:表示点p正好是形状表面上的点。
想象一张等高线地图,但地图上标注的不是海拔高度,而是「你距离最近的海岸线有多远」。
如果你在陆地内部,这个距离是正的;如果你在海洋中,这个距离是负的;而海岸线本身就是那条「零值」的等高线。
通过将 SDF 的值映射为灰阶颜色分量,可以获得一张灰阶的 SDF 纹理图:
一般来说,SDF 纹理通常在生成时,其 Alpha 通道会被归一化到 [0, 1] 的范围:
-
0:纯白色(完全透明,看不到黑色像素),代表距离形状内部最远的点(在背景中,且离边界最远)。 -
1:纯黑色(完全不透明),代表距离形状内部最近的点(在形状内部的核心区域)。 -
0.5:灰色(50% 的黑色),代表形状的原始边界。
线上有不少开源的 SDF 纹理生成工具,前端可以尝试使用 “image-sdf” 工具来生成纹理。
“image-sdf 工具链接:https://github.com/mattdesl/image-sdf)
“小贴士:该工具只嗅探白色像素,故需要先把原图染上 100% 的白色,再使用染色的新图去生成 SDF 纹理图。
基于 SDF 的发光与描边
假设存在两张 PNG 图片,后者是前者的 SDF 纹理图:
我们可以在片元着色器中,对这两张图片进行采样和处理,来实现一个高效的发光/描边效果:
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 参数:
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 的区间:
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;
}
}
发光部分的逻辑实现,关键在于光效衰减因子的计算:
} 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] 的区间。
“clamp() 的核心功能是“钳制”数值范围,其通用形式为 clamp(min, val, max)。
此时执行代码,便可获得一共仅采样两次的发光特效:
仔细观察会发现,发光的过渡比较生硬,特别是贴近边缘的部分过于明亮。
这是由于衰减因子 t 是线性均匀递减的,然而人眼对亮度的感知并不是线性的。
我们可以通过 smoothstep 方法,将线性的 t 值映射为 S 型曲线:
“smoothstep() 用于生成指定范围内 0 到 1 的平滑过渡值。
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:
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.rgb, alpha);
}
这里使用了 mix 方法对两图的 SDF 纹理值做了插值处理:
-
当 progress = 0时,完全是形状 A 的 SDF; -
当 progress = 1时,完全是形状 B 的 SDF; -
其它情况介于两个状态线性渐变。
接着只需判断插值 sdfMix 是否处于形状边界内部。
若在形状边界内部,则保留像素,否则抛弃像素(将透明度设为 0):
float alpha = sdfMix >= 0.5 ? 1.0 : 0.0;
此时执行效果如下:

我们接着把 B 图的颜色也按比例混合进来:
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 的插值,进而伪造出「新的一帧」做为过渡帧。
这样可以让序列帧动画在视觉上变得更流畅。
其他使用场景
除了发光、描边和形状转换,SDF 的应用场景还有很多。
矢量文字渲染
GPU 无法直接高效绘制字体的矢量曲线,所以常见的技巧是用 SDF 纹理来存储字体。
通过 SDF 纹理,可以获取每个像素离字体形状边界的距离。
再使用 smoothstep 函数消除边缘锯齿,就能得到在任何缩放比例下都边缘平滑的字体:
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 纹理,而常规(不使用 SDF 纹理)的实现,则需要对各方向多次采样,才能达到相同的效果。
光线步进(Ray Marching)
在 3D 游戏场景中,当计算一个点的间接光照时,着色器会从该点向各个方向发射光线。
利用 SDF,可以非常高效地进行光线步进。
SDF 保证了到最近物体表面至少还有距离 d,因此光线可以安全地前进 d 步,而不会错过任何物体。
这比传统的射线求交测试快得多。
作者介绍
大家好,我是 VaJoy,坐标深圳,曾就职于腾讯和他趣,现在是一名全职独立开发者,专注于自己的游戏项目与投资。
我一直觉得着色器(Shader)是一个非常有意思的方向 —— 它能够借助 GPU 的强大算力,高效实现游戏中各种丰富而绚丽的视觉效果。
在自学过程中,我陆续把一些心得和总结整理成文章,也希望借此机会,分享给更多对着色器感兴趣、希望入门的朋友。
这篇文章是我 Shader 系列分享中的其中一篇,更多文章,大家可以移步论坛阅读。
“论坛链接: https://forum.cocos.org/t/topic/169308/3
本文案例完整源码,可以点击【阅读原文】获取。
感谢阅读,希望这篇文章对你有所帮助!
欢迎在评论区交流心得,一起进步!
推荐关注 Cocos 官方公众号
获得更多干货与资讯!

