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 的插值,进而伪造出「新的一帧」做为过渡帧),可以让序列帧动画在视觉上变得更流畅。

相关推荐
LcGero17 天前
TypeScript 快速上手:泛型与工具类型
typescript·cocos creator·游戏开发
LcGero17 天前
Cocos Creator 3.x 高维护性打字机对话系统设计与实现
cocos creator·打字机
LcGero18 天前
Cocos Creator 三端接入穿山甲 SDK
sdk·cocos creator·穿山甲
LcGero19 天前
Cocos Creator平台适配层框架设计
cocos creator·平台·框架设计
LcGero20 天前
Cocos Creator 业务与原生通信详解
android·ios·cocos creator·游戏开发·jsb
LcGero21 天前
TypeScript 快速上手:前言
typescript·cocos creator·游戏开发
Setsuna_F_Seiei21 天前
CocosCreator 游戏开发 - 多维度状态机架构设计与实现
前端·cocos creator·游戏开发
CodeCaptain3 个月前
cocoscreator 2.4.x 场景运行时的JS生命周期浅析
cocos creator·开发经验
CodeCaptain4 个月前
CocosCreator 3.8.x [.gitignore]文件内容,仅供参考
经验分享·cocos creator
VaJoy5 个月前
Cocos Creator Shader 入门 (21) —— 高斯模糊的高性能实现
前端·cocos creator