
niri 动画时序与 LazyClock 时钟系统解析【免费下载链接】niriA scrollable-tiling Wayland compositor.项目地址: https://gitcode.com/GitHub_Trending/ni/nirioutput文章niri 动画时序LazyClock 与 AdjustableClock 时钟系统深度解析本篇技术指南以 niriscrollable-tiling Wayland compositor的开发文档 Development:-Animation-Timing.md 为主体系统讲解 niri 如何在固定刷新率的显示器上实现零抖动的动画渲染从显示器刷新周期与渲染时机的关系到LazyClock惰性时钟的设计动机与实现再到AdjustableClock可调速率时钟如何支撑动画全局减速/加速与测试时间控制。读完本文你将理解 niri 动画系统的三大支柱——预测式渲染、事件循环内时间一致性、统一速率调整——以及它们对应的源码实现与测试验证方式并能将这些设计思路迁移到自己的合成器或图形应用项目中。动画时序问题的本质固定刷新周期与可变渲染延迟niri 是一个 Wayland 合成器负责把一个或多个显示器monitor的输出合成并呈现。显示器的刷新周期在绝大多数情况下是固定的例如一台 170 Hz 的显示器每帧间隔约为 5.88 ms1 / 170 s ≈ 5.88 ms。合成器的渲染管线必须与这个节奏对齐。但合成器并非每一帧都要重绘。当屏幕上没有任何变化例如你正在阅读文档、鼠标静止不动时唤醒 GPU 去合成同一张图像纯属浪费。动画期间则不同——屏幕内容每一帧都在变化niri 通常会上一帧刚显示出来就立刻开始绘制下一帧。问题的关键矛盾在于渲染时机的不可预测性渲染代码可能因为处理新窗口事件等事务被延迟几毫秒但动画在显示器上的呈现时机必须严格对齐刷新周期显示器刷新周期是固定的即使启用 VRR也存在一个最大刷新率因此合成器可以预测下一帧何时显示在屏幕上用户操作必须即时响应例如按下工作区切换键的那一刻动画就应该从那一瞬间开始而不是从我们预测的下一个显示器帧该帧可能已经渲染完了开始。于是niri 的动画时序系统需要同时满足以下四个性质原文定义可获取未来某个时刻的动画状态为渲染一个与显示器显示时机精确对齐的帧必须能拿到指定时间点的动画状态且这种时间覆盖能力应在测试中可用以完全受控的方式推进时间用户操作触发即时开始响应式动画必须从动作发生的瞬间开始单次动作处理期间时间一致即使处理过程在开始后数微秒才结束期间查询时间都应返回完全相同的值——否则你可能要避免连续两次读取某个元素的位置因为它可能在两次读取之间移动了一个像素破坏逻辑此外获取系统时间本身的开销相当可观易于实现全局减速所有动画应能按同一系数被整体放慢或加快。核心方案LazyClock惰性时钟针对上述需求niri 的解决方案是一个LazyClock——一种只记住一个时间戳的时钟初始状态时间戳为空。首次调用获取当前时间时它会获取并返回系统时间同时记住这个时间戳后续行为只要时间戳未被清除后续每次查询都返回这个被记住的同一时间戳清除语义清除时间戳后下一次查询会重新获取系统时间。在 niri 中这个时间戳在每次事件循环迭代结束时被清除即在即将休眠等待新事件之前。这样任何随后发生的事件比如一次用户按键一旦需要时间就会取到最新鲜的时间戳而接下来的事件处理代码则会持续拿到完全相同的值——因为LazyClock已将其缓存。源码中的 LazyClock 实现src/animation/clock.rs 中的LazyClock结构体非常简洁struct LazyClock { time: OptionDuration, } 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_timepub 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是一个RcRefCellAdjustableClock包装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_timesrc/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.mdanimations { // 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-ratio1.0 stiffness1000 epsilon0.0001 } }damping-ratio0.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. 可变渲染延迟的矛盾层职责关键 APILazyClock缓存一个时间戳首次获取系统时间并记住单轮事件循环内恒定可被手动设置/清除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),仅供参考