niri 重绘循环(Redraw Loop)深度解析:RedrawState 状态机与帧回调节流机制
2026/9/10 16:29:53 网站建设 项目流程

niri 重绘循环(Redraw Loop)深度解析:RedrawState 状态机与帧回调节流机制

【免费下载链接】niriA scrollable-tiling Wayland compositor.项目地址: https://gitcode.com/GitHub_Trending/ni/niri

niri 是一款可滚动平铺的 Wayland 合成器,其渲染管线以"输出(output)重绘"为基本单位。在 TTY 环境下,每个输出同一时刻只能向内核提交一帧画面,合成器必须等到下一次 VBlank 事件后才能提交新帧。本文围绕官方开发文档 Development: Redraw-Loop 展开,深入剖析 niri 内部用于协调重绘与 VBlank 节奏的RedrawState状态机,并结合源码说明其如何通过帧回调(frame callback)节流来避免应用空转浪费 CPU。读完本文,你将掌握 niri 从"画面需要更新"到"帧真正呈现"的完整调用链,以及每个状态存在的底层原因。

一、问题背景:为什么重绘不能"随叫随到"

在 Wayland 合成器中,"屏幕需要更新"和"帧真正提交给显示硬件"是两件不同步的事。文档开篇就点明了约束的核心:

On a TTY, only one frame can be submitted to an output at a time, and the compositor must wait until the output repaints (indicated by a VBlank) to be able to submit the next frame.

即:在 TTY(KMS/DRM)环境下,同一输出同时只能提交一帧;帧提交后,必须等待硬件完成呈现、产生 VBlank 信号,才能提交下一帧。如果合成器无视这一节律盲目提交,就会导致撕裂(tearing)或帧排队混乱。

niri 解决这一问题的做法,是在每个OutputState中维护一个名为RedrawState的状态枚举,用一套显式的状态机来跟踪"这个输出当前处于重绘流程的哪个阶段"。定义位于 src/niri.rs:

#[derive(Debug, Default)] pub enum RedrawState { /// The compositor is idle. #[default] Idle, /// A redraw is queued. Queued, /// We submitted a frame to the KMS and waiting for it to be presented. WaitingForVBlank { redraw_needed: bool }, /// We did not submit anything to KMS and made a timer to fire at the estimated VBlank. WaitingForEstimatedVBlank(RegistrationToken), /// A redraw is queued on top of the above. WaitingForEstimatedVBlankAndQueued(RegistrationToken), }

从源码结构看,这套状态机共包含五个状态,分别对应重绘流程中的五个阶段:空闲、已排队、等待真实 VBlank、等待估计 VBlank,以及在等待估计 VBlank 期间又有新重绘请求的复合状态。

二、状态机全景:一张图看懂五个状态的流转

官方文档给出了RedrawState的完整状态转移图(同时提供深色与浅色两个版本,随系统配色自动切换):

整张图的逻辑可以用一句话概括:Idle出发,经过Queued进入真正渲染,渲染产生 damage 则等待 VBlank,不产生 damage 则等待估计 VBlank,最终总是回到Idle或再次进入Queued

三、五个状态逐一解析

结合 src/niri.rs 中每个变体的源码注释,各状态含义如下:

状态含义触发进入的途径
Idle输出空闲,当前不需要重绘重绘完成且无后续请求;默认初始状态
Queued已有一次重绘被排队任何可能导致屏幕更新的操作调用queue_redraw()
WaitingForVBlank { redraw_needed }帧已提交给 KMS,正在等待呈现(真实 VBlank);redraw_needed标记 VBlank 后是否还需再重绘redraw()渲染产生了 damage,提交帧后
WaitingForEstimatedVBlank(token)未向 KMS 提交任何内容,而是设了一个定时器,让其大约在下一个 VBlank 时刻触发redraw()渲染没有产生 damage,不提交帧
WaitingForEstimatedVBlankAndQueued(token)在等待估计 VBlank 的期间,又收到了一次新的重绘请求处于WaitingForEstimatedVBlank时再次调用queue_redraw()

其中WaitingForVBlank { redraw_needed: bool }这个字段值得特别说明:当输出已经向 KMS 提交了一帧、正在等待 VBlank 时,如果期间又发生了需要重绘的事件,niri 不会粗暴打断当前提交,而是把redraw_needed置为true,等 VBlank 到来后再补一次重绘(见下文queue_redraw()的源码)。

四、状态转移的源码级实现

4.1 入队:queue_redraw()

文档指出:"任何可能导致屏幕更新的操作都会调用queue_redraw(),使输出进入Queued状态。"这是整个状态机的唯一"入口",其实现位于 src/niri.rs:

impl RedrawState { fn queue_redraw(self) -> Self { match self { RedrawState::Idle => RedrawState::Queued, RedrawState::WaitingForEstimatedVBlank(token) => { RedrawState::WaitingForEstimatedVBlankAndQueued(token) } // A redraw is already queued. value @ (RedrawState::Queued | RedrawState::WaitingForEstimatedVBlankAndQueued(_)) => { value } // We're waiting for VBlank, request a redraw afterwards. RedrawState::WaitingForVBlank { .. } => RedrawState::WaitingForVBlank { redraw_needed: true, }, } } }

这段代码体现了四个关键设计决策:

  • Idle → Queued:空闲时收到重绘请求,直接入队;
  • WaitingForEstimatedVBlank → WaitingForEstimatedVBlankAndQueued:正在等估计 VBlank 时又来新请求,不取消原定时器,而是叠加一个排队标记,VBlank 定时器触发后立即补做一次重绘;
  • 幂等:如果已经处于QueuedWaitingForEstimatedVBlankAndQueued,重复请求直接忽略("A redraw is already queued"),避免重复渲染浪费;
  • WaitingForVBlank → WaitingForVBlank { redraw_needed: true }:正在等真实 VBlank 时,把需求记入redraw_needed,待 VBlank 事件处理时再决定是否补帧。

对外暴露的入口有两个,位于 src/niri.rs:

/// Schedules an immediate redraw on all outputs if one is not already scheduled. pub fn queue_redraw_all(&mut self) { for state in self.output_state.values_mut() { state.redraw_state = mem::take(&mut state.redraw_state).queue_redraw(); } } /// Schedules an immediate redraw if one is not already scheduled. pub fn queue_redraw(&mut self, output: &Output) { let state = self.output_state.get_mut(output).unwrap(); state.redraw_state = mem::take(&mut state.redraw_state).queue_redraw(); }

queue_redraw_all()遍历所有输出(例如全局动画、光标移动、桌面状态变化时使用),queue_redraw(output)则只针对单个输出(例如某个窗口或 layer-surface 的内容更新)。从调用点来看,输入处理(src/input/mod.rs)、xdg-shell 与 layer-shell 处理器(src/handlers/xdg_shell.rs、src/handlers/layer_shell.rs)、IPC 服务器(src/ipc/server.rs)等模块都会在事件发生时触发重绘请求。

4.2 统一出队:redraw_queued_outputs()

queue_redraw()只负责"记账",真正的渲染动作发生在事件循环一次分发(dispatch)的末尾。文档描述为:"在事件循环分发结束时,niri 为每个Queued输出调用redraw()。"

实现位于 src/niri.rs,采用while循环持续扫描,保证处理过程中新排队的输出也能在同一次分发中被渲染:

pub fn redraw_queued_outputs(&mut self, backend: &mut Backend) { let _span = tracy_client::span!("Niri::redraw_queued_outputs"); while let Some((output, _)) = self.output_state.iter().find(|(_, state)| { matches!( state.redraw_state, RedrawState::Queued | RedrawState::WaitingForEstimatedVBlankAndQueued(_) ) }) { trace!("redrawing output"); let output = output.clone(); self.redraw(backend, &output); } }

注意它只匹配QueuedWaitingForEstimatedVBlankAndQueued两种状态——这正是redraw()入口断言所要求的(见下文)。这也意味着:处于WaitingForVBlank(帧已提交等待呈现)或WaitingForEstimatedVBlank(等待定时器)状态的输出,不会被提前打扰。

4.3 核心渲染:redraw()

redraw()是单个输出的实际渲染函数,位于 src/niri.rs。其开头先验证不变式(invariant),确保调用时机正确:

fn redraw(&mut self, backend: &mut Backend, output: &Output) { let _span = tracy_client::span!("Niri::redraw"); // Verify our invariant. let state = self.output_state.get_mut(output).unwrap(); assert!(matches!( state.redraw_state, RedrawState::Queued | RedrawState::WaitingForEstimatedVBlankAndQueued(_) )); ...

随后它完成了一系列关键步骤,可以概括为"确定目标呈现时间 → 冻结动画时钟 → 渲染 → 依据结果更新状态 → 发送帧回调":

  1. 确定目标呈现时间:调用state.frame_clock.next_presentation_time()算出这一帧预计呈现的时刻;
  2. 冻结动画时钟self.clock.set_unadjusted(target_presentation_time),让所有动画以"目标呈现时间"为基准推进,保证动画的每一帧都对应到真实的硬件刷新时刻(这一点与 Development: Animation-Timing 中的计时思路一致);
  3. 更新渲染元素self.update_render_elements(Some(output))重建该输出上的所有渲染元素(窗口、边框、光标、layer-surface 等);
  4. 检查是否有未完成的动画:依次检查布局动画、配置错误通知、退出确认对话框、截图 UI、窗口 MRU UI、屏幕转场、光标动画以及 layer-surface 动画,任何一个仍在进行都会让unfinished_animations_remain为真,从而驱动后续持续重绘;
  5. 真正渲染res = backend.render(self, output, target_presentation_time)交给后端执行;
  6. 依据渲染结果更新状态机(见下文);
  7. 发送帧回调并处理 screencast / screencopy。
渲染结果如何决定下一个状态

redraw()的第 5~6 步是状态机最精妙的部分。渲染结果有三种可能,分别对应不同的状态迁移:

if res == RenderResult::Skipped { // Update the redraw state on failed render. state.redraw_state = if let RedrawState::WaitingForEstimatedVBlank(token) | RedrawState::WaitingForEstimatedVBlankAndQueued(token) = state.redraw_state { RedrawState::WaitingForEstimatedVBlank(token) } else { RedrawState::Idle }; }

结合文档描述可以整理出三条路径:

  • 渲染产生了 damage(画面有实际变化):帧被提交给 KMS,进入WaitingForVBlank,等待真实 VBlank 事件到来后才能提交下一帧;
  • 渲染没有产生 damage(画面无变化)不会立即回到Idle,而是设置一个定时器、让其在"大约下一次 VBlank 时刻"触发,进入WaitingForEstimatedVBlank
  • 渲染失败被跳过(RenderResult::Skipped:恢复为进入前的状态(若是从估计 VBlank 路径来的则维持该定时器),否则回到Idle

文档特别强调了第二点的重要性:

This is necessary in order to throttle frame callbacks sent to applications to at most once per output refresh cycle. Without this throttling, applications can start continuously redrawing without damage (for instance, if the application window is partially off-screen, and it is only the off-screen part that changes), and eating a lot of CPU in the process.

即使没有 damage,也要按刷新周期"空走"一遍重绘流程,目的是把发送给应用的帧回调限制在"每个输出刷新周期最多一次"。如果省掉这一步,应用就可能陷入"无 damage 的连续重绘"(典型场景:窗口部分在屏幕外,只有屏幕外部分发生变化),从而持续消耗大量 CPU。

等待估计 VBlank 的两个出口

文档描述了该状态的两种结束方式:

Then, either the estimated VBlank timer completes, and we go back toIdle, or maybe we callqueue_redraw()once more and try to redraw again.

  • 定时器正常触发且没有新的重绘请求 → 回到Idle
  • 定时器触发前又收到了queue_redraw()→ 状态变成WaitingForEstimatedVBlankAndQueued,定时器触发后立刻再走一遍redraw()

从状态机的角度,WaitingForEstimatedVBlankAndQueued是一个"复合态",它保证 niri 既不会错过新事件,也不会破坏既定的刷新节律——定时器仍然按估计 VBlank 触发,只是触发后的动作从"回到 Idle"变成"补一次重绘"。

五、帧回调节流:为什么每个刷新周期只发一次

5.1 帧回调与空转陷阱

在 Wayland 协议中,应用通过请求frame callback来与合成器的刷新节奏同步:应用收到回调后才会渲染下一帧。如果合成器无条件、频繁地发送帧回调,应用就会以最高速率持续渲染提交——即使画面根本没有实际变化(如部分离屏的窗口),白白烧掉 CPU。这正是文档中"eating a lot of CPU"警告的由来。

5.2 实现一:FrameClock 估算呈现时间

估计 VBlank 定时器"该设在什么时候",由 src/frame_clock.rs 中的FrameClock负责。它记录上一次呈现时间last_presentation_time与输出刷新间隔refresh_interval_ns,通过next_presentation_time()计算"下一帧应该在哪个时刻呈现":

  • 正常情况下,取last_presentation_time + 整数倍刷新间隔中下一个未来的时刻;
  • 若收到提前的 VBlank(now <= last_presentation_time),自动顺延一个刷新间隔;
  • 若开启 VRR(可变刷新率)且自上次呈现已超过一个刷新周期,则允许立即呈现(self.vrr && to_next_ns > refresh_interval_ns时直接返回now)。

redraw()state.frame_clock.next_presentation_time()返回的值,既被用作clock.set_unadjusted()的动画时间基准,也被传入backend.render()作为目标呈现时间,从而把"何时画、画到哪一帧"与"何时真正上屏"统一起来。

5.3 实现二:每个表面记录上次回调时刻

除了全局的frame_callback_sequence序列号,niri 还给每个 surface 维护了 SurfaceFrameThrottlingState:

// Not related to the one in Smithay. // // This state keeps track of when a surface last received a frame callback. struct SurfaceFrameThrottlingState { /// Output and sequence that the frame callback was last sent at. last_sent_at: RefCell<Option<(Output, u32)>>, }

send_frame_callbacks()(src/niri.rs)在发送帧回调前的判据非常直白:

// If we already sent a frame callback to this surface this output refresh // cycle, don't send one again to prevent empty-damage commit busy loops. if let Some((last_output, last_sequence)) = &*last_sent_at { if last_output == output && *last_sequence == sequence { send = false; } }

同一个输出、同一个刷新序列号,每个 surface 至多收到一次帧回调。这从机制上杜绝了应用"提交空 damage → 合成器发回调 → 应用再提交空 damage"的忙碌循环。

此外,should_send闭包还做了主扫描输出(primary scanout output)检查:只有当前主扫描输出与正在重绘的输出一致时才会发送回调,这既为指针表面在多个输出间去重,也避免向不可见表面发送帧回调。回调覆盖的对象包括布局中的窗口、layer-shell 表面、锁屏表面、拖拽图标(DnD icon)以及光标表面。

5.4 兜底:1 秒一次的 fallback 定时器

并不是所有窗口在每一帧都可见(例如在平铺布局中被完全遮挡的窗口)。为了不让这些窗口"饿死"——永远收不到帧回调而无法推进自己的动画——niri 还注册了一个周期为 1 秒的兜底定时器(src/niri.rs):

event_loop .insert_source( Timer::from_duration(Duration::from_secs(1)), |_, _, state| { state.niri.send_frame_callbacks_on_fallback_timer(); TimeoutAction::ToDuration(Duration::from_secs(1)) }, ) .unwrap();

send_frame_callbacks_on_fallback_timer()(src/niri.rs)会构造一个"假的"空输出(大小 0×0、名称空字符串),向所有窗口、layer-shell 表面、锁屏表面等统一发送帧回调。这个每秒一次的节拍,保证了隐藏窗口至少能以 1 FPS 的速率推进,同时又不至于造成明显 CPU 开销。

5.5 防御:VBlankThrottle 处理"过早的 VBlank"

作为节流的最后一道防线,src/utils/vblank_throttle.rs 专门应对某些有问题的显卡驱动提前发送 VBlank的情况。其模块注释写得很清楚:

Some buggy drivers deliver VBlanks way earlier than necessary. This helper throttles the VBlank in such cases to avoid tearing and to get more consistent timings.

实现逻辑是:记录上一次 VBlank 时间戳,若本次 VBlank 距离上次的时间不足半个刷新周期passed < refresh / 2),则不直接当作 VBlank 处理,而是注册一个定时器,在剩余时间(refresh - passed)之后才真正触发回调;同时只打印一次警告("output ... running faster than expected, throttling vblanks"),避免刷屏。这样一来,即使驱动"过于积极",niri 也能维持一致的呈现节奏,避免撕裂。

六、一次完整重绘的时序回顾

把上面的机制串起来,niri 在 TTY 后端下一次典型重绘的完整生命周期如下:

  1. 某事件发生(窗口提交新内容、光标移动、动画推进等),调用queue_redraw(output)queue_redraw_all(),输出进入Queued
  2. 事件循环分发结束,redraw_queued_outputs()找出所有Queued/WaitingForEstimatedVBlankAndQueued输出,逐个调用redraw()
  3. redraw()FrameClock::next_presentation_time()确定目标呈现时间、冻结动画时钟、重建渲染元素并渲染;
  4. 若渲染产生 damage:帧提交 KMS,进入WaitingForVBlank { redraw_needed },待 VBlank 事件(经VBlankThrottle过滤)到达后再决定是否补帧;
  5. 若渲染无 damage:不提交帧,设置"估计 VBlank"定时器,进入WaitingForEstimatedVBlank,把帧回调节流在"每刷新周期一次";
  6. redraw()末尾调用send_frame_callbacks()向主扫描输出上的可见表面发送帧回调(受序列号与SurfaceFrameThrottlingState双重去重);
  7. 定时器触发:若无新请求则回Idle,若有则经WaitingForEstimatedVBlankAndQueued补一次重绘;被遮挡的窗口则依赖 1 秒兜底定时器获得帧回调。

七、延伸与相关阅读

重绘循环是 niri 渲染与动画体系的基石,与之紧密相关的还有:

  • Development: Animation-Timing:动画时钟如何以"目标呈现时间"为基准推进,与clock.set_unadjusted()直接关联;
  • Development: Redraw-Loop:本文所依据的官方开发文档;
  • 后端差异:本机制在 TTY 后端(src/backend/tty.rs)下最为关键,因为它受 KMS 单帧提交约束;而 winit 与 headless 后端(src/backend/winit.rs、src/backend/headless.rs)同样复用RedrawStatequeue_redraw接口,保持了状态机逻辑的统一;
  • 性能观测:redrawredraw_queued_outputssend_frame_callbacks等关键路径均埋有tracy_client::span!,可用 Tracy Profiler 直观观测每次重绘的耗时与触发频率。

八、小结

RedrawState状态机是 niri 在 TTY 单帧提交约束下,保证"渲染节奏"与"硬件刷新节奏"对齐的核心抽象。它以五个状态精确刻画了"空闲 → 排队 → 渲染 → 等待 VBlank/估计 VBlank"的完整闭环,并通过帧回调节流、估计 VBlank 定时器、1 秒兜底定时器与 VBlankThrottle 四层机制,从根上杜绝了应用无 damage 空转导致的 CPU 浪费。理解这套状态机,也就理解了 niri 渲染管线高效与省电的底层原因。

【免费下载链接】niriA scrollable-tiling Wayland compositor.项目地址: https://gitcode.com/GitHub_Trending/ni/niri

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询