💡 本系列文章收录于个人专栏 ShaderMyHead,欢迎订阅。
在 Photoshop 中,我们可以对顶层的图层选用指定的「图层混合模式」,来让它与底层图像实现不同的叠加效果:

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

此类图层叠加样式在平面设计、页面或游戏设计领域都是常会用到的视觉效果,但在 Cocos Creator 中并没有内置这类功能,需要开发自行创建对应的着色器,本文将介绍这块的实现。
一、multiply 和 screen 的实现
multiply(正片叠底) 和 screen(滤色) 的实现相对简单,我们指定 CCEffect
中的 blendSrc
和 blendDst
的配置,交由 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
。
multiply
和 screen
的应用效果如下:

二、Render Texture 的应用
blendSrc
和 blendDst
是 GPU 硬件混合阶段的参数,虽然它们的实现非常高效,但只支持非常有限的线性(一次函数)混合计算:
css
outColor = A * srcColor + B * dstColor
multiply
和 screen
之外的图层叠加式(例如 overlay
、soft-light
、color-dodge
、hue
等)属于复杂的非线性混合逻辑,则无法简单的使用 blendSrc
和 blendDst
来实现了。
这意味我们需要自行想办法,在 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_X
和 BG_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
是前景节点的宽和高(这里为了方便直接写死,如果你的节点尺寸是变化的,可以通过参数传入),offsetX
和 offsetY
是前景色节点的偏移:

我们可以额外创建一个组件脚本,通过世界坐标获取这两个偏移值,然后赋值到材质中的 offsetX
和 offsetY
:
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 的实现
darken
和 lighten
分别对应 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
图层混合样式,执行效果如下:
