代码
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
})