分享一个场景切换效果,淡入淡出

代码

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

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

/**
 * 场景过渡工具(RT 快照 + 自定义材质 crossfade)。
 *
 * 之所以用「自定义材质」而不是引擎默认材质显示 RenderTexture:
 * 引擎默认材质在检测到 texture 是 RenderTexture 时会自动切到 `rt-*`(SAMPLE_FROM_RT) 材质,
 * 但这条路和「动态合图(dynamicAtlasManager)」冲突——合图每帧尝试把 frame._texture 从
 * RenderTexture 换成 Texture2D 图集,原生(Metal/GLES)上第二次跳转时旧 RT window 已释放,
 * texture 悬空 → simple.ts updateUVs 读 uv 为 null → 每帧崩溃(Web 的 GL 后端宽容故不崩)。
 *
 * 解法:给覆盖 Sprite 用一个从 builtin-sprite clone、并手动开启 USE_TEXTURE + SAMPLE_FROM_RT
 * 宏的自定义材质。设了 customMaterial 后,UIRenderer.updateMaterial 直接 return,
 * 完全不进 _updateBuiltinMaterial / 动态合图逻辑;SAMPLE_FROM_RT 宏又保证了 RT 的平台自动翻转。
 *
 * 流程:
 * 1. 切场景「前」——老场景 Canvas 仍存活——临时 Camera 渲染当前画面到 RenderTexture。
 * 2. 快照包成全屏 Sprite(自定义材质),挂常驻层(RootNode)最顶,opacity=255 盖屏。
 * 3. `director.runScene`(帧末切换)。
 * 4. 新场景 launched 回调里执行业务 init。
 * 5. init 完成后 opacity 255→0 手动逐帧淡出,露出新场景,最后严格清理 RT。
 */
export class SceneTransition {

    /** 缓存的 RT 专用材质(从 builtin-sprite clone + 开宏),全局复用一份。 */
    private static _rtMaterial: Material | null = null;

    /**
     * 上一次跳转遗留、待回收的资源。
     * 原生(Metal/GLES)上,RT/SpriteFrame 的 GFX 资源释放比 JS 侧晚若干帧;
     * 若在本次跳转流程内 destroy,第二次跳转时渲染管线可能仍持有旧引用 → uv null 崩溃。
     * 因此改为「跨跳转延迟回收」:本次 dispose 只解引用,真正 destroy 推迟到下一次跳转开头,
     * 此时旧对象已过完整若干帧、彻底离开渲染管线,绝不会再被引用。
     */
    private static _pendingDispose: (() => void) | null = null;

    /** 回收上一次跳转遗留的资源(在新一次跳转最开始调用)。 */
    private static flushPendingDispose(): void {
        const d = SceneTransition._pendingDispose;
        SceneTransition._pendingDispose = null;
        if (d) {
            try { d(); } catch (e) { Log.Warn('SceneTransition: flushPendingDispose error:', e); }
        }
    }

    /**
     * 立即、可靠地销毁一个覆盖层节点。
     * 关键:先 removeFromParent 让它当帧脱离渲染遍历(destroy 是延迟到帧末的,
     * 原生上「已 destroy 但仍挂在常驻层」的节点会被继续渲染 → 坏 material → 刷屏崩)。
     */
    private static killOverlayNode(node: Node | null): void {
        if (!node || !node.isValid) return;
        const sprite = node.getComponent(Sprite);
        if (sprite) sprite.spriteFrame = null;   // 先解除对 RT SpriteFrame 的引用
        node.removeFromParent();                 // 当帧脱离父节点,立刻停止被渲染
        node.destroy();
    }

    /**
     * 强扫:移除常驻层 + 当前场景里所有残留的过渡覆盖节点。
     * 兜底,任何一次清理遗漏(如 buildOverlay 中途抛异常留下半成品)都在下次进场被清掉。
     */
    private static purgeStrayOverlays(): void {
        const roots: Node[] = [];
        const persist = RootNode.Ins?.node?.parent;
        if (persist) roots.push(persist);
        const scene = director.getScene();
        if (scene) roots.push(scene as unknown as Node);

        const strays: Node[] = [];
        const visit = (n: Node) => {
            if (!n || !n.isValid) return;
            if (n.name === '__SceneTransitionOverlay__') strays.push(n);
            for (const c of n.children) visit(c);
        };
        roots.forEach(visit);
        if (strays.length) {
            Log.Warn(`SceneTransition: purge ${strays.length} stray overlay(s)`);
            strays.forEach((n) => SceneTransition.killOverlayNode(n));
        }
    }

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

        // 先回收上一次跳转遗留的 RT/SpriteFrame(此时它们已过完整若干帧、彻底离开渲染管线)。
        SceneTransition.flushPendingDispose();
        // 强扫任何残留的覆盖节点(上次清理遗漏 / buildOverlay 中途抛异常留下的半成品)。
        SceneTransition.purgeStrayOverlays();

        // ---- 1. 截图 + 2. 覆盖层:任何一步失败都降级为「无过渡直接切场景」,绝不卡死 ----
        let overlay: Node | null = null;
        let opacityComp: UIOpacity | null = null;
        let snapshot: { spriteFrame: SpriteFrame; dispose: () => void } | null = null;
        try {
            snapshot = SceneTransition.captureCurrentScene();
            if (snapshot) {
                overlay = SceneTransition.buildOverlay(snapshot.spriteFrame);
                opacityComp = overlay.getComponent(UIOpacity);
            }
        } catch (e) {
            Log.Warn('SceneTransition: capture/overlay failed, fallback to plain switch:', e);
            // 关键:buildOverlay 中途抛异常时,节点可能已 addChild 到常驻层,
            // 必须 removeFromParent+destroy 彻底移除,否则半成品坏 Sprite 每帧渲染 → 刷屏崩。
            SceneTransition.killOverlayNode(overlay);
            snapshot?.dispose();
            overlay = null;
            opacityComp = null;
            snapshot = null;
        }

        const _onLaunched = async () => {
            let finished = false;
            const finish = () => {
                if (finished) return;
                finished = true;
                // 可靠销毁覆盖层:先 removeFromParent 当帧脱离渲染,再 destroy。
                // RT/SpriteFrame 不在此处 destroy,登记为 pendingDispose,推迟到下一次跳转开头,
                // 届时已过完整若干帧、彻底离开原生渲染管线,避免「destroy 了仍被引用 → uv null」。
                SceneTransition.killOverlayNode(overlay);
                overlay = null;
                const snap = snapshot;
                snapshot = null;
                SceneTransition._pendingDispose = () => snap?.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);
        };
        // ---- 3. 切场景(帧末切换) ----
        if (typeof scene === 'string') {
            director.loadScene(scene, _onLaunched);
        }
        else {
            director.runScene(scene, undefined, _onLaunched);
        }
    }

    /**
     * 手动逐帧淡出覆盖层,不依赖 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);
    }

    /**
     * 取(或创建)RT 专用材质:全局缓存复用一份。
     * - customMaterial 令 Sprite 绕开动态合图与内置材质路径(updateMaterial 里 if(customMaterial) return),
     *   从而永不进入 _updateBlendFunc(原生上 getRenderMaterial 为 undefined → blendState 崩)。
     * - SAMPLE_FROM_RT 宏令 shader 按平台自动翻转 RT 采样(CC_HANDLE_RT_SAMPLE_FLIP)。
     * - effect 源从场景中任意一个已渲染的 Sprite 拿(稳定存在),不用 EffectAsset.get('builtin-sprite')
     *   (该名在部分原生构建取不到,实测 not found)。
     */
    private static getRTMaterial(): Material | null {
        if (SceneTransition._rtMaterial?.isValid) return SceneTransition._rtMaterial;

        const effect = SceneTransition.findSpriteEffect();
        if (!effect) {
            Log.Warn('SceneTransition: no sprite effect source found, skip custom material');
            return null;
        }
        const mat = new Material();
        mat.initialize({
            effectAsset: effect,
            defines: { USE_TEXTURE: true, SAMPLE_FROM_RT: true },
        });
        const pass = mat.passes[0];
        if (pass) {
            const bs = pass.blendState.targets[0];
            bs.blend = true;
            bs.blendSrc = gfx.BlendFactor.SRC_ALPHA;
            bs.blendDst = gfx.BlendFactor.ONE_MINUS_SRC_ALPHA;
            bs.blendDstAlpha = gfx.BlendFactor.ONE_MINUS_SRC_ALPHA;
            pass.blendState.setTarget(0, bs);
        }
        SceneTransition._rtMaterial = mat;
        return mat;
    }

    /** 从常驻层/当前场景里任意一个已有 Sprite 取其材质的 effectAsset,作为 clone 源。 */
    private static findSpriteEffect(): EffectAsset | null {
        const roots: Node[] = [];
        const persist = RootNode.Ins?.node?.parent;
        if (persist) roots.push(persist);
        const scene = director.getScene();
        if (scene) roots.push(scene as unknown as Node);

        let found: EffectAsset | null = null;
        const visit = (n: Node) => {
            if (found || !n || !n.isValid) return;
            if (n.name === '__SceneTransitionOverlay__') return;   // 跳过我们自己的覆盖层
            const sp = n.getComponent(Sprite);
            const eff = sp?.getSharedMaterial(0)?.effectAsset;
            if (eff) { found = eff; return; }
            for (const c of n.children) visit(c);
        };
        roots.forEach(visit);
        return found;
    }

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

        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。3.8 无 camera.render(scene),用 frameMove 驱动一次渲染。
        director.root?.frameMove(0);

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

        // 用 reset 一次性设 texture + rect + originalSize,触发 _calculateUV 生成 uv。
        const spriteFrame = new SpriteFrame();
        spriteFrame.reset({
            texture: rt,
            originalSize: new Size(width, height),
            rect: new Rect(0, 0, width, height),
            offset: new Vec2(0, 0),
            isRotate: false,
        }, true);
        spriteFrame.packable = false;   // 双保险:不进动态合图

        // 兜底:uv 未就绪则放弃快照,降级为无过渡直切,绝不把坏 frame 交给渲染管线。
        if (!spriteFrame.uv || spriteFrame.uv.length < 8) {
            Log.Warn('SceneTransition: spriteFrame uv not ready, skip snapshot');
            spriteFrame.destroy();
            rt.destroy();
            return null;
        }

        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.sizeMode = Sprite.SizeMode.CUSTOM;
        sprite.type = Sprite.Type.SIMPLE;

        // 关键顺序:必须在赋 spriteFrame「之前」就设好 customMaterial。
        // 否则赋 spriteFrame 会触发一次 updateMaterial → _updateBlendFunc,
        // 而此时无 customMaterial,原生上 getRenderMaterial(0) 为 undefined → blendState 崩。
        // 设了 customMaterial 后,updateMaterial 走 `if(customMaterial) return`,永不进 _updateBlendFunc。
        const mat = SceneTransition.getRTMaterial();
        if (mat) sprite.customMaterial = mat;

        // 挂进节点树(触发 Sprite onEnable / renderData 初始化)。
        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 {
            const scene = director.getScene();
            scene?.addChild(node);
        }

        // 最后赋 spriteFrame(此时 customMaterial 已就位,updateMaterial 直接 return,不崩)。
        sprite.spriteFrame = spriteFrame;
        spriteFrame.packable = false;

        return node;
    }
}

如何使用

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

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

嘿嘿,之前版本的在原生上有问题,已经修改了

报错了:找不到模块“…/RootNode”或其相应的类型声明

就是持久化的节点,你直接弄一下就行了,保证在顶层就行了

RootNode.Ins?.node是那个常驻节点?

是的。 ~

上午看到 下午正好老板说切屏的黑屏要优化 感谢楼主

用上了吗,有问题的话可以反馈

我目前的层级是这样的

,你只需要保证持久化节点在Canvas上层就行了