Cocos Creator Shader 入门 ⑻ —— 描边和发光效果的实现

2,569 阅读9分钟

💡 本系列文章收录于个人专栏 ShaderMyHead,欢迎订阅。

💡 完整源码可在 Cocos Creator 官方商店获取。

一、描边原理

描边原理和边缘查找的实现类似。

材质上镂空处(Alpha=0)的某个点,要判断它是否需要绘制描边色值,可以向四周做多个方向的发射采样(发射距离为描边的尺寸),查找最临近的实心(Alpha>0.5)像素:

image.png

若未找到实心像素,就在片元着色器中直接返回材质原色值,否则则返回描边色值。

二、描边的实现

2.1 基础代码

根据上述方案,我们先书写一个着色器基础模板:

CCEffect %{
  techniques:
  - name: glow
    passes:
    - vert: vs:vert
      frag: fs:frag
      blendState:
        targets:
        - blend: true
          blendSrc: src_alpha
          blendDst: one_minus_src_alpha
      depthStencilState:      
          depthTest: false  
          depthWrite: false 
      rasterizerState:
        cullMode: none
      properties:
        glowColor:  { value: [1.0, 0.8, 0.3, 1.0], editor: { type: color } }
        glowSpread: { value: 0.04, editor: { range: [0.0, 0.2, 0.005] } }
}%

CCProgram vs %{
  #include "../../resources/chunk/normal-vert.chunk"
}%

CCProgram fs %{
  precision highp float;
  #include <sprite-texture>

  in vec2 uv;

  uniform UBO {
    vec4  glowColor;    // 发光颜色
    float glowSpread;   // 发光最大半径(UV)
  };

  vec4 frag () {
    vec4 texColor = texture(cc_spriteTexture, uv);
    float centerAlpha = texColor.a;

    // 不透明像素不需要处理,直接返回材质原色值
    if (centerAlpha > 0.5) return texColor;

    // TODO: 搜索最近实心像素,计算要返回的色值
  }
}%

接着只需要把片元着色器里最终的逻辑(第 44 行)逐步实现即可。

留意我们定义了 glowColorglowSpread 两个参数,它们分别表示描边的色值,以及描边的尺寸。

其中描边的尺寸(glowSpread)的数值是基于 UV 坐标体系的,这里定为 [0.0, 0.2],意味着描边最粗可达材质宽高的 20%。

2.2 核心代码

在片元着色器中,如果设定当前像素会往周围的 12 个方向发射向量,则代码实现为:

    // ---------- 搜索最近实心像素 ----------
    const int DIR_COUNT = 12;  // 当前像素向四周发射向量的数量

    for (int dir = 0; dir < DIR_COUNT; dir++) {
      float angle = float(dir) / float(DIR_COUNT) * 6.2831853;  // 当前向量角度,其中 6.2831853 表示 2π

      // TODO - 查找当前方向最临近的实心像素
    }

在第 5 行我们通过 dir / DIR_COUNT * 2π 的计算来获取当前向量的角度,可用于后续计算。

接着我们给每个发散的方向设定发射步数(例如 8 步),让其一步步去探测实心像素:

image.png

其代码实现为:

    const int DIR_COUNT = 12;
    const int STEP_COUNT = 8;  // 每个方向探测的步数
    float stepSize = glowSpread / float(STEP_COUNT);  // 探测的每一步的步长

    for (int dir = 0; dir < DIR_COUNT; dir++) {
      float angle  = 6.2831853 * float(dir) / float(DIR_COUNT);
      vec2  dirVec = vec2(cos(angle), sin(angle));  // 当前向量

      for (int s = 1; s <= STEP_COUNT; s++) {
        float dist = float(s) * stepSize;  // 从中心到当前采样点的总距离(基于 uv 坐标体系)
        vec2  offset = dirVec * dist;  // 从中心到当前采样点的偏移量

        // TODO - 探测实心像素
      }
    }

其中第 7 行使用的就是极坐标转直角坐标的经典方式 —— 给定一个角度 θ,则单位圆上的方向向量是 x = cos(θ),y = sin(θ)

进而就能通过 dirVec * dist 获取当前方向的偏移。

另外留意我们的 glowSpread 是基于 UV 坐标体系的,故上方代码中 distoffset 得到的值也会基于 UV 坐标体系,因此我们可以通过 texture(cc_spriteTexture, uv + offset) 来获取当前步的偏移量所对应的像素:

    const int DIR_COUNT = 12; 
    const int STEP_COUNT = 8; 
    float stepSize = glowSpread / float(STEP_COUNT); 
    float minDist = glowSpread;  // 探测到实心像素的最短距离,初始化为描边尺寸(即最大距离)

    for (int dir = 0; dir < DIR_COUNT; dir++) {
      float angle  = 6.2831853 * float(dir) / float(DIR_COUNT);
      vec2  dirVec = vec2(cos(angle), sin(angle));

      for (int s = 1; s <= STEP_COUNT; s++) {
        float dist = float(s) * stepSize;
        vec2  offset = dirVec * dist;
        float a = texture(cc_spriteTexture, uv + offset).a;  // 当前探测到的像素

        if (a > 0.5) {  // 探测到了实心像素
          minDist = min(minDist, dist);
          break;
        }
      }
    }

通过「多方向发散」和「分步探测」两个嵌套循环,我们最终获得了当前像素最临近的实心像素的最短距离 dist(若没探测到实心像素,则 dist 等于初始化的描边尺寸),因此可以在片元着色器的最后返回对应的色值:

    vec4 color = texColor;
    if (minDist < glowSpread) {
      color = glowColor;
    }

    return color;

执行效果如下:

Jul-14-2025 10-12-52.gif

2.2 描边精细化

可以看到当 glowSpread 的数值越大时,材质上比较精细复杂的轮廓描边会出现毛糙的问题:

image.png

这是因为我们设定的 DIR_COUNTSTEP_COUNT 数值都相对较小,如果需要被描边的材质存在较复杂的轮廓,可以相应调大它们的数值(特别是 DIR_COUNT):

    const int DIR_COUNT = 80; 
    const int STEP_COUNT = 10; 

修改后的描边效果:

image.png

💡 虽然「多方向发散」和「分步探测」的循环遍历是发生在 GPU 侧的,但遍历的次数太多依旧会影响性能,因此推荐将 DIR_COUNTSTEP_COUNT 数值调整为自己能接受的最小数值。

三、发光的实现

在上一节我们实现了实心描边的效果,接着可以通过修改描边着色强度来实现发光的效果。

实现的方式很简单,通过 GLSL 内置的 smoothstep 方法返回一个 [0.0, 1.0] 区间的平滑值,来作为发光强度的因子即可:

float glowFactor = smoothstep(glowSpread, 0.0, minDist);

着色器中的应用如下:

    vec4 color = texColor;

    #if USE_GLOW_DECAY
      // 发光效果
      float glowFactor = smoothstep(glowSpread, 0.0, minDist);  // 发光强度的因子
      color = glowColor * glowFactor;
    #else
      // 实心描边
      if (minDist < glowSpread) {
        color = glowColor;
      }
    #endif

    return color;

我们使用了一个自定义宏 USE_GLOW_DECAY 来决定是否启用衰减发光的效果:

222.gif

这里存在一个问题 —— 当 glowColor 数值较高时,发光的衰减会程序出一种果冻分层效应:

image.png

这是由于 miniDist 不够精准导致的,我们把 STEP_COUNT10 加大到 20 来获取更精准的 miniDist,修改后的发光衰减变得更加平滑:

image.png

此时完整的着色器代码如下:

/** glow-simple.effect **/

CCEffect %{
  techniques:
  - name: glow
    passes:
    - vert: vs:vert
      frag: fs:frag
      blendState:
        targets:
        - blend: true
          blendSrc: src_alpha
          blendDst: one_minus_src_alpha
      depthStencilState:      
          depthTest: false  
          depthWrite: false 
      rasterizerState:
        cullMode: none
      properties:
        glowColor:  { value: [1.0, 0.8, 0.3, 1.0], editor: { type: color } }
        glowSpread: { value: 0.04, editor: { range: [0.0, 0.2, 0.005] } }
}%

CCProgram vs %{
  #include "../../resources/chunk/normal-vert.chunk"
}%

CCProgram fs %{
  precision highp float;
  #include <sprite-texture>

  in vec2 uv;

  uniform UBO {
    vec4  glowColor;    // 发光颜色
    float glowSpread;   // 发光最大半径(基于 UV 坐标体系)
  };

  vec4 frag () {
    vec4 texColor = texture(cc_spriteTexture, uv);
    float centerAlpha = texColor.a;

    // 不透明像素不处理外发光
    if (centerAlpha > 0.5) return texColor;

    // ---------- 搜索最近实心像素 ----------
    const int DIR_COUNT = 80;  // 当前像素向四周发射向量的数量,值越大越精准
    const int STEP_COUNT = 20;  // 每个方向探测的步数,值越大越精准
    float stepSize = glowSpread / float(STEP_COUNT);  // 探测的每一步的步长
    float minDist = glowSpread;  // 探测到实心像素的最短距离,初始化为最大距离

    for (int dir = 0; dir < DIR_COUNT; dir++) {
      float angle  = 6.2831853 * float(dir) / float(DIR_COUNT);  // 当前向量角度,其中 6.2831853 等于 2π
      vec2  dirVec = vec2(cos(angle), sin(angle));  // 当前向量

      for (int s = 1; s <= STEP_COUNT; s++) {
        float dist = float(s) * stepSize;  // 当前步的长度(基于 UV 坐标体系)
        vec2  offset = dirVec * dist; // 当前步的偏移量
        float a = texture(cc_spriteTexture, uv + offset).a;  // 当前步的偏移量所对应的像素

        if (a > 0.5) {  // 探测到了实心像素
          minDist = min(minDist, dist);
          break;
        }
      }
    }

    vec4 color = texColor;

    #if USE_GLOW_DECAY
      // 发光效果
      float glowFactor = smoothstep(glowSpread, 0.0, minDist);  // 发光强度的因子
      color = glowColor * glowFactor;
    #else
      // 实心描边
      if (minDist < glowSpread) {
        color = glowColor;
      }
    #endif

    return color;
  }
}%

四、潜在问题

4.1 描边粗细不均衡的处理

描边的尺寸(glowSpread)的数值是基于 UV 坐标体系的,若材质是一个正方形那没什么问题,但如果材质是一个长方形(特别是宽高差值较大的长方形),会导致描边在 U 轴和 V 轴方向上的的粗细不均:

image.png

解决方案是新增一个材质宽高比的参数 textureAspect

      properties:
        glowColor:  { value: [1.0, 0.8, 0.3, 1.0], editor: { type: color } }
        glowSpread: { value: 0.04, editor: { range: [0.0, 0.2, 0.005] } }
        textureAspect: { value: [1.0, 1.0], editor: { type: vector } }  #新增参数

并通过外部的组件脚本动态赋值:

    material.setProperty('textureAspect', new Vec2(texture.width / texture.height, 1.0));

在片元着色器中只需要让偏移量除以该宽高比参数即可:

// vec2  offset = dirVec * dist;
vec2  offset = dirVec * dist / textureAspect;

4.2 描边被材质边缘截断的处理

由于我们的着色器效果是在每张图片(材质)原有的像素上去着色的,因此描边或发光的效果无法超出材质外部的区域,即着色器的效果会被材质边缘截断:

image.png

要解决该问题,可以增大原生图片的镂空区域(来容纳更粗的描边),但这样会导致图片的体积变大,也为原生图片的处理增加了心智负担。

我们可以使用上一章所掌握的 Render Texture 来解决此问题。

我们创建一个等于画布尺寸 1280 * 720 的 Render Texture,以及一个绑定该 Render Texture 文件的摄像头(留意 Ortho Height 要设置为画布的高度的一半),接着修改 Effect 文件代码:

  • CCEffect 新增 renderTexture 材质参数
      # CCEffect
      properties:
        glowColor:  { value: [1.0, 0.8, 0.3, 1.0], editor: { type: color } }
        glowSpread: { value: 0.04, editor: { range: [0.0, 0.2, 0.005] } }
        textureAspect: { value: [1.78, 1.0], editor: { type: vector } }   # 宽高比修改为 1280 / 720 = 1.78
        renderTexture: { value: grey }  # 新增 renderTexture 材质参数
  • 顶点着色器改动
  precision highp float;
  #include <cc-global>
  #include <common/common-define>  // 引入 CC_HANDLE_RT_SAMPLE_FLIP 内置函数
  // 略...


  vec4 vert() {
    // 略...

    // 解决不同平台和渲染管线中 RenderTexture 的坐标系差异,可处理 RenderTexture 采样时的 UV 坐标翻转问题
    uv = CC_HANDLE_RT_SAMPLE_FLIP(uv);
    
    return  pos;
  }
  • 片元着色器改动
  precision highp float;
  #include <sprite-texture>
  in vec2 uv;

  uniform sampler2D renderTexture;  // 新增材质变量
  
  // 略...
  
  vec4 frag () {
    vec4 texColor = texture(renderTexture, uv);  // 将 cc_spriteTexture 更换为 renderTexture
    
    // 略...
    
    float a = texture(renderTexture, uv + offset).a; // 将 cc_spriteTexture 更换为 renderTexture
    
    // 略...
  }

接着在着色器材质的属性检查器面板,把 Render Texture 文件赋予 renderTexture 材质参数:

image.png

最后新增一个覆盖整个画布的 SpriteFrame 节点,并应用材质即可:

image.png

此时描边或发光效果已能超出每张图片原本的区域,不再被图片边缘截断:

image.png

💡 该方案还能解决 Spine 动画描边异常的问题。因为 Spine 通常由多个互相独立的材质叠加而成,仅把材质应用到单个 Spine 上时,每个材质部件都会被着色器绘制上描边,导致效果错乱:

image.png

使用 Render Texture 方案可以规避此问题:

4.gif