niri 动画时序与 LazyClock 时钟系统解析
【免费下载链接】niriA scrollable-tiling Wayland compositor.项目地址: https://gitcode.com/GitHub_Trending/ni/niri
<output文章>
niri 动画时序:LazyClock 与 AdjustableClock 时钟系统深度解析
本篇技术指南以 niri(scrollable-tiling Wayland compositor)的开发文档 Development:-Animation-Timing.md 为主体,系统讲解 niri 如何在固定刷新率的显示器上实现"零抖动"的动画渲染:从显示器刷新周期与渲染时机的关系,到
LazyClock(惰性时钟)的设计动机与实现,再到AdjustableClock(可调速率时钟)如何支撑动画全局减速/加速与测试时间控制。读完本文,你将理解 niri 动画系统的三大支柱——预测式渲染、事件循环内时间一致性、统一速率调整——以及它们对应的源码实现与测试验证方式,并能将这些设计思路迁移到自己的合成器或图形应用项目中。
动画时序问题的本质:固定刷新周期与可变渲染延迟
niri 是一个 Wayland 合成器,负责把一个或多个显示器(monitor)的输出合成并呈现。显示器的刷新周期在绝大多数情况下是固定的:例如一台 170 Hz 的显示器,每帧间隔约为 5.88 ms(1 / 170 s ≈ 5.88 ms)。合成器的渲染管线必须与这个节奏对齐。
但合成器并非每一帧都要重绘。当屏幕上没有任何变化(例如你正在阅读文档、鼠标静止不动)时,唤醒 GPU 去合成同一张图像纯属浪费。动画期间则不同——屏幕内容每一帧都在变化,niri 通常会"上一帧刚显示出来,就立刻开始绘制下一帧"。
问题的关键矛盾在于:
- 渲染时机的不可预测性:渲染代码可能因为处理新窗口事件等事务被延迟几毫秒,但动画在显示器上的呈现时机必须严格对齐刷新周期;
- 显示器刷新周期是固定的(即使启用 VRR,也存在一个最大刷新率),因此合成器可以预测下一帧何时显示在屏幕上;
- 用户操作必须即时响应:例如按下工作区切换键的那一刻,动画就应该从那一瞬间开始,而不是从"我们预测的下一个显示器帧"(该帧可能已经渲染完了)开始。
于是,niri 的动画时序系统需要同时满足以下四个性质(原文定义):
- 可获取"未来某个时刻"的动画状态:为渲染一个与显示器显示时机精确对齐的帧,必须能拿到指定时间点的动画状态;且这种"时间覆盖"能力应在测试中可用,以完全受控的方式推进时间;
- 用户操作触发即时开始:响应式动画必须从动作发生的瞬间开始;
- 单次动作处理期间时间一致:即使处理过程在开始后数微秒才结束,期间查询时间都应返回完全相同的值——否则你可能要避免连续两次读取某个元素的位置,因为它可能在两次读取之间移动了一个像素,破坏逻辑;此外,获取系统时间本身的开销相当可观;
- 易于实现全局减速:所有动画应能按同一系数被整体放慢或加快。
核心方案:LazyClock(惰性时钟)
针对上述需求,niri 的解决方案是一个LazyClock——一种"只记住一个时间戳"的时钟:
- 初始状态:时间戳为空。首次调用获取当前时间时,它会获取并返回系统时间,同时记住这个时间戳;
- 后续行为:只要时间戳未被清除,后续每次查询都返回这个被记住的同一时间戳;
- 清除语义:清除时间戳后,下一次查询会重新获取系统时间。
在 niri 中,这个时间戳在每次事件循环迭代结束时被清除,即在即将休眠等待新事件之前。这样,任何随后发生的事件(比如一次用户按键)一旦需要时间,就会取到最新鲜的时间戳;而接下来的事件处理代码则会持续拿到完全相同的值——因为LazyClock已将其缓存。
源码中的 LazyClock 实现
src/animation/clock.rs 中的LazyClock结构体非常简洁:
struct LazyClock { time: Option<Duration>, } impl LazyClock { pub fn with_time(time: Duration) -> Self { Self { time: Some(time) } } pub fn clear(&mut self) { self.time = None; } pub fn set(&mut self, time: Duration) { self.time = Some(time); } pub fn now(&mut self) -> Duration { *self.time.get_or_insert_with(get_monotonic_time) } }关键在now()的get_or_insert_with:时间戳已存在则直接返回缓存值,否则调用 src/utils/mod.rs 中的get_monotonic_time()(基于clock_gettime(ClockId::Monotonic)获取单调时钟)取系统时间并缓存。set()则允许外部手动把时间戳设成任意值——这正是两种核心用法的入口:
- 渲染预测:渲染一帧时,把时钟设到"显示器将要显示该帧的预测时间";
- 测试控制:测试中总是手动设置时间戳,完全不使用系统时间。
可调速率时钟:AdjustableClock
在LazyClock之上,niri 又包装了一层AdjustableClock,提供速率(rate)调整能力:它通过按比例修改时钟返回的时间戳,实现所有动画的全局减速/加速。
实现要点与"未调整"命名
src/animation/clock.rs 中的AdjustableClock维护了三个关键状态:current_time(调整后的当前时间)、last_seen_time(上次观察到的底层时间)与rate(速率)。每次now()时,它先取底层LazyClock的时间,计算与上次观察时间的差值,乘以速率后累加(或累减)到current_time:
pub fn now(&mut self) -> Duration { let time = self.inner.now(); if self.last_seen_time == time { return self.current_time; } if self.last_seen_time < time { let delta = time - self.last_seen_time; let delta = delta.mul_f64(self.rate); self.current_time = self.current_time.saturating_add(delta); } else { let delta = self.last_seen_time - time; let delta = delta.mul_f64(self.rate); self.current_time = self.current_time.saturating_sub(delta); } self.last_seen_time = time; self.current_time }这里有一个非常重要的细节(原文档专门强调):一旦速率发生改变,AdjustableClock返回的时间戳就会逐渐漂移,最终与系统时间不再相关。然而 niri 渲染所用的"目标时间戳"来自系统时间(显示器帧预测),因此时间覆盖(override)必须直接作用于底层的LazyClock。也就是说:
覆盖时间戳后,再查询
AdjustableClock会得到一个不同的时间戳——但这个值是正确且与AdjustableClock的调整保持一致的。
这一语义直接体现在 API 命名上:对外暴露的方法名为Clock::set_unadjusted()(设置未调整时间)与Clock::now_unadjusted()(获取未调整原始时间戳),参见 src/animation/clock.rs。rate的取值被 clamp 在0.0到1000.0之间(set_rate实现),should_complete_instantly/set_complete_instantly则用于"完全关闭动画"时的瞬时完成语义。
对外封装:共享的 Clock
对外暴露的Clock是一个Rc<RefCell<AdjustableClock>>包装(src/animation/clock.rs),并且实现了基于Rc::ptr_eq的PartialEq——所有动画通过传递和存储这个引用计数指针共享同一个时钟实例。因此:
- 覆盖时间会自动应用到所有动画(一次设置,全局生效);
- 测试中每个测试可以使用独立的
Clock,互不干扰。
三处关键集成:渲染预测、事件循环清除、配置联动
结合源码可以看到LazyClock/AdjustableClock在 niri 运行时中的三处关键集成:
1. 渲染时的时钟冻结(预测式渲染)
在 src/niri.rs 的redraw()中:
let target_presentation_time = state.frame_clock.next_presentation_time(); // Freeze the clock at the target time. self.clock.set_unadjusted(target_presentation_time);niri 先从帧时钟取得"下一次呈现时间"(next_presentation_time(),即显示器将显示该帧的预测时刻),然后调用set_unadjusted()把时钟冻结在这个时刻。随后整个渲染过程(update_render_elements、backend.render)都在这个时间点上求值所有动画——无论渲染代码实际运行得早还是晚,动画状态都精确对应显示器将要显示的时刻,因此不会出现抖动(jitter)。
2. 事件循环末尾清除时间戳
LazyClock的时间戳在每次事件循环迭代结束、即将休眠等待新事件时被清除。这样设计保证了需求 2 与 3 的平衡:新事件(如按键)触发时会取到"当下"的最新时间,而同一轮事件处理中的后续代码拿到的都是同一个缓存时间戳。从源码注释与结构推断,清除动作发生在事件循环的收尾阶段(对应Clock::clear()的语义定义)。
3. 配置联动:slowdown 与 off
动画的全局减速/关闭选项直接映射到时钟上。在 src/niri.rs 的配置应用逻辑中:
let rate = 1.0 / config.animations.slowdown.max(0.001); self.niri.clock.set_rate(rate); self.niri .clock .set_complete_instantly(config.animations.off);即配置项animations.slowdown先被取倒数(1.0 / slowdown)再作为速率设置到时钟上(slowdown 3.0→ 速率1/3,动画慢 3 倍;小于 1 的值则反过来加速),而animations.off则直接令所有动画瞬时完成。这与 Configuration:-Animations.md 中描述的"slow down all animations by this factor"语义完全一致。
测试与验证:可完全控制的时间推进
LazyClock的"时间覆盖"能力让测试变得完全确定。在 src/animation/clock.rs 的单元测试中可以看到它的典型用法:
frozen_clock:用Clock::with_time(Duration::ZERO)创建固定时间时钟,验证now()恒等于零;随后用set_unadjusted()把时间分别推进到 100ms、200ms,验证查询结果精确跟随;rate_change:验证速率调整的累积语义——set_rate(0.5)后,底层时间到 100ms 时now()返回 50ms(半速);底层时间后退(set_unadjusted(150ms))时调整值也相应后退(75ms);把速率改回2.0后,底层到 250ms 时now()返回 275ms(加速累积)。
这两组测试精确刻画了"覆盖作用于底层、调整值作用于上层"的双层语义,也是理解set_unadjusted/now_unadjusted命名的最直观例证。
动画求值:Animation 如何消费时钟
时钟最终服务于 src/animation/mod.rs 中的Animation抽象。每个动画在创建时通过clock.now()记录start_time(src/animation/mod.rs),此后:
value()调用value_at(self.clock.now())求当前动画值(src/animation/mod.rs)——由于同一轮事件循环中now()恒定,单次处理内多次求值必然一致;is_done()/is_clamped_done()用start_time + duration与时钟时间比较判断完成(src/animation/mod.rs),并尊重should_complete_instantly();- 初始速度会按
clock.rate()缩放(initial_velocity / clock.rate().max(0.001)),以确保触控板手势的甩动速度在动画被减速时依然"手感正确"(src/animation/mod.rs)。
动画的求值曲线(Curve)在 src/animation/mod.rs 中实现:Linear恒等、EaseOutQuad/EaseOutCubic来自 keyframe 库、EaseOutExpo为1 - 2^(-10x)、CubicBezier则基于 src/animation/bezier.rs 的二分求根实现(参考了 libadwaita 的 easing 实现)。弹簧动画(Spring)的解析解在 src/animation/spring.rs 中按临界阻尼/欠阻尼/过阻尼三种情形分别计算,其中过阻尼情形用牛顿法求"到达静止"的时长,注释中明确指出了"过阻尼弹簧存在数值稳定性问题"——这与配置文档中"不建议把damping-ratio设为大于 1.0"的警告相互印证。
配置侧补充:动画参数与全局控制
全局开关与减速
在animations配置块中(完整默认配置见 Configuration:-Animations.md):
animations { // Uncomment to turn off all animations. // off // Slow down all animations by this factor. Values below 1 speed them up instead. // slowdown 3.0 }off直接关闭全部动画(对应源码中的set_complete_instantly(true)),slowdown按系数整体放慢/加快(对应set_rate(1.0 / slowdown))。
两种动画类型
Easing(缓动):在设定时长内按插值曲线改变数值,参数为duration-ms(毫秒时长)与curve(缓动曲线):
animations { window-open { duration-ms 150 curve "ease-out-expo" } }当前支持 5 种曲线:ease-out-quad、ease-out-cubic、ease-out-expo、linear,以及自定义的cubic-bezier(需提供 4 个控制点数字,例如curve "cubic-bezier" 0.05 0.7 0.1 1,等价于 CSS 的cubic-bezier(0.05, 0.7, 0.1, 1))。
Spring(弹簧):基于物理弹簧模型,能感知触控板手势的甩动速度,手感更好;参数不可直接设定时长,需要试错调参:
animations { workspace-switch { spring damping-ratio=1.0 stiffness=1000 epsilon=0.0001 } }damping-ratio(0.1 ~ 10.0):小于 1.0 为欠阻尼(结尾会振荡);大于 1.0 为过阻尼(不振荡,但有数值稳定性问题,当前不建议使用);等于 1.0 为临界阻尼(无振荡地最快到达静止)。注意即使等于 1.0,若触控板甩动速度足够大,弹簧仍可能振荡;stiffness:越小动画越慢、越易振荡;epsilon:动画结尾"跳变"时调小该值;- 弹簧的
mass被硬编码为 1.0,无法修改——想等效"增大质量"就成比例减小stiffness(例如质量 ×2 等价于刚度 ÷2)。
同步动画(Synchronized Animations)
当两个动画需要同步播放时,niri 会用同一份配置驱动它们。例如:窗口 resize 导致视图移动时,视图移动动画使用window-resize的配置(而非horizontal-view-movement);列内窗口纵向 resize 使其他窗口移动时,也使用window-resize配置而非window-movement,以保持同步(这在center-focused-column "always"下对动画观感尤其重要)。仍有少数动作尚未接入该同步逻辑,因此官方建议让相关的horizontal-view-movement、window-movement、window-resize三组动画使用相同参数(默认值本就相同)。
小结
niri 的动画时序系统用两个层次解决了"固定刷新周期 vs. 可变渲染延迟"的矛盾:
| 层 | 职责 | 关键 API |
|---|---|---|
LazyClock | 缓存一个时间戳:首次获取系统时间并记住,单轮事件循环内恒定;可被手动设置/清除 | now()、set()、clear() |
AdjustableClock | 在底层时间之上按速率缩放,实现全局减速/加速;时间覆盖作用于底层 | set_rate()、now() |
Clock | 对外共享的引用计数封装,set_unadjusted/now_unadjusted直通底层 | set_unadjusted()、now_unadjusted()、set_complete_instantly() |
最终效果正如原文档所概括:动画帧完美对齐显示器刷新周期、无抖动——即使渲染因处理窗口事件被延迟几毫秒,动画时序依然精确贴合显示器刷新节奏;而测试则通过手动设置时间戳,获得完全确定、可复现的动画行为。相关实现可继续在 src/animation/clock.rs、src/animation/mod.rs、src/animation/spring.rs、src/niri.rs 中深入阅读,配置侧完整说明参见 Configuration:-Animations.md。
【免费下载链接】niriA scrollable-tiling Wayland compositor.项目地址: https://gitcode.com/GitHub_Trending/ni/niri
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考