大数跨境
0
0

告别2D锯齿|用SDF实现平滑发光、描边与形状转换特效Shader

告别2D锯齿|用SDF实现平滑发光、描边与形状转换特效Shader EcoCosy优可丝
2025-09-12
1
导读:一文搞懂SDF,提高画面质量!

为什么有些游戏中的字体无论怎么放大都边缘平滑?为什么那些炫酷的融合特效看起来如此自然?

这背后,藏着一个强大的数学工具:有向距离场(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: { value1editor: { range: [011]} }                   # 1 表示发光,0 表示实心描边
        glowColor: { value: [1.01.00.01.0], editor: { type: color} }  # 默认黄色发光
        glowWidth: { value0.1editor: { range: [0.00.40.01]} }       # 基于 UV 的发光范围,上限基于你的 SDF 纹理步长
        glowOpacity: { value1.0editor: { range: [0.01.00.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.01.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.01.0);  // 线性衰减因子
      t = smoothstep(0.01.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 官方公众号

获得更多干货与资讯!

【声明】内容源于网络
0
0
EcoCosy优可丝
1234
内容 703
粉丝 0
EcoCosy优可丝 1234
总阅读42
粉丝0
内容703