💡 本系列文章收录于个人专栏 ShaderMyHead。
💡 本文案例可以在 Github 上进行演示。
在游戏中,从场景 A 切换到场景 B 往往伴随着资源加载、接口请求等过程,这可能导致短暂的等待。为了避免用户感到枯燥或不适,加入一段生动有趣的转场动画,不仅可以缓解等待带来的不适感,还能弱化转场的生硬感,进而有效提升了整体的交互体验:
上图的转场动画包含了一个往左下角移动底图的纹理节点,以及一个呈缩放运动的猫咪镂空图案:
如果采用序列帧的形式来实现转场,会是非常低效和不可控的。本文将介绍如何通过着色器,来高效地实现纹理平铺和镂空动画的效果。
一、纹理平铺和偏移动画
1.1 纹理平铺
假设我们有一张支持平铺的纹理图(bg-tile.jpg):
只要在该图片文件的属性检查器中将其类型设定为 texture:
便可以在着色器中将其作为纹理来采样:
CCEffect %{
techniques:
- name: trans
passes:
- vert: vs:vert
frag: fs:frag
blendState:
# 略...
properties:
bgRT: { value: white } # 平铺纹理,在材质的属性检查器面板里,把 bg-tile.jpg 绑定到该参数
}%
CCProgram vs %{
#include "../../resources/chunk/render-texture-vert.chunk"
}%
CCProgram fs %{
precision highp float;
in vec2 uv;
uniform sampler2D bgRT;
vec4 frag () {
vec4 bgColor = texture(bgRT, uv); // 对平铺纹理进行采样
// TODO - 将纹理平铺到当前节点
}
}%
然而第 25 行的 uv 是基于被平铺的节点的 UV 坐标系,会导致纹理采样的结果异常,因此需要将其映射到平铺纹理的 UV 坐标系上。
假设我们希望将平铺纹理平铺到宽高为 1280x720 的节点上后,单个瓦片的宽度占据节点宽度的一半,则该瓦片映射到被平铺节点 UV 坐标系中的范畴为 vec2(0.5, 0.64):
其中 0.64 是通过 1280 / 720 * 0.5 计算得到的。
在获悉平铺纹理的 UV 映射范畴之后,即可算出当前像素点,在节点 UV 坐标系上的坐标,以及在平铺纹理 UV 坐标系上的坐标:
vec4 frag () {
float ratio = 1280.0 / 720.0;
float tileU = 0.5;
vec2 tileSize = vec2(tileU, tileU * ratio); // 平铺纹理的 UV 映射范畴
vec2 modUV = mod(uv, tileSize); // 取余获得当前像素在被平铺的节点 UV 坐标系上的坐标
vec2 bgUV = modUV / tileSize; // 获得当前像素在平铺纹理 UV 坐标系上的坐标
vec4 bgColor = texture(bgRT, bgUV);
}
💡
mod是 GLSL 内置的取余方法,详情可查阅《附录 —— 2.2 数学公式相关》。GLSL 在 3.0 版本才支持%计算符号,兼容性较差,故推荐使用mod方法。
为了更方便理解这段计算,我们假设有个像素位于节点 UV 坐标系 U 轴上 0.8U 的位置,通过取余可以知道它在单个瓦片上的距离为 0.3U(留意该值也是基于节点的 UV 坐标系):
接着除以纹理的渲染宽度后,可以得到在纹理 UV 坐标体系上的 UV 坐标:
改为使用 bgUV 坐标进行采样后,节点就能按预期被平铺上传入的纹理。执行效果:
1.2 纹理偏移动画
在取余的步骤对 UV 值加上偏移量,可以实现纹理的偏移:
uniform UBO {
float bgOffset; // 定义一个从外部传入的纹理偏移量
};
vec2 modUV = mod(uv + bgOffset, tileSize); // 加上 bgOffset 偏移量
在外部组件脚本的 update 生命周期中,通过动态累加 bgOffset 的值并传入给着色器使用,即可实现纹理的偏移动画:
@ccclass('TransComp')
export class TransComp extends Component {
bgOffset = 0;
animeBGMaterial: Material = null;
update(deltaTime: number) {
this.bgOffset += (deltaTime / 10); // 动态累加 bgOffset(除以 10 让速度慢一些)
this.animeBGMaterial.setProperty('bgOffset', this.bgOffset);
}
}
执行效果:
此时你可能会发现,在拼接的纹理瓦片之间会出现一条很细的白边,非常影响观感:
这是一个在 GPU 图形渲染中非常常见的纹理采样边缘泄露(Texture Bleeding) 问题。
在取余的步骤执行之后,生成的 bgUV 可能非常接近 tiledSize 的边缘,比如 0.199999,这会导致 GPU 采样时跨到纹理边缘外,从而出现采样到边界颜色(通常是白色、透明或未定义值)。
在 Cocos Creator 中,纹理的采样默认使用的 线性过滤(linear filtering) ,会混合邻近像素的颜色。
所以如果一个采样点刚好在贴图边缘,GPU 会尝试向“外侧”插值——结果就是“边缘溢色”,你会看到一个微小的白边或者半透明边。
解决方案很简单,把纹理文件 bg-tile.jpg 的过滤模式更换为 Neareast 即可:
💡 关于纹理过滤的更多细节,可以查阅《附录 —— 九、纹理过滤》。
二、镂空动画
在前面的几篇文章,我们使用 Render Texture 实现了许多方案,而镂空动画的原理也相当简单 —— 通过 Render Texture 获得动画中的镂空图案纹理,再在着色器中使用该纹理来实现遮罩的功能:
其中 TransMask 是一个纯白色的单色节点,Cat 是一张播放缩放动画的猫头剪映图片,我们新建一个摄像头和 Render Texture 文件来捕获 TransMask + Cat 的渲染纹理,将其传入到前文的着色器中使用:
uniform sampler2D bgRT;
uniform sampler2D maskRT; // 猫头剪映动画纹理
uniform UBO {
float bgOffset;
};
vec4 frag () {
// 略...
vec4 bgColor = texture(bgRT, bgUV);
vec4 maskColor = texture(maskRT, uv); // 对猫头剪映纹理进行采样
// TODO - 实现遮罩功能
return bgColor;
}
由于镂空动画纹理是基于黑白两色的,我们直接将 maskColor.r 赋予对应像素的 Alpha 通道,即可实现“白色保留,黑色移除”的遮罩效果:
vec4 maskColor = texture(maskRT, uv);
bgColor.a = maskColor.r; // 利用镂空纹理的黑白两色,来实现遮罩功能
return bgColor;
执行效果:
上图一共播放了两个 Animation Clip —— mask-start 动画(猫头从大到小缩放)和 mask-end 动画(猫头从小到大缩放),每个动画都被设置为仅播放一遍(而不是循环播放)。
其中 mask-start 动画会在最后一帧时,把猫头的 Position.Y 设为 3000,确保其被移出屏幕可视区域外部:
三、场景切换
3.1 常驻节点
另外为了动画衔接顺畅、方便统一维护,本案例是将转场动画相关的节点挂在一个新增的 Canvas 画布节点上,并将其作为常驻节点使用:
这需要在场景 A 启动时将其设置为常驻节点:
director.addPersistRootNode(this.transCanvasNode); // 将转场画布节点设为常驻节点
💡 Cocos Creator 的常驻节点要求必须是隶属于场景的根节点,且每次切换场景时都会触发
onEnable、start生命周期。
3.2 并行事件
本示例是在场景 A(scene-a.scene)上放置了一个“点击转场”的按钮,用户点击按钮后,将开始加载场景 B(scene-b.scene)的资源,同时播放转场开始的动画 mask-start(猫头从大到小缩放)。
在场景 B 的资源加载完毕,且 mask-start 动画播放完毕时,切换到场景 B 并开始播放转场结束的动画 mask-end(猫头从小到大缩放)。
因此这里涉及到并行事件的逻辑处理,我们可以使用 Promise.all 来轻松解决并行事件的时序问题:
// 绑定在画布 TransCanvas 上
@ccclass('TransComp')
export class TransComp extends Component {
bgOffset = 0;
catAnime: Animation = null;
animeBGMaterial: Material = null;
onEnable() {
this.catAnime = this.node.getChildByPath('TransMask/Cat').getComponent(Animation);
this.animeBGMaterial = this.node.getChildByName('TransAnimeBG').getComponent(Sprite).material;
}
update(deltaTime: number) {
this.bgOffset += (deltaTime / 10);
this.animeBGMaterial.setProperty('bgOffset', this.bgOffset);
}
// 按钮点击时触发的转场事件
startTransition() {
// 使用 Promise.all 处理并行事件,在事件都结束时,才执行 playMaskEnd
Promise.all([this.changeScene(), this.playMaskStart()]).then(this.playMaskEnd.bind(this));
}
// 切换场景到 ./scene-b.scene
changeScene() {
return new Promise((resolve, reject) => {
// 166syZgAxNRZBZxjstZHFM 是 ./scene-b.scene 的 uuid
assetManager.loadAny('166syZgAxNRZBZxjstZHFM', (err, sceneAsset: SceneAsset) => {
if (!err && sceneAsset) {
resolve(sceneAsset);
} else {
reject(err);
}
});
});
}
playMaskStart() {
return new Promise((resolve) => {
this.catAnime.once(Animation.EventType.FINISHED, resolve, this);
this.catAnime.play('mask-start'); // 播放遮罩开始动画(猫头从大到小缩放)
});
}
playMaskEnd(data) {
director.runScene(data[0].scene); // 切换场景到 scene-b.scene
this.catAnime.once(Animation.EventType.FINISHED, () => {
this.node.active = false;
}, this);
this.catAnime.stop();
this.catAnime.play('mask-end'); // 播放遮罩结束动画(猫头从小到大缩放)
}
}
这里的技巧是将「播放 mask-start 动画」和「加载场景 B 资源」封装为两个 Promise 对象,再扔到 Promise.all 队列中去使用。
3.3 弱网调试
通过 Chrome 开发者工具 Network 选项卡下的 Network throtting,可以轻松模拟弱网环境:
例如本案例我们选择 Slow 4G 的网络环境,来模拟「场景 B 的资源加载耗时较长」的情况,看看上文 3.2 小节的并行逻辑是否有被正确处理:
可以看到当场景资源加载较慢时,转场动画依旧表现正常,符合我们的预期。