Cocos Creator Shader 入门 ⑺ —— 图层混合样式的实现与 Render Texture

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

在 Photoshop 中,我们可以对顶层的图层选用指定的「图层混合模式」,来让它与底层图像实现不同的叠加效果:

在前端 CSS 中存在可实现同样叠加效果的 mix-blend-mode 属性,可以实现如下十多种图层叠加样式:

此类图层叠加样式在平面设计、页面或游戏设计领域都是常会用到的视觉效果,但在 Cocos Creator 中并没有内置这类功能,需要开发自行创建对应的着色器,本文将介绍这块的实现。

一、multiply 和 screen 的实现

multiply(正片叠底)screen(滤色) 的实现相对简单,我们指定 CCEffect 中的 blendSrcblendDst 的配置,交由 GPU 在硬件混合阶段按规则去混合前景色与后景色即可:

yaml 复制代码
CCEffect %{
common-pass-config: &common-pass-config
  depthStencilState:      
    depthTest: false  
    depthWrite: false

techniques:
  - name: multiply  # 正片叠底
    passes:
      - vert: vs:vert
        frag: fs:frag
        <<: *common-pass-config 
        blendState:
          targets:
          - blend: true
            blendSrc: dst_color   # 关键:使用目标颜色作为源因子
            blendDst: zero        # 关键:不使用目标混合因子

  - name: screen    # 滤色混合
    passes:
      - vert: vs:vert
        frag: fs:frag
        <<: *common-pass-config 
        blendState:
          targets:
          - blend: true
            blendSrc: one                  # 关键:完全使用源颜色
            blendDst: one_minus_src_color  # 关键:使用(1-源颜色)作为目标因子
}%

着色器片段:

js 复制代码
CCProgram vs %{
  #include "../../resources/chunk/normal-vert.chunk"
}%

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

  in vec2 uv;

  vec4 frag() {
      vec4 color = texture(cc_spriteTexture, uv);

      return color; 
  }
}%

其中 multiply 使用目标颜色作为源因子且不使用目标混合因子,其输出颜色的混合计算为:

js 复制代码
color.rgb * dst_color + dst_color.rgb * 0 = color.rgb * dst_color

即等于源颜色与目标颜色相乘 ("multiply" 本身也是 "相乘" 的意思),这意味着:

  • 无论是源颜色还是目标颜色,只要一方存在黑色的像素,混合结果会是黑色(黑色的 RGB 为 (0.0, 0.0, 0.0), 任何色值乘以它结果都是 (0.0, 0.0, 0.0));
  • 无论是源颜色还是目标颜色,其中一方若存在的白色像素,混合后会变成对方的色值(白色的 RGB 为 (1.0, 1.0, 1.0), 相乘后等于对方的色值)。

screen 则使用 100% 的源颜色,使用(1-源颜色)作为目标因子,其输出颜色的混合计算为:

js 复制代码
color.rgb + dst_color.rgb * (1 - color.rgb)

即在源颜色的基础上,加上了目标颜色与"源颜色反转色值"的混合色,这意味着:

  • 混合后的像素,通常会比源颜色更亮(除非源颜色是白色,再怎么混合也是白色);
  • 源颜色如果是黑色,会直接变成目标颜色,因为混合计算等于 (0.0, 0.0, 0.0) + dst_color.rgb * 1

multiplyscreen 的应用效果如下:

二、Render Texture 的应用

blendSrcblendDst 是 GPU 硬件混合阶段的参数,虽然它们的实现非常高效,但只支持非常有限的线性(一次函数)混合计算:

css 复制代码
outColor = A * srcColor + B * dstColor

multiplyscreen 之外的图层叠加式(例如 overlaysoft-lightcolor-dodgehue 等)属于复杂的非线性混合逻辑,则无法简单的使用 blendSrcblendDst 来实现了。

这意味我们需要自行想办法,在 Cocos Creator 着色器中获取背景色(目标颜色),来按指定范式跟前景色进行计算。

我们可以通过官方提供的渲染纹理资源(Render Texture),把摄像头拍摄到的画面作为材质纹理,并在着色器中使用该材质(来作为目标颜色)。

2.1 创建和绑定 Render Texture

在资源管理器中可以直接创建 Render Texture 文件(.rt 格式):

该资源将存储指定摄像机捕获的帧缓冲数据作为纹理使用。点击该文件并在属性检查器中设置其尺寸,鉴于我们需要获取的是整个画布大小的底图内容,故材质宽高与画布尺寸保持一致(本案例中的画布尺寸为 1280 * 720):

接着再额外创建一个材质摄像头用于来捕获背景:

留意 Otho Height 要设置为画布的高度的一半(让摄像机的位置刚好能捕获画布尺寸的画面),Visibility 选择背景节点对应的层级(上图项目中,背景节点层级归属于 DEFAULT,前景节点层级归属于 UI_2D)。

我们再把前面创建的 Render Texture 文件拖动到该摄像机的 Target Texture 配置框:

便完成了摄像机与 Render Texture 文件的绑定 ------ 后续该摄像机照射的内容会通过离屏framebuffer 绘制到 Render Texture 文件对应的纹理上。

💡 设置了 Target Texture 的摄像机,所捕获的画面会离屏,相当于该摄像机节点被隐藏了。

2.2 创建着色器和材质

创建 .effect 文件并初始化着色器模板:

js 复制代码
CCEffect %{
common-pass-config: &common-pass-config
  depthStencilState:      
    depthTest: false  
    depthWrite: false
  properties: 
    bgTexture: { value: grey }   # 对应 RenderTexture

techniques:
  - name: mix-blend-mode
    passes:
      - vert: vs:vert
        frag: fs:frag
        <<: *common-pass-config
}%

CCProgram vs %{
    precision highp float;
    #include <cc-global>
    #include <common/common-define>  // 引入 CC_HANDLE_RT_SAMPLE_FLIP 内置函数
    in vec3 a_position;
    in vec2 a_texCoord;   
    out vec2 uv;

    vec4 vert() {
    vec4 pos = vec4(a_position, 1);

    pos = cc_matViewProj * pos;
    uv = a_texCoord;
    
    // 解决不同平台和渲染管线中 RenderTexture 的坐标系差异,可处理 RenderTexture 采样时的 UV 坐标翻转问题
    uv = CC_HANDLE_RT_SAMPLE_FLIP(uv);
    
    return  pos;
  }
}%

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

  in vec2 uv;

  uniform sampler2D bgTexture;  // 材质变量无需走 UBO 形式定义

  vec4 frag() {
      // 前景色(暂时没用到)
      vec4 color = texture(cc_spriteTexture, uv);

      // 背景色(目标色)
      vec4 baseColor = texture(bgTexture, uv);

      return baseColor;  // 先不混合,直接返回背景色看下效果
  }
}%

其中第 7 行我们定义了一个材质类型的 bgTexture 变量,它会在材质的属性检查器界面被赋予前面创建的 Render Texture 文件(见后文),故我们可以在片元着色器中引入该变量作为目标材质来使用:

js 复制代码
      uniform sampler2D bgTexture;  // 材质变量无需走 UBO 形式定义
      
      ...

      // 背景色(目标色)
      vec4 baseColor = texture(bgTexture, uv);

需要留意的是,当我们在着色器中使用 Render Texture 时,必须在顶点着色器里引入 Cocos Creator 内置的 CC_HANDLE_RT_SAMPLE_FLIP 函数对 UV 坐标进行跨平台的兼容性处理(第 20 和第 32 行),否则可能出现目标纹理翻转的问题。


回到 Cocos Creator 编辑器,创建材质文件后,拖动 Render Texture 文件到材质属性检查器的 bgTexture 变量配置框进行绑定:

此时应用了该材质的 Sprite 节点效果如下:

可以看到该节点成功绘制出了 Render Texture 材质,但是尺寸被压缩了(从画布大小的尺寸,压缩到该节点尺寸)。

2.3 计算背景像素 UV

上述背景材质(Render Texture)尺寸被压缩的问题,是因为我们直接拿前景色的 UV 来作为背景材质的 UV 了,然而二者的材质尺寸是不相同的,UV 等比例映射后导致纹理采样范围错误。

例如前景中心的像素,在前景节点(下图绿框区域)中的 UV 坐标为 (0.5, 0.5)

但该像素在背景材质中的 UV 位置应是 (BG_X / 1280, BG_Y / 720)

我们需要通过计算来获得 BG_XBG_Y

js 复制代码
      /** CCEffect 处新增前景色节点的 X 和 Y 偏移变量 **/
      ...
      properties: 
        bgTexture: { value: grey } 
        offsetX: { value: 0.0 }      # 前景节点的 X 偏移
        offsetY: { value: 0.0 }      # 前景节点的 Y 偏移


      /** 顶点着色器 **/
      // 计算背景色的 UV 坐标
      float bgX = (uv.x * 192.0 + offsetX) / 1280.0;  // 192 是前景节点的宽和高
      float bgY = (uv.y * 192.0 + offsetY) / 720.0;
      bgUv = vec2(bgX, bgY);

      bgUv = CC_HANDLE_RT_SAMPLE_FLIP(bgUv);
      
      ...
      
      /** 片元着色器 **/
      ...

      in vec2 uv;
      in vec2 bgUv;

      uniform sampler2D bgTexture;

      vec4 frag() {
          // 前景色(暂时没用到)
          vec4 color = texture(cc_spriteTexture, uv);
          // 背景色(目标色)
          vec4 baseColor = texture(bgTexture, bgUv);

          return baseColor; 
      }

💡 留意需要在顶点着色器的 CC_HANDLE_RT_SAMPLE_FLIP 函数执行前去计算,否则 UV 可能因为被翻转了而导致计算不准确。

其中 192 是前景节点的宽和高(这里为了方便直接写死,如果你的节点尺寸是变化的,可以通过参数传入),offsetXoffsetY 是前景色节点的偏移:

我们可以额外创建一个组件脚本,通过世界坐标获取这两个偏移值,然后赋值到材质中的 offsetXoffsetY

js 复制代码
// BlendComp.ts
import { _decorator, Component, Sprite } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('BlendComp')
export class BlendComp extends Component {
    start() {
        const halfSize = 192 / 2;
        const material = this.node.getComponent(Sprite).material;
        const box = this.node.worldPosition;
        material.setProperty('offsetX', box.x - halfSize);
        material.setProperty('offsetY', 720 - box.y - halfSize);
    }
}

此时前景节点所截取的 RenderTexture 纹理,已和背景节点严丝合缝融合在一起了:

三、实现其它图层混合样式

3.1 darken 和 lighten 的实现

darkenlighten 分别对应 Photoshop 里的「变暗」和「变亮」图层混合效果。

「变亮」表示只保留明亮的部分,「变暗」反之,因此它们的着色器函数非常简单:

js 复制代码
  vec3 blendDarken(vec3 base, vec3 blend) {
    return min(base, blend);
  }

  vec3 blendLighten(vec3 base, vec3 blend) {
    return max(base, blend);
  }

其中 base 表示背景色,blend 表示前景色,我们在片元着色器中使用这两个函数:

js 复制代码
  vec4 frag() {
    // 前景色
    vec4 blendColor = texture(cc_spriteTexture, uv);
    // 背景色(目标色)
    vec4 baseColor = texture(bgTexture, bgUv);

    // 混合后的颜色
    vec3 color = vec3(1.0);

      // 通过自定义宏选择要使用的图层混合样式.
    #if USE_DARKEN
      color = blendDarken(baseColor.rgb, blendColor.rgb);
    #elif USE_LIGHTEN
      color = blendLighten(baseColor.rgb, blendColor.rgb);
    #endif

    return vec4(color, blendColor.a);
  }

对应效果如下:

3.2 其它图层混合效果

根据其它混合样式所需计算的固定范式,我们进一步完善着色器:

js 复制代码
/** 其它图层混合样式函数 **/

  const float EPSILON = 0.00001;  // EPSILON 是为了防止 `1.0 - blend` 为 `0.0`,导致紧接着的除零错误(`NaN` 或 `Infinity`)。

  // 获取亮度值
  float luminance(vec3 c) {
    return dot(c, vec3(0.2126, 0.7152, 0.0722));
  }

  // 设置指定颜色的亮度值
  vec3 setLum(vec3 c, float l) {
    float d = l - luminance(c);
    return c + vec3(d);
  }

  // 饱和度调整
  vec3 setSat(vec3 c, float s) {
    float l = luminance(c);
    vec3 grey = vec3(l);
    return mix(grey, c, s);
  }

  // 计算颜色的近似饱和度(saturation)
  float sat(vec3 c) {
    return max(max(c.r, c.g), c.b) - min(min(c.r, c.g), c.b);
  }

  vec3 blendOverlay(vec3 base, vec3 blend) {
    return mix(2.0 * base * blend, 1.0 - 2.0 * (1.0 - base) * (1.0 - blend), step(0.5, base));
  }

  vec3 blendColorDodge(vec3 base, vec3 blend) {
    return base / max(vec3(EPSILON), 1.0 - blend);
  }

  vec3 blendColorBurn(vec3 base, vec3 blend) {
    return 1.0 - (1.0 - base) / max(vec3(EPSILON), blend);
  }

  vec3 blendHardLight(vec3 base, vec3 blend) {
    return blendOverlay(blend, base);
  }

  vec3 blendSoftLight(vec3 base, vec3 blend) {
    return mix(
      sqrt(base) * blend * 2.0,
      1.0 - 2.0 * (1.0 - base) * (1.0 - blend),
      blend
    );
  }

  vec3 blendDifference(vec3 base, vec3 blend) {
    return abs(base - blend);
  }

  vec3 blendExclusion(vec3 base, vec3 blend) {
    return base + blend - 2.0 * base * blend;
  }

  vec3 blendHue(vec3 base, vec3 blend) {
    float s = sat(base);
    float l = luminance(base);
    return setLum(setSat(blend, s), l);
  }

  vec3 blendSaturation(vec3 base, vec3 blend) {
    float s = sat(blend);
    float l = luminance(base);
    return setLum(setSat(base, s), l);
  }

  vec3 blendColor(vec3 base, vec3 blend) {
    return setLum(blend, luminance(base));
  }

  vec3 blendLuminosity(vec3 base, vec3 blend) {
    return setLum(base, luminance(blend));
  }

在片元着色器入口函数中使用:

js 复制代码
  vec4 frag() {
    // 前景色
    vec4 blendColor = texture(cc_spriteTexture, uv);
    // 背景色(目标色)
    vec4 baseColor = texture(bgTexture, bgUv);

    // 混合后的颜色
    vec3 color = vec3(1.0);

      // 通过自定义宏选择要使用的图层混合样式.
    #if USE_DARKEN
        color = blendDarken(baseColor.rgb, blendColor.rgb);
    #elif USE_LIGHTEN
        color = blendLighten(baseColor.rgb, blendColor.rgb);
    #elif USE_OVERLAY
        color = blendOverlay(baseColor.rgb, blendColor.rgb);
    #elif USE_COLOR_DODGE
        color = blendColorDodge(baseColor.rgb, blendColor.rgb);
    #elif USE_COLOR_BURN
        color = blendColorBurn(baseColor.rgb, blendColor.rgb);
    #elif USE_HARD_LIGHT
        color = blendHardLight(baseColor.rgb, blendColor.rgb);
    #elif USE_SOFT_LIGHT
        color = blendSoftLight(baseColor.rgb, blendColor.rgb);
    #elif USE_DIFFERENCE
        color = blendDifference(baseColor.rgb, blendColor.rgb);
    #elif USE_EXCLUSION
        color = blendExclusion(baseColor.rgb, blendColor.rgb);
    #elif USE_HUE
        color = blendHue(baseColor.rgb, blendColor.rgb);
    #elif USE_SATURATION
        color = blendSaturation(baseColor.rgb, blendColor.rgb);
    #elif USE_COLOR
        color = blendColor(baseColor.rgb, blendColor.rgb);
    #elif USE_LUMINOSITY
        color = blendLuminosity(baseColor.rgb, blendColor.rgb);
    #endif

    return vec4(color, blendColor.a);
  }

此时我们可以在材质的属性检查器面板,选择前景节点需要叠加的混合效果:

例如这里选择了 hue 图层混合样式,执行效果如下:

相关推荐
成长ing121382 天前
cocos creator 3.x shader 流光
前端·cocos creator
VaJoy3 天前
Cocos Creator Shader 入门 ⑾ —— 光照跟随
cocos creator
成长ing121384 天前
闪白效果
前端·cocos creator
冷水金枪鱼5 天前
Light2D光照系统(基于CocosCreater引擎3.x/2.x)
cocos creator
VaJoy7 天前
Cocos Creator Shader 入门 ⑽ —— 拖尾效果的实现
cocos creator
VaJoy17 天前
Cocos Creator Shader 入门 ⑼ —— 溶解动画
cocos creator
VaJoy1 个月前
Cocos Creator Shader 入门 ⑹ —— 灰阶、反色等滤镜的实现
cocos creator
VaJoy2 个月前
Cocos Creator Shader 入门 ⑸ —— 代码复用与绿幕抠图技术
cocos creator
VaJoy2 个月前
Cocos Creator Shader 入门 ⑷ —— 纹理采样与受击闪白的实现
cocos creator