Cocos Creator Shader 入门 ⑿ —— 转场动画

798 阅读7分钟

💡 本系列文章收录于个人专栏 ShaderMyHead

💡 本文案例可以在 Github 上进行演示

在游戏中,从场景 A 切换到场景 B 往往伴随着资源加载、接口请求等过程,这可能导致短暂的等待。为了避免用户感到枯燥或不适,加入一段生动有趣的转场动画,不仅可以缓解等待带来的不适感,还能弱化转场的生硬感,进而有效提升了整体的交互体验:

2.gif

上图的转场动画包含了一个往左下角移动底图的纹理节点,以及一个呈缩放运动的猫咪镂空图案:

image.png

如果采用序列帧的形式来实现转场,会是非常低效和不可控的。本文将介绍如何通过着色器,来高效地实现纹理平铺和镂空动画的效果。

一、纹理平铺和偏移动画

1.1 纹理平铺

假设我们有一张支持平铺的纹理图(bg-tile.jpg):

bg-tile.jpg

只要在该图片文件的属性检查器中将其类型设定为 texture

image.png

便可以在着色器中将其作为纹理来采样:

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)

image.png

其中 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 坐标系):

image.png

接着除以纹理的渲染宽度后,可以得到在纹理 UV 坐标体系上的 UV 坐标:

image.png

改为使用 bgUV 坐标进行采样后,节点就能按预期被平铺上传入的纹理。执行效果:

未命名.jpg

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);
    }
}

执行效果:

6.gif

此时你可能会发现,在拼接的纹理瓦片之间会出现一条很细的白边,非常影响观感:

image.png

这是一个在 GPU 图形渲染中非常常见的纹理采样边缘泄露(Texture Bleeding) 问题。

在取余的步骤执行之后,生成的 bgUV 可能非常接近 tiledSize 的边缘,比如 0.199999,这会导致 GPU 采样时跨到纹理边缘外,从而出现采样到边界颜色(通常是白色、透明或未定义值)。

在 Cocos Creator 中,纹理的采样默认使用的 线性过滤(linear filtering) ,会混合邻近像素的颜色。

所以如果一个采样点刚好在贴图边缘,GPU 会尝试向“外侧”插值——结果就是“边缘溢色”,你会看到一个微小的白边或者半透明边。

解决方案很简单,把纹理文件 bg-tile.jpg 的过滤模式更换为 Neareast 即可:

image.png

💡 关于纹理过滤的更多细节,可以查阅《附录 —— 九、纹理过滤》。

二、镂空动画

在前面的几篇文章,我们使用 Render Texture 实现了许多方案,而镂空动画的原理也相当简单 —— 通过 Render Texture 获得动画中的镂空图案纹理,再在着色器中使用该纹理来实现遮罩的功能:

image.png

其中 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;

执行效果:

Jul-31-2025 22-17-35.gif

上图一共播放了两个 Animation Clip —— mask-start 动画(猫头从大到小缩放)和 mask-end 动画(猫头从小到大缩放),每个动画都被设置为仅播放一遍(而不是循环播放)。

其中 mask-start 动画会在最后一帧时,把猫头的 Position.Y 设为 3000,确保其被移出屏幕可视区域外部:

11.gif

三、场景切换

3.1 常驻节点

另外为了动画衔接顺畅、方便统一维护,本案例是将转场动画相关的节点挂在一个新增的 Canvas 画布节点上,并将其作为常驻节点使用:

12.jpg

这需要在场景 A 启动时将其设置为常驻节点:

 director.addPersistRootNode(this.transCanvasNode);  // 将转场画布节点设为常驻节点

💡 Cocos Creator 的常驻节点要求必须是隶属于场景的根节点,且每次切换场景时都会触发 onEnablestart 生命周期。

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,可以轻松模拟弱网环境:

image.png

例如本案例我们选择 Slow 4G 的网络环境,来模拟「场景 B 的资源加载耗时较长」的情况,看看上文 3.2 小节的并行逻辑是否有被正确处理:

123.gif

可以看到当场景资源加载较慢时,转场动画依旧表现正常,符合我们的预期。