💡 本系列文章收录于个人专栏 ShaderMyHead,欢迎订阅。
溶解在游戏领域属于经常使用的动画特效,例如在角色死亡时,角色的形象逐步被侵蚀瓦解:

然而溶解虽然效果酷炫,但它其实是着色器中比较简单的实现,很多入门课程都会拿它来作为第一个演示案例。
本文将介绍溶解动画的原理和实现方式。
一、溶解原理
噪声图是溶解动画必备的素材:

此类图片布满了随机、不规则的明暗纹理,可通过原材质 UV 映射来采样噪声图上对应位置的像素,通过判断该像素的明度是否小于指定阈值,来决定是否抛弃原材质像素:

通过动态修改阈值(例如从原本的 0.0
逐步加大到 1.0
),原材质就会有越来越多的像素被抛弃,最终使得所有像素从画面中消失。
二、着色器实现
2.1 CCEffect
根据上文,我们需要自定义 noiseTexture
和 dissolveThreshold
两个着色器参数:
yaml
CCEffect %{
techniques:
- name: dissolve
passes:
- vert: vs:vert
frag: fs:frag
blendState:
targets:
- blend: true
blendSrc: src_alpha
blendDst: one_minus_src_alpha
depthStencilState:
depthTest: false
depthWrite: false
properties:
noiseTexture: { value: grey, editor: { tooltip: '噪声贴图' } }
dissolveThreshold: { value: 0.0, editor: { range:[0.0, 1.0, 0.01], slide: true, tooltip: '溶解阈值' } }
}%
noiseTexture
为噪声图材质(需要在材质属性检查器中将噪声图绑定到该参数),dissolveThreshold
为溶解阈值,后续在噪声图上采样的像素明度,会与该阈值进行对比。
💡 噪声图上采样的像素明度,会简单的使用该像素 RGB 某通道的分量值来表示,故其取值范围为
[0.0, 1.0]
,进而dissolveThreshold
的取值范围也同样是[0.0, 1.0]
。
2.2 片元着色器
根据溶解原理,我们进一步书写片元着色器:
js
CCProgram fs %{
precision highp float;
#include <sprite-texture>
in vec2 uv;
uniform Dissolve{
float dissolveThreshold;
};
uniform sampler2D noiseTexture;
vec4 frag () {
vec4 noiseTextureColor = texture(noiseTexture, uv); // 对噪声材质采样
float noiseRedValue = noiseTextureColor.r; // 采样到的噪声像素 R 通道的分量值(任意选一个通道即可),用于表示明度
if (noiseRedValue < dissolveThreshold) {
discard; // 将小于阈值的片段丢弃,形成溶解
}
vec4 color = texture(cc_spriteTexture, uv); // 原纹理采样;
return color;
}
}%
此时随着 dissolveThreshold
的增大,原材质会丢弃越来越多的像素:

可以看到当 dissolveThreshold
为 0.0
时原材质所有像素都被保留,当 dissolveThreshold
为 1.0
时,原材质的所有像素都被丢弃。
为了让溶解效果更加美观,可以给溶解区域加一圈橘色的描边:
js
vec4 color = texture(cc_spriteTexture, uv); // 原纹理采样;
if (noiseRedValue < dissolveThreshold + 0.05) {
color = vec4(0.9, 0.6, 0.3, color.a); // 溶解的边缘设置一个橘色的边缘过渡色
}
return color;

2.3 组件脚本动态修改阈值
要让溶解动画自己动起来,需要通过脚本来修改材质 dissolveThreshold
参数的值:
js
@ccclass('DissolveComp')
export class DissolveComp extends Component {
private dissolveThreshold = 0;
private spriteComp: Sprite;
start() {
this.spriteComp = this.getComponent(Sprite);
}
update(deltaTime: number) {
if (this.dissolveThreshold === 1) return;
this.dissolveThreshold += (deltaTime / 5); // 逐步加大阈值
if (this.dissolveThreshold > 1) {
this.dissolveThreshold = 1;
}
this.spriteComp.material.setProperty('dissolveThreshold', this.dissolveThreshold);
}
}
将该组件脚本绑定到 Sprite 节点上,游戏运行时就会直接播放溶解动画。
三、兼容 Spine 动画
上述实现的代码还无法兼容 Spine 动画,需要做进一步改造。
我们在《Cocos Creator Shader 入门 ⑷ ------ 纹理采样与受击闪白的实现》中已做过类似的兼容操作。
3.1 Effect 文件修改
首先要在 CCEffect 中新增 rasterizerState
配置来关闭背景剔除:
yaml
CCEffect %{
techniques:
# 略...
depthStencilState:
depthTest: false
depthWrite: false
rasterizerState:
cullMode: none #兼容 Spine
properties:
# 略...
}%
接着修改片元着色器,加入顶点颜色混合的操作:
js
CCProgram fs %{
precision highp float;
#include <sprite-texture>
in vec2 uv;
#if USE_LOCAL
in vec4 v_color; // 兼容 Spine
#endif
uniform Dissolve{
float dissolveThreshold;
};
uniform sampler2D noiseTexture;
vec4 frag () {
vec4 noiseTextureColor = texture(noiseTexture, uv);
float noiseRedValue = noiseTextureColor.r;
if (noiseRedValue < dissolveThreshold) {
discard;
}
vec4 color = texture(cc_spriteTexture, uv);
#if USE_LOCAL
color *= v_color; // 兼容 Spine,混合顶点颜色
#endif
if (noiseRedValue < dissolveThreshold + 0.05) {
color = vec4(0.9, 0.6, 0.3, color.a);
}
return color;
}
}%
将着色器材质应用到 Spine 组件上,手动修改 dissolveThreshold
便可看到 Spine 已能被溶解:

然而这里存在一个问题 --------- 溶解区域的橘色边缘,在原本 Spine 透明的区域也上色了,这导致 Spine 的溶解像是在多个矩形区域里执行的,显得很不自然:

这是因为 Spine 插槽(Slot)或 Attachment 可能会设置一些图层混合效果,从而导致原本透明区域的 RGB 色值在后期被强行混合进来了。
解决该问题的方案很简单 ------ 在设置边缘过渡色时判断原材质的 Alpha 是否大于 0.0
即可:
js
if (noiseRedValue < dissolveThreshold + 0.05 && color.a > 0.0) { // 新增 color.a > 0.0 判断
color = vec4(0.9, 0.6, 0.3, color.a);
}
此时 Spine 可按预期一样只在实心像素区域进行溶解:

3.2 组件脚本修改
原先的组件脚本仅支持 Sprite 组件,我们再加上对 Spine 组件的处理即可:
js
@ccclass('DissolveComp')
export class DissolveComp extends Component {
private dissolveThreshold = 0;
private spriteComp: Sprite;
private spineComp: sp.Skeleton;
start() {
this.spriteComp = this.getComponent(Sprite);
this.spineComp = this.getComponent(sp.Skeleton);
}
update(deltaTime: number) {
if (this.dissolveThreshold === 1) return;
this.dissolveThreshold += (deltaTime / 5);
if (this.dissolveThreshold > 1) {
this.dissolveThreshold = 1;
}
if (this.spriteComp?.material) { // Sprite 组件处理
this.spriteComp.material.setProperty('dissolveThreshold', this.dissolveThreshold);
} else { // Spine 组件处理
// 更新 Spine 所有插槽的材质实例
const spineMatCaches = this.spineComp['_materialCache'];
for (let k in spineMatCaches) {
spineMatCaches[k].setProperty('dissolveThreshold', this.dissolveThreshold);
}
}
}
}
执行后即可实现文章开头的动画效果:

💡 着色器会在 Spine 的每个部件上独立执行,因此会看到 Spine 的溶解是在每个独立的部件上进行的。如果你希望将整个 Spine 当做一个独立的材质来进行溶解,可以参考上一章的 Render Texture 方案,本章不再赘述。