不用转场引擎做幻灯片转场:相机滑动、黑场淡入与真全屏的完整做法
学习目标
读完这篇,你能给自己的画布或幻灯片应用加上三种转场------相机滑动、黑场淡入、硬切------而不用引入任何转场引擎。核心结论先说:页面如果本来就并排摆在画布上,转场就不需要"切画面",只需要"移动相机"。
开场:放映的转场,不一定要"切"
很多幻灯片工具的转场是这样实现的:两张画面,一张淡出、一张淡入,或者一张把另一张推出去。这套做法需要一个转场引擎:同时持有两帧、做混合、管时间轴。东西不少。
但在一个无限画布白板里,情况不一样:所有页面本来就并排摆在同一张画布上,页面之间隔着固定空隙。放映翻页,本质上只是"相机从这一页移到下一页"。既然相机本来就要动,转场就可以搭在相机移动上------不用第二帧,不用混合,不用转场引擎。
这篇文章讲的就是这套做法:三种转场(滑动 / 淡入 / 硬切)、开场特效、真全屏、以及放映期间不该出现的东西。每一步都可以直接搬到你自己的画布应用里。
1. 前置知识:三个概念先说清
翻译成人话:相机就是"你正在看哪一块画布"。 画布是无限大的,用户一次只能看到其中一小块。相机由位置 + 缩放决定:相机在哪、放多大,就决定了屏幕上显示哪一块。放映翻页,就是把相机从当前页的取景位置,移动到下一页的取景位置。
页面并排布局。 每新增一页,就在上一页的右边、隔着固定空隙(PAGE_GAP)摆下一页,第一页固定在原点 (0, 0)。这个布局决定了转场的形态:页面在水平方向上排开,相机滑动过去,视觉上就是横向扫过去的效果,跟 PowerPoint 的"推出"转场很像------但实现上只是相机在动。
黑场遮罩(veil)。 一块全屏黑色遮罩,透明度 0 到 1 可调。淡入淡出转场就是:遮罩从透明变到不透明(盖住画面)、在遮罩后面把相机瞬间切到下一页、遮罩再从不透明变回透明。观众看到的是"画面黑下去、再亮起来就是新的一页",中间的瞬间切换被黑场盖住了。
2. 分步骤实操:六步搭一套放映转场
第 1 步:给每个页面加一个"转场"属性
转场是页面的属性,不是全局设置。每个页面自己决定"别人翻到我的时候用什么转场"。先定义三种取值:
rust
/// The transition used when this page appears (flip into it / show open).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PageEffect {
/// Camera glides from the current framing into the page (default).
Slide,
/// Fade through black: veil in, instant switch, veil out.
Fade,
/// Hard cut, no animation.
None,
}
impl PageEffect {
pub fn parse(s: &str) -> Option<PageEffect> {
match s {
"slide" => Some(PageEffect::Slide),
"fade" => Some(PageEffect::Fade),
"none" => Some(PageEffect::None),
_ => None,
}
}
}
impl Default for PageEffect {
fn default() -> Self {
PageEffect::Slide
}
}
pub struct Page {
pub title: String,
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
/// How this page enters the view (flip transition / show opener).
#[serde(default)]
pub effect: PageEffect,
}
两个细节:#[serde(default)] 让旧数据(没有 effect 字段的页面文件)打开时自动按默认值 Slide 解析,不会因为缺字段而打不开;parse 返回 Option,未知字符串返回 None,调用方用它做参数校验------工具层收到 effect: "zoom" 这种非法值,直接拒绝,而不是默默按默认处理。
add_page 工具的参数校验就是这样写的:
rust
if let Some(e) = &args.effect {
if crate::scene::pages::PageEffect::parse(e).is_none() {
return fail_tool(/* ... "effect 只支持 slide / fade / none" */).await;
}
}
第 2 步:翻页时让"被进入的页面"说了算
翻页函数里,先读出目标页的 effect,再决定走哪条路径:
rust
fn goto_page(&mut self, index: usize, cx: &mut Context<Self>) {
let i = index.min(self.scene.pages.len() - 1);
let presenting = self.presenting.is_some();
if presenting {
// The page being entered owns its transition.
let effect = self.scene.pages.get(i).map(|p| p.effect).unwrap_or_default();
match effect {
PageEffect::Fade => { self.enter_fade_page(i, cx); return; }
PageEffect::None => { self.fit_to_page(i, cx); return; }
PageEffect::Slide => {} // 继续走下面的相机滑动
}
}
// Slide:算出目标页的相机位置,滑过去(第 3 步)。
if let Some(mut to) = self.page_exact_camera(i, cx) {
self.animate_camera_to(to, 300, cx);
}
}
"被进入的页面说了算"这个设计值得抄。转场效果跟着目标页走,作者可以在排版时就定好每一页的出场方式:章节首页用淡入、快节奏的连续要点页用硬切、其余用默认滑动。翻页逻辑本身不用知道任何叙事意图,只管执行目标页的决定。
第 3 步:滑动转场------相机插值 + smoothstep 缓动 + 16ms 驱动
滑动转场就是一个相机动画:从当前相机位置插值到目标相机位置,300 毫秒,缓动函数用 smoothstep:
rust
/// One camera glide: lerp from -> to with smoothstep over `dur_ms`.
struct PageAnim {
from: Camera,
to: Camera,
started: std::time::Instant,
dur_ms: u64,
}
fn animate_camera_to(&mut self, to: Camera, dur_ms: u64, cx: &mut Context<Self>) {
if self.camera == to {
return; // 已经在目标位置,不启动动画。
}
self.page_anim = Some(PageAnim {
from: self.camera, to, started: std::time::Instant::now(), dur_ms,
});
cx.notify();
// 16ms tick 任务:只在动画进行期间存活,动画结束即退出。
cx.spawn(async move |this, cx| loop {
cx.background_executor().timer(std::time::Duration::from_millis(16)).await;
let mut running = false;
this.update(cx, |this, cx| {
if let Some(anim) = &this.page_anim {
running = anim.started.elapsed().as_millis() < anim.dur_ms as u128 + 2;
cx.notify();
}
}).ok();
if !running { break; }
}).detach();
}
渲染时按时间算插值系数,t 是 0 到 1 的进度:
rust
let e = t * t * (3.0 - 2.0 * t); // smoothstep
有人可能会问:为什么不用线性插值?因为线性插值的相机是"匀速启动、匀速停止",起步和刹车都生硬。smoothstep 在两端斜率为零------起步缓、中间快、到站缓,眼睛看起来就是"滑"过去,而不是"挪"过去。缓动函数的选择,就是转场质感的全部。
另外两个细节:16ms 的 tick 任务只在动画期间存活,动画一结束就退出,不留后台任务一直空转;动画进行期间,窗口 resize 触发的"漂移重适配"要让路,等动画结束、相机落位之后再同步适配状态------两个机制不能同时抢相机。
第 4 步:淡入转场------黑场遮罩盖住瞬间切换
淡入转场的核心是"切相机"和"观众看到切换"这两件事解耦:相机切换是瞬间的,观众看到的是遮罩的渐变:
rust
fn enter_fade_page(&mut self, index: usize, cx: &mut Context<Self>) {
self.page_anim = None; // 先停掉可能在跑的滑动动画。
self.fit_to_page(index, cx); // 在遮罩后面瞬间切相机。
self.fade_veil = Some(FadeVeil {
started: std::time::Instant::now(),
in_ms: 140, // 遮罩从透明到不透明
hold_ms: 80, // 全黑停留
out_ms: 260, // 遮罩从不透明到透明
});
cx.notify();
// 总时长 480ms + 20ms 余量后清理遮罩状态。
}
遮罩透明度按时间分段算:
rust
fn opacity_at(&self, now: std::time::Instant) -> Option<f32> {
let t = now.duration_since(self.started).as_millis() as u64;
let end = self.in_ms + self.hold_ms + self.out_ms;
if t >= end { return None; } // 动画结束,遮罩消失。
let o = if t < self.in_ms {
t as f32 / self.in_ms as f32 // 淡入:0 → 1
} else if t < self.in_ms + self.hold_ms {
1.0 // 黑场:保持不透明
} else {
1.0 - (t - self.in_ms - self.hold_ms) as f32 / self.out_ms as f32 // 淡出:1 → 0
};
Some(o.clamp(0.0, 1.0))
}
时序 140 / 80 / 260 是调过的:淡入太快会闪、太慢会拖;80 毫秒的黑场是给眼睛一个"断点"信号,让观众感知到"换章节了",但又短到不会觉得卡;淡出 260 毫秒最长,因为淡出阶段观众已经在看新页面了,慢一点更舒服。
硬切(None)最简单:fit_to_page 直接把相机放到目标页,不启动任何动画。快节奏的连续要点页用硬切------每页停留不到两秒的地方,加任何动画都是拖节奏。
第 5 步:开场特效------第一页的转场就是整场放映的开场
放映开始时,封面(第一页)的 effect 同时是整场放映的开场特效:
| 第一页的 effect | 开场行为 |
|---|---|
Slide |
相机 450ms 滑入封面(比翻页的 300ms 慢一点,开场要稳) |
Fade |
直接切到封面,遮罩从不透明淡出 420ms(入场时不需要"淡入黑场",画面本来就是黑的) |
None |
直接切到封面,无动画 |
开场有个容易踩的坑:全屏切换要一帧之后才落地 。调用 toggle_fullscreen 之后,窗口尺寸还没变,这时候按旧视口算相机适配,算出来是错的。做法是先铺黑场遮罩、用 300 毫秒定时器等全屏落地、视口稳定之后,再执行开场动画。定时器,不是渲染帧------因为黑场铺上之后什么都不重绘,靠渲染帧驱动的揭示会在等一个永远不来的"下一次输入",实测会卡约 2 秒。
第 6 步:真全屏 + 放映期间藏起编辑痕迹
放映不是"把窗口填满",而是"真正的全屏":
rust
fn start_presenting(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
if self.scene.pages.is_empty() {
self.set_notice("画布上还没有幻灯片页面", cx);
return false; // 空画布拒绝放映,且不碰窗口状态。
}
self.presenting = Some(start);
if !window.is_fullscreen() {
window.toggle_fullscreen(); // 标题栏、任务栏全部消失。
}
// ...
}
fn exit_presenting(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.presenting = None;
if window.is_fullscreen() {
window.toggle_fullscreen(); // 退出时恢复窗口几何。
}
}
is_fullscreen 守卫让进入和退出都幂等------重复触发不会来回横跳;空画布拒绝放映时不碰窗口状态,不会把用户的窗口莫名其妙切成全屏。
放映期间还要藏起三样东西:
- 页面边框和编号标签不渲染。 编辑时画在页面周围的边框和"第 N 页"标签,放映时是穿帮镜头------一场演出里,观众不该看到舞台的标线。渲染时用
presenting.is_none()判断,只在非放映时画。 - 操作面板不出现。 AI 面板、设置面板在放映期间全部隐藏,退出放映也不复活------退出时如果把设置面板带出来,等于演出结束灯亮了、道具还留在台上。
- 控制条自动隐藏。 放映控制条在鼠标静止 2500 毫秒后淡出,鼠标一动就回来(400 毫秒轮询的看门任务负责检查)。观众不操作时,屏幕上只剩页面本身。
3. 为什么这样设计
转场搭在已有机制上,而不是另起一套。 相机本来就要动、遮罩本来就是一层矩形、全屏本来就是窗口的一个状态。三种转场没有引入任何新引擎,只是把这三个已有机制按不同方式组合:滑动 = 相机动画,淡入 = 遮罩渐变 + 瞬间切相机,硬切 = 直接切相机。机制越少,出 bug 的地方越少。
动效的选择权交给内容,不交给全局设置。 每页自带 effect,作者按叙事定:绝大多数页用默认滑动,章节切换页用淡入,快节奏要点页用硬切。全篇一致是作者的纪律,不是工具的强制------工具只负责"每页的决定都被忠实执行"。
开场和翻页共用同一套代码路径。 开场动画就是"翻到第一页"的变体:同样的 animate_camera_to、同样的 FadeVeil,只是时序参数不同(450ms / 420ms)。一套路径,两种场景,维护成本减半。
4. 避坑清单
- 别用渲染帧驱动开场揭示。 黑场铺上后什么都不重绘,等渲染帧等于等一个永远不来的输入。用定时器(300ms)等全屏落地。
- 别在旧视口下算相机适配。 全屏切换一帧后才落地,适配必须等视口稳定之后再算,否则封面会偏。
- 别让 resize 适配和相机动画抢相机。 动画进行期间 suppress 漂移重适配,动画结束、相机落位后再同步。
- 别让旧数据打不开。
effect字段加#[serde(default)],旧页面文件缺字段时按Slide解析。 - 别把非法 effect 静默成默认。 工具层用
parse返回的None做校验,非法值直接拒绝并告诉调用方支持哪三个值。 - 别让动画任务泄漏。 16ms tick 任务只在动画期间存活,动画结束即退出;遮罩清理用总时长 + 20ms 余量的定时器兜底。
- 放映时别漏穿帮镜头。 页面边框、编号标签、操作面板、设置面板,全部要在放映期间隐藏,退出时不复活。
5. 总结:给开发者的建议
给画布应用加放映转场,按这个顺序来:页面并排布局 → 每页加 effect 属性(Slide / Fade / None,默认滑动)→ 翻页时由被进入页决定转场 → 滑动用相机插值 + smoothstep(300ms)→ 淡入用黑场遮罩盖住瞬间切换(140 / 80 / 260ms)→ 第一页的 effect 就是开场特效 → 真全屏 + 放映期间藏起所有编辑痕迹。
记住一句话:转场不是"画面怎么切",而是"观众的注意力怎么被带到下一页"。 相机滑动带注意力走过去,黑场给注意力一个断点,硬切是不给注意力留停顿------选哪种,取决于你这一页想让观众怎么看,而不是取决于你有什么特效库。