分析一个场景淡入淡出的切换效果

代码

import {
    Camera,
    Color,
    Director,
    Layers,
    Node,
    RenderTexture,
    Scene,
    SceneAsset,
    Sprite,
    SpriteFrame,
    UIOpacity,
    UITransform,
    Widget,
    Vec3,
    director,
    game,
    view,
} from 'cc';
import RootNode from '../RootNode';
import Log from '../../util/Log';

/** crossfade 过渡参数。 */
export interface CrossFadeOptions {
    /** 淡出时长(秒),默认 0.35。 */
    duration?: number;
}

/**
 * 场景过渡工具(方案 B:老场景 crossfade 到新场景)。
 *
 * 原理:
 * 1. 在切场景「前」——此时老场景 Canvas 仍存活——用一个临时 Camera 把当前
 *    UI 画面渲染到 RenderTexture,得到老画面的静态快照。
 * 2. 把快照包成全屏 Sprite,挂到常驻层(RootNode)最顶,opacity=255,盖住屏幕。
 * 3. `director.runScene(scene)`——注意用 runScene 而非 runSceneImmediate:
 *    切换延迟到当前帧末尾,保证第 1 步截图时老场景还没被销毁。
 * 4. 新场景 launched 回调里执行业务 init。
 * 5. init 完成后把覆盖层 opacity 从 255 tween 到 0,露出新场景,结束销毁所有临时资源。
 */
export class SceneTransition {

    /**
     * 带 crossfade 过渡地切换场景。
     *
     * @param scene       已加载好的目标 Scene(loadScene 的产物)。
     * @param opts        过渡参数。
     * @param onLaunched  新场景上屏后的回调(业务 init 放这里)。返回的 Promise
     *                    resolve(或函数同步返回)后才开始淡出,从而保证「init 完成才露出新场景」。
     */
    public static async crossFadeRunScene(
        scene: SceneAsset,
        opts: CrossFadeOptions,
        onLaunched: () => void | Promise<void>,
    ): Promise<void> {
        const duration = opts?.duration ?? 0.35;

        // ---- 1. 截图:老场景仍存活时抓一帧 ----
        const snapshot = SceneTransition.captureCurrentScene();

        // ---- 2. 覆盖层:把快照挂到常驻层最顶 ----
        let overlay: Node | null = null;
        let opacityComp: UIOpacity | null = null;
        if (snapshot) {
            overlay = SceneTransition.buildOverlay(snapshot.spriteFrame);
            opacityComp = overlay.getComponent(UIOpacity);
        }

        // ---- 3. 切场景(帧末切换) ----
        director.runScene(scene, undefined, async () => {
            // 清理只执行一次,且不依赖 tween 是否被正常调度。
            let finished = false;
            const finish = () => {
                if (finished) return;
                finished = true;
                if (overlay?.isValid) overlay.destroy();
                snapshot?.dispose();
            };

            try {
                // ---- 4. 业务 init(等待其完成才淡出) ----
                await onLaunched();
            } catch (e) {
                Log.Warn('SceneTransition onLaunched error:', e);
            }

            // ---- 5. 淡出 + 清理 ----
            if (!overlay || !opacityComp) {
                finish();
                return;
            }

            SceneTransition.fadeOut(overlay, opacityComp, duration, finish);
        });
    }

    /**
     * 手动逐帧淡出覆盖层,不依赖 tween 系统。
     *
     * 之所以不用 tween:runScene 的 launched 回调发生在切场景边界,tween 有概率
     * 未被正常调度/被打断,导致 opacity 卡在 255、快照永远盖屏(“有概率不消失”)。
     * 这里用 director 的 tick 手动插值,并带兜底:无论如何到时都会 finish。
     */
    private static fadeOut(overlay: Node, opacityComp: UIOpacity, duration: number, finish: () => void): void {
        if (duration <= 0) {
            finish();
            return;
        }

        // Director 事件回调不传 dt,用 game.totalTime 自己算增量。
        let last = game.totalTime;
        let elapsed = 0;
        const onTick = () => {
            // 覆盖层若已被外部销毁,立即收尾,避免访问失效节点。
            if (!overlay.isValid || !opacityComp.isValid) {
                director.off(Director.EVENT_AFTER_UPDATE, onTick);
                finish();
                return;
            }
            const now = game.totalTime;
            elapsed += Math.max(0, (now - last) / 1000);
            last = now;
            const t = Math.min(1, elapsed / duration);
            opacityComp.opacity = Math.round(255 * (1 - t));
            if (t >= 1) {
                director.off(Director.EVENT_AFTER_UPDATE, onTick);
                finish();
            }
        };
        director.on(Director.EVENT_AFTER_UPDATE, onTick);
    }

    /**
     * 把「当前正在显示的场景 Canvas」渲染到一张 RenderTexture。
     * 必须在 runScene 之前调用(老场景 Canvas 尚未销毁)。
     *
     * @returns 快照的 SpriteFrame + dispose 清理函数;无法截图时返回 null。
     */
    private static captureCurrentScene(): { spriteFrame: SpriteFrame; dispose: () => void } | null {
        const scene = director.getScene();
        if (!scene) return null;

        // 找到场景里用于渲染 UI 的相机(排除我们自己临时挂的)。
        const srcCamera = SceneTransition.findSceneCamera(scene);
        if (!srcCamera) {
            Log.Warn('SceneTransition: no camera found in current scene, skip snapshot');
            return null;
        }

        const size = view.getVisibleSize();
        const width = Math.max(1, Math.floor(size.width));
        const height = Math.max(1, Math.floor(size.height));

        const rt = new RenderTexture();
        rt.reset({ width, height });

        // 用临时 Camera 复制源相机参数,渲染一帧到 RT。
        // 直接复用源相机的 targetTexture 会污染屏幕显示,故新建独立相机。
        const camNode = new Node('__SceneSnapshotCamera__');
        camNode.setWorldPosition(srcCamera.node.worldPosition);
        camNode.setWorldRotation(srcCamera.node.worldRotation);
        scene.addChild(camNode);

        const cam = camNode.addComponent(Camera);
        cam.projection = srcCamera.projection;
        cam.priority = srcCamera.priority + 1;
        cam.visibility = srcCamera.visibility;
        cam.clearFlags = Camera.ClearFlag.SOLID_COLOR;
        cam.clearColor = new Color(0, 0, 0, 0);
        cam.orthoHeight = srcCamera.orthoHeight;
        cam.near = srcCamera.near;
        cam.far = srcCamera.far;
        cam.fov = srcCamera.fov;
        cam.targetTexture = rt;

        // 主动渲染一帧到 RT。
        cam.camera?.update(true);
        director.root?.frameMove(0);

        // 拆掉临时相机(RT 内容已经写入)。
        camNode.destroy();

        const spriteFrame = new SpriteFrame();
        spriteFrame.texture = rt;

        return {
            spriteFrame,
            dispose: () => {
                spriteFrame.destroy();
                rt.destroy();
            },
        };
    }

    /** 在场景里找一个可用于截图的相机(跳过我们临时挂的快照相机)。 */
    private static findSceneCamera(scene: Scene): Camera | null {
        const cams = scene.getComponentsInChildren(Camera);
        for (const c of cams) {
            if (c.node.name === '__SceneSnapshotCamera__') continue;
            if (!c.enabledInHierarchy) continue;
            return c;
        }
        return cams.length > 0 ? cams[0] : null;
    }

    /** 构建盖住屏幕的全屏快照覆盖层,挂到常驻层最顶。 */
    private static buildOverlay(spriteFrame: SpriteFrame): Node {
        const parent = RootNode.Ins?.node;

        const node = new Node('__SceneTransitionOverlay__');
        node.layer = Layers.Enum.UI_2D;

        const uiTf = node.addComponent(UITransform);
        const size = view.getVisibleSize();
        uiTf.setContentSize(size.width, size.height);

        const opacity = node.addComponent(UIOpacity);
        opacity.opacity = 255;

        const sprite = node.addComponent(Sprite);
        sprite.spriteFrame = spriteFrame;
        sprite.sizeMode = Sprite.SizeMode.CUSTOM;
        sprite.type = Sprite.Type.SIMPLE;

        if (parent) {
            parent.addChild(node);
            node.setPosition(Vec3.ZERO);
            // 拉满全屏,并置于最顶。
            const w = node.addComponent(Widget);
            w.isAlignTop = w.isAlignBottom = w.isAlignLeft = w.isAlignRight = true;
            w.top = w.bottom = w.left = w.right = 0;
            w.updateAlignment();
            node.setSiblingIndex(parent.children.length - 1);
        } else {
            // 兜底:常驻 RootNode 不可用时挂当前场景(此时尚未 runScene)。
            const scene = director.getScene();
            scene?.addChild(node);
        }

        return node;
    }
}

如何使用

// 原本使用director.runScene的地方改为以下代码
SceneTransition.crossFadeRunScene(scene, { duration: 0.45 }, async () => {
    // code
})
3赞

不错不错,现在是我的了.