💡 本系列文章收录于个人专栏 ShaderMyHead:juejin.cn/column/7505...
一、纹理采样
在之前的文章中,我们在片元着色器输出了一个固定的 RGBA 色值,它与节点纹理无关,如果希望片元着色器中返回的色值与当前节点的纹理相关,则需要通过纹理采样获取像素颜色。
纹理采样的流程大致如下:

简单来说,就是需要在片元着色器中把纹理 UV 坐标传给采样函数,让它采样当前 UV 对应的纹理色值(RGBA)。
💡 UV 坐标的概念请查阅《附录 ------ 四、UV坐标》。
我们需要使用到两个 Cocos Creator 的两个着色器内置变量,一个是在顶点着色器里使用的纹理坐标变量 a_texCoord
,另一个是在片元着色器里使用的 uniform
纹理采样器 cc_spriteTexture
:
c
CCProgram vs %{
precision highp float;
#include <cc-global>
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; // 赋值
return pos;
}
}%
CCProgram fs %{
precision highp float;
#include <sprite-texture> // 引入内置纹理采样器变量 cc_spriteTexture
in vec2 uv;
vec4 frag() {
vec4 color = texture(cc_spriteTexture, uv); // 使用内置的 GLSL 方法 texture 采样纹理
return color;
}
}%
留意 cc_spriteTexture
属于 Uniform 全局变量,需要先引入定义了该变量的 chunk:
c
#include <sprite-texture>
接着把从顶点着色器透传过来的纹理坐标变量 uv
,以及纹理采样器变量 cc_spriteTexture
,一起交给 GLSL 内置的纹理采样方法 texture
,来获取当前像素 UV 坐标对应的纹理色值:
js
vec4 color = texture(cc_spriteTexture, uv);
💡 了解
texture
纹理采样方法请查阅《附录 ------ 二、GLSL 内置方法》。
在片元着色器里直接输出采样结果,可以得到和原纹理一模一样的图案(即节点看不出有什么变化):

二、使用 mix 函数给纹理上色
既然已经可以获得纹理的色值,那么通过修改该色值就可以来给图案加上各种滤镜效果。
我们先使用 GLSL 内置的 mix
方法,来给纹理染上 30% 的绿色:
c
vec4 frag() {
vec3 green = vec3(0.0, 1.0, 0.0);
vec4 color = texture(cc_spriteTexture, uv);
color.rgb = mix(color.rgb, green, 0.3); // 使用内置的 GLSL 方法 mix 混合颜色
return color;
}
💡
mix
函数常用于混合两个标量、向量或颜色。它的作用是基于第三个参数a
,在两个值之间进行线性插值。
语法
mix(x, y, alpha)
参数
名称 类型 描述 x 标量、向量或颜色 起始值 y 标量、向量或颜色 结束值 a float
插值因子,在 [0.0, 1.0]
之间返回值
返回
x * (1 - a) + y * a
。
混合后的结果如下:

三、PNG 开启混合
当我们将纹理换成底色透明的 PNG 图片时,会发现透明的部分在颜色混合后都变成了黑色:

聪明的你肯定想起了上篇文章提到的方案 ------ 需要显式开启 Blending 混合模式来启用透明效果:
yaml
- vert: vs:vert
frag: fs:frag
blendState:
targets:
- blend: true # 开启 Blending 混合模式,避免透明度为0的像素写入帧缓冲
blendSrc: src_alpha
blendDst: one_minus_src_alpha
depthStencilState: # 如果是纯 2D 游戏,顺手关闭深度测试和写入
depthTest: false
depthWrite: false

四、受击闪白的实现
在战斗类的游戏中,经常会有角色(Sprite 或 Spine)在受击时闪白的效果,来强化战斗过程的视觉体验。
参照前文的例子,我们可以给角色材质染上白色,并在单位时间内控制染色的混合百分比,即可实现角色材质闪白渐变的效果。
4.1 自定义变量
通过往 CCEffect
里添加 properties
配置可以添加自定义的 uniform
全局变量,用于外部脚本和着色器的数据传递:
yaml
CCEffect %{
techniques:
- name: flash
passes:
- vert: vs:vert
frag: fs:frag
blendState:
略...
properties: # 配置自定义变量
# 要覆盖的颜色(白色)
coverColor: { value: [1.0, 1.0, 1.0, 0.0], editor: { type: color } }
# 覆盖的占比
mixPercent: { value: 0.0 }
}%
上方代码段在第 11 和第 13 行增加了 coverColor
和 mixPercent
两个 uniform
全局变量,分别代表角色闪白颜色,以及闪白颜色覆盖在角色身上的百分比。
为了具备扩展性,properties
里各自定义变量的配置值会是一个对象,对象中用 value
声明其默认值,用 editor
声明其在 Cocos Creator 属性检查器界面的显示配置:
变量配置对象 key 名 | 描述 |
---|---|
value | 变量的默认值: 若数据为 vec 类型则使用数组表示; 若数据为材质类型则用 grey 表示。 |
[editor] | 在 Cocos Creator 属性检查器界面的显示配置,常用配置参考: { visible: false } 不要在属性检查器界面显示该变量; { type: color } 在属性检查器界面显示为可选颜色控件; { type: vector } 在属性检查器界面显示为向量输入框控件; { range: [min, max, [step]] } 属性检查器界可填写的数值范围; 其它配置项可查阅官方文档。 |
留意在着色器中使用自定义的全局变量时,需要使用 uniform BlockName { ... }
的形式来引入和访问:
c
CCProgram fs %{
precision highp float;
#include <sprite-texture>
in vec2 uv;
// 同步 properties 的变量声明
uniform Args { // Args 是随便起的 UBO 块级名字,它们最终会被打包到名为 "Args" 的 UBO 中
vec4 coverColor;
float mixPercent;
};
vec4 frag() {
vec4 color = texture(cc_spriteTexture, uv);
color.rgb = mix(color.rgb, coverColor.rgb, mixPercent); // 应用自定义变量
return color;
}
}%
💡 Cocos Creator 底层使用 Uniform Buffer Objects (UBO) 来高效传递
uniform
数据,所有在properties
中定义的变量会被自动打包到同一个 UBO 中,这种方式可减少 CPU-GPU 的通信次数。这也是为何需要在着色器中使用 UBO 形式(而非uniform vec4 coverColor
)来声明和引入自定义变量的原因。
此时查看应用了该 Effect 的材质的属性检查器,也可以看到这两个自定义的全局变量:

甚至可以直接手动修改两个变量的值,来实时查看着色器效果:

💡 看完效果记得改回去,不然会同步更改了对应变量的默认值。
4.2 通过脚本修改闪白颜色覆盖占比
我们新增一个按钮并编写一个组件脚本,让按钮被点击时,角色播放闪白动画(通过动态修改 mixPercent
变量值)。
脚本组件如下:
js
import { _decorator, Component, Material, find, Sprite, Node } from 'cc';
const { ccclass, property } = _decorator;
enum FlashAnimateSate {
none = 0,
fadeIn = 1,
fadeOut = 2,
}
@ccclass('BtnCtrl')
export class BtnCtrl extends Component {
private material: Material = null;
private flashAnimateState: FlashAnimateSate = FlashAnimateSate.none;
private curFlashPercent: number = 0;
start() {
// 获取材质
this.material = find('Canvas/SpriteNode').getComponent(Sprite).sharedMaterial;
}
protected update(dt: number): void {
if (this.flashAnimateState === FlashAnimateSate.fadeIn) { // 淡入
if (this.curFlashPercent >= 1) {
this.flashAnimateState = FlashAnimateSate.fadeOut;
this.curFlashPercent = 1;
} else {
this.curFlashPercent += dt * 5;
this.changeFlashPercent(this.curFlashPercent);
}
} else if (this.flashAnimateState === FlashAnimateSate.fadeOut) { // 淡出
if (this.curFlashPercent <= 0) {
this.flashAnimateState = FlashAnimateSate.none;
this.curFlashPercent = 0;
} else {
this.curFlashPercent -= dt * 5;
this.changeFlashPercent(this.curFlashPercent);
}
}
}
protected onLoad(): void {
this.node.on(Node.EventType.TOUCH_START, this.startAttack, this);
}
private startAttack(): void {
this.changeFlashPercent(0.0); // 先重置
this.flashAnimateState = FlashAnimateSate.fadeIn;
}
// 改变 mixPercent 的值
private changeFlashPercent(percent: number) {
this.material.setProperty('mixPercent', percent);
}
}
执行效果:

💡 在《Cocos Creator Shader 入门 (2)》的「3.4 uniform 存储限定符」中,我们提到了
uniform
变量属于"着色器程序执行期间保持不变的全局变量",即它在着色器环节必须是只读的,但我们依旧可以在着色器外部通过脚本的形式动态地修改这些变量值(这些修改发生在 CPU 层面)。
4.3 在 Spine 动画中应用
4.3.1 空间坐标转换
由骨骼、插槽、附着物、网格等拼接而成的 Spine 动画,比起单材质的 SpriteFrame 要复杂的多,它的材质处理也会更麻烦些。
如果仅将前文的 Sprite 材质赋值给 Spine 动画组件,很可能你的 Spine 动画会直接消失不见:

首先它遇到了坐标空间转换的问题。
在《附录》的「7.2 世界空间」中,提到了 "Sprite、Label、UI 等简单的 2D 节点,Cocos Creator 已经帮我们把 a_position
从模型空间坐标转换为世界空间坐标",但 Spine 动画的顶点数据通常使用的是本地空间坐标,需要应用世界变换(否则会导致缩放为 0 或位置错误):
c
CCProgram vs %{
precision highp float;
#include <cc-global>
#if USE_LOCAL // 如果是本地坐标(即模型空间坐标),引入 cc_matWorld 变量
#include <builtin/uniforms/cc-local>
#endif
in vec3 a_position;
in vec2 a_texCoord;
out vec4 v_color;
vec4 vert() {
vec4 pos = vec4(a_position, 1);
#if USE_LOCAL
pos = cc_matWorld * pos; // 如果是本地坐标,通过乘以 cc_matWorld 矩阵转换为世界空间坐标
#endif
pos = cc_matViewProj * pos;
uv = a_texCoord;
return pos;
}
}%
其中 USE_LOCAL
是 Cocos Creator 内置的智能宏,搭配 #if
预编译条件语句会自动判断当前顶点是否走的本地/模型空间。
💡 更多内置的智能宏请查阅《附录 ------ 八、Cocos Creator 智能宏》。
接着我们需要在 CCEffect
的 passes
中关闭背面剔除,因为 Cocos Creator 基于性能考量会默认剔除背面,而 Spine 动画中难免出现很多界面翻转的处理。
关闭背面剔除的配置如下:
yaml
passes:
- vert: vs:vert
frag: fs:frag
略...
rasterizerState: #光栅化状态配置
cullMode: none #关闭背面剔除
经过上方的操作后,Spine 节点已经可以近乎正常的渲染出来了:

4.3.2 应用顶点颜色
但你可能会发现有些地方的颜色渲染异常,例如上图这个角色的眼睛变成白色的了(原本应是黄色的)。
这是因为在 Spine 中,并非所有的渲染都基于材质(图片),也有可能是基于顶点染色的形式,例如上图角色的眼睛,是走插槽染色的:

对此我们需要在顶点着色器额外引入 Cocos Creator 内置的顶点颜色变量 a_color
,通过它获取顶点颜色并透传到片元着色器中去做进一步处理:
c
CCProgram vs %{
略...
in vec2 a_texCoord;
in vec4 a_color; // 引入 Cocos 内置的顶点颜色变量
out vec2 uv;
out vec4 v_color; // 输出给片元着色器的中介变量
vec4 vert() {
略...
uv = a_texCoord;
v_color = a_color; // 透传顶点颜色(给片元着色器)
return pos;
}
}%
CCProgram fs %{
precision highp float;
#include <sprite-texture>
in vec2 uv;
in vec4 v_color; // 输入上一阶段的顶点颜色
uniform Args {
vec4 coverColor;
float mixPercent;
};
vec4 frag() {
vec4 color = texture(cc_spriteTexture, uv);
// 应用顶点颜色(插槽染色)
color *= v_color;
color.rgb = mix(color.rgb, coverColor.rgb, mixPercent);
return color;
}
}%
其中 color *= v_color
是应用了 Spine 的顶点颜色:
- 走材质渲染时,顶点颜色 RGB 为
(1.0, 1.0, 1.0)
,与纹理采样后的色值相乘的话,不会影响纹理采样色值; - 走顶点渲染时,纹理采样得到的 RGB 为
(1.0, 1.0, 1.0)
,与顶点颜色相乘后得到结果等同于顶点颜色。
此时 Spine 节点已能被正常渲染。
4.3.3 预乘 Alpha 处理
如果我们开启了 Spine 的 Premultiplied Alpha(预乘Alpha)特性,在使用材质给它染色时(即增大 mixPercent
的值),会发现很多原本透明的部分也被染上了颜色:

这个问题的根源在于预乘 Alpha 的渲染机制 与常规混合方式的差异 ------ 在预乘Alpha纹理中,RGB 值已经预先乘以了 Alpha 值。
例如完全透明的白色 (1, 1, 1, 0)
在预乘后会被存储为 (0, 0, 0, 0)
,而通过 mix(color.rgb, coverColor.rgb, mixPercent)
后会变成 (0, 0, 0, mixPercent)
,进而导致原本 Alpha 通道透明的部分变成不透明的白色了。
对此我们需要动态地来修改 Alpha 通道:
c
vec4 frag() {
vec4 color = texture(cc_spriteTexture, uv);
// 1. 先应用顶点颜色(插槽染色)
color *= v_color;
// 2. 计算基于最终透明度的混合因子
float finalAlpha = color.a;
float blendFactor = mixPercent * finalAlpha;
// 3. 应用混合效果
color.rgb = mix(color.rgb, coverColor.rgb, blendFactor);
return color;
}
此时无论 Spine 是否开启预乘 Alpha,都能被正常染色了。我们可以进一步修改 4.2 小节的脚本,让 Spine 动画也支持上受击闪白特效.
留意 Spine 的材质修改也相对麻烦,因为 Spine 动画为每个插槽创建了材质实例并缓存,需更新所有实例:
js
import { _decorator, Component, Material, find, Sprite, Node, sp } from 'cc';
@ccclass('BtnCtrl')
export class BtnCtrl extends Component {
private spriteMaterial: Material = null;
private skeletonComp: sp.Skeleton = null; // 新增 Spine 组件引用变量
private flashAnimateState: FlashAnimateSate = FlashAnimateSate.none;
private curFlashPercent: number = 0;
start() {
this.spriteMaterial = find('Canvas/SpriteNode').getComponent(Sprite).sharedMaterial;
this.skeletonComp = find('Canvas/SpineNode').getComponent(sp.Skeleton); // 初始化
}
// 略...
// 改变 mixPercent 的值
private changeFlashPercent(percent: number) {
this.spriteMaterial.setProperty('mixPercent', percent);
// 更新 Spine 所有插槽的材质实例
const spineMatCaches = this.skeletonComp['_materialCache'];
for (let k in spineMatCaches) {
spineMatCaches[k].setProperty('mixPercent', percent);
}
}
}
💡 Spine 是由多个插槽构成的动画,每个插槽可能有不同的渲染状态(混合模式、透明度等),因此引擎需要为每个插槽创建独立的材质实例。通过缓存系统来管理这些材质实例,可以对材质进行复用、避免每帧创建/销毁材质对象,进而有效减少 GPU 的资源分配开销。
最终执行效果如下(Spine 动画也能成功被染色来实现受击闪白):
