Cocos Creator Shader 入门 ⒃ —— 有向距离场 SDF

💡 本系列文章收录于个人专栏 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) 范围内时,t0.01.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 的插值,进而伪造出「新的一帧」做为过渡帧),可以让序列帧动画在视觉上变得更流畅。

相关推荐
VaJoy7 天前
Cocos Creator Shader 入门 ⒂ —— 自定义后处理管线
前端·cocos creator
Thomas游戏开发11 天前
Cocos Creator 面试技巧分享
面试·微信小程序·cocos creator
IkeShyZz11 天前
cocos creator android项目接入deeplink问题总结
cocos creator
VaJoy18 天前
Cocos Creator Shader 入门 ⒀ —— UBO 内存布局策略
cocos creator
成长ing121381 个月前
cocos creator 3.x shader 流光
前端·cocos creator
VaJoy1 个月前
Cocos Creator Shader 入门 ⑾ —— 光照跟随
cocos creator
成长ing121381 个月前
闪白效果
前端·cocos creator
冷水金枪鱼1 个月前
Light2D光照系统(基于CocosCreater引擎3.x/2.x)
cocos creator
VaJoy1 个月前
Cocos Creator Shader 入门 ⑽ —— 拖尾效果的实现
cocos creator