不用转场引擎做幻灯片转场:相机滑动、黑场淡入与真全屏的完整做法

不用转场引擎做幻灯片转场:相机滑动、黑场淡入与真全屏的完整做法

学习目标

读完这篇,你能给自己的画布或幻灯片应用加上三种转场------相机滑动、黑场淡入、硬切------而不用引入任何转场引擎。核心结论先说:页面如果本来就并排摆在画布上,转场就不需要"切画面",只需要"移动相机"。

开场:放映的转场,不一定要"切"

很多幻灯片工具的转场是这样实现的:两张画面,一张淡出、一张淡入,或者一张把另一张推出去。这套做法需要一个转场引擎:同时持有两帧、做混合、管时间轴。东西不少。

但在一个无限画布白板里,情况不一样:所有页面本来就并排摆在同一张画布上,页面之间隔着固定空隙。放映翻页,本质上只是"相机从这一页移到下一页"。既然相机本来就要动,转场就可以搭在相机移动上------不用第二帧,不用混合,不用转场引擎。

这篇文章讲的就是这套做法:三种转场(滑动 / 淡入 / 硬切)、开场特效、真全屏、以及放映期间不该出现的东西。每一步都可以直接搬到你自己的画布应用里。

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 守卫让进入和退出都幂等------重复触发不会来回横跳;空画布拒绝放映时不碰窗口状态,不会把用户的窗口莫名其妙切成全屏。

放映期间还要藏起三样东西:

  1. 页面边框和编号标签不渲染。 编辑时画在页面周围的边框和"第 N 页"标签,放映时是穿帮镜头------一场演出里,观众不该看到舞台的标线。渲染时用 presenting.is_none() 判断,只在非放映时画。
  2. 操作面板不出现。 AI 面板、设置面板在放映期间全部隐藏,退出放映也不复活------退出时如果把设置面板带出来,等于演出结束灯亮了、道具还留在台上。
  3. 控制条自动隐藏。 放映控制条在鼠标静止 2500 毫秒后淡出,鼠标一动就回来(400 毫秒轮询的看门任务负责检查)。观众不操作时,屏幕上只剩页面本身。

3. 为什么这样设计

转场搭在已有机制上,而不是另起一套。 相机本来就要动、遮罩本来就是一层矩形、全屏本来就是窗口的一个状态。三种转场没有引入任何新引擎,只是把这三个已有机制按不同方式组合:滑动 = 相机动画,淡入 = 遮罩渐变 + 瞬间切相机,硬切 = 直接切相机。机制越少,出 bug 的地方越少。

动效的选择权交给内容,不交给全局设置。 每页自带 effect,作者按叙事定:绝大多数页用默认滑动,章节切换页用淡入,快节奏要点页用硬切。全篇一致是作者的纪律,不是工具的强制------工具只负责"每页的决定都被忠实执行"。

开场和翻页共用同一套代码路径。 开场动画就是"翻到第一页"的变体:同样的 animate_camera_to、同样的 FadeVeil,只是时序参数不同(450ms / 420ms)。一套路径,两种场景,维护成本减半。

4. 避坑清单

  1. 别用渲染帧驱动开场揭示。 黑场铺上后什么都不重绘,等渲染帧等于等一个永远不来的输入。用定时器(300ms)等全屏落地。
  2. 别在旧视口下算相机适配。 全屏切换一帧后才落地,适配必须等视口稳定之后再算,否则封面会偏。
  3. 别让 resize 适配和相机动画抢相机。 动画进行期间 suppress 漂移重适配,动画结束、相机落位后再同步。
  4. 别让旧数据打不开。 effect 字段加 #[serde(default)],旧页面文件缺字段时按 Slide 解析。
  5. 别把非法 effect 静默成默认。 工具层用 parse 返回的 None 做校验,非法值直接拒绝并告诉调用方支持哪三个值。
  6. 别让动画任务泄漏。 16ms tick 任务只在动画期间存活,动画结束即退出;遮罩清理用总时长 + 20ms 余量的定时器兜底。
  7. 放映时别漏穿帮镜头。 页面边框、编号标签、操作面板、设置面板,全部要在放映期间隐藏,退出时不复活。

5. 总结:给开发者的建议

给画布应用加放映转场,按这个顺序来:页面并排布局 → 每页加 effect 属性(Slide / Fade / None,默认滑动)→ 翻页时由被进入页决定转场 → 滑动用相机插值 + smoothstep(300ms)→ 淡入用黑场遮罩盖住瞬间切换(140 / 80 / 260ms)→ 第一页的 effect 就是开场特效 → 真全屏 + 放映期间藏起所有编辑痕迹。

记住一句话:转场不是"画面怎么切",而是"观众的注意力怎么被带到下一页"。 相机滑动带注意力走过去,黑场给注意力一个断点,硬切是不给注意力留停顿------选哪种,取决于你这一页想让观众怎么看,而不是取决于你有什么特效库。

相关推荐
CoderIsArt1 小时前
使用 Ultralytics YOLO 进行模型训练
人工智能·深度学习·yolo
人工智能培训1 小时前
具身智能环境交互与学习范式原理
大数据·linux·人工智能·学习·算法·机器学习
一线数智1 小时前
敛海智库亮相26届工博会制造业AI场景应用生态大会以结构式融合思维推动AI工业落地
人工智能
云和数据.ChenGuang1 小时前
Golang AI 工程师路线图:从入门到实战
开发语言·人工智能·后端·golang
2601_956743681 小时前
上海GEO营销公司技术评估解析|以盾码无界公开产品路线为观察对象,梳理问题池构建、知识库事实锚定、内容审核与跨模型监测流程,明确现场演示和任务追溯的核验要求
大数据·人工智能·产品运营·技术分享·geo·上海
梦仔生信进阶1 小时前
【机器学习】ACC = 1 是否意味着过拟合?还是数据泄露?一个好的模型应该是什么样的
人工智能·深度学习·机器学习
救救孩子把1 小时前
AI面试八股文-12_11-多模态大模型面试题
人工智能·面试·职场和发展·多模态·clip·rag·llave
zxz520zmg1 小时前
地质学笔记
人工智能·深度学习·机器学习